├── changelogs ├── fragments │ ├── .keep │ ├── 211-ipam-info-vmid.yml │ ├── 225-add-version-info.yaml │ ├── 240-add-with-local-disks-option.yaml │ ├── 218_modulesutils_proxmox_timeout.yml │ ├── 220-add-network-interface-info.yaml │ ├── 232-update-proxmox_node-certificates.yml │ ├── 221-fix-nfs-options.yml │ ├── 241-replace-deprecated-collections-compat.yaml │ ├── 216-vxlan-zone-peers-support.yml │ ├── 220-fix-CIFS-authentication-and-subdir-feature.yml │ └── 236-fix-size-units-in-docs.yaml ├── changelog.yaml.license └── config.yaml ├── LICENSES ├── GPL-3.0-or-later.txt └── BSD-2-Clause.txt ├── tests ├── integration │ └── targets │ │ ├── connection_proxmox_pct_remote │ │ ├── test.sh │ │ ├── aliases │ │ ├── dependencies.yml │ │ ├── test_connection.inventory │ │ ├── runme.sh │ │ ├── files │ │ │ └── pct │ │ └── plugin-specific-tests.yml │ │ ├── connection │ │ ├── aliases │ │ ├── test.sh │ │ └── test_connection.yml │ │ ├── connection_posix │ │ ├── aliases │ │ └── test.sh │ │ ├── proxmox_template │ │ ├── aliases │ │ └── tasks │ │ │ └── main.yml │ │ ├── proxmox_pool │ │ ├── aliases │ │ └── defaults │ │ │ └── main.yml │ │ └── proxmox │ │ └── aliases ├── sanity │ ├── ignore-2.21.txt.license │ └── ignore-2.21.txt └── unit │ ├── requirements.txt │ ├── requirements.yml │ └── plugins │ └── modules │ ├── test_proxmox_lxc.py │ ├── test_proxmox_template.py │ ├── test_proxmox_storage_contents_info.py │ ├── test_proxmox_zone_info.py │ ├── test_proxmox_cluster.py │ ├── test_proxmox_snap.py │ ├── test_proxmox_cluster_join_info.py │ ├── test_proxmox_vnet.py │ ├── test_proxmox_access_acl.py │ ├── test_proxmox_node.py │ ├── test_proxmox_kvm.py │ ├── test_proxmox_user.py │ ├── test_proxmox_tasks_info.py │ ├── test_proxmox_ipam_info.py │ ├── test_proxmox_subnet.py │ └── test_proxmox_firewall_info.py ├── CHANGELOG.md.license ├── CHANGELOG.rst.license ├── CODE_OF_CONDUCT.md ├── REUSE.toml ├── .github ├── dependabot.yml └── workflows │ └── nox.yml ├── plugins ├── module_utils │ ├── version.py │ ├── _filelock.py │ └── proxmox_sdn.py ├── plugin_utils │ └── unsafe.py ├── doc_fragments │ ├── attributes.py │ └── proxmox.py └── modules │ ├── proxmox_domain_info.py │ ├── proxmox_zone_info.py │ ├── proxmox_group_info.py │ ├── proxmox_node_info.py │ ├── proxmox_storage_contents_info.py │ ├── proxmox_cluster_join_info.py │ ├── proxmox_group.py │ ├── proxmox_pool.py │ ├── proxmox_storage_info.py │ ├── proxmox_tasks_info.py │ ├── proxmox_access_acl.py │ ├── proxmox_vnet_info.py │ └── proxmox_cluster_ha_groups.py ├── meta ├── ee-bindep.txt ├── ee-requirements.txt ├── execution-environment.yml └── runtime.yml ├── noxfile.py ├── galaxy.yml ├── antsibull-nox.toml ├── CONTRIBUTING.md ├── docs └── docsite │ └── links.yml └── .gitignore /changelogs/fragments/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSES/GPL-3.0-or-later.txt: -------------------------------------------------------------------------------- 1 | ../COPYING -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/test.sh: -------------------------------------------------------------------------------- 1 | ../connection_posix/test.sh -------------------------------------------------------------------------------- /changelogs/fragments/211-ipam-info-vmid.yml: -------------------------------------------------------------------------------- 1 | bugfixes: 2 | - "proxmox_ipam_info - fix bug where selecting by vmid did not work (https://github.com/ansible-collections/community.proxmox/pull/211)." -------------------------------------------------------------------------------- /changelogs/fragments/225-add-version-info.yaml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - proxmox_node_info - add information on node's PVE version (https://github.com/ansible-collections/community.proxmox/pull/225). 3 | -------------------------------------------------------------------------------- /changelogs/fragments/240-add-with-local-disks-option.yaml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - proxmox_kvm - add option to migrate local disks as well (https://github.com/ansible-collections/community.proxmox/pull/240). 3 | -------------------------------------------------------------------------------- /CHANGELOG.md.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /changelogs/fragments/218_modulesutils_proxmox_timeout.yml: -------------------------------------------------------------------------------- 1 | bugfixes: 2 | - "proxmox all - add missing timeout parameter to proxmoxer object creation (https://github.com/ansible-collections/community.proxmox/pull/218)." 3 | -------------------------------------------------------------------------------- /changelogs/changelog.yaml.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /changelogs/fragments/220-add-network-interface-info.yaml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - proxmox_node_info - add information on node network interfaces to node information output (https://github.com/ansible-collections/community.proxmox/pull/220). 3 | -------------------------------------------------------------------------------- /changelogs/fragments/232-update-proxmox_node-certificates.yml: -------------------------------------------------------------------------------- 1 | bugfixes: 2 | - "remove wrong api endpoints and error messages from proxmod_node certificate management(https://github.com/ansible-collections/community.proxmox/pull/232)." 3 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.21.txt.license: -------------------------------------------------------------------------------- 1 | GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | SPDX-FileCopyrightText: Ansible Project 4 | -------------------------------------------------------------------------------- /tests/integration/targets/connection/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | hidden 6 | -------------------------------------------------------------------------------- /tests/unit/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | paramiko 6 | proxmoxer >= 2.0.0 7 | -------------------------------------------------------------------------------- /changelogs/fragments/221-fix-nfs-options.yml: -------------------------------------------------------------------------------- 1 | --- 2 | minor_changes: 3 | - proxmox_storage - fix passing nfs_options to API payload (https://github.com/ansible-collections/community.proxmox/issues/203, https://github.com/ansible-collections/community.proxmox/pull/221). 4 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_posix/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | needs/target/connection 6 | hidden 7 | -------------------------------------------------------------------------------- /tests/integration/targets/proxmox_template/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | unsupported 6 | proxmox_template 7 | -------------------------------------------------------------------------------- /tests/unit/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | collections: 7 | - community.internal_test_tools 8 | -------------------------------------------------------------------------------- /tests/integration/targets/proxmox_pool/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | unsupported 6 | proxmox_pool 7 | proxmox_pool_member 8 | -------------------------------------------------------------------------------- /changelogs/fragments/241-replace-deprecated-collections-compat.yaml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - inventory plugin, plugin_utils - replace deprecated ``ansible.module_utils.common._collections_compat`` imports with ``collections.abc`` from the Python standard library (https://github.com/ansible-collections/community.proxmox/issues/241). 3 | -------------------------------------------------------------------------------- /tests/integration/targets/proxmox_pool/defaults/main.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Sergei Antipov 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | poolid: test 6 | member: local 7 | member_type: storage 8 | -------------------------------------------------------------------------------- /tests/integration/targets/proxmox/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | unsupported 6 | proxmox_domain_info 7 | proxmox_group_info 8 | proxmox_user_info 9 | proxmox_storage_info 10 | -------------------------------------------------------------------------------- /changelogs/fragments/216-vxlan-zone-peers-support.yml: -------------------------------------------------------------------------------- 1 | --- 2 | bugfixes: 3 | - proxmox_zone - fix validation logic for VXLAN zones to accept either ``fabric`` or ``peers`` parameter. Previously, only ``fabric`` was accepted, but Proxmox VE also supports creating VXLAN zones with a peer address list (https://github.com/ansible-collections/community.proxmox/issues/216). 4 | -------------------------------------------------------------------------------- /changelogs/fragments/220-fix-CIFS-authentication-and-subdir-feature.yml: -------------------------------------------------------------------------------- 1 | --- 2 | minor_changes: 3 | - proxmox_storage - fixed CIFS authentication by sending username and password parameters to proxmoxer (https://github.com/ansible-collections/community.proxmox/pull/214). 4 | - proxmox_storage - add feature of subdirectory in CIFS share. (https://github.com/ansible-collections/community.proxmox/pull/214). 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Community Code of Conduct 8 | 9 | Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). 10 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | version = 1 6 | 7 | [[annotations]] 8 | path = "changelogs/fragments/**" 9 | precedence = "aggregate" 10 | SPDX-FileCopyrightText = "Ansible Project" 11 | SPDX-License-Identifier = "GPL-3.0-or-later" 12 | -------------------------------------------------------------------------------- /changelogs/fragments/236-fix-size-units-in-docs.yaml: -------------------------------------------------------------------------------- 1 | minor_changes: 2 | - proxmox - change disk size units to GiB (https://github.com/ansible-collections/community.proxmox/pull/236). 3 | - proxmox_disk - change disk size units to GiB (https://github.com/ansible-collections/community.proxmox/pull/236). 4 | - proxmox_kvm - change disk size units to GiB (https://github.com/ansible-collections/community.proxmox/pull/236). 5 | -------------------------------------------------------------------------------- /tests/sanity/ignore-2.21.txt: -------------------------------------------------------------------------------- 1 | plugins/modules/proxmox_backup_info.py validate-modules:bad-return-value-key # backup_info.next-run key is returned this way by API 2 | plugins/modules/proxmox_storage_info.py validate-modules:bad-return-value-key # proxmox_storages.prune-backups key is returned this way by API 3 | plugins/modules/proxmox_user_info.py validate-modules:bad-return-value-key # proxmox_users.keys key is returned this way by API 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | ci: 14 | patterns: 15 | - "*" 16 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/aliases: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Nils Stein (@mietzen) 2 | # Copyright (c) 2025 Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | azp/posix/3 7 | destructive 8 | needs/root 9 | needs/target/connection 10 | skip/docker 11 | skip/alpine 12 | skip/macos 13 | -------------------------------------------------------------------------------- /plugins/module_utils/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2021, Felix Fontein 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """Provide version object to compare version numbers.""" 8 | 9 | from __future__ import absolute_import, division, print_function 10 | __metaclass__ = type 11 | 12 | 13 | from ansible.module_utils.compat.version import LooseVersion # noqa: F401, pylint: disable=unused-import 14 | -------------------------------------------------------------------------------- /tests/integration/targets/connection/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | set -eux 7 | 8 | [ -f "${INVENTORY}" ] 9 | 10 | # Run connection tests with both the default and C locale. 11 | 12 | ansible-playbook test_connection.yml -i "${INVENTORY}" "$@" 13 | 14 | if ansible --version | grep ansible | grep -E ' 2\.(9|10|11|12|13)\.'; then 15 | LC_ALL=C LANG=C ansible-playbook test_connection.yml -i "${INVENTORY}" "$@" 16 | fi 17 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/dependencies.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) 2025 Nils Stein (@mietzen) 3 | # Copyright (c) 2025 Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | - hosts: localhost 8 | gather_facts: true 9 | serial: 1 10 | tasks: 11 | - name: Copy pct mock 12 | copy: 13 | src: files/pct 14 | dest: /usr/sbin/pct 15 | mode: '0755' 16 | - name: Install paramiko 17 | pip: 18 | name: "paramiko>=3.0.0" 19 | -------------------------------------------------------------------------------- /meta/ee-bindep.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | 6 | # List your controller-side system package requirements here if exist 7 | # to support easy execution environments building for your collection users 8 | # https://docs.ansible.com/ansible/devel/getting_started_ee/index.html 9 | # See https://ansible.readthedocs.io/projects/builder/en/latest/collection_metadata/#how-to-verify-collection-level-metadata 10 | # to learn how to test your configuration. 11 | # Do not delete this file even if the collection has no dependencies! 12 | -------------------------------------------------------------------------------- /meta/ee-requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | 6 | # List your controller-side Python package requirements here if exist 7 | # to support easy execution environments building for your collection users 8 | # https://docs.ansible.com/ansible/devel/getting_started_ee/index.html 9 | # See https://ansible.readthedocs.io/projects/builder/en/latest/collection_metadata/#how-to-verify-collection-level-metadata 10 | # to learn how to test your configuration. 11 | # Do not delete this file even if the collection has no dependencies! 12 | 13 | proxmoxer>=2.0 14 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/test_connection.inventory: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Nils Stein (@mietzen) 2 | # Copyright (c) 2025 Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | [proxmox_pct_remote] 7 | proxmox_pct_remote-pipelining ansible_ssh_pipelining=true 8 | proxmox_pct_remote-no-pipelining ansible_ssh_pipelining=false 9 | [proxmox_pct_remote:vars] 10 | ansible_host=localhost 11 | ansible_user=root 12 | ansible_python_interpreter="{{ ansible_playbook_python }}" 13 | ansible_connection=community.proxmox.proxmox_pct_remote 14 | proxmox_vmid=123 15 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/runme.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) 2025 Nils Stein (@mietzen) 3 | # Copyright (c) 2025 Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | set -eux 8 | 9 | ANSIBLE_ROLES_PATH=../ \ 10 | ansible-playbook dependencies.yml -v "$@" 11 | 12 | ./test.sh "$@" 13 | 14 | ansible-playbook plugin-specific-tests.yml -i "./test_connection.inventory" \ 15 | -e target_hosts="proxmox_pct_remote" \ 16 | -e action_prefix= \ 17 | -e local_tmp=/tmp/ansible-local \ 18 | -e remote_tmp=/tmp/ansible-remote \ 19 | "$@" 20 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | # SPDX-FileCopyrightText: Ansible Project 4 | 5 | # /// script 6 | # dependencies = ["nox>=2025.02.09", "antsibull-nox"] 7 | # /// 8 | 9 | import sys 10 | 11 | import nox 12 | 13 | 14 | try: 15 | import antsibull_nox 16 | except ImportError: 17 | print("You need to install antsibull-nox in the same Python environment as nox.") 18 | sys.exit(1) 19 | 20 | 21 | antsibull_nox.load_antsibull_nox_toml() 22 | 23 | 24 | # Allow to run the noxfile with `python noxfile.py`, `pipx run noxfile.py`, or similar. 25 | # Requires nox >= 2025.02.09 26 | if __name__ == "__main__": 27 | nox.main() 28 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_posix/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | set -eux 7 | 8 | # Connection tests for POSIX platforms use this script by linking to it from the appropriate 'connection_' target dir. 9 | # The name of the inventory group to test is extracted from the directory name following the 'connection_' prefix. 10 | 11 | group=$(python -c \ 12 | "from os import path; print(path.basename(path.abspath(path.dirname('$0'))).replace('connection_', ''))") 13 | 14 | cd ../connection 15 | 16 | INVENTORY="../connection_${group}/test_connection.inventory" ./test.sh \ 17 | -e target_hosts="${group}" \ 18 | -e action_prefix= \ 19 | -e local_tmp=/tmp/ansible-local \ 20 | -e remote_tmp=/tmp/ansible-remote \ 21 | "$@" 22 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/files/pct: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) 2025 Nils Stein (@mietzen) 3 | # Copyright (c) 2025 Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | # Shell script to mock proxmox pct behaviour 8 | 9 | >&2 echo "[DEBUG] INPUT: $@" 10 | 11 | pwd="$(pwd)" 12 | 13 | # Get quoted parts and restore quotes 14 | declare -a cmd=() 15 | for arg in "$@"; do 16 | if [[ $arg =~ [[:space:]] ]]; then 17 | arg="'$arg'" 18 | fi 19 | cmd+=("$arg") 20 | done 21 | 22 | cmd="${cmd[@]:3}" 23 | vmid="${@:2:1}" 24 | >&2 echo "[INFO] MOCKING: pct ${@:1:3} ${cmd}" 25 | tmp_dir="/tmp/ansible-remote/proxmox_pct_remote/integration_test/ct_${vmid}" 26 | mkdir -p "$tmp_dir" 27 | >&2 echo "[INFO] PWD: $tmp_dir" 28 | >&2 echo "[INFO] CMD: ${cmd}" 29 | cd "$tmp_dir" 30 | 31 | eval "${cmd}" 32 | 33 | cd "$pwd" 34 | -------------------------------------------------------------------------------- /.github/workflows/nox.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | name: nox 7 | 'on': 8 | push: 9 | branches: 10 | - main 11 | - stable-* 12 | pull_request: 13 | # Run CI once per day (at 07:30 UTC) 14 | schedule: 15 | - cron: '30 7 * * *' 16 | workflow_dispatch: 17 | 18 | jobs: 19 | nox: 20 | runs-on: ubuntu-latest 21 | name: "Run extra sanity tests" 22 | steps: 23 | - name: Check out collection 24 | uses: actions/checkout@v6 25 | with: 26 | persist-credentials: false 27 | - name: Run nox 28 | uses: ansible-community/antsibull-nox@main 29 | 30 | ansible-test: 31 | uses: ansible-community/antsibull-nox/.github/workflows/reusable-nox-matrix.yml@main 32 | with: 33 | upload-codecov: true 34 | secrets: 35 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /tests/integration/targets/connection_proxmox_pct_remote/plugin-specific-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - hosts: "{{ target_hosts }}" 7 | gather_facts: false 8 | serial: 1 9 | tasks: 10 | - name: create file without content 11 | copy: 12 | content: "" 13 | dest: "{{ remote_tmp }}/test_empty.txt" 14 | force: no 15 | mode: '0644' 16 | 17 | - name: assert file without content exists 18 | stat: 19 | path: "{{ remote_tmp }}/test_empty.txt" 20 | register: empty_file_stat 21 | 22 | - name: verify file without content exists 23 | assert: 24 | that: 25 | - empty_file_stat.stat.exists 26 | fail_msg: "The file {{ remote_tmp }}/test_empty.txt does not exist." 27 | 28 | - name: verify file without content is empty 29 | assert: 30 | that: 31 | - empty_file_stat.stat.size == 0 32 | fail_msg: "The file {{ remote_tmp }}/test_empty.txt is not empty." 33 | -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # See https://docs.ansible.com/ansible/latest/dev_guide/collections_galaxy_meta.html 7 | 8 | namespace: community 9 | name: proxmox 10 | version: 1.5.0 11 | readme: README.md 12 | authors: 13 | - Ansible community (https://github.com/ansible-community) 14 | description: Community modules and plugins for Proxmox. 15 | license_file: COPYING 16 | tags: 17 | # tags so people can search for collections https://galaxy.ansible.com/search 18 | # tags are all lower-case, no spaces, no dashes. 19 | - community 20 | - proxmox 21 | repository: https://github.com/ansible-collections/community.proxmox 22 | # documentation: ... 23 | homepage: https://github.com/ansible-collections/community.proxmox 24 | issues: https://github.com/ansible-collections/community.proxmox/issues 25 | build_ignore: 26 | # https://docs.ansible.com/ansible/devel/dev_guide/developing_collections_distributing.html#ignoring-files-and-folders 27 | - .gitignore 28 | - changelogs/.plugin-cache.yaml 29 | -------------------------------------------------------------------------------- /LICENSES/BSD-2-Clause.txt: -------------------------------------------------------------------------------- 1 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 2 | 3 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 4 | 5 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 6 | 7 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 8 | 9 | -------------------------------------------------------------------------------- /meta/execution-environment.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # List your controller-side system package and/or Python package requirements 7 | # if exist in the files specified below to support easy execution environments building for your collection users 8 | # https://docs.ansible.com/ansible/devel/getting_started_ee/index.html 9 | # See https://ansible.readthedocs.io/projects/builder/en/latest/collection_metadata/#how-to-verify-collection-level-metadata 10 | # to learn how to test your configuration. 11 | 12 | # Do not delete these files even if the collection has no dependencies! 13 | # If you delete them, ansible-builder will use bindep.txt and requirements.txt 14 | # from the collection root by default. Especially requirements.txt in the root is often 15 | # used for test/build/development requirements for the collection, that are 16 | # not needed for end-users to use the collection. Ansible-builder installing these 17 | # would increase the EE size and increase the chance of dependency collisions. 18 | 19 | dependencies: 20 | python: meta/ee-requirements.txt 21 | system: meta/ee-bindep.txt 22 | -------------------------------------------------------------------------------- /antsibull-nox.toml: -------------------------------------------------------------------------------- 1 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | # SPDX-FileCopyrightText: Ansible Project 4 | 5 | [collection_sources] 6 | "ansible.posix" = "git+https://github.com/ansible-collections/ansible.posix.git,main" 7 | "community.docker" = "git+https://github.com/ansible-collections/community.docker.git,main" 8 | "community.general" = "git+https://github.com/ansible-collections/community.general.git,main" 9 | "community.internal_test_tools" = "git+https://github.com/ansible-collections/community.internal_test_tools.git,main" 10 | 11 | [sessions] 12 | 13 | [sessions.docs_check] 14 | validate_collection_refs="all" 15 | 16 | [sessions.license_check] 17 | 18 | [sessions.extra_checks] 19 | run_no_unwanted_files = true 20 | no_unwanted_files_module_extensions = [".py"] 21 | no_unwanted_files_yaml_extensions = [".yml"] 22 | run_action_groups = true 23 | 24 | [[sessions.extra_checks.action_groups_config]] 25 | name = "proxmox" 26 | pattern = "^.*$" 27 | exclusions = [] 28 | doc_fragment = "community.proxmox.proxmox.actiongroup_proxmox" 29 | 30 | [sessions.build_import_check] 31 | run_galaxy_importer = true 32 | 33 | [sessions.ansible_test_sanity] 34 | include_devel = true 35 | 36 | [sessions.ansible_test_units] 37 | include_devel = true 38 | -------------------------------------------------------------------------------- /changelogs/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | changes_file: changelog.yaml 7 | changes_format: combined 8 | keep_fragments: false 9 | mention_ancestor: true 10 | new_plugins_after_name: removed_features 11 | notesdir: fragments 12 | prelude_section_name: release_summary 13 | prelude_section_title: Release Summary 14 | sections: 15 | - - major_changes 16 | - Major Changes 17 | - - minor_changes 18 | - Minor Changes 19 | - - breaking_changes 20 | - Breaking Changes / Porting Guide 21 | - - deprecated_features 22 | - Deprecated Features 23 | - - removed_features 24 | - Removed Features (previously deprecated) 25 | - - security_fixes 26 | - Security Fixes 27 | - - bugfixes 28 | - Bugfixes 29 | - - known_issues 30 | - Known Issues 31 | title: Community Proxmox Collection 32 | trivial_section_name: trivial 33 | use_fqcn: true 34 | add_plugin_period: true 35 | changelog_nice_yaml: true 36 | changelog_sort: version 37 | vcs: auto 38 | output: 39 | - file: CHANGELOG.rst 40 | format: rst 41 | global_toc: true 42 | per_release_toc: false 43 | - file: CHANGELOG.md 44 | format: md 45 | global_toc: true 46 | per_release_toc: false 47 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | requires_ansible: '>=2.17.0' 7 | 8 | action_groups: 9 | proxmox: 10 | - proxmox 11 | - proxmox_access_acl 12 | - proxmox_backup 13 | - proxmox_backup_info 14 | - proxmox_backup_schedule 15 | - proxmox_cluster 16 | - proxmox_cluster_ha_resources 17 | - proxmox_cluster_ha_rules 18 | - proxmox_cluster_ha_groups 19 | - proxmox_cluster_join_info 20 | - proxmox_disk 21 | - proxmox_domain_info 22 | - proxmox_firewall 23 | - proxmox_firewall_info 24 | - proxmox_group 25 | - proxmox_group_info 26 | - proxmox_ipam_info 27 | - proxmox_kvm 28 | - proxmox_nic 29 | - proxmox_node 30 | - proxmox_node_info 31 | - proxmox_node_network 32 | - proxmox_node_network_info 33 | - proxmox_pool 34 | - proxmox_pool_member 35 | - proxmox_snap 36 | - proxmox_storage 37 | - proxmox_storage_contents_info 38 | - proxmox_storage_info 39 | - proxmox_subnet 40 | - proxmox_tasks_info 41 | - proxmox_template 42 | - proxmox_user 43 | - proxmox_user_info 44 | - proxmox_vm_info 45 | - proxmox_vnet 46 | - proxmox_vnet_info 47 | - proxmox_zone 48 | - proxmox_zone_info 49 | -------------------------------------------------------------------------------- /plugins/plugin_utils/unsafe.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Felix Fontein 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | import re 9 | 10 | from collections.abc import Mapping, Set 11 | from ansible.module_utils.common.collections import is_sequence 12 | from ansible.utils.unsafe_proxy import ( 13 | AnsibleUnsafe, 14 | wrap_var as _make_unsafe, 15 | ) 16 | 17 | _RE_TEMPLATE_CHARS = re.compile('[{}]') 18 | _RE_TEMPLATE_CHARS_BYTES = re.compile(b'[{}]') 19 | 20 | 21 | def make_unsafe(value): 22 | if value is None or isinstance(value, AnsibleUnsafe): 23 | return value 24 | 25 | if isinstance(value, Mapping): 26 | return {make_unsafe(key): make_unsafe(val) for key, val in value.items()} 27 | elif isinstance(value, Set): 28 | return set(make_unsafe(elt) for elt in value) 29 | elif is_sequence(value): 30 | return type(value)(make_unsafe(elt) for elt in value) 31 | elif isinstance(value, bytes): 32 | if _RE_TEMPLATE_CHARS_BYTES.search(value): 33 | value = _make_unsafe(value) 34 | return value 35 | elif isinstance(value, str): 36 | if _RE_TEMPLATE_CHARS.search(value): 37 | value = _make_unsafe(value) 38 | return value 39 | 40 | return value 41 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_lxc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Ryan Smith 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from unittest.mock import MagicMock, patch 8 | from ansible.module_utils.basic import AnsibleModule 9 | from ansible_collections.community.proxmox.plugins.modules import proxmox 10 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ProxmoxAnsible 11 | from ansible.module_utils.compat.version import LooseVersion 12 | 13 | 14 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 15 | @patch.object(ProxmoxAnsible, "version", return_value=LooseVersion("4.0")) 16 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 17 | @patch.object(ProxmoxAnsible, "module", create=True) 18 | def test_mount_formatting(mock_api, *_): 19 | """Test the process_mount_keys method correctly formats mounts.""" 20 | lxc_ansible = proxmox.ProxmoxLxcAnsible(MagicMock(spec=AnsibleModule)) 21 | mount_volumes = [{ 22 | 'host_path': '/mnt/dir', 23 | 'mountpoint': 'mnt/dir', 24 | 'id': 'mp0', 25 | 'storage': None, 26 | 'volume': None, 27 | 'size': None, 28 | 'options': None, 29 | }] 30 | mounts = lxc_ansible.process_mount_keys( 31 | 100, "my-node", None, mount_volumes 32 | ) 33 | assert mounts == {'mp0': '/mnt/dir,mp=mnt/dir'} 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Contributing 8 | 9 | Refer to the [Ansible community guide](https://docs.ansible.com/ansible/devel/community/index.html). 10 | 11 | # Making changelogs 12 | When you make a change, please add a changelog fragment in [changelogs](changelogs), see below for some examples: 13 | 14 | * Minor change, bugfixes or anything else small that does break existing tasks: 15 | ``` 16 | --- 17 | minor_changes: 18 | - module name - short description of the change, PR title could be fine (https://github.com/ansible-collections/community.proxmox/issues/XXX, https://github.com/ansible-collections/community.proxmox/pull/XXX). 19 | ``` 20 | 21 | * Breaking changes, anything that requires end-users to change something on their end as well: 22 | ``` 23 | --- 24 | breaking_changes: 25 | - module name - will start eating your dog without ``dont_eat_dog: true`` (https://github.com/ansible-collections/community.proxmox/issues/XXX, https://github.com/ansible-collections/community.proxmox/pull/XXX). 26 | ``` 27 | 28 | * Removed features: 29 | ``` 30 | --- 31 | removed_features: 32 | - Description of removed feature, module etc (https://github.com/ansible-collections/community.proxmox/issues/XXX, https://github.com/ansible-collections/community.proxmox/pull/XXX). 33 | ``` 34 | 35 | * Changelog entries for new modules and plugins are automatically generated (based on `version_added`), so do not add changelog fragments for them. 36 | -------------------------------------------------------------------------------- /tests/integration/targets/connection/test_connection.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | - hosts: "{{ target_hosts }}" 7 | gather_facts: false 8 | serial: 1 9 | tasks: 10 | 11 | ### raw with unicode arg and output 12 | 13 | - name: raw with unicode arg and output 14 | raw: echo 汉语 15 | register: command 16 | - name: check output of raw with unicode arg and output 17 | assert: 18 | that: 19 | - "'汉语' in command.stdout" 20 | - command is changed # as of 2.2, raw should default to changed: true for consistency w/ shell/command/script modules 21 | 22 | ### copy local file with unicode filename and content 23 | 24 | - name: create local file with unicode filename and content 25 | local_action: lineinfile dest={{ local_tmp }}-汉语/汉语.txt create=true line=汉语 26 | - name: remove remote file with unicode filename and content 27 | action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语/汉语.txt state=absent" 28 | - name: create remote directory with unicode name 29 | action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语 state=directory" 30 | - name: copy local file with unicode filename and content 31 | action: "{{ action_prefix }}copy src={{ local_tmp }}-汉语/汉语.txt dest={{ remote_tmp }}-汉语/汉语.txt" 32 | 33 | ### fetch remote file with unicode filename and content 34 | 35 | - name: remove local file with unicode filename and content 36 | local_action: file path={{ local_tmp }}-汉语/汉语.txt state=absent 37 | - name: fetch remote file with unicode filename and content 38 | fetch: src={{ remote_tmp }}-汉语/汉语.txt dest={{ local_tmp }}-汉语/汉语.txt fail_on_missing=true validate_checksum=true flat=true 39 | 40 | ### remove local and remote temp files 41 | 42 | - name: remove local temp file 43 | local_action: file path={{ local_tmp }}-汉语 state=absent 44 | - name: remove remote temp file 45 | action: "{{ action_prefix }}file path={{ remote_tmp }}-汉语 state=absent" 46 | 47 | ### test wait_for_connection plugin 48 | - ansible.builtin.wait_for_connection: 49 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2023, Sergei Antipov 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | import os 12 | from unittest.mock import patch, Mock 13 | 14 | import pytest 15 | 16 | proxmoxer = pytest.importorskip('proxmoxer') 17 | 18 | from ansible_collections.community.proxmox.plugins.modules import proxmox_template 19 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 20 | AnsibleFailJson, 21 | ModuleTestCase, 22 | set_module_args, 23 | ) 24 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 25 | 26 | 27 | class TestProxmoxTemplateModule(ModuleTestCase): 28 | def setUp(self): 29 | super(TestProxmoxTemplateModule, self).setUp() 30 | proxmox_utils.HAS_PROXMOXER = True 31 | self.module = proxmox_template 32 | self.connect_mock = patch( 33 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect" 34 | ) 35 | self.connect_mock.start() 36 | 37 | def tearDown(self): 38 | self.connect_mock.stop() 39 | super(TestProxmoxTemplateModule, self).tearDown() 40 | 41 | @patch("os.stat") 42 | @patch.multiple(os.path, exists=Mock(return_value=True), isfile=Mock(return_value=True)) 43 | def test_module_fail_when_toolbelt_not_installed_and_file_size_is_big(self, mock_stat): 44 | self.module.HAS_REQUESTS_TOOLBELT = False 45 | mock_stat.return_value.st_size = 268435460 46 | with set_module_args( 47 | { 48 | "api_host": "host", 49 | "api_user": "user", 50 | "api_password": "password", 51 | "node": "pve", 52 | "src": "/tmp/mock.iso", 53 | "content_type": "iso" 54 | } 55 | ): 56 | with pytest.raises(AnsibleFailJson) as exc_info: 57 | self.module.main() 58 | 59 | result = exc_info.value.args[0] 60 | assert result["failed"] is True 61 | assert "upload files larger than 256MB" in result["msg"] 62 | -------------------------------------------------------------------------------- /docs/docsite/links.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright (c) Ansible Project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | # This will make sure that plugin and module documentation gets Edit on GitHub links 7 | # that allow users to directly create a PR for this plugin or module in GitHub's UI. 8 | # Remove this section if the collection repository is not on GitHub, or if you do not want this 9 | # functionality for your collection. 10 | edit_on_github: 11 | repository: ansible-collections/community.proxmox 12 | branch: main 13 | # If your collection root (the directory containing galaxy.yml) does not coincide with your 14 | # repository's root, you have to specify the path to the collection root here. For example, 15 | # if the collection root is in a subdirectory ansible_collections/community/REPO_NAME 16 | # in your repository, you have to set path_prefix to 'ansible_collections/community/REPO_NAME'. 17 | path_prefix: '' 18 | 19 | # Here you can add arbitrary extra links. Please keep the number of links down to a 20 | # minimum! Also please keep the description short, since this will be the text put on 21 | # a button. 22 | # 23 | # Also note that some links are automatically added from information in galaxy.yml. 24 | # The following are automatically added: 25 | # 1. A link to the issue tracker (if `issues` is specified); 26 | # 2. A link to the homepage (if `homepage` is specified and does not equal the 27 | # `documentation` or `repository` link); 28 | # 3. A link to the collection's repository (if `repository` is specified). 29 | 30 | extra_links: 31 | - description: Report an issue 32 | url: https://github.com/ansible-collections/community.proxmox/issues/new/choose 33 | 34 | # Specify communication channels for your collection. We suggest to not specify more 35 | # than one place for communication per communication tool to avoid confusion. 36 | communication: 37 | matrix_rooms: 38 | - topic: General usage and support questions 39 | room: '#users:ansible.im' 40 | # You can also add a `subscribe` field with an URI that allows to subscribe 41 | # to the mailing list. For lists on https://groups.google.com/ a subscribe link is 42 | # automatically generated. 43 | forums: 44 | - topic: Ansible Forum 45 | # The following URL directly points to the "Get Help" section 46 | url: https://forum.ansible.com/c/help/6 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright (c) Ansible Project 2 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | /tests/output/ 6 | /changelogs/.plugin-cache.yaml 7 | /.nox/ 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | -------------------------------------------------------------------------------- /plugins/doc_fragments/attributes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) Ansible Project 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | 11 | class ModuleDocFragment(object): 12 | 13 | # Standard documentation fragment 14 | DOCUMENTATION = r""" 15 | options: {} 16 | attributes: 17 | check_mode: 18 | description: Can run in C(check_mode) and return changed status prediction without modifying target. 19 | diff_mode: 20 | description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. 21 | """ 22 | 23 | PLATFORM = r""" 24 | options: {} 25 | attributes: 26 | platform: 27 | description: Target OS/families that can be operated against. 28 | support: N/A 29 | """ 30 | 31 | # Should be used together with the standard fragment 32 | INFO_MODULE = r''' 33 | options: {} 34 | attributes: 35 | check_mode: 36 | support: full 37 | details: 38 | - This action does not modify state. 39 | diff_mode: 40 | support: N/A 41 | details: 42 | - This action does not modify state. 43 | ''' 44 | 45 | CONN = r""" 46 | options: {} 47 | attributes: 48 | become: 49 | description: Is usable alongside C(become) keywords. 50 | connection: 51 | description: Uses the target's configured connection information to execute code on it. 52 | delegation: 53 | description: Can be used in conjunction with C(delegate_to) and related keywords. 54 | """ 55 | 56 | FACTS = r""" 57 | options: {} 58 | attributes: 59 | facts: 60 | description: Action returns an C(ansible_facts) dictionary that will update existing host facts. 61 | """ 62 | 63 | # Should be used together with the standard fragment and the FACTS fragment 64 | FACTS_MODULE = r''' 65 | options: {} 66 | attributes: 67 | check_mode: 68 | support: full 69 | details: 70 | - This action does not modify state. 71 | diff_mode: 72 | support: N/A 73 | details: 74 | - This action does not modify state. 75 | facts: 76 | support: full 77 | ''' 78 | 79 | FILES = r""" 80 | options: {} 81 | attributes: 82 | safe_file_operations: 83 | description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. 84 | """ 85 | 86 | FLOW = r""" 87 | options: {} 88 | attributes: 89 | action: 90 | description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. 91 | async: 92 | description: Supports being used with the C(async) keyword. 93 | """ 94 | -------------------------------------------------------------------------------- /plugins/doc_fragments/proxmox.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) Ansible project 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) 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | 10 | class ModuleDocFragment(object): 11 | # Common parameters for Proxmox VE modules 12 | DOCUMENTATION = r""" 13 | options: 14 | api_host: 15 | description: 16 | - Specify the target host of the Proxmox VE cluster. 17 | - Uses the E(PROXMOX_HOST) environment variable if not specified. 18 | type: str 19 | required: true 20 | api_port: 21 | description: 22 | - Specify the target port of the Proxmox VE cluster. 23 | - Uses the E(PROXMOX_PORT) environment variable if not specified. 24 | type: int 25 | required: false 26 | api_user: 27 | description: 28 | - Specify the user to authenticate with. 29 | - Uses the E(PROXMOX_USER) environment variable if not specified. 30 | type: str 31 | required: true 32 | api_password: 33 | description: 34 | - Specify the password to authenticate with. 35 | - Uses the E(PROXMOX_PASSWORD) environment variable if not specified. 36 | type: str 37 | api_token_id: 38 | description: 39 | - Specify the token ID. 40 | - Uses the E(PROXMOX_TOKEN_ID) environment variable if not specified. 41 | type: str 42 | api_token_secret: 43 | description: 44 | - Specify the token secret. 45 | - Uses the E(PROXMOX_TOKEN_SECRET) environment variable if not specified. 46 | type: str 47 | validate_certs: 48 | description: 49 | - If V(false), SSL certificates will not be validated. 50 | - This should only be used on personally controlled sites using self-signed certificates. 51 | - Uses the E(PROXMOX_VALIDATE_CERTS) environment variable if not specified. 52 | type: bool 53 | default: false 54 | requirements: ["proxmoxer >= 2.0", "requests"] 55 | """ 56 | 57 | SELECTION = r""" 58 | options: 59 | vmid: 60 | description: 61 | - Specifies the instance ID. 62 | - If not set the next available ID will be fetched from ProxmoxAPI. 63 | type: int 64 | node: 65 | description: 66 | - Proxmox VE node on which to operate. 67 | - Only required for O(state=present). 68 | - For every other states it will be autodiscovered. 69 | type: str 70 | pool: 71 | description: 72 | - Add the new VM to the specified pool. 73 | type: str 74 | """ 75 | 76 | ACTIONGROUP_PROXMOX = r""" 77 | options: {} 78 | attributes: 79 | action_group: 80 | description: Use C(group/community.proxmox.proxmox) in C(module_defaults) to set defaults for this module. 81 | support: full 82 | membership: 83 | - community.proxmox.proxmox 84 | """ 85 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_storage_contents_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2023, Julian Vanden Broeck 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_storage_contents_info 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | AnsibleExitJson, 20 | AnsibleFailJson, 21 | ModuleTestCase, 22 | set_module_args, 23 | ) 24 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 25 | 26 | NODE1 = "pve" 27 | RAW_LIST_OUTPUT = [ 28 | { 29 | "content": "backup", 30 | "ctime": 1702528474, 31 | "format": "pbs-vm", 32 | "size": 273804166061, 33 | "subtype": "qemu", 34 | "vmid": 931, 35 | "volid": "datastore:backup/vm/931/2023-12-14T04:34:34Z", 36 | }, 37 | { 38 | "content": "backup", 39 | "ctime": 1702582560, 40 | "format": "pbs-vm", 41 | "size": 273804166059, 42 | "subtype": "qemu", 43 | "vmid": 931, 44 | "volid": "datastore:backup/vm/931/2023-12-14T19:36:00Z", 45 | }, 46 | ] 47 | 48 | 49 | def get_module_args(node, storage, content="all", vmid=None): 50 | return { 51 | "api_host": "host", 52 | "api_user": "user", 53 | "api_password": "password", 54 | "node": node, 55 | "storage": storage, 56 | "content": content, 57 | "vmid": vmid, 58 | } 59 | 60 | 61 | class TestProxmoxStorageContentsInfo(ModuleTestCase): 62 | def setUp(self): 63 | super(TestProxmoxStorageContentsInfo, self).setUp() 64 | proxmox_utils.HAS_PROXMOXER = True 65 | self.module = proxmox_storage_contents_info 66 | self.connect_mock = patch( 67 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 68 | ).start() 69 | self.connect_mock.return_value.nodes.return_value.storage.return_value.content.return_value.get.return_value = ( 70 | RAW_LIST_OUTPUT 71 | ) 72 | self.connect_mock.return_value.nodes.get.return_value = [{"node": NODE1}] 73 | 74 | def tearDown(self): 75 | self.connect_mock.stop() 76 | super(TestProxmoxStorageContentsInfo, self).tearDown() 77 | 78 | def test_module_fail_when_required_args_missing(self): 79 | with pytest.raises(AnsibleFailJson) as exc_info: 80 | with set_module_args({}): 81 | self.module.main() 82 | 83 | def test_storage_contents_info(self): 84 | with pytest.raises(AnsibleExitJson) as exc_info: 85 | with set_module_args(get_module_args(node=NODE1, storage="datastore")): 86 | expected_output = {} 87 | self.module.main() 88 | 89 | result = exc_info.value.args[0] 90 | assert not result["changed"] 91 | assert result["proxmox_storage_content"] == RAW_LIST_OUTPUT 92 | -------------------------------------------------------------------------------- /plugins/module_utils/_filelock.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018, Ansible Project 2 | # Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | 5 | # NOTE: 6 | # This has been vendored from ansible.module_utils.common.file. This code has been removed from there for ansible-core 2.16. 7 | 8 | from __future__ import (absolute_import, division, print_function) 9 | __metaclass__ = type 10 | 11 | import os 12 | import stat 13 | import time 14 | import fcntl 15 | import sys 16 | 17 | from contextlib import contextmanager 18 | 19 | 20 | class LockTimeout(Exception): 21 | pass 22 | 23 | 24 | class FileLock: 25 | ''' 26 | Currently FileLock is implemented via fcntl.flock on a lock file, however this 27 | behaviour may change in the future. Avoid mixing lock types fcntl.flock, 28 | fcntl.lockf and module_utils.common.file.FileLock as it will certainly cause 29 | unwanted and/or unexpected behaviour 30 | ''' 31 | def __init__(self): 32 | self.lockfd = None 33 | 34 | @contextmanager 35 | def lock_file(self, path, tmpdir, lock_timeout=None): 36 | ''' 37 | Context for lock acquisition 38 | ''' 39 | try: 40 | self.set_lock(path, tmpdir, lock_timeout) 41 | yield 42 | finally: 43 | self.unlock() 44 | 45 | def set_lock(self, path, tmpdir, lock_timeout=None): 46 | ''' 47 | Create a lock file based on path with flock to prevent other processes 48 | using given path. 49 | Please note that currently file locking only works when it is executed by 50 | the same user, for example single user scenarios 51 | 52 | :kw path: Path (file) to lock 53 | :kw tmpdir: Path where to place the temporary .lock file 54 | :kw lock_timeout: 55 | Wait n seconds for lock acquisition, fail if timeout is reached. 56 | 0 = Do not wait, fail if lock cannot be acquired immediately, 57 | Default is None, wait indefinitely until lock is released. 58 | :returns: True 59 | ''' 60 | lock_path = os.path.join(tmpdir, 'ansible-{0}.lock'.format(os.path.basename(path))) 61 | l_wait = 0.1 62 | r_exception = IOError 63 | if sys.version_info[0] == 3: 64 | r_exception = BlockingIOError 65 | 66 | self.lockfd = open(lock_path, 'w') 67 | 68 | if lock_timeout <= 0: 69 | fcntl.flock(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB) 70 | os.chmod(lock_path, stat.S_IWRITE | stat.S_IREAD) 71 | return True 72 | 73 | if lock_timeout: 74 | e_secs = 0 75 | while e_secs < lock_timeout: 76 | try: 77 | fcntl.flock(self.lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB) 78 | os.chmod(lock_path, stat.S_IWRITE | stat.S_IREAD) 79 | return True 80 | except r_exception: 81 | time.sleep(l_wait) 82 | e_secs += l_wait 83 | continue 84 | 85 | self.lockfd.close() 86 | raise LockTimeout('{0} sec'.format(lock_timeout)) 87 | 88 | fcntl.flock(self.lockfd, fcntl.LOCK_EX) 89 | os.chmod(lock_path, stat.S_IWRITE | stat.S_IREAD) 90 | 91 | return True 92 | 93 | def unlock(self): 94 | ''' 95 | Make sure lock file is available for everyone and Unlock the file descriptor 96 | locked by set_lock 97 | 98 | :returns: True 99 | ''' 100 | if not self.lockfd: 101 | return True 102 | 103 | try: 104 | fcntl.flock(self.lockfd, fcntl.LOCK_UN) 105 | self.lockfd.close() 106 | except ValueError: # file wasn't opened, let context manager fail gracefully 107 | pass 108 | 109 | return True 110 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_zone_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch, Mock 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_zone_info 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | ModuleTestCase, 20 | set_module_args, 21 | ) 22 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 23 | 24 | RAW_ZONES = [ 25 | { 26 | "zone": "ans1", 27 | "digest": "e3105246736ab2420104e34bca1dea68d152acc7", 28 | "ipam": "pve", 29 | "dhcp": "dnsmasq", 30 | "type": "simple" 31 | }, 32 | { 33 | "type": "vlan", 34 | "zone": "lab", 35 | "digest": "e3105246736ab2420104e34bca1dea68d152acc7", 36 | "ipam": "pve", 37 | "bridge": "vmbr100" 38 | }, 39 | { 40 | "digest": "e3105246736ab2420104e34bca1dea68d152acc7", 41 | "ipam": "pve", 42 | "zone": "test1", 43 | "type": "simple", 44 | "dhcp": "dnsmasq" 45 | } 46 | ] 47 | 48 | 49 | def exit_json(*args, **kwargs): 50 | """function to patch over exit_json; package return data into an exception""" 51 | if 'changed' not in kwargs: 52 | kwargs['changed'] = False 53 | raise SystemExit(kwargs) 54 | 55 | 56 | def fail_json(*args, **kwargs): 57 | """function to patch over fail_json; package return data into an exception""" 58 | kwargs['failed'] = True 59 | raise SystemExit(kwargs) 60 | 61 | 62 | def get_module_args_state_none(): 63 | return { 64 | 'api_host': 'host', 65 | 'api_user': 'user', 66 | 'api_password': 'password', 67 | } 68 | 69 | 70 | def get_module_args_zone(zone_type, zone, state='present', update=True, bridge=None): 71 | return { 72 | 'api_host': 'host', 73 | 'api_user': 'user', 74 | 'api_password': 'password', 75 | 'type': zone_type, 76 | 'zone': zone, 77 | 'state': state, 78 | 'update': update, 79 | 'bridge': bridge 80 | } 81 | 82 | 83 | class TestProxmoxZoneInfoModule(ModuleTestCase): 84 | def setUp(self): 85 | super(TestProxmoxZoneInfoModule, self).setUp() 86 | proxmox_utils.HAS_PROXMOXER = True 87 | self.module = proxmox_zone_info 88 | self.fail_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.fail_json', 89 | new=Mock(side_effect=fail_json)) 90 | self.exit_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.exit_json', new=exit_json) 91 | 92 | self.fail_json_mock = self.fail_json_patcher.start() 93 | self.exit_json_patcher.start() 94 | self.connect_mock = patch( 95 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 96 | ).start() 97 | self.connect_mock.return_value.cluster.return_value.sdn.return_value.zones.return_value.get.return_value = RAW_ZONES 98 | 99 | def tearDown(self): 100 | self.connect_mock.stop() 101 | self.exit_json_patcher.stop() 102 | self.fail_json_patcher.stop() 103 | super(TestProxmoxZoneInfoModule, self).tearDown() 104 | 105 | def test_get_zones(self): 106 | with pytest.raises(SystemExit) as exc_info: 107 | with set_module_args(get_module_args_state_none()): 108 | self.module.main() 109 | result = exc_info.value.args[0] 110 | assert result["changed"] is False 111 | assert result["msg"] == "Successfully retrieved zone info." 112 | assert result["zones"] == RAW_ZONES 113 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_domain_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright Tristan Le Guern (@tleguern) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_domain_info 14 | short_description: Retrieve information about one or more Proxmox VE domains 15 | description: 16 | - Retrieve information about one or more Proxmox VE domains. 17 | options: 18 | domain: 19 | description: 20 | - Restrict results to a specific authentication realm. 21 | aliases: ['realm', 'name'] 22 | type: str 23 | author: Tristan Le Guern (@tleguern) 24 | extends_documentation_fragment: 25 | - community.proxmox.proxmox.actiongroup_proxmox 26 | - community.proxmox.proxmox.documentation 27 | - community.proxmox.attributes 28 | - community.proxmox.attributes.info_module 29 | """ 30 | 31 | 32 | EXAMPLES = r""" 33 | - name: List existing domains 34 | community.proxmox.proxmox_domain_info: 35 | api_host: helldorado 36 | api_user: root@pam 37 | api_password: "{{ password | default(omit) }}" 38 | api_token_id: "{{ token_id | default(omit) }}" 39 | api_token_secret: "{{ token_secret | default(omit) }}" 40 | register: proxmox_domains 41 | 42 | - name: Retrieve information about the pve domain 43 | community.proxmox.proxmox_domain_info: 44 | api_host: helldorado 45 | api_user: root@pam 46 | api_password: "{{ password | default(omit) }}" 47 | api_token_id: "{{ token_id | default(omit) }}" 48 | api_token_secret: "{{ token_secret | default(omit) }}" 49 | domain: pve 50 | register: proxmox_domain_pve 51 | """ 52 | 53 | 54 | RETURN = r""" 55 | proxmox_domains: 56 | description: List of authentication domains. 57 | returned: always, but can be empty 58 | type: list 59 | elements: dict 60 | contains: 61 | comment: 62 | description: Short description of the realm. 63 | returned: on success 64 | type: str 65 | realm: 66 | description: Realm name. 67 | returned: on success 68 | type: str 69 | type: 70 | description: Realm type. 71 | returned: on success 72 | type: str 73 | digest: 74 | description: Realm hash. 75 | returned: on success, can be absent 76 | type: str 77 | """ 78 | 79 | 80 | from ansible.module_utils.basic import AnsibleModule 81 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 82 | proxmox_auth_argument_spec, ProxmoxAnsible) 83 | 84 | 85 | class ProxmoxDomainInfoAnsible(ProxmoxAnsible): 86 | def get_domain(self, realm): 87 | try: 88 | domain = self.proxmox_api.access.domains.get(realm) 89 | except Exception: 90 | self.module.fail_json(msg="Domain '%s' does not exist" % realm) 91 | domain['realm'] = realm 92 | return domain 93 | 94 | def get_domains(self): 95 | domains = self.proxmox_api.access.domains.get() 96 | return domains 97 | 98 | 99 | def proxmox_domain_info_argument_spec(): 100 | return dict( 101 | domain=dict(type='str', aliases=['realm', 'name']), 102 | ) 103 | 104 | 105 | def main(): 106 | module_args = proxmox_auth_argument_spec() 107 | domain_info_args = proxmox_domain_info_argument_spec() 108 | module_args.update(domain_info_args) 109 | 110 | module = AnsibleModule( 111 | argument_spec=module_args, 112 | required_one_of=[('api_password', 'api_token_id')], 113 | required_together=[('api_token_id', 'api_token_secret')], 114 | supports_check_mode=True 115 | ) 116 | result = dict( 117 | changed=False 118 | ) 119 | 120 | proxmox = ProxmoxDomainInfoAnsible(module) 121 | domain = module.params['domain'] 122 | 123 | if domain: 124 | domains = [proxmox.get_domain(realm=domain)] 125 | else: 126 | domains = proxmox.get_domains() 127 | result['proxmox_domains'] = domains 128 | 129 | module.exit_json(**result) 130 | 131 | 132 | if __name__ == '__main__': 133 | main() 134 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_cluster.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2025, Florian Paul Azim Hoberg (@gyptazy) 3 | # 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | import pytest 8 | from unittest.mock import MagicMock, patch 9 | from ansible.module_utils.basic import AnsibleModule 10 | from ansible_collections.community.proxmox.plugins.modules import proxmox_cluster 11 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ProxmoxAnsible 12 | from ansible_collections.community.proxmox.plugins.modules.proxmox_cluster import validate_cluster_name 13 | 14 | 15 | @pytest.fixture 16 | def module_args_join(): 17 | return { 18 | "api_host": "10.10.10.76", 19 | "api_user": "root@pam", 20 | "api_password": "secret", 21 | "state": "present", 22 | "master_ip": "10.10.10.75", 23 | "fingerprint": "BD:D0:A4:04:E6:05:30:74:30:E6:5A:83:78:A8:8F:F7:4C:25:71:DB:07:92:7C:A1:04:B9:CB:12:BB:3C:BE:4D", 24 | "cluster_name": "devcluster" 25 | } 26 | 27 | 28 | @pytest.fixture 29 | def module_args_create(): 30 | return { 31 | "api_host": "10.10.10.76", 32 | "api_user": "root@pam", 33 | "api_password": "secret", 34 | "state": "present", 35 | "cluster_name": "devcluster", 36 | "link0": "10.10.1.1", 37 | "link1": "10.10.2.1", 38 | } 39 | 40 | 41 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 42 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 43 | def test_cluster_join(mock_api, mock_init, module_args_join): 44 | module = MagicMock(spec=AnsibleModule) 45 | module.params = module_args_join 46 | module.check_mode = False 47 | 48 | mock_api_instance = MagicMock() 49 | mock_api.return_value = mock_api_instance 50 | mock_api_instance.cluster.config.join.post.return_value = {} 51 | 52 | module.exit_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 53 | module.fail_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 54 | 55 | proxmox = proxmox_cluster.ProxmoxClusterAnsible(module) 56 | proxmox.module = module 57 | proxmox.proxmox_api = mock_api_instance 58 | 59 | with pytest.raises(SystemExit) as exc: 60 | proxmox.cluster_join() 61 | 62 | result = exc.value.args[0] 63 | assert result["changed"] is True 64 | assert result["msg"] == "Node joined the cluster." 65 | assert result["cluster"] == "devcluster" 66 | 67 | mock_api_instance.cluster.config.join.post.assert_called_once_with( 68 | hostname="10.10.10.75", 69 | fingerprint=module_args_join["fingerprint"], 70 | password="secret" 71 | ) 72 | 73 | 74 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 75 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 76 | def test_cluster_create(mock_api, mock_init, module_args_create): 77 | module = MagicMock(spec=AnsibleModule) 78 | module.params = module_args_create 79 | module.check_mode = False 80 | 81 | mock_api_instance = MagicMock() 82 | mock_api.return_value = mock_api_instance 83 | mock_api_instance.cluster.config.nodes.get.return_value = [] 84 | mock_api_instance.cluster.config.post.return_value = {} 85 | 86 | module.exit_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 87 | module.fail_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 88 | 89 | proxmox = proxmox_cluster.ProxmoxClusterAnsible(module) 90 | proxmox.module = module 91 | proxmox.proxmox_api = mock_api_instance 92 | 93 | with pytest.raises(SystemExit) as exc: 94 | proxmox.cluster_create() 95 | 96 | result = exc.value.args[0] 97 | assert result["changed"] is True 98 | assert result["msg"] == "Cluster 'devcluster' created." 99 | assert result["cluster"] == "devcluster" 100 | 101 | expected_payload = { 102 | "clustername": module_args_create["cluster_name"], 103 | "link0": module_args_create["link0"], 104 | "link1": module_args_create["link1"], 105 | } 106 | 107 | mock_api_instance.cluster.config.post.assert_called_once_with(**expected_payload) 108 | 109 | 110 | def test_validate_cluster_name_valid(module_args_create): 111 | module = MagicMock(spec=AnsibleModule) 112 | module.params = module_args_create 113 | 114 | validate_cluster_name(module) 115 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_zone_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2025, Jana Hoch 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | 10 | __metaclass__ = type 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_zone_info 14 | short_description: Get Proxmox zone info. 15 | description: 16 | - List all available zones. 17 | version_added: "1.4.0" 18 | author: 'Jana Hoch (!UNKNOWN)' 19 | options: 20 | type: 21 | description: 22 | - Filter zones on based on type. 23 | type: str 24 | choices: 25 | - evpn 26 | - faucet 27 | - qinq 28 | - simple 29 | - vlan 30 | - vxlan 31 | extends_documentation_fragment: 32 | - community.proxmox.proxmox.actiongroup_proxmox 33 | - community.proxmox.proxmox.documentation 34 | - community.proxmox.attributes 35 | - community.proxmox.attributes.info_module 36 | """ 37 | 38 | EXAMPLES = r""" 39 | - name: Get all zones 40 | community.proxmox.proxmox_zone_info: 41 | api_user: "root@pam" 42 | api_password: "{{ vault.proxmox.root_password }}" 43 | api_host: "{{ pc.proxmox.api_host }}" 44 | validate_certs: false 45 | 46 | - name: Get all simple zones 47 | community.proxmox.proxmox_zone_info: 48 | api_user: "root@pam" 49 | api_password: "{{ vault.proxmox.root_password }}" 50 | api_host: "{{ pc.proxmox.api_host }}" 51 | validate_certs: false 52 | type: simple 53 | register: zones 54 | """ 55 | 56 | RETURN = r""" 57 | zones: 58 | description: 59 | - List of zones. 60 | - If type is passed it'll filter based on type 61 | returned: on success 62 | type: list 63 | elements: dict 64 | sample: 65 | [ 66 | { 67 | "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", 68 | "type": "simple", 69 | "zone": "ans1" 70 | }, 71 | { 72 | "bridge": "vmbr0", 73 | "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", 74 | "mtu": 1200, 75 | "type": "vlan", 76 | "zone": "ansible" 77 | }, 78 | { 79 | "bridge": "vmbr100", 80 | "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", 81 | "ipam": "pve", 82 | "type": "vlan", 83 | "zone": "lab" 84 | }, 85 | { 86 | "dhcp": "dnsmasq", 87 | "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", 88 | "ipam": "pve", 89 | "type": "simple", 90 | "zone": "test1" 91 | }, 92 | { 93 | "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", 94 | "ipam": "pve", 95 | "type": "simple", 96 | "zone": "tsjsfv" 97 | } 98 | ] 99 | 100 | """ 101 | 102 | from ansible.module_utils.basic import AnsibleModule 103 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox_sdn import ProxmoxSdnAnsible 104 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import proxmox_auth_argument_spec 105 | 106 | 107 | def get_proxmox_args(): 108 | return dict( 109 | type=dict(type='str', choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan"], required=False) 110 | ) 111 | 112 | 113 | def get_ansible_module(): 114 | module_args = proxmox_auth_argument_spec() 115 | module_args.update(get_proxmox_args()) 116 | return AnsibleModule( 117 | argument_spec=module_args, 118 | supports_check_mode=True, 119 | ) 120 | 121 | 122 | class ProxmoxZoneInfoAnsible(ProxmoxSdnAnsible): 123 | def __init__(self, module): 124 | super(ProxmoxZoneInfoAnsible, self).__init__(module) 125 | self.params = module.params 126 | 127 | def run(self): 128 | zones = self.get_zones( 129 | zone_type=self.params.get('type') 130 | ) 131 | self.module.exit_json( 132 | changed=False, zones=zones, msg="Successfully retrieved zone info." 133 | ) 134 | 135 | 136 | def main(): 137 | module = get_ansible_module() 138 | proxmox = ProxmoxZoneInfoAnsible(module) 139 | 140 | try: 141 | proxmox.run() 142 | except Exception as e: 143 | module.fail_json(msg=f'An error occurred: {e}') 144 | 145 | 146 | if __name__ == "__main__": 147 | main() 148 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_snap.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2019, Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | import json 10 | from unittest.mock import MagicMock, patch 11 | 12 | import pytest 13 | 14 | proxmoxer = pytest.importorskip('proxmoxer') 15 | 16 | from ansible_collections.community.proxmox.plugins.modules import proxmox_snap 17 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args 19 | 20 | 21 | def get_resources(type): 22 | return [{"diskwrite": 0, 23 | "vmid": 100, 24 | "node": "localhost", 25 | "id": "lxc/100", 26 | "maxdisk": 10000, 27 | "template": 0, 28 | "disk": 10000, 29 | "uptime": 10000, 30 | "maxmem": 10000, 31 | "maxcpu": 1, 32 | "netin": 10000, 33 | "type": "lxc", 34 | "netout": 10000, 35 | "mem": 10000, 36 | "diskread": 10000, 37 | "cpu": 0.01, 38 | "name": "test-lxc", 39 | "status": "running"}] 40 | 41 | 42 | def fake_api(mocker): 43 | r = mocker.MagicMock() 44 | r.cluster.resources.get = MagicMock(side_effect=get_resources) 45 | return r 46 | 47 | 48 | def test_proxmox_snap_without_argument(capfd): 49 | with set_module_args({}): 50 | with pytest.raises(SystemExit) as results: 51 | proxmox_snap.main() 52 | 53 | out, err = capfd.readouterr() 54 | assert not err 55 | assert json.loads(out)['failed'] 56 | 57 | 58 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 59 | def test_create_snapshot_check_mode(connect_mock, capfd, mocker): 60 | with set_module_args({ 61 | "hostname": "test-lxc", 62 | "api_user": "root@pam", 63 | "api_password": "secret", 64 | "api_host": "127.0.0.1", 65 | "state": "present", 66 | "snapname": "test", 67 | "timeout": "1", 68 | "force": True, 69 | "_ansible_check_mode": True 70 | }): 71 | proxmox_utils.HAS_PROXMOXER = True 72 | connect_mock.side_effect = lambda: fake_api(mocker) 73 | with pytest.raises(SystemExit) as results: 74 | proxmox_snap.main() 75 | 76 | out, err = capfd.readouterr() 77 | assert not err 78 | assert not json.loads(out)['changed'] 79 | 80 | 81 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 82 | def test_remove_snapshot_check_mode(connect_mock, capfd, mocker): 83 | with set_module_args({ 84 | "hostname": "test-lxc", 85 | "api_user": "root@pam", 86 | "api_password": "secret", 87 | "api_host": "127.0.0.1", 88 | "state": "absent", 89 | "snapname": "test", 90 | "timeout": "1", 91 | "force": True, 92 | "_ansible_check_mode": True 93 | }): 94 | proxmox_utils.HAS_PROXMOXER = True 95 | connect_mock.side_effect = lambda: fake_api(mocker) 96 | with pytest.raises(SystemExit) as results: 97 | proxmox_snap.main() 98 | 99 | out, err = capfd.readouterr() 100 | assert not err 101 | assert not json.loads(out)['changed'] 102 | 103 | 104 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 105 | def test_rollback_snapshot_check_mode(connect_mock, capfd, mocker): 106 | with set_module_args({ 107 | "hostname": "test-lxc", 108 | "api_user": "root@pam", 109 | "api_password": "secret", 110 | "api_host": "127.0.0.1", 111 | "state": "rollback", 112 | "snapname": "test", 113 | "timeout": "1", 114 | "force": True, 115 | "_ansible_check_mode": True 116 | }): 117 | proxmox_utils.HAS_PROXMOXER = True 118 | connect_mock.side_effect = lambda: fake_api(mocker) 119 | with pytest.raises(SystemExit) as results: 120 | proxmox_snap.main() 121 | 122 | out, err = capfd.readouterr() 123 | assert not err 124 | output = json.loads(out) 125 | assert not output['changed'] 126 | assert output['msg'] == "Snapshot test does not exist" 127 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_group_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright Tristan Le Guern 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_group_info 14 | short_description: Retrieve information about one or more Proxmox VE groups 15 | description: 16 | - Retrieve information about one or more Proxmox VE groups. 17 | options: 18 | group: 19 | description: 20 | - Restrict results to a specific group. 21 | aliases: ['groupid', 'name'] 22 | type: str 23 | author: Tristan Le Guern (@tleguern) 24 | extends_documentation_fragment: 25 | - community.proxmox.proxmox.actiongroup_proxmox 26 | - community.proxmox.proxmox.documentation 27 | - community.proxmox.attributes 28 | - community.proxmox.attributes.info_module 29 | """ 30 | 31 | 32 | EXAMPLES = r""" 33 | - name: List existing groups 34 | community.proxmox.proxmox_group_info: 35 | api_host: helldorado 36 | api_user: root@pam 37 | api_password: "{{ password | default(omit) }}" 38 | api_token_id: "{{ token_id | default(omit) }}" 39 | api_token_secret: "{{ token_secret | default(omit) }}" 40 | register: proxmox_groups 41 | 42 | - name: Retrieve information about the admin group 43 | community.proxmox.proxmox_group_info: 44 | api_host: helldorado 45 | api_user: root@pam 46 | api_password: "{{ password | default(omit) }}" 47 | api_token_id: "{{ token_id | default(omit) }}" 48 | api_token_secret: "{{ token_secret | default(omit) }}" 49 | group: admin 50 | register: proxmox_group_admin 51 | """ 52 | 53 | 54 | RETURN = r""" 55 | proxmox_groups: 56 | description: List of groups. 57 | returned: always, but can be empty 58 | type: list 59 | elements: dict 60 | contains: 61 | comment: 62 | description: Short description of the group. 63 | returned: on success, can be absent 64 | type: str 65 | groupid: 66 | description: Group name. 67 | returned: on success 68 | type: str 69 | users: 70 | description: List of users in the group. 71 | returned: on success 72 | type: list 73 | elements: str 74 | """ 75 | 76 | 77 | from ansible.module_utils.basic import AnsibleModule 78 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 79 | proxmox_auth_argument_spec, ProxmoxAnsible) 80 | 81 | 82 | class ProxmoxGroupInfoAnsible(ProxmoxAnsible): 83 | def get_group(self, groupid): 84 | try: 85 | group = self.proxmox_api.access.groups.get(groupid) 86 | except Exception: 87 | self.module.fail_json(msg="Group '%s' does not exist" % groupid) 88 | group['groupid'] = groupid 89 | return ProxmoxGroup(group) 90 | 91 | def get_groups(self): 92 | groups = self.proxmox_api.access.groups.get() 93 | return [ProxmoxGroup(group) for group in groups] 94 | 95 | 96 | class ProxmoxGroup: 97 | def __init__(self, group): 98 | self.group = dict() 99 | # Data representation is not the same depending on API calls 100 | for k, v in group.items(): 101 | if k == 'users' and isinstance(v, str): 102 | self.group['users'] = v.split(',') 103 | elif k == 'members': 104 | self.group['users'] = group['members'] 105 | else: 106 | self.group[k] = v 107 | 108 | 109 | def proxmox_group_info_argument_spec(): 110 | return dict( 111 | group=dict(type='str', aliases=['groupid', 'name']), 112 | ) 113 | 114 | 115 | def main(): 116 | module_args = proxmox_auth_argument_spec() 117 | group_info_args = proxmox_group_info_argument_spec() 118 | module_args.update(group_info_args) 119 | 120 | module = AnsibleModule( 121 | argument_spec=module_args, 122 | required_one_of=[('api_password', 'api_token_id')], 123 | required_together=[('api_token_id', 'api_token_secret')], 124 | supports_check_mode=True 125 | ) 126 | result = dict( 127 | changed=False 128 | ) 129 | 130 | proxmox = ProxmoxGroupInfoAnsible(module) 131 | group = module.params['group'] 132 | 133 | if group: 134 | groups = [proxmox.get_group(groupid=group)] 135 | else: 136 | groups = proxmox.get_groups() 137 | result['proxmox_groups'] = [group.group for group in groups] 138 | 139 | module.exit_json(**result) 140 | 141 | 142 | if __name__ == '__main__': 143 | main() 144 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_cluster_join_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2025, Florian Paul Azim Hoberg (@gyptazy) 3 | # 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | __metaclass__ = type 9 | 10 | import json 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip('proxmoxer') 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_cluster_join_info 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args 19 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 20 | 21 | 22 | JOIN_INFO = { 23 | "config_digest": "111418c98000acfda99059d29cd89123583020a0", 24 | "nodelist": [ 25 | { 26 | "name": "pmx01", 27 | "nodeid": "1", 28 | "pve_addr": "10.10.10.75", 29 | "pve_fp": "95:AC:F2:21:0C:09:A8:5F:06:9A:BD:0D:FB:68:8B:32:4A:26:36:DE:29:23:88:D2:49:C5:BB:91:AB:39:6E:48", 30 | "quorum_votes": "1", 31 | "ring0_addr": "10.10.10.75" 32 | }, 33 | { 34 | "name": "pmxcdev02", 35 | "nodeid": "2", 36 | "pve_addr": "10.10.10.76", 37 | "pve_fp": "BD:D0:A4:04:E6:05:30:74:30:E6:5A:83:78:A8:8F:F7:4C:25:71:DB:07:92:7C:A1:04:B9:CB:12:BB:3C:BE:4D", 38 | "quorum_votes": "1", 39 | "ring0_addr": "10.10.10.76" 40 | } 41 | ], 42 | "preferred_node": "pmxcdev02", 43 | "totem": { 44 | "cluster_name": "devcluster", 45 | "config_version": "2", 46 | "interface": { 47 | "0": { 48 | "linknumber": "0" 49 | } 50 | }, 51 | "ip_version": "ipv4-6", 52 | "link_mode": "passive", 53 | "secauth": "on", 54 | "version": "2" 55 | } 56 | } 57 | 58 | 59 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 60 | def test_without_required_parameters(connect_mock, capfd): 61 | with set_module_args({}): 62 | with pytest.raises(SystemExit): 63 | proxmox_cluster_join_info.main() 64 | out, err = capfd.readouterr() 65 | assert not err 66 | assert json.loads(out)["failed"] 67 | 68 | 69 | def mock_api_join_info(mocker): 70 | cluster = mocker.MagicMock() 71 | config = mocker.MagicMock() 72 | join = mocker.MagicMock() 73 | join.get.return_value = JOIN_INFO 74 | config.join = join 75 | cluster.config = config 76 | 77 | api = mocker.MagicMock() 78 | api.cluster = cluster 79 | return api 80 | 81 | 82 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 83 | def test_cluster_join_success(connect_mock, capfd, mocker): 84 | with set_module_args({ 85 | "api_host": "proxmoxhost", 86 | "api_user": "root@pam", 87 | "api_password": "supersecret" 88 | }): 89 | connect_mock.side_effect = lambda: mock_api_join_info(mocker) 90 | proxmox_utils.HAS_PROXMOXER = True 91 | 92 | with pytest.raises(SystemExit): 93 | proxmox_cluster_join_info.main() 94 | 95 | out, err = capfd.readouterr() 96 | assert not err 97 | result = json.loads(out) 98 | assert result['cluster_join'] == JOIN_INFO 99 | assert result['changed'] is False 100 | 101 | 102 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 103 | def test_cluster_join_node_not_in_cluster(connect_mock, capfd, mocker): 104 | with set_module_args({ 105 | "api_host": "proxmoxhost", 106 | "api_user": "root@pam", 107 | "api_password": "supersecret" 108 | }): 109 | def raise_resource_exception(): 110 | raise proxmoxer.core.ResourceException("Not in cluster") 111 | 112 | cluster = mocker.MagicMock() 113 | config = mocker.MagicMock() 114 | join = mocker.MagicMock() 115 | join.get.side_effect = raise_resource_exception 116 | config.join = join 117 | cluster.config = config 118 | 119 | api = mocker.MagicMock() 120 | api.cluster = cluster 121 | connect_mock.return_value = api 122 | 123 | proxmox_utils.HAS_PROXMOXER = True 124 | 125 | with pytest.raises(SystemExit): 126 | proxmox_cluster_join_info.main() 127 | 128 | out, err = capfd.readouterr() 129 | assert not err 130 | result = json.loads(out) 131 | assert result['failed'] 132 | assert 'join information' in result['msg'] 133 | assert 'cluster_join' not in result 134 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_node_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright John Berninger (@jberning) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_node_info 14 | short_description: Retrieve information about one or more Proxmox VE nodes 15 | description: 16 | - Retrieve information about one or more Proxmox VE nodes. 17 | author: John Berninger (@jwbernin) 18 | extends_documentation_fragment: 19 | - community.proxmox.proxmox.actiongroup_proxmox 20 | - community.proxmox.proxmox.documentation 21 | - community.proxmox.attributes 22 | - community.proxmox.attributes.info_module 23 | """ 24 | 25 | 26 | EXAMPLES = r""" 27 | - name: List existing nodes 28 | community.proxmox.proxmox_node_info: 29 | api_host: proxmox1 30 | api_user: root@pam 31 | api_password: "{{ password | default(omit) }}" 32 | api_token_id: "{{ token_id | default(omit) }}" 33 | api_token_secret: "{{ token_secret | default(omit) }}" 34 | register: proxmox_nodes 35 | """ 36 | 37 | 38 | RETURN = r""" 39 | proxmox_nodes: 40 | description: List of Proxmox VE nodes. 41 | returned: always, but can be empty 42 | type: list 43 | elements: dict 44 | contains: 45 | cpu: 46 | description: Current CPU usage in fractional shares of this host's total available CPU. 47 | returned: on success 48 | type: float 49 | disk: 50 | description: Current local disk usage of this host. 51 | returned: on success 52 | type: int 53 | id: 54 | description: Identity of the node. 55 | returned: on success 56 | type: str 57 | level: 58 | description: Support level. Can be blank if not under a paid support contract. 59 | returned: on success 60 | type: str 61 | maxcpu: 62 | description: Total number of available CPUs on this host. 63 | returned: on success 64 | type: int 65 | maxdisk: 66 | description: Size of local disk in bytes. 67 | returned: on success 68 | type: int 69 | maxmem: 70 | description: Memory size in bytes. 71 | returned: on success 72 | type: int 73 | mem: 74 | description: Used memory in bytes. 75 | returned: on success 76 | type: int 77 | network: 78 | description: Active network interfaces on the node 79 | returned: on success 80 | type: dict 81 | node: 82 | description: Short hostname of this node. 83 | returned: on success 84 | type: str 85 | ssl_fingerprint: 86 | description: SSL fingerprint of the node certificate. 87 | returned: on success 88 | type: str 89 | status: 90 | description: Node status. 91 | returned: on success 92 | type: str 93 | type: 94 | description: Object type being returned. 95 | returned: on success 96 | type: str 97 | uptime: 98 | description: Node uptime in seconds. 99 | returned: on success 100 | type: int 101 | version: 102 | description: Version of PVE on the node 103 | returned: on success 104 | type: dict 105 | """ 106 | 107 | 108 | from ansible.module_utils.basic import AnsibleModule 109 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 110 | proxmox_auth_argument_spec, ProxmoxAnsible) 111 | 112 | 113 | class ProxmoxNodeInfoAnsible(ProxmoxAnsible): 114 | def get_nodes(self): 115 | nodes = self.proxmox_api.nodes.get() 116 | for node in nodes: 117 | node_name = node['node'] 118 | ifaces = self.proxmox_api.nodes(node_name).network.get() 119 | node['network'] = ifaces 120 | node['version'] = self.proxmox_api.nodes(node_name).version.get() 121 | return nodes 122 | 123 | 124 | def proxmox_node_info_argument_spec(): 125 | return dict() 126 | 127 | 128 | def main(): 129 | module_args = proxmox_auth_argument_spec() 130 | node_info_args = proxmox_node_info_argument_spec() 131 | module_args.update(node_info_args) 132 | 133 | module = AnsibleModule( 134 | argument_spec=module_args, 135 | required_one_of=[('api_password', 'api_token_id')], 136 | required_together=[('api_token_id', 'api_token_secret')], 137 | supports_check_mode=True, 138 | ) 139 | result = dict( 140 | changed=False 141 | ) 142 | 143 | proxmox = ProxmoxNodeInfoAnsible(module) 144 | 145 | nodes = proxmox.get_nodes() 146 | result['proxmox_nodes'] = nodes 147 | 148 | module.exit_json(**result) 149 | 150 | 151 | if __name__ == '__main__': 152 | main() 153 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_storage_contents_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright Julian Vanden Broeck (@l00ptr) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | 10 | __metaclass__ = type 11 | 12 | 13 | DOCUMENTATION = r""" 14 | module: proxmox_storage_contents_info 15 | short_description: List content from a Proxmox VE storage 16 | description: 17 | - Retrieves information about stored objects on a specific storage attached to a node. 18 | options: 19 | storage: 20 | description: 21 | - Only return content stored on that specific storage. 22 | aliases: ['name'] 23 | type: str 24 | required: true 25 | node: 26 | description: 27 | - Proxmox node to which the storage is attached. 28 | type: str 29 | required: true 30 | content: 31 | description: 32 | - Filter on a specific content type. 33 | type: str 34 | choices: ["all", "backup", "rootdir", "images", "iso"] 35 | default: "all" 36 | vmid: 37 | description: 38 | - Filter on a specific VMID. 39 | type: int 40 | author: Julian Vanden Broeck (@l00ptr) 41 | extends_documentation_fragment: 42 | - community.proxmox.proxmox.actiongroup_proxmox 43 | - community.proxmox.proxmox.documentation 44 | - community.proxmox.attributes 45 | - community.proxmox.attributes.info_module 46 | """ 47 | 48 | 49 | EXAMPLES = r""" 50 | - name: List existing storages 51 | community.proxmox.proxmox_storage_contents_info: 52 | api_host: helldorado 53 | api_user: root@pam 54 | api_password: "{{ password | default(omit) }}" 55 | api_token_id: "{{ token_id | default(omit) }}" 56 | api_token_secret: "{{ token_secret | default(omit) }}" 57 | storage: lvm2 58 | content: backup 59 | vmid: 130 60 | """ 61 | 62 | 63 | RETURN = r""" 64 | proxmox_storage_content: 65 | description: Content of of storage attached to a node. 66 | type: list 67 | returned: success 68 | elements: dict 69 | contains: 70 | content: 71 | description: Proxmox content of listed objects on this storage. 72 | type: str 73 | returned: success 74 | ctime: 75 | description: Creation time of the listed objects. 76 | type: str 77 | returned: success 78 | format: 79 | description: Format of the listed objects (can be V(raw), V(pbs-vm), V(iso),...). 80 | type: str 81 | returned: success 82 | size: 83 | description: Size of the listed objects. 84 | type: int 85 | returned: success 86 | subtype: 87 | description: Subtype of the listed objects (can be V(qemu) or V(lxc)). 88 | type: str 89 | returned: When storage is dedicated to backup, typically on PBS storage. 90 | verification: 91 | description: Backup verification status of the listed objects. 92 | type: dict 93 | returned: When storage is dedicated to backup, typically on PBS storage. 94 | sample: { 95 | "state": "ok", 96 | "upid": "UPID:backup-srv:00130F49:1A12D8375:00001CD7:657A2258:verificationjob:daily\\x3av\\x2dd0cc18c5\\x2d8707:root@pam:" 97 | } 98 | volid: 99 | description: Volume identifier of the listed objects. 100 | type: str 101 | returned: success 102 | """ 103 | 104 | 105 | from ansible.module_utils.basic import AnsibleModule 106 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 107 | ProxmoxAnsible, proxmox_auth_argument_spec) 108 | 109 | 110 | def proxmox_storage_info_argument_spec(): 111 | return dict( 112 | storage=dict(type="str", required=True, aliases=["name"]), 113 | content=dict(type="str", required=False, default="all", choices=["all", "backup", "rootdir", "images", "iso"]), 114 | vmid=dict(type="int"), 115 | node=dict(required=True, type="str"), 116 | ) 117 | 118 | 119 | def main(): 120 | module_args = proxmox_auth_argument_spec() 121 | storage_info_args = proxmox_storage_info_argument_spec() 122 | module_args.update(storage_info_args) 123 | 124 | module = AnsibleModule( 125 | argument_spec=module_args, 126 | required_one_of=[("api_password", "api_token_id")], 127 | required_together=[("api_token_id", "api_token_secret")], 128 | supports_check_mode=True, 129 | ) 130 | result = dict(changed=False) 131 | proxmox = ProxmoxAnsible(module) 132 | res = proxmox.get_storage_content( 133 | node=module.params["node"], 134 | storage=module.params["storage"], 135 | content=None if module.params["content"] == "all" else module.params["content"], 136 | vmid=module.params["vmid"], 137 | ) 138 | result["proxmox_storage_content"] = res 139 | module.exit_json(**result) 140 | 141 | 142 | if __name__ == "__main__": 143 | main() 144 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_vnet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch, Mock 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_vnet 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | ModuleTestCase, 20 | set_module_args, 21 | ) 22 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 23 | 24 | 25 | def exit_json(*args, **kwargs): 26 | """function to patch over exit_json; package return data into an exception""" 27 | if 'changed' not in kwargs: 28 | kwargs['changed'] = False 29 | raise SystemExit(kwargs) 30 | 31 | 32 | def fail_json(*args, **kwargs): 33 | """function to patch over fail_json; package return data into an exception""" 34 | kwargs['failed'] = True 35 | raise SystemExit(kwargs) 36 | 37 | 38 | def get_module_args_zone(zone, vnet, state='present', update=True, alias=None): 39 | return { 40 | 'api_host': 'host', 41 | 'api_user': 'user', 42 | 'api_password': 'password', 43 | 'zone': zone, 44 | 'state': state, 45 | 'update': update, 46 | 'vnet': vnet, 47 | 'alias': alias 48 | } 49 | 50 | 51 | RAW_VNETS = [ 52 | { 53 | "type": "vnet", 54 | "vnet": "test", 55 | "zone": "ans1", 56 | "alias": "test1", 57 | "digest": "79ee852ce6fd2cc12c047363e7059a761fe68a8c", 58 | "isolate-ports": 1 59 | }, 60 | { 61 | "type": "vnet", 62 | "zone": "test1", 63 | "digest": "79ee852ce6fd2cc12c047363e7059a761fe68a8c", 64 | "vnet": "test2" 65 | } 66 | ] 67 | 68 | 69 | class TestProxmoxVnetModule(ModuleTestCase): 70 | def setUp(self): 71 | super(TestProxmoxVnetModule, self).setUp() 72 | proxmox_utils.HAS_PROXMOXER = True 73 | self.module = proxmox_vnet 74 | self.fail_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.fail_json', 75 | new=Mock(side_effect=fail_json)) 76 | self.exit_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.exit_json', new=exit_json) 77 | 78 | self.fail_json_mock = self.fail_json_patcher.start() 79 | self.exit_json_patcher.start() 80 | self.connect_mock = patch( 81 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 82 | ).start() 83 | self.connect_mock.return_value.cluster.return_value.sdn.return_value.vnets.return_value.get.return_value = RAW_VNETS.copy() 84 | 85 | def tearDown(self): 86 | self.connect_mock.stop() 87 | self.exit_json_patcher.stop() 88 | self.fail_json_patcher.stop() 89 | super(TestProxmoxVnetModule, self).tearDown() 90 | 91 | def test_vnet_present(self): 92 | # Create new Vnet 93 | with pytest.raises(SystemExit) as exc_info: 94 | with set_module_args(get_module_args_zone(zone='ztest', vnet='vtest')): 95 | self.module.main() 96 | result = exc_info.value.args[0] 97 | assert result["changed"] is True 98 | assert result["msg"] == "Create new vnet vtest" 99 | assert result['vnet'] == 'vtest' 100 | 101 | # Update the vnet 102 | with pytest.raises(SystemExit) as exc_info: 103 | with set_module_args(get_module_args_zone(zone='test1', vnet='test2', alias='test', update=True)): 104 | self.module.main() 105 | result = exc_info.value.args[0] 106 | assert result["changed"] is True 107 | assert result["msg"] == "updated vnet test2" 108 | assert result['vnet'] == 'test2' 109 | 110 | # Vnet needs to be updated but update=False 111 | with pytest.raises(SystemExit) as exc_info: 112 | with set_module_args(get_module_args_zone(zone='test1', vnet='test2', alias='test', update=False)): 113 | self.module.main() 114 | result = exc_info.value.args[0] 115 | assert self.fail_json_mock.called 116 | assert result["failed"] is True 117 | assert result["msg"] == 'vnet test2 needs to be updated but update is false.' 118 | 119 | def test_zone_absent(self): 120 | with pytest.raises(SystemExit) as exc_info: 121 | with set_module_args(get_module_args_zone(zone='test1', vnet='test2', state='absent')): 122 | self.module.main() 123 | result = exc_info.value.args[0] 124 | assert result["changed"] is True 125 | assert result["msg"] == "Deleted vnet test2" 126 | assert result['vnet'] == 'test2' 127 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_access_acl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_kvm 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | AnsibleExitJson, 20 | AnsibleFailJson, 21 | ModuleTestCase, 22 | set_module_args, 23 | ) 24 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 25 | from ansible_collections.community.proxmox.plugins.modules import proxmox_access_acl 26 | 27 | ACE = { 28 | "path": "/vms/100", 29 | "propagate": 1, 30 | "roleid": "PVEVMUser", 31 | "type": "user", 32 | "ugid": "a01mako@pam" 33 | } 34 | 35 | API = { 36 | "api_user": "root@pam", 37 | "api_password": "secret", 38 | "api_host": "127.0.0.1", 39 | } 40 | 41 | 42 | def return_get_api(): 43 | return [ACE] 44 | 45 | 46 | class TestProxmoxAccessACLModule(ModuleTestCase): 47 | def setUp(self): 48 | super(TestProxmoxAccessACLModule, self).setUp() 49 | proxmox_utils.HAS_PROXMOXER = True 50 | self.module = proxmox_kvm 51 | self.connect_mock = patch( 52 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect" 53 | ).start() 54 | self.mock_get = patch.object(proxmox_access_acl.ProxmoxAccessACLAnsible, "_get").start() 55 | self.mock_put = patch.object(proxmox_access_acl.ProxmoxAccessACLAnsible, "_put").start() 56 | 57 | self.mock_get.side_effect = return_get_api 58 | 59 | def tearDown(self): 60 | self.connect_mock.stop() 61 | super(TestProxmoxAccessACLModule, self).tearDown() 62 | 63 | def test_module_present_missing_args(self): 64 | with set_module_args( 65 | { 66 | **API, 67 | "state": "present", 68 | "path": "/vms/100", 69 | "roleid": "PVEVMUser", 70 | } 71 | ): 72 | with pytest.raises(AnsibleFailJson) as exc_info: 73 | proxmox_access_acl.main() 74 | 75 | result = exc_info.value.args[0] 76 | assert result["failed"] is True 77 | assert result["missing_parameters"] == frozenset({'ugid', 'type'}) 78 | assert result["changed"] is False, result 79 | assert self.mock_get.call_count == 0 80 | assert self.mock_put.call_count == 0 81 | 82 | def test_module_present_exists(self): 83 | with set_module_args( 84 | { 85 | **API, 86 | "state": "present", 87 | **ACE, 88 | } 89 | ): 90 | with pytest.raises(AnsibleExitJson) as exc_info: 91 | proxmox_access_acl.main() 92 | 93 | result = exc_info.value.args[0] 94 | 95 | assert result["changed"] is False 96 | assert self.mock_get.call_count == 1 97 | assert self.mock_put.call_count == 0 98 | 99 | def test_module_present_missing(self): 100 | 101 | with set_module_args( 102 | { 103 | **API, 104 | "state": "present", 105 | **ACE, 106 | "path": "/vms/101" 107 | } 108 | ): 109 | with pytest.raises(AnsibleExitJson) as exc_info: 110 | proxmox_access_acl.main() 111 | 112 | result = exc_info.value.args[0] 113 | 114 | assert result["changed"] is True 115 | assert self.mock_get.call_count == 2 116 | assert self.mock_put.call_count == 1 117 | 118 | def test_module_absent_exists(self): 119 | with set_module_args( 120 | { 121 | **API, 122 | "state": "absent", 123 | **ACE, 124 | } 125 | ): 126 | with pytest.raises(AnsibleExitJson) as exc_info: 127 | proxmox_access_acl.main() 128 | 129 | result = exc_info.value.args[0] 130 | 131 | assert result["changed"] is True 132 | assert self.mock_get.call_count == 2 133 | assert self.mock_put.call_count == 1 134 | 135 | def test_module_absent_missing(self): 136 | with set_module_args( 137 | { 138 | **API, 139 | "state": "absent", 140 | **ACE, 141 | "path": "/vms/101" 142 | } 143 | ): 144 | with pytest.raises(AnsibleExitJson) as exc_info: 145 | proxmox_access_acl.main() 146 | 147 | result = exc_info.value.args[0] 148 | 149 | assert result["changed"] is False 150 | assert self.mock_get.call_count == 1 151 | assert self.mock_put.call_count == 0 152 | -------------------------------------------------------------------------------- /tests/integration/targets/proxmox_template/tasks/main.yml: -------------------------------------------------------------------------------- 1 | #################################################################### 2 | # WARNING: These are designed specifically for Ansible tests # 3 | # and should not be used as examples of how to write Ansible roles # 4 | #################################################################### 5 | 6 | # Copyright (c) 2023, Sergei Antipov 7 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 8 | # SPDX-License-Identifier: GPL-3.0-or-later 9 | 10 | - name: Proxmox VE virtual machines templates management 11 | tags: ['template'] 12 | vars: 13 | filename: /tmp/dummy.iso 14 | block: 15 | - name: Create dummy ISO file 16 | ansible.builtin.command: 17 | cmd: 'truncate -s 300M {{ filename }}' 18 | 19 | - name: Delete requests_toolbelt module if it is installed 20 | ansible.builtin.pip: 21 | name: requests_toolbelt 22 | state: absent 23 | 24 | - name: Install latest proxmoxer 25 | ansible.builtin.pip: 26 | name: proxmoxer 27 | state: latest 28 | 29 | - name: Upload ISO as template to Proxmox VE cluster should fail 30 | proxmox_template: 31 | api_host: '{{ api_host }}' 32 | api_user: '{{ user }}@{{ domain }}' 33 | api_password: '{{ api_password | default(omit) }}' 34 | api_token_id: '{{ api_token_id | default(omit) }}' 35 | api_token_secret: '{{ api_token_secret | default(omit) }}' 36 | validate_certs: '{{ validate_certs }}' 37 | node: '{{ node }}' 38 | src: '{{ filename }}' 39 | content_type: iso 40 | force: true 41 | register: result 42 | ignore_errors: true 43 | 44 | - assert: 45 | that: 46 | - result is failed 47 | - result.msg is match('\'requests_toolbelt\' module is required to upload files larger than 256MB') 48 | 49 | - name: Install old (1.1.2) version of proxmoxer 50 | ansible.builtin.pip: 51 | name: proxmoxer==1.1.1 52 | state: present 53 | 54 | - name: Upload ISO as template to Proxmox VE cluster should be successful 55 | proxmox_template: 56 | api_host: '{{ api_host }}' 57 | api_user: '{{ user }}@{{ domain }}' 58 | api_password: '{{ api_password | default(omit) }}' 59 | api_token_id: '{{ api_token_id | default(omit) }}' 60 | api_token_secret: '{{ api_token_secret | default(omit) }}' 61 | validate_certs: '{{ validate_certs }}' 62 | node: '{{ node }}' 63 | src: '{{ filename }}' 64 | content_type: iso 65 | force: true 66 | register: result 67 | 68 | - assert: 69 | that: 70 | - result is changed 71 | - result is success 72 | - result.msg is match('template with volid=local:iso/dummy.iso uploaded') 73 | 74 | - name: Install latest proxmoxer 75 | ansible.builtin.pip: 76 | name: proxmoxer 77 | state: latest 78 | 79 | - name: Make smaller dummy file 80 | ansible.builtin.command: 81 | cmd: 'truncate -s 128M {{ filename }}' 82 | 83 | - name: Upload ISO as template to Proxmox VE cluster should be successful 84 | proxmox_template: 85 | api_host: '{{ api_host }}' 86 | api_user: '{{ user }}@{{ domain }}' 87 | api_password: '{{ api_password | default(omit) }}' 88 | api_token_id: '{{ api_token_id | default(omit) }}' 89 | api_token_secret: '{{ api_token_secret | default(omit) }}' 90 | validate_certs: '{{ validate_certs }}' 91 | node: '{{ node }}' 92 | src: '{{ filename }}' 93 | content_type: iso 94 | force: true 95 | register: result 96 | 97 | - assert: 98 | that: 99 | - result is changed 100 | - result is success 101 | - result.msg is match('template with volid=local:iso/dummy.iso uploaded') 102 | 103 | - name: Install requests_toolbelt 104 | ansible.builtin.pip: 105 | name: requests_toolbelt 106 | state: present 107 | 108 | - name: Make big dummy file 109 | ansible.builtin.command: 110 | cmd: 'truncate -s 300M {{ filename }}' 111 | 112 | - name: Upload ISO as template to Proxmox VE cluster should be successful 113 | proxmox_template: 114 | api_host: '{{ api_host }}' 115 | api_user: '{{ user }}@{{ domain }}' 116 | api_password: '{{ api_password | default(omit) }}' 117 | api_token_id: '{{ api_token_id | default(omit) }}' 118 | api_token_secret: '{{ api_token_secret | default(omit) }}' 119 | validate_certs: '{{ validate_certs }}' 120 | node: '{{ node }}' 121 | src: '{{ filename }}' 122 | content_type: iso 123 | force: true 124 | register: result 125 | 126 | - assert: 127 | that: 128 | - result is changed 129 | - result is success 130 | - result.msg is match('template with volid=local:iso/dummy.iso uploaded') 131 | 132 | always: 133 | - name: Delete ISO file from host 134 | ansible.builtin.file: 135 | path: '{{ filename }}' 136 | state: absent 137 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_node.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2025, Florian Paul Azim Hoberg (@gyptazy) 3 | # 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | import pytest 8 | from unittest.mock import MagicMock, patch 9 | from ansible.module_utils.basic import AnsibleModule 10 | from ansible_collections.community.proxmox.plugins.modules import proxmox_node 11 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ProxmoxAnsible 12 | 13 | 14 | @pytest.fixture 15 | def module_args_power_on(): 16 | return { 17 | "api_host": "proxmoxhost", 18 | "api_user": "root@pam", 19 | "api_password": "secret", 20 | "validate_certs": False, 21 | "node_name": "test-node", 22 | "power_state": "online", 23 | } 24 | 25 | 26 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 27 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 28 | def test_power_state_present(mock_api, mock_init, module_args_power_on): 29 | module = MagicMock(spec=AnsibleModule) 30 | module.params = module_args_power_on 31 | module.check_mode = False 32 | 33 | mock_api_instance = MagicMock() 34 | mock_api.return_value = mock_api_instance 35 | 36 | nodes = { 37 | "nodes": { 38 | "test-node": { 39 | "name": "test-node", 40 | "status": "offline" 41 | } 42 | } 43 | } 44 | 45 | module.exit_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 46 | module.fail_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 47 | 48 | proxmox = proxmox_node.ProxmoxNodeAnsible(module) 49 | proxmox.module = module 50 | proxmox.proxmox_api = mock_api_instance 51 | 52 | with patch.object(mock_api_instance.nodes("test-node").wakeonlan, 'post') as mock_post: 53 | changed, msg = proxmox.power_state(nodes) 54 | 55 | assert changed is True 56 | assert "powered on" in msg 57 | mock_post.assert_called_once_with(node_name="test-node") 58 | 59 | 60 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 61 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 62 | def test_power_state_already_online(mock_api, mock_init, module_args_power_on): 63 | module = MagicMock(spec=AnsibleModule) 64 | module.params = module_args_power_on 65 | module.check_mode = False 66 | 67 | mock_api_instance = MagicMock() 68 | mock_api.return_value = mock_api_instance 69 | 70 | nodes = { 71 | "nodes": { 72 | "test-node": { 73 | "name": "test-node", 74 | "status": "online" 75 | } 76 | } 77 | } 78 | 79 | proxmox = proxmox_node.ProxmoxNodeAnsible(module) 80 | proxmox.module = module 81 | proxmox.proxmox_api = mock_api_instance 82 | 83 | changed, msg = proxmox.power_state(nodes) 84 | 85 | assert changed is False 86 | assert "already online" in msg 87 | 88 | 89 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 90 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 91 | def test_subscription_present_new_key(mock_api, mock_init): 92 | module = MagicMock(spec=AnsibleModule) 93 | module.params = { 94 | "node_name": "test-node", 95 | "subscription": { 96 | "state": "present", 97 | "key": "ABCD-1234" 98 | } 99 | } 100 | module.check_mode = False 101 | 102 | mock_api_instance = MagicMock() 103 | mock_api.return_value = mock_api_instance 104 | mock_api_instance.nodes("test-node").subscription.get.return_value = {"key": "OLD-KEY"} 105 | 106 | module.exit_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 107 | module.fail_json = lambda **kwargs: (x for x in ()).throw(SystemExit(kwargs)) 108 | 109 | proxmox = proxmox_node.ProxmoxNodeAnsible(module) 110 | proxmox.module = module 111 | proxmox.proxmox_api = mock_api_instance 112 | 113 | changed, msg = proxmox.subscription() 114 | 115 | assert changed is True 116 | assert "uploaded" in msg 117 | mock_api_instance.nodes("test-node").subscription.put.assert_called_once_with(key="ABCD-1234") 118 | 119 | 120 | @patch.object(ProxmoxAnsible, "__init__", return_value=None) 121 | @patch.object(ProxmoxAnsible, "proxmox_api", create=True) 122 | def test_subscription_already_present(mock_api, mock_init): 123 | module = MagicMock(spec=AnsibleModule) 124 | module.params = { 125 | "node_name": "test-node", 126 | "subscription": { 127 | "state": "present", 128 | "key": "ABCD-1234" 129 | } 130 | } 131 | module.check_mode = False 132 | 133 | mock_api_instance = MagicMock() 134 | mock_api.return_value = mock_api_instance 135 | mock_api_instance.nodes("test-node").subscription.get.return_value = {"key": "ABCD-1234"} 136 | 137 | proxmox = proxmox_node.ProxmoxNodeAnsible(module) 138 | proxmox.module = module 139 | proxmox.proxmox_api = mock_api_instance 140 | 141 | changed, msg = proxmox.subscription() 142 | 143 | assert changed is False 144 | assert msg == "Unchanged" 145 | mock_api_instance.nodes("test-node").subscription.put.assert_not_called() 146 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_cluster_join_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2025, Florian Paul Azim Hoberg (@gyptazy) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | DOCUMENTATION = r""" 12 | module: proxmox_cluster_join_info 13 | version_added: 1.0.0 14 | short_description: Retrieve the join information of the Proxmox VE cluster 15 | description: 16 | - Retrieve the join information of the Proxmox VE cluster. 17 | author: Florian Paul Azim Hoberg (@gyptazy) 18 | extends_documentation_fragment: 19 | - community.proxmox.proxmox.actiongroup_proxmox 20 | - community.proxmox.proxmox.documentation 21 | - community.proxmox.attributes 22 | - community.proxmox.attributes.info_module 23 | """ 24 | 25 | EXAMPLES = r""" 26 | - name: List existing Proxmox VE cluster join information 27 | community.proxmox.proxmox_cluster_join_info: 28 | api_host: proxmox1 29 | api_user: root@pam 30 | api_password: "{{ password | default(omit) }}" 31 | api_token_id: "{{ token_id | default(omit) }}" 32 | api_token_secret: "{{ token_secret | default(omit) }}" 33 | register: proxmox_cluster_join 34 | """ 35 | 36 | RETURN = r""" 37 | cluster_join: 38 | description: List of Proxmox VE nodes including the join information within the cluster. 39 | returned: always, but can be empty 40 | type: list 41 | elements: dict 42 | contains: 43 | config_digest: 44 | description: Digest of the cluster configuration. 45 | type: str 46 | sample: "aef68412f7976505ed083e6173b96274a281da25" 47 | nodelist: 48 | description: List of nodes in the cluster. 49 | type: list 50 | elements: dict 51 | contains: 52 | name: 53 | description: Node name. 54 | type: str 55 | sample: "pve2" 56 | nodeid: 57 | description: Node ID. 58 | type: str 59 | sample: "1" 60 | pve_addr: 61 | description: Proxmox VE address. 62 | type: str 63 | sample: "10.10.10.159" 64 | pve_fp: 65 | description: Proxmox VE fingerprint. 66 | type: str 67 | sample: "08:B5:B2:F9:EC:01:0B:D0:..." 68 | quorum_votes: 69 | description: Quorum votes assigned to the node. 70 | type: str 71 | sample: "1" 72 | ring0_addr: 73 | description: Address for ring0. 74 | type: str 75 | sample: "vmbr0" 76 | preferred_node: 77 | description: The preferred cluster node. 78 | type: str 79 | sample: "pve2" 80 | totem: 81 | description: Totem protocol configuration. 82 | type: dict 83 | contains: 84 | cluster_name: 85 | description: Cluster name from totem. 86 | type: str 87 | sample: "devcluster" 88 | config_version: 89 | description: Config version. 90 | type: str 91 | sample: "1" 92 | interface: 93 | description: Interface configuration. 94 | type: dict 95 | ip_version: 96 | description: IP version. 97 | type: str 98 | sample: "ipv4-6" 99 | link_mode: 100 | description: Link mode. 101 | type: str 102 | sample: "passive" 103 | secauth: 104 | description: Whether secure authentication is on. 105 | type: str 106 | sample: "on" 107 | version: 108 | description: Totem protocol version. 109 | type: str 110 | sample: "2" 111 | """ 112 | 113 | 114 | import traceback 115 | from ansible.module_utils.basic import AnsibleModule 116 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 117 | proxmox_auth_argument_spec, ProxmoxAnsible) 118 | 119 | 120 | try: 121 | import proxmoxer 122 | except ImportError: 123 | PROXMOXER_LIBRARY = False 124 | PROXMOXER_LIBRARY_IMPORT_ERROR = traceback.format_exc() 125 | else: 126 | PROXMOXER_LIBRARY = True 127 | PROXMOXER_LIBRARY_IMPORT_ERROR = None 128 | 129 | 130 | class ProxmoxClusterJoinInfoAnsible(ProxmoxAnsible): 131 | def get_cluster_join(self): 132 | try: 133 | return self.proxmox_api.cluster.config.join.get() 134 | except proxmoxer.core.ResourceException: 135 | self.module.fail_json(msg="Node is not part of a cluster and does not have any join information.") 136 | except Exception as e: 137 | self.module.fail_json(msg="Error obtaining cluster join information: {}".format(str(e))) 138 | 139 | 140 | def proxmox_cluster_join_info_argument_spec(): 141 | return dict() 142 | 143 | 144 | def main(): 145 | module_args = proxmox_auth_argument_spec() 146 | cluster_join_info_args = proxmox_cluster_join_info_argument_spec() 147 | module_args.update(cluster_join_info_args) 148 | 149 | module = AnsibleModule( 150 | argument_spec=module_args, 151 | required_one_of=[('api_password', 'api_token_id')], 152 | required_together=[('api_token_id', 'api_token_secret')], 153 | supports_check_mode=True, 154 | ) 155 | result = dict( 156 | changed=False 157 | ) 158 | 159 | proxmox = ProxmoxClusterJoinInfoAnsible(module) 160 | 161 | cluster_join = proxmox.get_cluster_join() 162 | result['cluster_join'] = cluster_join 163 | 164 | module.exit_json(**result) 165 | 166 | 167 | if __name__ == '__main__': 168 | main() 169 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_group.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2025, Jeffrey van Pelt (@Thulium-Drake) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-FileCopyrightText: (c) 2025, Jeffrey van Pelt (Thulium-Drake) 7 | # SPDX-License-Identifier: GPL-3.0-or-later 8 | from __future__ import (absolute_import, division, print_function) 9 | __metaclass__ = type 10 | 11 | DOCUMENTATION = r""" 12 | module: proxmox_group 13 | short_description: Group management for Proxmox VE cluster 14 | description: 15 | - Create or delete a user group for Proxmox VE clusters. 16 | author: "Jeffrey van Pelt (@Thulium-Drake) " 17 | version_added: "1.2.0" 18 | attributes: 19 | check_mode: 20 | support: full 21 | diff_mode: 22 | support: none 23 | options: 24 | groupid: 25 | description: 26 | - The group name. 27 | type: str 28 | aliases: ["name"] 29 | required: true 30 | state: 31 | description: 32 | - Indicate desired state of the group. 33 | choices: ['present', 'absent'] 34 | default: present 35 | type: str 36 | comment: 37 | description: 38 | - Specify the description for the group. 39 | - Parameter is ignored when group already exists or O(state=absent). 40 | type: str 41 | 42 | extends_documentation_fragment: 43 | - community.proxmox.proxmox.actiongroup_proxmox 44 | - community.proxmox.proxmox.documentation 45 | - community.proxmox.attributes 46 | """ 47 | 48 | EXAMPLES = r""" 49 | - name: Create new Proxmox VE user group 50 | community.proxmox.proxmox_group: 51 | api_host: node1 52 | api_user: root@pam 53 | api_password: password 54 | name: administrators 55 | comment: IT Admins 56 | 57 | - name: Delete a Proxmox VE user group 58 | community.proxmox.proxmox_group: 59 | api_host: node1 60 | api_user: root@pam 61 | api_password: password 62 | name: administrators 63 | state: absent 64 | """ 65 | 66 | RETURN = r""" 67 | groupid: 68 | description: The group name. 69 | returned: success 70 | type: str 71 | sample: test 72 | msg: 73 | description: A short message on what the module did. 74 | returned: always 75 | type: str 76 | sample: "Group administrators successfully created" 77 | """ 78 | 79 | from ansible.module_utils.basic import AnsibleModule 80 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, ProxmoxAnsible) 81 | 82 | 83 | class ProxmoxGroupAnsible(ProxmoxAnsible): 84 | 85 | def is_group_existing(self, groupid): 86 | """Check whether group already exist 87 | 88 | :param groupid: str - name of the group 89 | :return: bool - is group exists? 90 | """ 91 | try: 92 | groups = self.proxmox_api.access.groups.get() 93 | for group in groups: 94 | if group['groupid'] == groupid: 95 | return True 96 | return False 97 | except Exception as e: 98 | self.module.fail_json(msg="Unable to retrieve groups: {0}".format(e)) 99 | 100 | def create_group(self, groupid, comment=None): 101 | """Create Proxmox VE group 102 | 103 | :param groupid: str - name of the group 104 | :param comment: str, optional - Description of a group 105 | :return: None 106 | """ 107 | if self.is_group_existing(groupid): 108 | self.module.exit_json(changed=False, groupid=groupid, msg="Group {0} already exists".format(groupid)) 109 | 110 | if self.module.check_mode: 111 | return 112 | 113 | try: 114 | self.proxmox_api.access.groups.post(groupid=groupid, comment=comment) 115 | except Exception as e: 116 | self.module.fail_json(msg="Failed to create group with ID {0}: {1}".format(groupid, e)) 117 | 118 | def delete_group(self, groupid): 119 | """Delete Proxmox VE group 120 | 121 | :param groupid: str - name of the group 122 | :return: None 123 | """ 124 | if not self.is_group_existing(groupid): 125 | self.module.exit_json(changed=False, groupid=groupid, msg="Group {0} doesn't exist".format(groupid)) 126 | 127 | if self.module.check_mode: 128 | return 129 | 130 | try: 131 | self.proxmox_api.access.groups(groupid).delete() 132 | except Exception as e: 133 | self.module.fail_json(msg="Failed to delete group with ID {0}: {1}".format(groupid, e)) 134 | 135 | 136 | def main(): 137 | module_args = proxmox_auth_argument_spec() 138 | groups_args = dict( 139 | groupid=dict(type="str", aliases=["name"], required=True), 140 | comment=dict(type="str"), 141 | state=dict(default="present", choices=["present", "absent"]), 142 | ) 143 | 144 | module_args.update(groups_args) 145 | 146 | module = AnsibleModule( 147 | argument_spec=module_args, 148 | required_together=[("api_token_id", "api_token_secret")], 149 | required_one_of=[("api_password", "api_token_id")], 150 | supports_check_mode=True 151 | ) 152 | 153 | groupid = module.params["groupid"] 154 | comment = module.params["comment"] 155 | state = module.params["state"] 156 | 157 | proxmox = ProxmoxGroupAnsible(module) 158 | 159 | if state == "present": 160 | proxmox.create_group(groupid, comment) 161 | module.exit_json(changed=True, groupid=groupid, msg="Group {0} successfully created".format(groupid)) 162 | else: 163 | proxmox.delete_group(groupid) 164 | module.exit_json(changed=True, groupid=groupid, msg="Group {0} successfully deleted".format(groupid)) 165 | 166 | 167 | if __name__ == "__main__": 168 | main() 169 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_pool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2023, Sergei Antipov (UnderGreen) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | DOCUMENTATION = r""" 11 | module: proxmox_pool 12 | short_description: Pool management for Proxmox VE cluster 13 | description: 14 | - Create or delete a pool for Proxmox VE clusters. 15 | - For pool members management please consult M(community.proxmox.proxmox_pool_member) module. 16 | author: "Sergei Antipov (@UnderGreen) " 17 | attributes: 18 | check_mode: 19 | support: full 20 | diff_mode: 21 | support: none 22 | options: 23 | poolid: 24 | description: 25 | - The pool ID. 26 | type: str 27 | aliases: ["name"] 28 | required: true 29 | state: 30 | description: 31 | - Indicate desired state of the pool. 32 | - The pool must be empty prior deleting it with O(state=absent). 33 | choices: ['present', 'absent'] 34 | default: present 35 | type: str 36 | comment: 37 | description: 38 | - Specify the description for the pool. 39 | - Parameter is ignored when pool already exists or O(state=absent). 40 | type: str 41 | 42 | extends_documentation_fragment: 43 | - community.proxmox.proxmox.actiongroup_proxmox 44 | - community.proxmox.proxmox.documentation 45 | - community.proxmox.attributes 46 | """ 47 | 48 | EXAMPLES = r""" 49 | - name: Create new Proxmox VE pool 50 | community.proxmox.proxmox_pool: 51 | api_host: node1 52 | api_user: root@pam 53 | api_password: password 54 | poolid: test 55 | comment: 'New pool' 56 | 57 | - name: Delete the Proxmox VE pool 58 | community.proxmox.proxmox_pool: 59 | api_host: node1 60 | api_user: root@pam 61 | api_password: password 62 | poolid: test 63 | state: absent 64 | """ 65 | 66 | RETURN = r""" 67 | poolid: 68 | description: The pool ID. 69 | returned: success 70 | type: str 71 | sample: test 72 | msg: 73 | description: A short message on what the module did. 74 | returned: always 75 | type: str 76 | sample: "Pool test successfully created" 77 | """ 78 | 79 | from ansible.module_utils.basic import AnsibleModule 80 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, ProxmoxAnsible) 81 | 82 | 83 | class ProxmoxPoolAnsible(ProxmoxAnsible): 84 | 85 | def is_pool_existing(self, poolid): 86 | """Check whether pool already exist 87 | 88 | :param poolid: str - name of the pool 89 | :return: bool - is pool exists? 90 | """ 91 | try: 92 | pools = self.proxmox_api.pools.get() 93 | for pool in pools: 94 | if pool['poolid'] == poolid: 95 | return True 96 | return False 97 | except Exception as e: 98 | self.module.fail_json(msg="Unable to retrieve pools: {0}".format(e)) 99 | 100 | def is_pool_empty(self, poolid): 101 | """Check whether pool has members 102 | 103 | :param poolid: str - name of the pool 104 | :return: bool - is pool empty? 105 | """ 106 | return True if not self.get_pool(poolid)['members'] else False 107 | 108 | def create_pool(self, poolid, comment=None): 109 | """Create Proxmox VE pool 110 | 111 | :param poolid: str - name of the pool 112 | :param comment: str, optional - Description of a pool 113 | :return: None 114 | """ 115 | if self.is_pool_existing(poolid): 116 | self.module.exit_json(changed=False, poolid=poolid, msg="Pool {0} already exists".format(poolid)) 117 | 118 | if self.module.check_mode: 119 | return 120 | 121 | try: 122 | self.proxmox_api.pools.post(poolid=poolid, comment=comment) 123 | except Exception as e: 124 | self.module.fail_json(msg="Failed to create pool with ID {0}: {1}".format(poolid, e)) 125 | 126 | def delete_pool(self, poolid): 127 | """Delete Proxmox VE pool 128 | 129 | :param poolid: str - name of the pool 130 | :return: None 131 | """ 132 | if not self.is_pool_existing(poolid): 133 | self.module.exit_json(changed=False, poolid=poolid, msg="Pool {0} doesn't exist".format(poolid)) 134 | 135 | if self.is_pool_empty(poolid): 136 | if self.module.check_mode: 137 | return 138 | 139 | try: 140 | self.proxmox_api.pools(poolid).delete() 141 | except Exception as e: 142 | self.module.fail_json(msg="Failed to delete pool with ID {0}: {1}".format(poolid, e)) 143 | else: 144 | self.module.fail_json(msg="Can't delete pool {0} with members. Please remove members from pool first.".format(poolid)) 145 | 146 | 147 | def main(): 148 | module_args = proxmox_auth_argument_spec() 149 | pools_args = dict( 150 | poolid=dict(type="str", aliases=["name"], required=True), 151 | comment=dict(type="str"), 152 | state=dict(default="present", choices=["present", "absent"]), 153 | ) 154 | 155 | module_args.update(pools_args) 156 | 157 | module = AnsibleModule( 158 | argument_spec=module_args, 159 | required_together=[("api_token_id", "api_token_secret")], 160 | required_one_of=[("api_password", "api_token_id")], 161 | supports_check_mode=True 162 | ) 163 | 164 | poolid = module.params["poolid"] 165 | comment = module.params["comment"] 166 | state = module.params["state"] 167 | 168 | proxmox = ProxmoxPoolAnsible(module) 169 | 170 | if state == "present": 171 | proxmox.create_pool(poolid, comment) 172 | module.exit_json(changed=True, poolid=poolid, msg="Pool {0} successfully created".format(poolid)) 173 | else: 174 | proxmox.delete_pool(poolid) 175 | module.exit_json(changed=True, poolid=poolid, msg="Pool {0} successfully deleted".format(poolid)) 176 | 177 | 178 | if __name__ == "__main__": 179 | main() 180 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_kvm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021, Ansible Project 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch, DEFAULT 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_kvm 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | AnsibleExitJson, 20 | AnsibleFailJson, 21 | ModuleTestCase, 22 | set_module_args, 23 | ) 24 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 25 | 26 | 27 | class TestProxmoxKvmModule(ModuleTestCase): 28 | def setUp(self): 29 | super(TestProxmoxKvmModule, self).setUp() 30 | proxmox_utils.HAS_PROXMOXER = True 31 | self.module = proxmox_kvm 32 | self.connect_mock = patch( 33 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect" 34 | ).start() 35 | self.get_node_mock = patch.object( 36 | proxmox_utils.ProxmoxAnsible, "get_node" 37 | ).start() 38 | self.get_vm_mock = patch.object(proxmox_utils.ProxmoxAnsible, "get_vm").start() 39 | self.create_vm_mock = patch.object( 40 | proxmox_kvm.ProxmoxKvmAnsible, "create_vm" 41 | ).start() 42 | 43 | def tearDown(self): 44 | self.create_vm_mock.stop() 45 | self.get_vm_mock.stop() 46 | self.get_node_mock.stop() 47 | self.connect_mock.stop() 48 | super(TestProxmoxKvmModule, self).tearDown() 49 | 50 | def test_module_fail_when_required_args_missing(self): 51 | with self.assertRaises(AnsibleFailJson): 52 | with set_module_args({}): 53 | self.module.main() 54 | 55 | def test_module_exits_unchaged_when_provided_vmid_exists(self): 56 | with set_module_args( 57 | { 58 | "api_host": "host", 59 | "api_user": "user", 60 | "api_password": "password", 61 | "vmid": "100", 62 | "node": "pve", 63 | } 64 | ): 65 | self.get_vm_mock.return_value = [{"vmid": "100"}] 66 | with pytest.raises(AnsibleExitJson) as exc_info: 67 | self.module.main() 68 | 69 | assert self.get_vm_mock.call_count == 1 70 | result = exc_info.value.args[0] 71 | assert result["changed"] is False 72 | assert result["msg"] == "VM with vmid <100> already exists" 73 | 74 | def test_vm_created_when_vmid_not_exist_but_name_already_exist(self): 75 | with set_module_args( 76 | { 77 | "api_host": "host", 78 | "api_user": "user", 79 | "api_password": "password", 80 | "vmid": "100", 81 | "name": "existing.vm.local", 82 | "node": "pve", 83 | } 84 | ): 85 | self.get_vm_mock.return_value = None 86 | with pytest.raises(AnsibleExitJson) as exc_info: 87 | self.module.main() 88 | 89 | assert self.get_vm_mock.call_count == 1 90 | assert self.get_node_mock.call_count == 1 91 | result = exc_info.value.args[0] 92 | assert result["changed"] is True 93 | assert result["msg"] == "VM existing.vm.local with vmid 100 deployed" 94 | 95 | def test_vm_not_created_when_name_already_exist_and_vmid_not_set(self): 96 | with set_module_args( 97 | { 98 | "api_host": "host", 99 | "api_user": "user", 100 | "api_password": "password", 101 | "name": "existing.vm.local", 102 | "node": "pve", 103 | } 104 | ): 105 | with patch.object(proxmox_utils.ProxmoxAnsible, "get_vmid") as get_vmid_mock: 106 | get_vmid_mock.return_value = { 107 | "vmid": 100, 108 | "name": "existing.vm.local", 109 | } 110 | with pytest.raises(AnsibleExitJson) as exc_info: 111 | self.module.main() 112 | 113 | assert get_vmid_mock.call_count == 1 114 | result = exc_info.value.args[0] 115 | assert result["changed"] is False 116 | 117 | def test_vm_created_when_name_doesnt_exist_and_vmid_not_set(self): 118 | with set_module_args( 119 | { 120 | "api_host": "host", 121 | "api_user": "user", 122 | "api_password": "password", 123 | "name": "existing.vm.local", 124 | "node": "pve", 125 | } 126 | ): 127 | self.get_vm_mock.return_value = None 128 | with patch.multiple( 129 | proxmox_utils.ProxmoxAnsible, get_vmid=DEFAULT, get_nextvmid=DEFAULT 130 | ) as utils_mock: 131 | utils_mock["get_vmid"].return_value = None 132 | utils_mock["get_nextvmid"].return_value = 101 133 | with pytest.raises(AnsibleExitJson) as exc_info: 134 | self.module.main() 135 | 136 | assert utils_mock["get_vmid"].call_count == 1 137 | assert utils_mock["get_nextvmid"].call_count == 1 138 | result = exc_info.value.args[0] 139 | assert result["changed"] is True 140 | assert result["msg"] == "VM existing.vm.local with vmid 101 deployed" 141 | 142 | def test_parse_mac(self): 143 | assert ( 144 | proxmox_kvm.parse_mac("virtio=00:11:22:AA:BB:CC,bridge=vmbr0,firewall=1") 145 | == "00:11:22:AA:BB:CC" 146 | ) 147 | 148 | def test_parse_dev(self): 149 | assert ( 150 | proxmox_kvm.parse_dev("local-lvm:vm-1000-disk-0,format=qcow2") 151 | == "local-lvm:vm-1000-disk-0" 152 | ) 153 | assert ( 154 | proxmox_kvm.parse_dev("local-lvm:vm-101-disk-1,size=8G") 155 | == "local-lvm:vm-101-disk-1" 156 | ) 157 | assert ( 158 | proxmox_kvm.parse_dev("local-zfs:vm-1001-disk-0") 159 | == "local-zfs:vm-1001-disk-0" 160 | ) 161 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_storage_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright Tristan Le Guern (@tleguern) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_storage_info 14 | short_description: Retrieve information about one or more Proxmox VE storages 15 | description: 16 | - Retrieve information about one or more Proxmox VE storages. 17 | options: 18 | storage: 19 | description: 20 | - Only return information on a specific storage. 21 | aliases: ['name'] 22 | type: str 23 | type: 24 | description: 25 | - Filter on a specific storage type. 26 | type: str 27 | author: Tristan Le Guern (@tleguern) 28 | extends_documentation_fragment: 29 | - community.proxmox.proxmox.actiongroup_proxmox 30 | - community.proxmox.proxmox.documentation 31 | - community.proxmox.attributes 32 | - community.proxmox.attributes.info_module 33 | notes: 34 | - Storage specific options can be returned by this module, please look at the documentation at U(https://pve.proxmox.com/wiki/Storage). 35 | """ 36 | 37 | 38 | EXAMPLES = r""" 39 | - name: List existing storages 40 | community.proxmox.proxmox_storage_info: 41 | api_host: helldorado 42 | api_user: root@pam 43 | api_password: "{{ password | default(omit) }}" 44 | api_token_id: "{{ token_id | default(omit) }}" 45 | api_token_secret: "{{ token_secret | default(omit) }}" 46 | register: proxmox_storages 47 | 48 | - name: List NFS storages only 49 | community.proxmox.proxmox_storage_info: 50 | api_host: helldorado 51 | api_user: root@pam 52 | api_password: "{{ password | default(omit) }}" 53 | api_token_id: "{{ token_id | default(omit) }}" 54 | api_token_secret: "{{ token_secret | default(omit) }}" 55 | type: nfs 56 | register: proxmox_storages_nfs 57 | 58 | - name: Retrieve information about the lvm2 storage 59 | community.proxmox.proxmox_storage_info: 60 | api_host: helldorado 61 | api_user: root@pam 62 | api_password: "{{ password | default(omit) }}" 63 | api_token_id: "{{ token_id | default(omit) }}" 64 | api_token_secret: "{{ token_secret | default(omit) }}" 65 | storage: lvm2 66 | register: proxmox_storage_lvm 67 | """ 68 | 69 | 70 | RETURN = r""" 71 | proxmox_storages: 72 | description: List of storage pools. 73 | returned: on success 74 | type: list 75 | elements: dict 76 | contains: 77 | content: 78 | description: Proxmox content types available in this storage. 79 | returned: on success 80 | type: list 81 | elements: str 82 | digest: 83 | description: Storage's digest. 84 | returned: on success 85 | type: str 86 | nodes: 87 | description: List of nodes associated to this storage. 88 | returned: on success, if storage is not local 89 | type: list 90 | elements: str 91 | path: 92 | description: Physical path to this storage. 93 | returned: on success 94 | type: str 95 | prune-backups: 96 | description: Backup retention options. 97 | returned: on success 98 | type: list 99 | elements: dict 100 | shared: 101 | description: Is this storage shared. 102 | returned: on success 103 | type: bool 104 | storage: 105 | description: Storage name. 106 | returned: on success 107 | type: str 108 | type: 109 | description: Storage type. 110 | returned: on success 111 | type: str 112 | """ 113 | 114 | 115 | from ansible.module_utils.basic import AnsibleModule 116 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 117 | proxmox_auth_argument_spec, ProxmoxAnsible, proxmox_to_ansible_bool) 118 | 119 | 120 | class ProxmoxStorageInfoAnsible(ProxmoxAnsible): 121 | def get_storage(self, storage): 122 | try: 123 | storage = self.proxmox_api.storage.get(storage) 124 | except Exception: 125 | self.module.fail_json(msg="Storage '%s' does not exist" % storage) 126 | return ProxmoxStorage(storage) 127 | 128 | def get_storages(self, type=None): 129 | storages = self.proxmox_api.storage.get(type=type) 130 | storages = [ProxmoxStorage(storage) for storage in storages] 131 | return storages 132 | 133 | 134 | class ProxmoxStorage: 135 | def __init__(self, storage): 136 | self.storage = storage 137 | # Convert proxmox representation of lists, dicts and boolean for easier 138 | # manipulation within ansible. 139 | if 'shared' in self.storage: 140 | self.storage['shared'] = proxmox_to_ansible_bool(self.storage['shared']) 141 | if 'content' in self.storage: 142 | self.storage['content'] = self.storage['content'].split(',') 143 | if 'nodes' in self.storage: 144 | self.storage['nodes'] = self.storage['nodes'].split(',') 145 | if 'prune-backups' in storage: 146 | options = storage['prune-backups'].split(',') 147 | self.storage['prune-backups'] = dict() 148 | for option in options: 149 | k, v = option.split('=') 150 | self.storage['prune-backups'][k] = v 151 | 152 | 153 | def proxmox_storage_info_argument_spec(): 154 | return dict( 155 | storage=dict(type='str', aliases=['name']), 156 | type=dict(type='str'), 157 | ) 158 | 159 | 160 | def main(): 161 | module_args = proxmox_auth_argument_spec() 162 | storage_info_args = proxmox_storage_info_argument_spec() 163 | module_args.update(storage_info_args) 164 | 165 | module = AnsibleModule( 166 | argument_spec=module_args, 167 | required_one_of=[('api_password', 'api_token_id')], 168 | required_together=[('api_token_id', 'api_token_secret')], 169 | mutually_exclusive=[('storage', 'type')], 170 | supports_check_mode=True 171 | ) 172 | result = dict( 173 | changed=False 174 | ) 175 | 176 | proxmox = ProxmoxStorageInfoAnsible(module) 177 | storage = module.params['storage'] 178 | storagetype = module.params['type'] 179 | 180 | if storage: 181 | storages = [proxmox.get_storage(storage)] 182 | else: 183 | storages = proxmox.get_storages(type=storagetype) 184 | result['proxmox_storages'] = [storage.storage for storage in storages] 185 | 186 | module.exit_json(**result) 187 | 188 | 189 | if __name__ == '__main__': 190 | main() 191 | -------------------------------------------------------------------------------- /plugins/module_utils/proxmox_sdn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from typing import List, Dict 12 | 13 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 14 | ansible_to_proxmox_bool, 15 | proxmox_to_ansible_bool, 16 | ProxmoxAnsible 17 | ) 18 | 19 | 20 | class ProxmoxSdnAnsible(ProxmoxAnsible): 21 | """Base Class for All Proxmox SDN Classes""" 22 | 23 | def __init__(self, module): 24 | super(ProxmoxSdnAnsible, self).__init__(module) 25 | self.module = module 26 | 27 | def get_global_sdn_lock(self) -> str: 28 | """Acquire global SDN lock. Needed for any changes under SDN. 29 | 30 | :return: lock-token 31 | """ 32 | try: 33 | return self.proxmox_api.cluster().sdn().lock().post() 34 | except Exception as e: 35 | self.module.fail_json( 36 | msg=f'Failed to acquire global sdn lock {e}' 37 | ) 38 | 39 | def apply_sdn_changes_and_release_lock(self, lock: str, release_lock: bool = True) -> None: 40 | """Apply all SDN changes done under a lock token. 41 | 42 | :param lock: Global SDN lock token 43 | :param release_lock: if True release lock after successfully applying changes 44 | """ 45 | lock_params = { 46 | 'lock-token': lock, 47 | 'release-lock': ansible_to_proxmox_bool(release_lock) 48 | } 49 | try: 50 | self.proxmox_api.cluster().sdn().put(**lock_params) 51 | except Exception as e: 52 | self.rollback_sdn_changes_and_release_lock(lock) 53 | self.module.fail_json( 54 | msg=f'Failed to apply sdn changes {e}. Rolling back all pending changes.' 55 | ) 56 | 57 | def rollback_sdn_changes_and_release_lock(self, lock: str, release_lock: bool = True) -> None: 58 | """Rollback all changes done under a lock token. 59 | 60 | :param lock: Global SDN lock token 61 | :param release_lock: if True release lock after successfully rolling back changes 62 | """ 63 | lock_params = { 64 | 'lock-token': lock, 65 | 'release-lock': ansible_to_proxmox_bool(release_lock) 66 | } 67 | try: 68 | self.proxmox_api.cluster().sdn().rollback().post(**lock_params) 69 | except Exception as e: 70 | self.module.fail_json( 71 | msg=f'Rollback attempt failed - {e}. Manually clear lock by deleting /etc/pve/sdn/.lock' 72 | ) 73 | 74 | def release_lock(self, lock: str, force: bool = False) -> None: 75 | """Release Global SDN lock 76 | 77 | :param lock: Global SDN lock token 78 | :param force: if true, allow releasing lock without providing the token 79 | """ 80 | lock_params = { 81 | 'lock-token': lock, 82 | 'force': ansible_to_proxmox_bool(force) 83 | } 84 | try: 85 | self.proxmox_api.cluster().sdn().lock().delete(**lock_params) 86 | except Exception as e: 87 | self.module.fail_json( 88 | msg=f'Failed to release lock - {e}. Manually clear lock by deleting /etc/pve/sdn/.lock' 89 | ) 90 | 91 | def get_zones(self, zone_type: str = None) -> List[Dict]: 92 | """Get Proxmox SDN zones 93 | 94 | :param zone_type: Filter zones based on type. 95 | :return: list of all zones and their properties. 96 | """ 97 | try: 98 | return self.proxmox_api.cluster().sdn().zones().get(type=zone_type) 99 | except Exception as e: 100 | self.module.fail_json( 101 | msg=f'Failed to retrieve zone information from cluster: {e}' 102 | ) 103 | 104 | def get_aliases(self, firewall_obj): 105 | """Get aliases for IP/CIDR at given firewall endpoint level 106 | 107 | :param firewall_obj: Firewall endpoint as a ProxmoxResource e.g. self.proxmox_api.cluster().firewall 108 | If it is None it'll return an empty list 109 | :return: List of aliases and corresponding IP/CIDR 110 | """ 111 | if firewall_obj is None: 112 | return list() 113 | try: 114 | return firewall_obj().aliases().get() 115 | except Exception as e: 116 | self.module.fail_json( 117 | msg=f'Failed to retrieve aliases - {e}' 118 | ) 119 | 120 | def get_fw_rules(self, rules_obj, pos=None): 121 | """Get firewall rules at given rules endpoint level 122 | 123 | :param rules_obj: Firewall Rules endpoint as a ProxmoxResource e.g. self.proxmox_api.cluster().firewall().rules 124 | :param pos: Rule position if it is None it'll return all rules 125 | :return: Firewall rules as a list of dict 126 | """ 127 | if pos is not None: 128 | pos = str(pos) 129 | try: 130 | return rules_obj(pos).get() 131 | except Exception as e: 132 | self.module.fail_json( 133 | msg=f'Failed to retrieve firewall rules: {e}' 134 | ) 135 | 136 | def get_groups(self): 137 | """Get firewall security groups 138 | 139 | :return: list of groups 140 | """ 141 | try: 142 | return [x['group'] for x in self.proxmox_api.cluster().firewall().groups().get()] 143 | except Exception as e: 144 | self.module.fail_json( 145 | msg=f'Failed to retrieve firewall security groups: {e}' 146 | ) 147 | 148 | def get_ip_sets(self): 149 | """Get ipsets for firewall. 150 | 151 | :return: dict of ip_set name and cidr 152 | """ 153 | try: 154 | ip_sets = self.proxmox_api.cluster().firewall().ipset().get() 155 | for ip_set in ip_sets: 156 | ip_set_obj = getattr(self.proxmox_api.cluster().firewall().ipset(), ip_set['name']) 157 | cidrs = ip_set_obj.get() 158 | for cidr in cidrs: 159 | cidr['nomatch'] = proxmox_to_ansible_bool(cidr.get('nomatch')) 160 | ip_set['cidrs'] = cidrs 161 | return ip_sets 162 | except Exception as e: 163 | self.module.fail_json( 164 | msg=f'Failed to retrieve firewall ipsets: {e}' 165 | ) 166 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_tasks_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2021, Andreas Botzner (@paginabianca) 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | __metaclass__ = type 10 | 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_tasks_info 14 | short_description: Retrieve information about one or more Proxmox VE tasks 15 | description: 16 | - Retrieve information about one or more Proxmox VE tasks. 17 | author: 'Andreas Botzner (@paginabianca) ' 18 | options: 19 | node: 20 | description: 21 | - Node where to get tasks. 22 | required: true 23 | type: str 24 | task: 25 | description: 26 | - Return specific task. 27 | aliases: ['upid', 'name'] 28 | type: str 29 | source: 30 | description: 31 | - Source of tasks to be considered. 32 | type: str 33 | choices: ['archive', 'active', 'all'] 34 | default: archive 35 | extends_documentation_fragment: 36 | - community.proxmox.proxmox.actiongroup_proxmox 37 | - community.proxmox.proxmox.documentation 38 | - community.proxmox.attributes 39 | - community.proxmox.attributes.info_module 40 | """ 41 | 42 | 43 | EXAMPLES = r""" 44 | - name: List finished tasks on node01 45 | community.proxmox.proxmox_tasks_info: 46 | api_host: proxmoxhost 47 | api_user: root@pam 48 | api_password: '{{ password | default(omit) }}' 49 | api_token_id: '{{ token_id | default(omit) }}' 50 | api_token_secret: '{{ token_secret | default(omit) }}' 51 | node: node01 52 | register: result 53 | 54 | - name: List active tasks on node02 55 | community.proxmox.proxmox_tasks_info: 56 | api_host: proxmoxhost 57 | api_user: root@pam 58 | api_password: '{{ password | default(omit) }}' 59 | api_token_id: '{{ token_id | default(omit) }}' 60 | api_token_secret: '{{ token_secret | default(omit) }}' 61 | node: node02 62 | source: active 63 | register: result 64 | 65 | - name: Retrieve information about specific tasks on node01 66 | community.proxmox.proxmox_tasks_info: 67 | api_host: proxmoxhost 68 | api_user: root@pam 69 | api_password: '{{ password | default(omit) }}' 70 | api_token_id: '{{ token_id | default(omit) }}' 71 | api_token_secret: '{{ token_secret | default(omit) }}' 72 | task: 'UPID:node01:00003263:16167ACE:621EE230:srvreload:networking:root@pam:' 73 | node: node01 74 | register: proxmox_tasks 75 | """ 76 | 77 | 78 | RETURN = r""" 79 | proxmox_tasks: 80 | description: List of tasks. 81 | returned: on success 82 | type: list 83 | elements: dict 84 | contains: 85 | id: 86 | description: ID of the task. 87 | returned: on success 88 | type: str 89 | node: 90 | description: Node name. 91 | returned: on success 92 | type: str 93 | pid: 94 | description: PID of the task. 95 | returned: on success 96 | type: int 97 | pstart: 98 | description: Pastart of the task. 99 | returned: on success 100 | type: int 101 | starttime: 102 | description: Starting time of the task. 103 | returned: on success 104 | type: int 105 | type: 106 | description: Type of the task. 107 | returned: on success 108 | type: str 109 | upid: 110 | description: UPID of the task. 111 | returned: on success 112 | type: str 113 | user: 114 | description: User that owns the task. 115 | returned: on success 116 | type: str 117 | endtime: 118 | description: Endtime of the task. 119 | returned: on success, can be absent 120 | type: int 121 | status: 122 | description: Status of the task. 123 | returned: on success, can be absent 124 | type: str 125 | failed: 126 | description: If the task failed. 127 | returned: when status is defined 128 | type: bool 129 | msg: 130 | description: Short message. 131 | returned: on failure 132 | type: str 133 | sample: 'Task: UPID:xyz:xyz does not exist on node: proxmoxnode' 134 | """ 135 | 136 | from ansible.module_utils.basic import AnsibleModule 137 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 138 | proxmox_auth_argument_spec, ProxmoxAnsible) 139 | 140 | 141 | class ProxmoxTaskInfoAnsible(ProxmoxAnsible): 142 | def get_task(self, upid, node, source): 143 | tasks = self.get_tasks(node, source) 144 | for task in tasks: 145 | if task.info['upid'] == upid: 146 | return [task] 147 | 148 | def get_tasks(self, node, source): 149 | tasks = self.proxmox_api.nodes(node).tasks.get(source=source) 150 | return [ProxmoxTask(task) for task in tasks] 151 | 152 | 153 | class ProxmoxTask: 154 | def __init__(self, task): 155 | self.info = dict() 156 | for k, v in task.items(): 157 | if k == 'status' and isinstance(v, str): 158 | self.info[k] = v 159 | if v != 'OK': 160 | self.info['failed'] = True 161 | else: 162 | self.info[k] = v 163 | 164 | 165 | def proxmox_task_info_argument_spec(): 166 | return dict( 167 | task=dict(type='str', aliases=['upid', 'name'], required=False), 168 | node=dict(type='str', required=True), 169 | source=dict(default='archive', choices=['archive', 'active', 'all']), 170 | ) 171 | 172 | 173 | def main(): 174 | module_args = proxmox_auth_argument_spec() 175 | task_info_args = proxmox_task_info_argument_spec() 176 | module_args.update(task_info_args) 177 | 178 | module = AnsibleModule( 179 | argument_spec=module_args, 180 | required_together=[('api_token_id', 'api_token_secret')], 181 | required_one_of=[('api_password', 'api_token_id')], 182 | supports_check_mode=True) 183 | result = dict(changed=False) 184 | 185 | proxmox = ProxmoxTaskInfoAnsible(module) 186 | upid = module.params['task'] 187 | node = module.params['node'] 188 | source = module.params['source'] 189 | if upid: 190 | tasks = proxmox.get_task(upid=upid, node=node, source=source) 191 | else: 192 | tasks = proxmox.get_tasks(node=node, source=source) 193 | if tasks is not None: 194 | result['proxmox_tasks'] = [task.info for task in tasks] 195 | module.exit_json(**result) 196 | else: 197 | result['msg'] = 'Task: {0} does not exist on node: {1}.'.format( 198 | upid, node) 199 | module.fail_json(**result) 200 | 201 | 202 | if __name__ == '__main__': 203 | main() 204 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_access_acl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright (c) 2025, Markus Kötter 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-FileCopyrightText: (c) 2025, Markus Kötter 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | DOCUMENTATION = r''' 11 | --- 12 | module: proxmox_access_acl 13 | 14 | short_description: Management of ACLs for objects in Proxmox VE Cluster 15 | 16 | version_added: "1.1.0" 17 | 18 | description: 19 | - Setting ACLs via C(/access/acls) to grant permission to interact with objects. 20 | 21 | attributes: 22 | check_mode: 23 | support: none 24 | diff_mode: 25 | support: none 26 | 27 | options: 28 | state: 29 | description: create or delete 30 | required: true 31 | choices: ['present', 'absent'] 32 | type: str 33 | path: 34 | description: Access Control Path 35 | required: false 36 | type: str 37 | roleid: 38 | description: name of the role 39 | required: false 40 | type: str 41 | type: 42 | description: type of access control 43 | choices: ["user", "group", "token"] 44 | required: false 45 | type: str 46 | ugid: 47 | description: id of user or group 48 | required: false 49 | type: str 50 | propagate: 51 | description: Allow to propagate (inherit) permissions. 52 | required: false 53 | type: bool 54 | default: 1 55 | extends_documentation_fragment: 56 | - community.proxmox.proxmox.actiongroup_proxmox 57 | - community.proxmox.proxmox.documentation 58 | - community.proxmox.attributes 59 | author: 60 | - Markus Kötter (@commonism) 61 | ''' 62 | 63 | EXAMPLES = r''' 64 | - name: Create ACE 65 | community.proxmox.proxmox_access_acl: 66 | api_host: "{{ ansible_host }}" 67 | api_password: "{{ proxmox_root_pw | default(lookup('ansible.builtin.env', 'PROXMOX_PASSWORD', default='')) }}" 68 | api_user: root@pam 69 | 70 | state: "present" 71 | path: /vms/100 72 | type: user 73 | ugid: "a01mako@pam" 74 | roleid: PVEVMUser 75 | propagate: 1 76 | 77 | - name: Delete all ACEs for a given path 78 | community.proxmox.proxmox_access_acl: 79 | api_host: "{{ ansible_host }}" 80 | api_password: "{{ proxmox_root_pw | default(lookup('ansible.builtin.env', 'PROXMOX_PASSWORD', default='')) }}" 81 | api_user: root@pam 82 | 83 | state: "absent" 84 | path: /vms/100 85 | ''' 86 | 87 | RETURN = r''' 88 | # These are examples of possible return values, and in general should use other names for return values. 89 | old_acls: 90 | description: The original name param that was passed in. 91 | type: list 92 | returned: always 93 | new_acls: 94 | description: The output message that the test module generates. 95 | type: list 96 | returned: when changed 97 | ''' 98 | 99 | from ansible.module_utils.basic import AnsibleModule 100 | 101 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, ProxmoxAnsible) 102 | 103 | 104 | class ProxmoxAccessACLAnsible(ProxmoxAnsible): 105 | def _get(self): 106 | acls = self.proxmox_api.access.acl.get() 107 | return acls 108 | 109 | def _put(self, **data): 110 | return self.proxmox_api.access.acl.put(**data) 111 | 112 | def create(self, acls, path, roleid, type, ugid, propagate): 113 | for ace in acls: 114 | if (ace["path"], ace["roleid"], ace["type"], ace["ugid"], bool(ace.get("propagate", 1))) == (path, roleid, type, ugid, propagate): 115 | return False 116 | 117 | data = { 118 | "path": path, 119 | "roles": roleid, 120 | "propagate": int(propagate), 121 | f"{type}s": ugid 122 | } 123 | 124 | self._put(**data) 125 | return True 126 | 127 | def delete(self, acls, path, roleid, type, ugid, propagate): 128 | changed = False 129 | for ace in acls: 130 | if path != ace["path"]: 131 | continue 132 | if roleid and roleid != ace["roleid"]: 133 | continue 134 | if type and type != ace["type"]: 135 | continue 136 | if ugid and ace["ugid"] != ugid: 137 | continue 138 | if propagate and bool(ace.get("propagate", 1)) != propagate: 139 | continue 140 | 141 | data = { 142 | "path": ace["path"], 143 | "roles": ace["roleid"], 144 | "propagate": ace["propagate"], 145 | f'{ace["type"]}s': ace["ugid"] 146 | } 147 | 148 | self._put(**data, delete="1") 149 | changed = True 150 | return changed 151 | 152 | 153 | def run_module(): 154 | module_args = proxmox_auth_argument_spec() 155 | 156 | acl_args = dict( 157 | state=dict(choices=['present', 'absent'], required=True), 158 | path=dict(type='str', required=False), 159 | roleid=dict(type='str', required=False), 160 | type=dict(type='str', choices=["user", "group", "token"]), 161 | ugid=dict(type='str'), 162 | propagate=dict(type='bool', default=True), 163 | ) 164 | 165 | module_args.update(acl_args) 166 | 167 | result = dict( 168 | changed=False, 169 | old_acls=[], 170 | ) 171 | 172 | module = AnsibleModule( 173 | argument_spec=module_args, 174 | supports_check_mode=False 175 | ) 176 | 177 | if module.params["state"] == "present": 178 | required = frozenset({"path", "roleid", "type", "ugid"}) 179 | exists = frozenset(map(lambda x: x[0], filter(lambda x: x[1] is not None, module.params.items()))) 180 | if len(required - exists) > 0: 181 | result["failed"] = True 182 | result["missing_parameters"] = required - exists 183 | module.fail_json(msg="The following required parameters are not provided {}".format(sorted(required - exists)), **result) 184 | 185 | proxmox = ProxmoxAccessACLAnsible(module) 186 | 187 | path = module.params["path"] 188 | roleid = module.params["roleid"] 189 | type = module.params["type"] 190 | ugid = module.params["ugid"] 191 | propagate = module.params["propagate"] 192 | 193 | try: 194 | result["old_acls"] = acls = proxmox._get() 195 | 196 | if module.params["state"] == "present": 197 | r = proxmox.create(acls, path, roleid, type, ugid, propagate) 198 | else: 199 | r = proxmox.delete(acls, path, roleid, type, ugid, propagate) 200 | 201 | result['changed'] = r 202 | if r: 203 | result["new_acls"] = proxmox._get() 204 | except Exception as e: 205 | module.fail_json(msg=str(e), **result) 206 | 207 | module.exit_json(**result) 208 | 209 | 210 | def main(): 211 | run_module() 212 | 213 | 214 | if __name__ == '__main__': 215 | main() 216 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Kevin Quick 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | import sys 10 | from unittest.mock import MagicMock as MagicMike, patch 11 | 12 | import pytest 13 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 14 | AnsibleExitJson, AnsibleFailJson, set_module_args, ModuleTestCase) 15 | 16 | # Skip tests if proxmoxer is not available 17 | proxmoxer = pytest.importorskip('proxmoxer') 18 | 19 | # Handle different import paths for different test environments 20 | try: 21 | from ansible_collections.community.proxmox.plugins.modules import proxmox_user 22 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 23 | except ImportError: 24 | sys.path.insert(0, 'plugins/modules') 25 | import proxmox_user 26 | sys.path.insert(0, 'plugins/module_utils') 27 | import proxmox as proxmox_utils 28 | 29 | 30 | class TestProxmoxUserModule(ModuleTestCase): 31 | """Test cases for proxmox_user module using ModuleTestCase pattern.""" 32 | 33 | # Common test data 34 | BASIC_MODULE_ARGS = { 35 | 'api_host': 'test.proxmox.com', 36 | 'api_user': 'root@pam', 37 | 'api_password': 'secret', 38 | } 39 | 40 | SAMPLE_USER = { 41 | 'userid': 'testuser@pam', 42 | 'comment': 'Test User', 43 | 'email': 'test@example.com', 44 | 'enable': 1, 45 | 'expire': 0, 46 | 'firstname': 'John', 47 | 'lastname': 'Doe', 48 | 'groups': ['admins'], 49 | 'keys': '' 50 | } 51 | 52 | def setUp(self): 53 | super(TestProxmoxUserModule, self).setUp() 54 | proxmox_utils.HAS_PROXMOXER = True 55 | self.module = proxmox_user 56 | self.connect_mock = patch("ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect") 57 | self.connect_mock.start() 58 | 59 | def tearDown(self): 60 | self.connect_mock.stop() 61 | super(TestProxmoxUserModule, self).tearDown() 62 | 63 | def _create_module_args(self, **kwargs): 64 | """Helper to create module arguments with defaults.""" 65 | args = self.BASIC_MODULE_ARGS.copy() 66 | args.update(kwargs) 67 | return args 68 | 69 | def test_module_fail_when_required_args_missing(self): 70 | """Test module fails with missing required arguments""" 71 | with set_module_args({}): 72 | with pytest.raises(AnsibleFailJson): 73 | proxmox_user.main() 74 | 75 | def test_user_creation_check_mode(self): 76 | """Test user creation in check mode""" 77 | module_args = self._create_module_args(userid='testuser@pam', comment='Test User', state='present', _ansible_check_mode=True) 78 | 79 | with set_module_args(module_args): 80 | with patch.object(proxmox_user.ProxmoxUserAnsible, 'is_user_existing', return_value=False): 81 | with pytest.raises(AnsibleExitJson) as exc_info: 82 | proxmox_user.main() 83 | 84 | result = exc_info.value.args[0] 85 | assert result['changed'] is True 86 | assert 'check mode' in result['msg'] 87 | 88 | def test_user_update_no_changes_needed(self): 89 | """Test user update when no changes needed""" 90 | module_args = self._create_module_args(userid='testuser@pam', comment='Test User', state='present') 91 | existing_user = { 92 | 'userid': 'testuser@pam', 'comment': 'Test User', 'email': '', 'enable': 1, 'expire': 0, 93 | 'firstname': '', 'lastname': '', 'groups': [], 'keys': '' 94 | } 95 | 96 | with set_module_args(module_args): 97 | mock_user_exists = patch.object(proxmox_user.ProxmoxUserAnsible, 'is_user_existing', return_value=existing_user) 98 | mock_needs_update = patch.object(proxmox_user.ProxmoxUserAnsible, '_user_needs_update', return_value=False) 99 | 100 | with mock_user_exists, mock_needs_update: 101 | with pytest.raises(AnsibleExitJson) as exc_info: 102 | proxmox_user.main() 103 | 104 | result = exc_info.value.args[0] 105 | assert result['changed'] is False 106 | assert 'already up to date' in result['msg'] 107 | 108 | 109 | # Tests for internal methods and business logic 110 | class TestProxmoxUserInternals: 111 | """Test internal methods and business logic of ProxmoxUserAnsible class.""" 112 | 113 | # Test data for internal method testing 114 | SAMPLE_EXISTING_USER = { 115 | 'userid': 'testuser@pam', 116 | 'comment': 'Old comment', 117 | 'email': 'old@example.com', 118 | 'enable': 1, 119 | 'expire': 0, 120 | 'firstname': 'John', 121 | 'lastname': 'Doe', 122 | 'groups': ['admins'], 123 | 'keys': '' 124 | } 125 | 126 | @pytest.fixture 127 | def user_manager(self): 128 | """Create a ProxmoxUserAnsible instance for internal testing.""" 129 | module = MagicMike() 130 | module.check_mode = False 131 | module.exit_json = MagicMike() 132 | module.fail_json = MagicMike() 133 | 134 | with patch.object(proxmox_utils.ProxmoxAnsible, '__init__', return_value=None): 135 | manager = proxmox_user.ProxmoxUserAnsible(module) 136 | manager.module = module 137 | manager.proxmox_api = MagicMike() 138 | return manager 139 | 140 | def test_user_needs_update_logic(self, user_manager): 141 | """Test the _user_needs_update comparison logic for various scenarios.""" 142 | existing_user = self.SAMPLE_EXISTING_USER.copy() 143 | 144 | # Test case: No update needed - identical data 145 | no_update_needed = user_manager._user_needs_update( 146 | existing_user, 'Old comment', 'old@example.com', 1, 0, 'John', 'Doe', 'admins', '' 147 | ) 148 | assert no_update_needed is False 149 | 150 | # Test case: Update needed - different comment 151 | update_needed = user_manager._user_needs_update( 152 | existing_user, 'New comment', 'old@example.com', 1, 0, 'John', 'Doe', 'admins', '' 153 | ) 154 | assert update_needed is True 155 | 156 | def test_groups_format_handling(self, user_manager): 157 | """Test groups comparison between API format (list) and module input format (string).""" 158 | existing_user_with_groups = {'userid': 'testuser@pam', 'groups': ['admins', 'users']} 159 | 160 | # Test case: Same groups in different formats - no update needed 161 | same_groups = user_manager._user_needs_update( 162 | existing_user_with_groups, None, None, 1, None, None, None, 'admins,users', None 163 | ) 164 | assert same_groups is False 165 | 166 | # Test case: Different groups - update needed 167 | different_groups = user_manager._user_needs_update( 168 | existing_user_with_groups, None, None, 1, None, None, None, 'admins', None 169 | ) 170 | assert different_groups is True 171 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_vnet_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2025, Jana Hoch 5 | # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | 8 | from __future__ import absolute_import, division, print_function 9 | 10 | __metaclass__ = type 11 | 12 | DOCUMENTATION = r""" 13 | module: proxmox_vnet_info 14 | short_description: Retrieve information about one or more Proxmox VE SDN vnets. 15 | version_added: "1.4.0" 16 | description: 17 | - Retrieve information about one or more Proxmox VE SDN vnets. 18 | author: 'Jana Hoch (!UNKNOWN)' 19 | options: 20 | vnet: 21 | description: 22 | - Restrict results to a specific vnet. 23 | type: str 24 | 25 | extends_documentation_fragment: 26 | - community.proxmox.proxmox.actiongroup_proxmox 27 | - community.proxmox.proxmox.documentation 28 | - community.proxmox.attributes 29 | - community.proxmox.attributes.info_module 30 | """ 31 | 32 | EXAMPLES = r""" 33 | - name: Get all vnet details 34 | community.proxmox.proxmox_vnet_info: 35 | api_user: "{{ proxmox.api_user }}" 36 | api_token_id: "{{ proxmox.api_token_id }}" 37 | api_token_secret: "{{ vault.proxmox.api_token_secret }}" 38 | api_host: "{{ proxmox.api_host }}" 39 | validate_certs: false 40 | 41 | - name: Get details for vnet - test 42 | community.proxmox.proxmox_vnet_info: 43 | api_user: "{{ proxmox.api_user }}" 44 | api_token_id: "{{ proxmox.api_token_id }}" 45 | api_token_secret: "{{ vault.proxmox.api_token_secret }}" 46 | api_host: "{{ proxmox.api_host }}" 47 | vnet: test 48 | validate_certs: false 49 | """ 50 | 51 | RETURN = r""" 52 | vnets: 53 | description: List of vnets. 54 | returned: on success 55 | type: list 56 | elements: dict 57 | sample: 58 | [ 59 | { 60 | "digest": "01505201eb33919888fb0cacba27d3aae803f6d2", 61 | "firewall_rules": [], 62 | "subnets": [ 63 | { 64 | "cidr": "10.10.100.0/24", 65 | "dhcp-range": [], 66 | "digest": "47684c511d9b67e8eb41b93bc5c0b078786b0ee3", 67 | "id": "lab-10.10.100.0-24", 68 | "mask": "24", 69 | "network": "10.10.100.0", 70 | "snat": 1, 71 | "subnet": "lab-10.10.100.0-24", 72 | "type": "subnet", 73 | "vnet": "lab", 74 | "zone": "lab" 75 | } 76 | ], 77 | "tag": 100, 78 | "type": "vnet", 79 | "vnet": "lab", 80 | "zone": "lab" 81 | }, 82 | { 83 | "digest": "01505201eb33919888fb0cacba27d3aae803f6d2", 84 | "firewall_rules": [ 85 | { 86 | "action": "ACCEPT", 87 | "dest": "+sdn/test2-gateway", 88 | "digest": "36016a02a5387d4c1171d29be966d550216bc500", 89 | "enable": 1, 90 | "log": "nolog", 91 | "macro": "DNS", 92 | "pos": 0, 93 | "type": "forward" 94 | }, 95 | { 96 | "action": "ACCEPT", 97 | "digest": "36016a02a5387d4c1171d29be966d550216bc500", 98 | "enable": 1, 99 | "log": "nolog", 100 | "macro": "DHCPfwd", 101 | "pos": 1, 102 | "type": "forward" 103 | } 104 | ], 105 | "subnets": [ 106 | { 107 | "cidr": "10.10.0.0/24", 108 | "dhcp-range": [ 109 | { 110 | "end-address": "10.10.0.50", 111 | "start-address": "10.10.0.5" 112 | } 113 | ], 114 | "digest": "47684c511d9b67e8eb41b93bc5c0b078786b0ee3", 115 | "gateway": "10.10.0.1", 116 | "id": "test1-10.10.0.0-24", 117 | "mask": "24", 118 | "network": "10.10.0.0", 119 | "subnet": "test1-10.10.0.0-24", 120 | "type": "subnet", 121 | "vnet": "test2", 122 | "zone": "test1" 123 | }, 124 | { 125 | "cidr": "10.10.1.0/24", 126 | "dhcp-range": [ 127 | { 128 | "end-address": "10.10.1.50", 129 | "start-address": "10.10.1.5" 130 | } 131 | ], 132 | "digest": "47684c511d9b67e8eb41b93bc5c0b078786b0ee3", 133 | "gateway": "10.10.1.0", 134 | "id": "test1-10.10.1.0-24", 135 | "mask": "24", 136 | "network": "10.10.1.0", 137 | "subnet": "test1-10.10.1.0-24", 138 | "type": "subnet", 139 | "vnet": "test2", 140 | "zone": "test1" 141 | } 142 | ], 143 | "type": "vnet", 144 | "vnet": "test2", 145 | "zone": "test1" 146 | } 147 | ] 148 | """ 149 | 150 | from ansible.module_utils.basic import AnsibleModule 151 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( 152 | proxmox_auth_argument_spec, 153 | ProxmoxAnsible 154 | ) 155 | 156 | 157 | class ProxmoxVnetInfoAnsible(ProxmoxAnsible): 158 | def get_subnets(self, vnet): 159 | try: 160 | return self.proxmox_api.cluster().sdn().vnets(vnet).subnets().get() 161 | except Exception as e: 162 | self.module.fail_json( 163 | msg=f'Failed to retrieve subnet information from vnet {vnet}: {e}' 164 | ) 165 | 166 | def get_firewall(self, vnet_name): 167 | try: 168 | return self.proxmox_api.cluster().sdn().vnets(vnet_name).firewall().rules().get() 169 | except Exception as e: 170 | self.module.fail_json( 171 | msg=f'Failed to retrieve subnet information from vnet {vnet_name}: {e}' 172 | ) 173 | 174 | def get_vnet_detail(self): 175 | try: 176 | vnets = self.proxmox_api.cluster().sdn().vnets().get() 177 | for vnet in vnets: 178 | vnet['subnets'] = self.get_subnets(vnet['vnet']) 179 | vnet['firewall_rules'] = self.get_firewall(vnet['vnet']) 180 | return vnets 181 | except Exception as e: 182 | self.module.fail_json( 183 | msg=f'Failed to retrieve vnet information from cluster: {e}' 184 | ) 185 | 186 | 187 | def main(): 188 | module_args = proxmox_auth_argument_spec() 189 | vnet_info_args = dict( 190 | vnet=dict(type="str", required=False) 191 | ) 192 | module_args.update(vnet_info_args) 193 | 194 | module = AnsibleModule( 195 | argument_spec=module_args, 196 | required_together=[("api_token_id", "api_token_secret")], 197 | required_one_of=[("api_password", "api_token_id")], 198 | supports_check_mode=True, 199 | ) 200 | 201 | proxmox = ProxmoxVnetInfoAnsible(module) 202 | vnet = module.params['vnet'] 203 | vnets = proxmox.get_vnet_detail() 204 | 205 | if vnet: 206 | vnets = [vnet_details for vnet_details in vnets if vnet_details['vnet'] == vnet] 207 | 208 | module.exit_json( 209 | changed=False, 210 | vnets=vnets, 211 | msg='Successfully retrieved vnet info' 212 | ) 213 | 214 | 215 | if __name__ == "__main__": 216 | main() 217 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_tasks_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021, Andreas Botzner (@paginabianca) 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | # 7 | # Proxmox Tasks module unit tests. 8 | # The API responses used in these tests were recorded from PVE version 6.4-8 9 | 10 | from __future__ import (absolute_import, division, print_function) 11 | __metaclass__ = type 12 | 13 | import json 14 | from unittest.mock import patch 15 | 16 | import pytest 17 | 18 | proxmoxer = pytest.importorskip('proxmoxer') 19 | 20 | from ansible_collections.community.proxmox.plugins.modules import proxmox_tasks_info 21 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 22 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args 23 | 24 | NODE = 'node01' 25 | TASK_UPID = 'UPID:iaclab-01-01:000029DD:1599528B:6108F068:srvreload:networking:root@pam:' 26 | TASKS = [ 27 | { 28 | "endtime": 1629092710, 29 | "id": "networking", 30 | "node": "iaclab-01-01", 31 | "pid": 3539, 32 | "pstart": 474062216, 33 | "starttime": 1629092709, 34 | "status": "OK", 35 | "type": "srvreload", 36 | "upid": "UPID:iaclab-01-01:00000DD3:1C419D88:6119FB65:srvreload:networking:root@pam:", 37 | "user": "root@pam" 38 | }, 39 | { 40 | "endtime": 1627975785, 41 | "id": "networking", 42 | "node": "iaclab-01-01", 43 | "pid": 10717, 44 | "pstart": 362369675, 45 | "starttime": 1627975784, 46 | "status": "command 'ifreload -a' failed: exit code 1", 47 | "type": "srvreload", 48 | "upid": "UPID:iaclab-01-01:000029DD:1599528B:6108F068:srvreload:networking:root@pam:", 49 | "user": "root@pam" 50 | }, 51 | { 52 | "endtime": 1627975503, 53 | "id": "networking", 54 | "node": "iaclab-01-01", 55 | "pid": 6778, 56 | "pstart": 362341540, 57 | "starttime": 1627975503, 58 | "status": "OK", 59 | "type": "srvreload", 60 | "upid": "UPID:iaclab-01-01:00001A7A:1598E4A4:6108EF4F:srvreload:networking:root@pam:", 61 | "user": "root@pam" 62 | } 63 | ] 64 | EXPECTED_TASKS = [ 65 | { 66 | "endtime": 1629092710, 67 | "id": "networking", 68 | "node": "iaclab-01-01", 69 | "pid": 3539, 70 | "pstart": 474062216, 71 | "starttime": 1629092709, 72 | "status": "OK", 73 | "type": "srvreload", 74 | "upid": "UPID:iaclab-01-01:00000DD3:1C419D88:6119FB65:srvreload:networking:root@pam:", 75 | "user": "root@pam", 76 | "failed": False 77 | }, 78 | { 79 | "endtime": 1627975785, 80 | "id": "networking", 81 | "node": "iaclab-01-01", 82 | "pid": 10717, 83 | "pstart": 362369675, 84 | "starttime": 1627975784, 85 | "status": "command 'ifreload -a' failed: exit code 1", 86 | "type": "srvreload", 87 | "upid": "UPID:iaclab-01-01:000029DD:1599528B:6108F068:srvreload:networking:root@pam:", 88 | "user": "root@pam", 89 | "failed": True 90 | }, 91 | { 92 | "endtime": 1627975503, 93 | "id": "networking", 94 | "node": "iaclab-01-01", 95 | "pid": 6778, 96 | "pstart": 362341540, 97 | "starttime": 1627975503, 98 | "status": "OK", 99 | "type": "srvreload", 100 | "upid": "UPID:iaclab-01-01:00001A7A:1598E4A4:6108EF4F:srvreload:networking:root@pam:", 101 | "user": "root@pam", 102 | "failed": False 103 | } 104 | ] 105 | 106 | EXPECTED_SINGLE_TASK = [ 107 | { 108 | "endtime": 1627975785, 109 | "id": "networking", 110 | "node": "iaclab-01-01", 111 | "pid": 10717, 112 | "pstart": 362369675, 113 | "starttime": 1627975784, 114 | "status": "command 'ifreload -a' failed: exit code 1", 115 | "type": "srvreload", 116 | "upid": "UPID:iaclab-01-01:000029DD:1599528B:6108F068:srvreload:networking:root@pam:", 117 | "user": "root@pam", 118 | "failed": True 119 | }, 120 | ] 121 | 122 | 123 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 124 | def test_without_required_parameters(connect_mock, capfd, mocker): 125 | with set_module_args({}): 126 | with pytest.raises(SystemExit): 127 | proxmox_tasks_info.main() 128 | out, err = capfd.readouterr() 129 | assert not err 130 | assert json.loads(out)['failed'] 131 | 132 | 133 | def mock_api_tasks_response(mocker): 134 | m = mocker.MagicMock() 135 | g = mocker.MagicMock() 136 | m.nodes = mocker.MagicMock(return_value=g) 137 | g.tasks.get = mocker.MagicMock(return_value=TASKS) 138 | return m 139 | 140 | 141 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 142 | def test_get_tasks(connect_mock, capfd, mocker): 143 | with set_module_args({ 144 | 'api_host': 'proxmoxhost', 145 | 'api_user': 'root@pam', 146 | 'api_password': 'supersecret', 147 | 'node': NODE 148 | }): 149 | connect_mock.side_effect = lambda: mock_api_tasks_response(mocker) 150 | proxmox_utils.HAS_PROXMOXER = True 151 | 152 | with pytest.raises(SystemExit): 153 | proxmox_tasks_info.main() 154 | out, err = capfd.readouterr() 155 | assert not err 156 | assert len(json.loads(out)['proxmox_tasks']) != 0 157 | assert not json.loads(out)['changed'] 158 | 159 | 160 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 161 | def test_get_single_task(connect_mock, capfd, mocker): 162 | with set_module_args({ 163 | 'api_host': 'proxmoxhost', 164 | 'api_user': 'root@pam', 165 | 'api_password': 'supersecret', 166 | 'node': NODE, 167 | 'task': TASK_UPID 168 | }): 169 | connect_mock.side_effect = lambda: mock_api_tasks_response(mocker) 170 | proxmox_utils.HAS_PROXMOXER = True 171 | 172 | with pytest.raises(SystemExit): 173 | proxmox_tasks_info.main() 174 | out, err = capfd.readouterr() 175 | assert not err 176 | assert len(json.loads(out)['proxmox_tasks']) == 1 177 | assert json.loads(out) 178 | assert not json.loads(out)['changed'] 179 | 180 | 181 | @patch('ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect') 182 | def test_get_non_existent_task(connect_mock, capfd, mocker): 183 | with set_module_args({ 184 | 'api_host': 'proxmoxhost', 185 | 'api_user': 'root@pam', 186 | 'api_password': 'supersecret', 187 | 'node': NODE, 188 | 'task': 'UPID:nonexistent' 189 | }): 190 | connect_mock.side_effect = lambda: mock_api_tasks_response(mocker) 191 | proxmox_utils.HAS_PROXMOXER = True 192 | 193 | with pytest.raises(SystemExit): 194 | proxmox_tasks_info.main() 195 | out, err = capfd.readouterr() 196 | assert not err 197 | assert json.loads(out)['failed'] 198 | assert 'proxmox_tasks' not in json.loads(out) 199 | assert not json.loads(out)['changed'] 200 | assert json.loads( 201 | out)['msg'] == 'Task: UPID:nonexistent does not exist on node: node01.' 202 | -------------------------------------------------------------------------------- /plugins/modules/proxmox_cluster_ha_groups.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright (c) 2025, Markus Kötter 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | # SPDX-FileCopyrightText: (c) 2025, Markus Kötter 6 | # SPDX-License-Identifier: GPL-3.0-or-later 7 | from __future__ import (absolute_import, division, print_function) 8 | 9 | __metaclass__ = type 10 | 11 | DOCUMENTATION = r''' 12 | --- 13 | module: proxmox_cluster_ha_groups 14 | 15 | short_description: Management of HA groups in Proxmox VE Cluster 16 | 17 | version_added: "1.1.0" 18 | 19 | description: 20 | - Configure HA groups via C(/cluster/ha/groups). 21 | 22 | attributes: 23 | check_mode: 24 | support: none 25 | diff_mode: 26 | support: none 27 | 28 | options: 29 | state: 30 | description: Whether the HA groups should be there (created if missing) or not (deleted if they exist). 31 | required: true 32 | choices: ['present', 'absent'] 33 | type: str 34 | name: 35 | description: group name 36 | required: true 37 | type: str 38 | comment: 39 | description: Description 40 | required: false 41 | type: str 42 | nodes: 43 | description: | 44 | List of cluster node members, where a priority can be given to each node. A resource bound to a group will run on the available nodes with the 45 | highest priority. If there are more nodes in the highest priority class, the services will get distributed to those nodes. The priorities have a 46 | relative meaning only. The higher the number, the higher the priority. 47 | It can either be a string C(node_name:priority,node_name:priority) or an actual list of strings. 48 | required: false 49 | type: list 50 | elements: str 51 | nofailback: 52 | description: | 53 | The CRM tries to run services on the node with the highest priority. If a node with higher priority comes online, the CRM migrates the service to 54 | that node. Setting O(nofailback=true) prevents that behavior. 55 | required: false 56 | type: bool 57 | default: false 58 | restricted: 59 | description: | 60 | Resources bound to restricted groups may only run on nodes defined by the group. The resource will be placed in the stopped state if no group node 61 | member is online. Resources on unrestricted groups may run on any cluster node if all group members are offline, but they will migrate back as 62 | soon as a group member comes online. One can implement a 'preferred node' behavior using an unrestricted group with only one member. 63 | required: False 64 | type: bool 65 | default: false 66 | extends_documentation_fragment: 67 | - community.proxmox.proxmox.actiongroup_proxmox 68 | - community.proxmox.proxmox.documentation 69 | - community.proxmox.attributes 70 | author: 71 | - Markus Kötter (@commonism) 72 | ''' 73 | 74 | EXAMPLES = r''' 75 | - name: Create HA group 76 | community.proxmox.proxmox_cluster_ha_groups: 77 | api_host: "{{ ansible_host }}" 78 | api_password: "{{ proxmox_root_pw | default(lookup('ansible.builtin.env', 'PROXMOX_PASSWORD', default='')) }}" 79 | api_user: root@pam 80 | 81 | state: "present" 82 | name: ha0 83 | comment: yes 84 | nodes: node0:0,node1:1 85 | nofailback: true 86 | restricted: false 87 | 88 | - name: Delete HA group 89 | community.proxmox.proxmox_cluster_ha_groups: 90 | api_host: "{{ ansible_host }}" 91 | api_password: "{{ proxmox_root_pw | default(lookup('ansible.builtin.env', 'PROXMOX_PASSWORD', default='')) }}" 92 | api_user: root@pam 93 | 94 | state: "absent" 95 | name: ha0 96 | ''' 97 | 98 | RETURN = r'''#''' 99 | 100 | from ansible.module_utils.basic import AnsibleModule 101 | 102 | from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, 103 | ProxmoxAnsible) 104 | 105 | 106 | class ProxmoxClusterHAGroupsAnsible(ProxmoxAnsible): 107 | def _get(self): 108 | groups = self.proxmox_api.cluster.ha.groups.get() 109 | return groups 110 | 111 | def _post(self, **data): 112 | return self.proxmox_api.cluster.ha.groups.post(**data) 113 | 114 | def _put(self, name, data): 115 | return self.proxmox_api.cluster.ha.groups(name).put(**data) 116 | 117 | def _delete(self, name): 118 | return self.proxmox_api.cluster.ha.groups(name).delete() 119 | 120 | def create(self, groups, name, comment, nodes, nofailback, restricted): 121 | data = { 122 | "comment": comment, 123 | "nodes": ",".join(nodes), 124 | "nofailback": int(nofailback), 125 | "restricted": int(restricted) 126 | } 127 | 128 | for group in groups: 129 | if group["group"] != name: 130 | continue 131 | 132 | group["nodes"] = sorted( 133 | group.get("nodes", "").split(",") 134 | ) 135 | 136 | if ( 137 | group.get("comment", ""), 138 | group.get("nodes", ""), 139 | bool(group.get("nofailback", 0)), 140 | bool(group.get("restricted", 0)) 141 | ) == (comment, nodes, nofailback, restricted): 142 | return False 143 | else: 144 | self._put(name, data) 145 | return True 146 | 147 | self._post(group=name, **data) 148 | return True 149 | 150 | def delete(self, groups, name): 151 | for group in groups: 152 | if group["group"] != name: 153 | continue 154 | self._delete(name) 155 | return True 156 | 157 | return False 158 | 159 | 160 | def run_module(): 161 | module_args = proxmox_auth_argument_spec() 162 | 163 | acl_args = dict( 164 | state=dict(choices=['present', 'absent'], required=True), 165 | name=dict(type='str', required=True), 166 | comment=dict(type='str', required=False), 167 | nodes=dict(type='list', elements='str', required=False), 168 | nofailback=dict(type='bool', default=False), 169 | restricted=dict(type='bool', default=False), 170 | ) 171 | 172 | module_args.update(acl_args) 173 | 174 | result = dict( 175 | changed=False, 176 | ) 177 | 178 | module = AnsibleModule( 179 | argument_spec=module_args, 180 | supports_check_mode=False 181 | ) 182 | 183 | proxmox = ProxmoxClusterHAGroupsAnsible(module) 184 | 185 | name = module.params['name'] 186 | comment = module.params['comment'] 187 | nodes = sorted(module.params['nodes']) 188 | nofailback = module.params['nofailback'] 189 | restricted = module.params['restricted'] 190 | try: 191 | groups = proxmox._get() 192 | 193 | if module.params["state"] == "present": 194 | changed = proxmox.create(groups, name, comment, nodes, nofailback, restricted) 195 | else: 196 | changed = proxmox.delete(groups, name) 197 | 198 | result['changed'] = changed 199 | except Exception as e: 200 | module.fail_json(msg=str(e), **result) 201 | 202 | module.exit_json(**result) 203 | 204 | 205 | def main(): 206 | run_module() 207 | 208 | 209 | if __name__ == '__main__': 210 | main() 211 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_ipam_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible.module_utils import basic 18 | from ansible_collections.community.proxmox.plugins.modules import proxmox_ipam_info 19 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 20 | ModuleTestCase, 21 | set_module_args, 22 | ) 23 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 24 | 25 | RAW_IPAM_STATUS = [ 26 | { 27 | "subnet": "10.10.1.0/24", 28 | "vnet": "test2", 29 | "zone": "test1", 30 | "ip": "10.10.1.0", 31 | "gateway": 1 32 | }, 33 | { 34 | "ip": "10.10.0.1", 35 | "gateway": 1, 36 | "vnet": "test2", 37 | "subnet": "10.10.0.0/24", 38 | "zone": "test1" 39 | }, 40 | { 41 | "zone": "test1", 42 | "vnet": "test2", 43 | "subnet": "10.10.0.0/24", 44 | "mac": "BC:24:11:F3:B1:81", 45 | "vmid": 102, 46 | "hostname": "ns3.proxmox.pc", 47 | "ip": "10.10.0.8" 48 | }, 49 | { 50 | "subnet": "10.10.0.0/24", 51 | "vnet": "test2", 52 | "zone": "test1", 53 | "ip": "10.10.0.7", 54 | "hostname": "ns4.proxmox.pc", 55 | "vmid": 103, 56 | "mac": "BC:24:11:D5:CD:82" 57 | }, 58 | { 59 | "ip": "10.10.0.5", 60 | "hostname": "ns2.proxmox.pc.test3", 61 | "mac": "BC:24:11:86:77:56", 62 | "vmid": 101, 63 | "subnet": "10.10.0.0/24", 64 | "vnet": "test2", 65 | "zone": "test1" 66 | } 67 | ] 68 | 69 | RAW_IPAM_STATUS_PVE8 = [ 70 | { 71 | "subnet": "10.10.1.0/24", 72 | "vnet": "test2", 73 | "zone": "test1", 74 | "ip": "10.10.1.0", 75 | "gateway": 1 76 | }, 77 | { 78 | "ip": "10.10.0.1", 79 | "gateway": 1, 80 | "vnet": "test2", 81 | "subnet": "10.10.0.0/24", 82 | "zone": "test1" 83 | }, 84 | { 85 | "zone": "test1", 86 | "vnet": "test2", 87 | "subnet": "10.10.0.0/24", 88 | "mac": "BC:24:11:F3:B1:81", 89 | "vmid": "102", 90 | "hostname": "ns3.proxmox.pc", 91 | "ip": "10.10.0.8" 92 | }, 93 | { 94 | "subnet": "10.10.0.0/24", 95 | "vnet": "test2", 96 | "zone": "test1", 97 | "ip": "10.10.0.7", 98 | "hostname": "ns4.proxmox.pc", 99 | "vmid": "103", 100 | "mac": "BC:24:11:D5:CD:82" 101 | }, 102 | { 103 | "ip": "10.10.0.5", 104 | "hostname": "ns2.proxmox.pc.test3", 105 | "mac": "BC:24:11:86:77:56", 106 | "vmid": "101", 107 | "subnet": "10.10.0.0/24", 108 | "vnet": "test2", 109 | "zone": "test1" 110 | } 111 | ] 112 | 113 | RAW_IPAM = [ 114 | { 115 | "ipam": "pve", 116 | "type": "pve", 117 | "digest": "da39a3ee5e6b4b0d3255bfef95601890afd80709" 118 | } 119 | ] 120 | 121 | 122 | def exit_json(*args, **kwargs): 123 | """function to patch over exit_json; package return data into an exception""" 124 | if 'changed' not in kwargs: 125 | kwargs['changed'] = False 126 | raise SystemExit(kwargs) 127 | 128 | 129 | def fail_json(*args, **kwargs): 130 | """function to patch over fail_json; package return data into an exception""" 131 | kwargs['failed'] = True 132 | raise SystemExit(kwargs) 133 | 134 | 135 | def get_module_args(ipam=None, vmid=None): 136 | return { 137 | 'api_host': 'host', 138 | 'api_user': 'user', 139 | 'api_password': 'password', 140 | 'ipam': ipam, 141 | 'vmid': vmid 142 | } 143 | 144 | 145 | class TestProxmoxIpamInfoModule(ModuleTestCase): 146 | def setUp(self): 147 | super(TestProxmoxIpamInfoModule, self).setUp() 148 | proxmox_utils.HAS_PROXMOXER = True 149 | self.module = proxmox_ipam_info 150 | self.mock_module_helper = patch.multiple(basic.AnsibleModule, 151 | exit_json=exit_json, 152 | fail_json=fail_json) 153 | self.mock_module_helper.start() 154 | self.connect_mock = patch( 155 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 156 | ).start() 157 | self.mock_ipam = self.connect_mock.return_value.cluster.return_value.sdn.return_value.ipams.return_value 158 | self.mock_ipam.get.return_value = RAW_IPAM 159 | self.mock_ipam.pve.return_value.status.return_value.get.return_value = RAW_IPAM_STATUS 160 | self.mock_ipam.status.return_value.get.return_value = RAW_IPAM_STATUS 161 | 162 | def tearDown(self): 163 | self.connect_mock.stop() 164 | self.mock_module_helper.stop() 165 | super(TestProxmoxIpamInfoModule, self).tearDown() 166 | 167 | def test_get_all_ipam_status(self): 168 | with pytest.raises(SystemExit) as exc_info: 169 | with set_module_args(get_module_args(ipam=None)): 170 | self.module.main() 171 | 172 | result = exc_info.value.args[0] 173 | assert result["changed"] is False 174 | assert result["ipams"] == {'pve': RAW_IPAM_STATUS} 175 | 176 | def test_get_all_ipam_pve_status(self): 177 | with pytest.raises(SystemExit) as exc_info: 178 | with set_module_args(get_module_args(ipam='pve')): 179 | self.module.main() 180 | 181 | result = exc_info.value.args[0] 182 | assert result["changed"] is False 183 | assert result["ipams"] == RAW_IPAM_STATUS 184 | 185 | def test_get_ip_by_vmid(self): 186 | with pytest.raises(SystemExit) as exc_info: 187 | with set_module_args(get_module_args(vmid=102)): 188 | self.module.main() 189 | 190 | result = exc_info.value.args[0] 191 | assert result["changed"] is False 192 | assert result["ips"] == [{ 193 | "zone": "test1", 194 | "vnet": "test2", 195 | "subnet": "10.10.0.0/24", 196 | "mac": "BC:24:11:F3:B1:81", 197 | "vmid": 102, 198 | "hostname": "ns3.proxmox.pc", 199 | "ip": "10.10.0.8" 200 | }] 201 | 202 | def test_get_ip_by_vmid_pve8(self): 203 | self.mock_ipam.pve.return_value.status.return_value.get.return_value = RAW_IPAM_STATUS_PVE8 204 | self.mock_ipam.status.return_value.get.return_value = RAW_IPAM_STATUS_PVE8 205 | 206 | with pytest.raises(SystemExit) as exc_info: 207 | with set_module_args(get_module_args(vmid=102)): 208 | self.module.main() 209 | 210 | result = exc_info.value.args[0] 211 | assert result["changed"] is False 212 | assert result["ips"] == [{ 213 | "zone": "test1", 214 | "vnet": "test2", 215 | "subnet": "10.10.0.0/24", 216 | "mac": "BC:24:11:F3:B1:81", 217 | "vmid": "102", 218 | "hostname": "ns3.proxmox.pc", 219 | "ip": "10.10.0.8" 220 | }] 221 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_subnet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch, Mock 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible_collections.community.proxmox.plugins.modules import proxmox_subnet 18 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 19 | ModuleTestCase, 20 | set_module_args, 21 | ) 22 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 23 | 24 | RAW_SUBNETS = [ 25 | { 26 | "type": "subnet", 27 | "subnet": "ans1-10.10.2.0-24", 28 | "zone": "ans1", 29 | "snat": 0, 30 | "cidr": "10.10.2.0/24", 31 | "id": "ans1-10.10.2.0-24", 32 | "mask": "24", 33 | "dhcp-range": [ 34 | { 35 | "start-address": "10.10.2.5", 36 | "end-address": "10.10.2.25" 37 | }, 38 | { 39 | "start-address": "10.10.2.50", 40 | "end-address": "10.10.2.100" 41 | } 42 | ], 43 | "digest": "c870dc42a3b5356b6037590e9552cbd5d2334963", 44 | "vnet": "test", 45 | "network": "10.10.2.0" 46 | } 47 | ] 48 | 49 | 50 | def exit_json(*args, **kwargs): 51 | """function to patch over exit_json; package return data into an exception""" 52 | if 'changed' not in kwargs: 53 | kwargs['changed'] = False 54 | raise SystemExit(kwargs) 55 | 56 | 57 | def fail_json(*args, **kwargs): 58 | """function to patch over fail_json; package return data into an exception""" 59 | kwargs['failed'] = True 60 | raise SystemExit(kwargs) 61 | 62 | 63 | def get_module_args(vnet, subnet, zone, state='present', dhcp_range=None, snat=0, dhcp_range_update_mode='append'): 64 | return { 65 | 'api_host': 'host', 66 | 'api_user': 'user', 67 | 'api_password': 'password', 68 | 'vnet': vnet, 69 | 'subnet': subnet, 70 | 'zone': zone, 71 | 'state': state, 72 | 'dhcp_range': dhcp_range, 73 | 'snat': snat, 74 | 'dhcp_range_update_mode': dhcp_range_update_mode 75 | } 76 | 77 | 78 | class TestProxmoxSubnetModule(ModuleTestCase): 79 | def setUp(self): 80 | super(TestProxmoxSubnetModule, self).setUp() 81 | proxmox_utils.HAS_PROXMOXER = True 82 | self.module = proxmox_subnet 83 | self.fail_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.fail_json', 84 | new=Mock(side_effect=fail_json)) 85 | self.exit_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.exit_json', new=exit_json) 86 | 87 | self.fail_json_mock = self.fail_json_patcher.start() 88 | self.exit_json_patcher.start() 89 | self.connect_mock = patch( 90 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 91 | ).start() 92 | self.connect_mock.return_value.cluster.return_value.sdn.return_value.vnets.return_value.subnets.return_value.get.return_value = RAW_SUBNETS 93 | 94 | def tearDown(self): 95 | self.connect_mock.stop() 96 | self.exit_json_patcher.stop() 97 | self.fail_json_patcher.stop() 98 | super(TestProxmoxSubnetModule, self).tearDown() 99 | 100 | def test_subnet_create(self): 101 | # Create new Zone 102 | with pytest.raises(SystemExit) as exc_info: 103 | with set_module_args(get_module_args(vnet='new_vnet', 104 | subnet='10.10.10.0/24', 105 | zone='test_zone')): 106 | self.module.main() 107 | result = exc_info.value.args[0] 108 | assert result["changed"] is True 109 | assert result["msg"] == "Created new subnet 10.10.10.0/24" 110 | assert result['subnet'] == 'test_zone-10.10.10.0-24' 111 | 112 | def test_subnet_update(self): 113 | # Normal subnet param (snat) differ 114 | with pytest.raises(SystemExit) as exc_info: 115 | with set_module_args(get_module_args(vnet='test', 116 | subnet='10.10.2.0/24', 117 | zone='ans1', 118 | snat=1)): 119 | self.module.main() 120 | result = exc_info.value.args[0] 121 | assert result["changed"] is True 122 | assert result["msg"] == "Updated subnet ans1-10.10.2.0-24" 123 | assert result['subnet'] == 'ans1-10.10.2.0-24' 124 | 125 | # No update needed 126 | with pytest.raises(SystemExit) as exc_info: 127 | with set_module_args(get_module_args(vnet='test', 128 | subnet='10.10.2.0/24', 129 | zone='ans1')): 130 | self.module.main() 131 | result = exc_info.value.args[0] 132 | assert result["changed"] is False 133 | assert result["msg"] == "subnet ans1-10.10.2.0-24 is already present with correct parameters." 134 | assert result['subnet'] == 'ans1-10.10.2.0-24' 135 | 136 | # New dhcp_range 137 | with pytest.raises(SystemExit) as exc_info: 138 | with set_module_args(get_module_args(vnet='test', 139 | subnet='10.10.2.0/24', 140 | zone='ans1', 141 | dhcp_range=[{'start': '10.10.2.150', 'end': '10.10.2.200'}])): 142 | self.module.main() 143 | result = exc_info.value.args[0] 144 | assert result["changed"] is True 145 | assert result["msg"] == "Updated subnet ans1-10.10.2.0-24" 146 | assert result['subnet'] == 'ans1-10.10.2.0-24' 147 | 148 | # dhcp_range is partially overlapping and mode is append 149 | with pytest.raises(SystemExit) as exc_info: 150 | with set_module_args(get_module_args(vnet='test', 151 | subnet='10.10.2.0/24', 152 | zone='ans1', 153 | dhcp_range=[{'start': '10.10.2.10', 'end': '10.10.2.20'}])): 154 | self.module.main() 155 | result = exc_info.value.args[0] 156 | assert self.fail_json_mock.called 157 | assert result["failed"] is True 158 | assert result["msg"] == "There are partially overlapping DHCP ranges. this is not allowed." 159 | 160 | def test_subnet_absent(self): 161 | with pytest.raises(SystemExit) as exc_info: 162 | with set_module_args(get_module_args(vnet='test', 163 | subnet='10.10.2.0/24', 164 | zone='ans1', state='absent')): 165 | self.module.main() 166 | result = exc_info.value.args[0] 167 | assert result["changed"] is True 168 | assert result["msg"] == "Deleted subnet ans1-10.10.2.0-24" 169 | assert result['subnet'] == 'ans1-10.10.2.0-24' 170 | -------------------------------------------------------------------------------- /tests/unit/plugins/modules/test_proxmox_firewall_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2025, Jana Hoch 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) 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | from __future__ import absolute_import, division, print_function 8 | 9 | __metaclass__ = type 10 | 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | proxmoxer = pytest.importorskip("proxmoxer") 16 | 17 | from ansible.module_utils import basic 18 | from ansible_collections.community.proxmox.plugins.modules import proxmox_firewall_info 19 | from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( 20 | ModuleTestCase, 21 | set_module_args, 22 | ) 23 | import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils 24 | 25 | RAW_FIREWALL_RULES = [ 26 | { 27 | "ipversion": 4, 28 | "digest": "245f9fb31d5f59543dedc5a84ba7cd6afa4dbcc0", 29 | "log": "nolog", 30 | "action": "ACCEPT", 31 | "enable": 1, 32 | "type": "out", 33 | "source": "1.1.1.1", 34 | "pos": 0 35 | }, 36 | { 37 | "enable": 1, 38 | "pos": 1, 39 | "source": "1.0.0.1", 40 | "type": "out", 41 | "action": "ACCEPT", 42 | "digest": "245f9fb31d5f59543dedc5a84ba7cd6afa4dbcc0", 43 | "ipversion": 4 44 | } 45 | ] 46 | 47 | RAW_GROUPS = [ 48 | { 49 | "digest": "fdb62dec01018d4f35c83ecc2ae3f110a8b3bd62", 50 | "group": "test1" 51 | }, 52 | { 53 | "group": "test2", 54 | "digest": "fdb62dec01018d4f35c83ecc2ae3f110a8b3bd62" 55 | } 56 | ] 57 | 58 | RAW_ALIASES = [ 59 | { 60 | "name": "test1", 61 | "cidr": "10.10.1.0/24", 62 | "digest": "978391f460484e8d4fb3ca785cfe5a9d16fe8b1f", 63 | "ipversion": 4 64 | }, 65 | { 66 | "name": "test2", 67 | "cidr": "10.10.2.0/24", 68 | "digest": "978391f460484e8d4fb3ca785cfe5a9d16fe8b1f", 69 | "ipversion": 4 70 | }, 71 | { 72 | "name": "test3", 73 | "cidr": "10.10.3.0/24", 74 | "digest": "978391f460484e8d4fb3ca785cfe5a9d16fe8b1f", 75 | "ipversion": 4 76 | } 77 | ] 78 | 79 | RAW_CLUSTER_RESOURCES = [ 80 | { 81 | "vmid": 100, 82 | "maxcpu": 8, 83 | "memhost": 860138496, 84 | "type": "qemu", 85 | "id": "qemu/100", 86 | "diskread": 127452302, 87 | "netin": 42, 88 | "netout": 0, 89 | "cpu": 0.0046731498237984, 90 | "uptime": 119787, 91 | "template": 0, 92 | "disk": 0, 93 | "name": "nextcloud", 94 | "maxdisk": 644245094400, 95 | "mem": 445415424, 96 | "status": "running", 97 | "diskwrite": 1024, 98 | "maxmem": 8589934592, 99 | "node": "pve" 100 | } 101 | ] 102 | 103 | RAW_IPSET = [ 104 | { 105 | "digest": "48671c29c6503157990fc99354b78f32e8654c78", 106 | "name": "test_ipset" 107 | } 108 | ] 109 | 110 | RAW_IPSET_CIDR = [ 111 | { 112 | "digest": "dce088809f001ca83c39c8dcfc2a5e4892bf3d1b", 113 | "cidr": "192.168.1.10", 114 | "comment": "Proxmox pve-01" 115 | } 116 | ] 117 | 118 | EXPECTED_IPSET = [ 119 | { 120 | "digest": "48671c29c6503157990fc99354b78f32e8654c78", 121 | "name": "test_ipset", 122 | "cidrs": [ 123 | { 124 | "digest": "dce088809f001ca83c39c8dcfc2a5e4892bf3d1b", 125 | "cidr": "192.168.1.10", 126 | "comment": "Proxmox pve-01", 127 | "nomatch": False 128 | } 129 | ] 130 | 131 | } 132 | ] 133 | 134 | 135 | def exit_json(*args, **kwargs): 136 | """function to patch over exit_json; package return data into an exception""" 137 | if 'changed' not in kwargs: 138 | kwargs['changed'] = False 139 | raise SystemExit(kwargs) 140 | 141 | 142 | def fail_json(*args, **kwargs): 143 | """function to patch over fail_json; package return data into an exception""" 144 | kwargs['failed'] = True 145 | raise SystemExit(kwargs) 146 | 147 | 148 | def get_module_args(level="cluster", vmid=None, node=None, vnet=None, group=None): 149 | return { 150 | "api_host": "host", 151 | "api_user": "user", 152 | "api_password": "password", 153 | "level": level, 154 | "vmid": vmid, 155 | "node": node, 156 | "vnet": vnet, 157 | "group": group 158 | } 159 | 160 | 161 | class TestProxmoxFirewallModule(ModuleTestCase): 162 | def setUp(self): 163 | super(TestProxmoxFirewallModule, self).setUp() 164 | proxmox_utils.HAS_PROXMOXER = True 165 | self.module = proxmox_firewall_info 166 | self.mock_module_helper = patch.multiple(basic.AnsibleModule, 167 | exit_json=exit_json, 168 | fail_json=fail_json) 169 | self.mock_module_helper.start() 170 | self.connect_mock = patch( 171 | "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", 172 | ).start() 173 | 174 | self.connect_mock.return_value.cluster.resources.get.return_value = ( 175 | RAW_CLUSTER_RESOURCES 176 | ) 177 | 178 | mock_cluster_fw = self.connect_mock.return_value.cluster.return_value.firewall.return_value 179 | mock_vm100_fw = self.connect_mock.return_value.nodes.return_value.return_value.return_value.firewall.return_value 180 | 181 | mock_cluster_fw.rules.return_value.get.return_value = RAW_FIREWALL_RULES 182 | mock_cluster_fw.groups.return_value.get.return_value = RAW_GROUPS 183 | mock_cluster_fw.aliases.return_value.get.return_value = RAW_ALIASES 184 | mock_cluster_fw.ipset.return_value.test_ipset.get.return_value = RAW_IPSET_CIDR 185 | mock_cluster_fw.ipset.return_value.get.return_value = RAW_IPSET 186 | 187 | mock_vm100_fw.rules.return_value.get.return_value = RAW_FIREWALL_RULES 188 | mock_vm100_fw.aliases.return_value.get.return_value = RAW_ALIASES 189 | 190 | def tearDown(self): 191 | self.connect_mock.stop() 192 | self.mock_module_helper.stop() 193 | super(TestProxmoxFirewallModule, self).tearDown() 194 | 195 | def test_cluster_level_info(self): 196 | with pytest.raises(SystemExit) as exc_info: 197 | with set_module_args(get_module_args()): 198 | self.module.main() 199 | result = exc_info.value.args[0] 200 | 201 | assert result["changed"] is False 202 | assert result["msg"] == "successfully retrieved firewall rules and groups" 203 | assert result["firewall_rules"] == RAW_FIREWALL_RULES 204 | assert result["groups"] == ['test1', 'test2'] 205 | assert result["aliases"] == RAW_ALIASES 206 | assert result["ip_sets"] == EXPECTED_IPSET 207 | 208 | def test_vm_level_info(self): 209 | with pytest.raises(SystemExit) as exc_info: 210 | with set_module_args(get_module_args(level='vm', vmid=100)): 211 | self.module.main() 212 | result = exc_info.value.args[0] 213 | assert result["changed"] is False 214 | assert result["msg"] == "successfully retrieved firewall rules and groups" 215 | assert result["firewall_rules"] == RAW_FIREWALL_RULES 216 | assert result["groups"] == ['test1', 'test2'] 217 | assert result["aliases"] == RAW_ALIASES 218 | --------------------------------------------------------------------------------