├── src └── molecule_proxmox │ ├── modules │ ├── __init__.py │ └── proxmox_qemu_agent.py │ ├── __init__.py │ ├── cookiecutter │ ├── cookiecutter.json │ └── {{cookiecutter.molecule_directory}} │ │ └── {{cookiecutter.scenario_name}} │ │ └── INSTALL.rst │ ├── playbooks │ ├── prepare.yml │ ├── common │ │ └── secrets.yml │ ├── destroy.yml │ └── create.yml │ └── driver.py ├── .ansible-lint ├── MANIFEST.in ├── pyproject.toml ├── .gitignore ├── requirements.txt ├── tests ├── proxmox_driver │ └── molecule │ │ ├── secrets-file │ │ ├── secrets-file.yml │ │ ├── converge.yml │ │ └── molecule.yml │ │ ├── secrets-script │ │ ├── secrets.sh │ │ ├── converge.yml │ │ └── molecule.yml │ │ ├── by-name │ │ ├── converge.yml │ │ └── molecule.yml │ │ ├── by-vmid │ │ ├── converge.yml │ │ └── molecule.yml │ │ ├── cloud-init │ │ ├── converge.yml │ │ └── molecule.yml │ │ ├── default │ │ ├── converge.yml │ │ └── molecule.yml │ │ └── linked-clone │ │ ├── converge.yml │ │ └── molecule.yml └── test_proxmox_driver.py ├── .yamllint ├── envrc.sample ├── LICENSE ├── setup.py ├── Makefile ├── tox.ini └── README.rst /src/molecule_proxmox/modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ansible-lint: -------------------------------------------------------------------------------- 1 | skip_list: 2 | - var-naming[no-jinja] 3 | -------------------------------------------------------------------------------- /src/molecule_proxmox/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.0' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src/molecule_proxmox 2 | global-exclude *.pyc *.pyo *.pyd __pycache__ *.so 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.pyd 4 | *.egg-info/ 5 | __pycache__/ 6 | .* 7 | dist/ 8 | build/ 9 | !.ansible-lint 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements for development. 2 | ansible 3 | pyflakes 4 | pylint 5 | yamllint 6 | pytest 7 | collective.checkdocs 8 | twine 9 | -------------------------------------------------------------------------------- /src/molecule_proxmox/cookiecutter/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "molecule_directory": "molecule", 3 | "role_name": "OVERRIDDEN", 4 | "scenario_name": "OVERRIDDEN" 5 | } 6 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-file/secrets-file.yml: -------------------------------------------------------------------------------- 1 | api_user: "{{ lookup('env', 'TEST_PROXMOX_USER') }}" 2 | api_password: "{{ lookup('env', 'TEST_PROXMOX_PASSWORD') }}" 3 | api_host: "{{ lookup('env', 'TEST_PROXMOX_HOST') }}" 4 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-script/secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "api_host: ${TEST_PROXMOX_HOST}" 3 | echo "api_port: ${TEST_PROXMOX_PORT}" 4 | echo "api_user: ${TEST_PROXMOX_USER}" 5 | echo "api_password: ${TEST_PROXMOX_PASSWORD}" 6 | -------------------------------------------------------------------------------- /src/molecule_proxmox/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst: -------------------------------------------------------------------------------- 1 | *********************** 2 | Molecule Proxmox Plugin 3 | *********************** 4 | 5 | Requires 6 | ======== 7 | 8 | 9 | Example 10 | ======= 11 | 12 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/by-name/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/by-vmid/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/cloud-init/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/default/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/linked-clone/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-file/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-script/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: Example 7 | become: yes 8 | command: uname -a 9 | changed_when: false 10 | register: uname_results 11 | 12 | - debug: 13 | var: uname_results.stdout 14 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-file/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | proxmox_secrets: "${TEST_PROXMOX_SECRETS_FILE}" 9 | node: "${TEST_PROXMOX_NODE}" 10 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 11 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 12 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 13 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 14 | debug: "${TEST_PROXMOX_DEBUG}" 15 | 16 | platforms: 17 | - name: m01 18 | 19 | provisioner: 20 | name: ansible 21 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/secrets-script/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | proxmox_secrets: "${TEST_PROXMOX_SECRETS_SCRIPT}" 9 | node: "${TEST_PROXMOX_NODE}" 10 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 11 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 12 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 13 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 14 | debug: "${TEST_PROXMOX_DEBUG}" 15 | 16 | platforms: 17 | - name: m01 18 | 19 | provisioner: 20 | name: ansible 21 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/linked-clone/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | api_host: "${TEST_PROXMOX_HOST}" 9 | api_port: ${TEST_PROXMOX_PORT} 10 | api_user: "${TEST_PROXMOX_USER}" 11 | api_password: "${TEST_PROXMOX_PASSWORD}" 12 | node: "${TEST_PROXMOX_NODE}" 13 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 14 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 15 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 16 | full: "false" 17 | debug: "${TEST_PROXMOX_DEBUG}" 18 | 19 | platforms: 20 | - name: m01 21 | 22 | provisioner: 23 | name: ansible 24 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/default/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | api_host: "${TEST_PROXMOX_HOST}" 9 | api_port: ${TEST_PROXMOX_PORT} 10 | api_user: "${TEST_PROXMOX_USER}" 11 | api_password: "${TEST_PROXMOX_PASSWORD}" 12 | node: "${TEST_PROXMOX_NODE}" 13 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 14 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 15 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 16 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 17 | debug: "${TEST_PROXMOX_DEBUG}" 18 | 19 | platforms: 20 | - name: m01 21 | 22 | provisioner: 23 | name: ansible 24 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # Based on ansible-lint config 3 | extends: default 4 | 5 | rules: 6 | braces: 7 | max-spaces-inside: 1 8 | level: error 9 | brackets: 10 | max-spaces-inside: 1 11 | level: error 12 | colons: 13 | max-spaces-after: -1 14 | level: error 15 | commas: 16 | max-spaces-after: -1 17 | level: error 18 | comments: disable 19 | comments-indentation: disable 20 | document-start: disable 21 | empty-lines: 22 | max: 3 23 | level: error 24 | hyphens: 25 | level: error 26 | indentation: disable 27 | key-duplicates: enable 28 | line-length: disable 29 | new-line-at-end-of-file: disable 30 | new-lines: 31 | type: unix 32 | trailing-spaces: disable 33 | truthy: disable 34 | -------------------------------------------------------------------------------- /envrc.sample: -------------------------------------------------------------------------------- 1 | # 2 | # Copy this file to .envrc and add your test values, then source the .envrc 3 | # script to export all environment variables to the current shell. 4 | # 5 | # $ . .envrc 6 | # 7 | export TEST_PROXMOX_TEMPLATE_NAME= 8 | export TEST_PROXMOX_TEMPLATE_VMID= 9 | export TEST_PROXMOX_PORT= 10 | export TEST_PROXMOX_USER= 11 | export TEST_PROXMOX_SSH_IDENTITY_FILE= 12 | export TEST_PROXMOX_PASSWORD= 13 | export TEST_PROXMOX_SSH_USER= 14 | export TEST_PROXMOX_NODE= 15 | export TEST_PROXMOX_HOST= 16 | export TEST_PROXMOX_SECRETS_FILE= 17 | export TEST_PROXMOX_TOKEN_ID= 18 | export TEST_PROXMOX_TOKEN_SECRET= 19 | export TEST_PROXMOX_SECRETS_SCRIPT= 20 | export TEST_PROXMOX_CLOUDINIT= 21 | export TEST_PROXMOX_DEBUG="false" 22 | export TEST_PROXMOX_FULL_CLONE="false" 23 | export TEST_PROXMOX_NAMESERVER= 24 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/by-vmid/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | api_host: "${TEST_PROXMOX_HOST}" 9 | api_user: "${TEST_PROXMOX_USER}" 10 | api_password: "${TEST_PROXMOX_PASSWORD}" 11 | node: "${TEST_PROXMOX_NODE}" 12 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 13 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 14 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 15 | timeout: 120 16 | debug: "${TEST_PROXMOX_DEBUG}" 17 | 18 | platforms: 19 | - name: m01 20 | proxmox_template_vmid: ${TEST_PROXMOX_TEMPLATE_VMID:-9000} 21 | 22 | - name: m02 23 | # Alias keyword, for compatibility with old versions. 24 | template_vmid: ${TEST_PROXMOX_TEMPLATE_VMID:-9000} 25 | 26 | provisioner: 27 | name: ansible 28 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/cloud-init/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: molecule-proxmox 7 | options: 8 | api_host: "${TEST_PROXMOX_HOST}" 9 | api_user: "${TEST_PROXMOX_USER}" 10 | api_password: "${TEST_PROXMOX_PASSWORD}" 11 | node: "${TEST_PROXMOX_NODE}" 12 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 13 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 14 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 15 | debug: "${TEST_PROXMOX_DEBUG}" 16 | 17 | platforms: 18 | - name: m01 19 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 20 | ciuser: tycobb 21 | cipassword: secret 22 | nameservers: 23 | - "${TEST_PROXMOX_NAMESERVER:-192.168.96.4}" 24 | ipconfig: 25 | ipconfig0: "${TEST_PROXMOX_CLOUDINIT:-ip=192.168.136.99/24,gw=192.168.0.1}" 26 | 27 | provisioner: 28 | name: ansible 29 | -------------------------------------------------------------------------------- /tests/proxmox_driver/molecule/by-name/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | driver: 5 | name: molecule-proxmox 6 | options: 7 | api_host: "${TEST_PROXMOX_HOST}" 8 | api_user: "${TEST_PROXMOX_USER}" 9 | api_password: "${TEST_PROXMOX_PASSWORD}" 10 | node: "${TEST_PROXMOX_NODE}" 11 | ssh_user: "${TEST_PROXMOX_SSH_USER}" 12 | ssh_identity_file: "${TEST_PROXMOX_SSH_IDENTITY_FILE}" 13 | # Default template name. Defaults to 'molecule' if not set. 14 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 15 | full: "${TEST_PROXMOX_FULL_CLONE:-true}" 16 | timeout: 120 17 | debug: "${TEST_PROXMOX_DEBUG}" 18 | 19 | platforms: 20 | # Uses the default template name. 21 | - name: m01 22 | 23 | # Instance specific template name. 24 | - name: m02 25 | proxmox_template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 26 | 27 | # Instance specific template name with alternate keyword. 28 | - name: m03 29 | template_name: "${TEST_PROXMOX_TEMPLATE_NAME}" 30 | 31 | provisioner: 32 | name: ansible 33 | -------------------------------------------------------------------------------- /src/molecule_proxmox/playbooks/prepare.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Prepare 3 | hosts: all 4 | gather_facts: no 5 | tasks: 6 | - name: "Waiting for instance ssh connection." 7 | ansible.builtin.wait_for_connection: 8 | 9 | - name: "Set host name." 10 | when: molecule_yml.driver.options.sethostname | d('yes') | bool 11 | block: 12 | - name: "Set instance hostname." 13 | become: yes 14 | ansible.builtin.hostname: 15 | name: "{{ inventory_hostname }}" 16 | 17 | - name: "Gather facts." 18 | ansible.builtin.setup: 19 | 20 | - name: "Remove workaround loopback from /etc/hosts file." 21 | become: yes 22 | ansible.builtin.lineinfile: 23 | state: absent 24 | path: /etc/hosts 25 | regex: '^127\.0\.1\.1\s+\S+' 26 | 27 | - name: "Add address and hostname to /etc/hosts file." 28 | become: yes 29 | ansible.builtin.lineinfile: 30 | state: present 31 | path: /etc/hosts 32 | line: "{{ ansible_default_ipv4.address }} {{ ansible_hostname }}" 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Sorin Sbarnea 4 | Copyright (c) 2022 Sine Nomine Associates 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/molecule_proxmox/playbooks/common/secrets.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Check secrets file." 3 | ansible.builtin.stat: 4 | path: "{{ options.proxmox_secrets }}" 5 | register: proxmox_secrets_st 6 | 7 | - name: "Fail if secrets file not found." 8 | ansible.builtin.fail: 9 | msg: "proxmox_secrets not found: {{ options.proxmox_secrets }}" 10 | when: not proxmox_secrets_st.stat.exists 11 | 12 | - name: "Load proxmox secrets from file." 13 | ansible.builtin.include_vars: "{{ options.proxmox_secrets }}" 14 | when: not proxmox_secrets_st.stat.executable 15 | no_log: "{{ not (options.debug | d(False) | bool) }}" 16 | 17 | - name: "Load promox secrets from executable output." 18 | when: proxmox_secrets_st.stat.executable 19 | no_log: "{{ not (options.debug | d(False) | bool) }}" 20 | block: 21 | - name: "Run proxmox secrets script." 22 | ansible.builtin.command: "{{ options.proxmox_secrets }}" 23 | changed_when: false 24 | register: proxmox_secrets_cmd 25 | 26 | - name: "Load proxmox secrets from script output." 27 | vars: 28 | secrets: "{{ proxmox_secrets_cmd.stdout | from_yaml }}" 29 | ansible.builtin.set_fact: 30 | "{{ item }}": "{{ secrets[item] }}" 31 | with_items: "{{ secrets.keys() }}" 32 | -------------------------------------------------------------------------------- /tests/test_proxmox_driver.py: -------------------------------------------------------------------------------- 1 | # 2 | # Proxmox driver tests. 3 | # 4 | 5 | import contextlib 6 | import os 7 | import pathlib 8 | import subprocess 9 | import pytest 10 | 11 | 12 | @contextlib.contextmanager 13 | def chdir(path): 14 | prev = os.getcwd() 15 | os.chdir(path) 16 | try: 17 | yield 18 | finally: 19 | os.chdir(prev) 20 | 21 | 22 | def molecule(command, *args): 23 | args = ['molecule', command] + list(args) 24 | proc = subprocess.Popen(args) 25 | rc = proc.wait() 26 | assert rc == 0 27 | 28 | 29 | def test_molecule_init_scenario(tmpdir): 30 | print('') 31 | with chdir(tmpdir): 32 | molecule('init', 'scenario', '--driver-name', 'molecule-proxmox') 33 | assert pathlib.Path('molecule/default/converge.yml').exists() 34 | assert pathlib.Path('molecule/default/create.yml').exists() 35 | assert pathlib.Path('molecule/default/destroy.yml').exists() 36 | assert pathlib.Path('molecule/default/molecule.yml').exists() 37 | 38 | 39 | @pytest.mark.parametrize('scenario', [ 40 | 'default', 'by-name', 'by-vmid', 'cloud-init', 41 | 'secrets-file', 'secrets-script', 'linked-clone']) 42 | def test_molecule_test(scenario): 43 | print('') 44 | testdir = pathlib.Path(__file__).resolve().parent 45 | projectdir = testdir / 'proxmox_driver' 46 | with chdir(projectdir): 47 | molecule('test', '--scenario-name', scenario) 48 | molecule('reset') 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name='molecule-proxmox', 5 | version='1.1.0', 6 | author='Michael Meffie', 7 | author_email='mmeffie@sinenomine.net', 8 | description='Proxmox Molecule Plugin :: run molecule tests using proxmox', 9 | long_description=open('README.rst').read(), 10 | long_description_content_type='text/x-rst', 11 | url='https://github.com/meffie/molecule-proxmox', 12 | packages=[ 13 | 'molecule_proxmox', 14 | 'molecule_proxmox.cookiecutter', 15 | 'molecule_proxmox.modules', 16 | 'molecule_proxmox.playbooks', 17 | 'molecule_proxmox.playbooks.common', 18 | ], 19 | package_dir={'': 'src'}, 20 | include_package_data=True, 21 | entry_points={ 22 | 'molecule.driver': [ 23 | 'proxmox = molecule_proxmox.driver:Proxmox', 24 | ], 25 | }, 26 | install_requires=[ 27 | # molecule plugins are not allowed to mention Ansible as a direct dependency 28 | 'molecule>=6.0.0,<=25.4.0', 29 | 'PyYAML', 30 | 'proxmoxer>=1.3.1', 31 | 'requests', 32 | ], 33 | classifiers=[ 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.10', 36 | 'Programming Language :: Python :: 3.11', 37 | 'Programming Language :: Python :: 3.12', 38 | 'License :: OSI Approved :: MIT License', 39 | 'Operating System :: OS Independent', 40 | ], 41 | python_requires='>=3.10', 42 | ) 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2020-2025 Sine Nomine Associates 2 | # 3 | # This makefile is a optional tox front-end. 4 | # You can run tox directly without this makefile if you 5 | # prefer. For example: 6 | # 7 | # $ pipx tox 8 | # $ tox list 9 | # $ tox -e -- 10 | # 11 | 12 | TOX=tox 13 | TESTENV=latest 14 | PYTEST_FLAGS= 15 | 16 | .PHONY: help 17 | help: 18 | @echo "usage: make [options]" 19 | @echo "" 20 | @echo "targets:" 21 | @echo " lint run lint checks" 22 | @echo " test run tests" 23 | @echo " docs generate html docs" 24 | @echo " preview local preview html docs" 25 | @echo " release upload to pypi.org" 26 | @echo " clean remove generated files" 27 | @echo " distclean remove generated files and venvs" 28 | @echo "" 29 | @echo "options:" 30 | @echo " TOX= tox path [default: .venv/bin/tox]" 31 | @echo " TESTENV= tox testenv [default: latest]" 32 | @echo " PYTEST_FLAGS= pytest options [default: (none)]" 33 | 34 | .PHONY: lint 35 | lint: 36 | $(TOX) -e lint 37 | 38 | .PHONY: test 39 | test: lint 40 | $(TOX) -e $(TESTENV) -- $(PYTEST_FLAGS) 41 | 42 | .PHONY: docs 43 | docs: 44 | $(TOX) -e docs 45 | 46 | .PHONY: preview 47 | preview: docs 48 | xdg-open docs/build/html/index.html 49 | 50 | .PHONY: release upload 51 | release upload: 52 | $(TOX) -e release 53 | 54 | .PHONY: clean 55 | clean: 56 | rm -rf .pytest_cache src/*/__pycache__ tests/__pycache__ 57 | rm -rf build dist 58 | rm -rf .eggs *.egg-info src/*.egg-info 59 | rm -rf docs/build 60 | 61 | .PHONY: reallyclean distclean 62 | reallyclean distclean: clean 63 | rm -rf .config .tox 64 | -------------------------------------------------------------------------------- /src/molecule_proxmox/playbooks/destroy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Destroy 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | vars: 7 | options: "{{ molecule_yml.driver.options }}" 8 | tasks: 9 | - name: "Load proxmox connection secrets." 10 | ansible.builtin.include_tasks: common/secrets.yml 11 | when: options.proxmox_secrets is defined 12 | 13 | # Remove instances by numeric vmid instead of by name, which seems 14 | # safer and more reliable. Since the Ansible lookup() plugin complains 15 | # even when error=ingore is set, just create an empty file to ignore 16 | # a missing instance_configs. 17 | - name: "Check for instance configs." 18 | ansible.builtin.stat: 19 | path: "{{ molecule_instance_config }}" 20 | register: instance_config_stat 21 | 22 | - name: "Write empty instance configs." 23 | ansible.builtin.copy: 24 | content: "[]" 25 | dest: "{{ molecule_instance_config }}" 26 | mode: '0644' 27 | when: not instance_config_stat.stat.exists 28 | 29 | - name: "Remove molecule instance(s)." 30 | community.general.proxmox_kvm: 31 | api_host: "{{ api_host | d(options.api_host) | d(omit) }}" 32 | api_port: "{{ api_port | d(options.api_port) | d(omit) }}" 33 | api_user: "{{ api_user | d(options.api_user) | d(omit) }}" 34 | api_password: "{{ api_password | d(options.api_password) | d(omit) }}" 35 | api_token_id: "{{ api_token_id | d(options.api_token_id) | d(omit) }}" 36 | api_token_secret: "{{ api_token_secret | d(options.api_token_secret) | d(omit) }}" 37 | state: absent 38 | vmid: "{{ i.vmid }}" 39 | node: "{{ options.node }}" 40 | force: yes 41 | timeout: "{{ options.timeout | d(omit) }}" 42 | loop: "{{ lookup('file', molecule_instance_config) | from_yaml }}" 43 | loop_control: 44 | loop_var: i 45 | label: "{{ i.instance, i.vmid }}" 46 | -------------------------------------------------------------------------------- /src/molecule_proxmox/driver.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Sine Nomine Associates 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to 5 | # deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import os 22 | 23 | from molecule import logger 24 | from molecule import util 25 | from molecule.api import Driver 26 | 27 | 28 | LOG = logger.get_logger(__name__) 29 | 30 | 31 | class Proxmox(Driver): 32 | """ 33 | The class responsible for managing instances with Proxmox. 34 | 35 | .. code-block:: yaml 36 | 37 | driver: 38 | name: proxmox 39 | platforms: 40 | - name: instance 41 | template: generic-centos-8 42 | memory: 1024 43 | cpus: 1 44 | 45 | .. code-block:: bash 46 | 47 | $ pip install molecule-proxmox 48 | 49 | """ # noqa 50 | 51 | def __init__(self, config=None): 52 | super(Proxmox, self).__init__(config) 53 | self._name = "molecule-proxmox" 54 | library_path = os.environ.get("ANSIBLE_LIBRARY", "") 55 | if library_path: 56 | library_path = self.modules_dir() + ":" + library_path 57 | else: 58 | library_path = self.modules_dir() 59 | os.environ["ANSIBLE_LIBRARY"] = library_path 60 | 61 | @property 62 | def name(self): 63 | return self._name 64 | 65 | @name.setter 66 | def name(self, value): 67 | self._name = value 68 | 69 | @property 70 | def login_cmd_template(self): 71 | connection_options = " ".join(self.ssh_connection_options) 72 | return ( 73 | "ssh {{address}} " 74 | "-l {{user}} " 75 | "-p {{port}} " 76 | "-i {{identity_file}} " 77 | "{}" 78 | ).format(connection_options) 79 | 80 | @property 81 | def default_safe_files(self): 82 | return [] 83 | 84 | @property 85 | def default_ssh_connection_options(self): 86 | return self._get_ssh_connection_options() 87 | 88 | def login_options(self, instance_name): 89 | d = {"instance": instance_name} 90 | return util.merge_dicts(d, self._get_instance_config(instance_name)) 91 | 92 | def ansible_connection_options(self, instance_name): 93 | try: 94 | d = self._get_instance_config(instance_name) 95 | return { 96 | "ansible_user": d["user"], 97 | "ansible_host": d["address"], 98 | "ansible_port": d["port"], 99 | "ansible_private_key_file": d["identity_file"], 100 | "connection": "ssh", 101 | "ansible_ssh_common_args": " ".join(self.ssh_connection_options), # noqa: E501 102 | } 103 | except StopIteration: 104 | return {} 105 | except IOError: 106 | # Instance has yet to be provisioned, therefore the 107 | # instance_config is not on disk. 108 | return {} 109 | 110 | def _get_instance_config(self, instance_name): 111 | instance_config_dict = util.safe_load_file(self._config.driver.instance_config) # noqa: E501 112 | return next( 113 | item for item in instance_config_dict if item["instance"] == instance_name # noqa: E501 114 | ) 115 | 116 | def sanity_checks(self): 117 | pass 118 | 119 | def template_dir(self): 120 | """Return path to its own cookiecutterm templates. It is used by init 121 | command in order to figure out where to load the templates from. 122 | """ 123 | return os.path.join(os.path.dirname(__file__), "cookiecutter") 124 | 125 | def modules_dir(self): 126 | return os.path.join(os.path.dirname(__file__), "modules") 127 | -------------------------------------------------------------------------------- /src/molecule_proxmox/playbooks/create.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | vars: 7 | options: "{{ molecule_yml.driver.options }}" 8 | tasks: 9 | - name: "Load proxmox connection secrets." 10 | ansible.builtin.include_tasks: common/secrets.yml 11 | when: options.proxmox_secrets is defined 12 | 13 | - name: "Create molecule instance(s)." 14 | community.general.proxmox_kvm: 15 | state: present 16 | api_host: "{{ api_host | d(options.api_host) | d(omit) }}" 17 | api_port: "{{ api_port | d(options.api_port) | d(omit) }}" 18 | api_user: "{{ api_user | d(options.api_user) | d(omit) }}" 19 | api_password: "{{ api_password | d(options.api_password) | d(omit) }}" 20 | api_token_id: "{{ api_token_id | d(options.api_token_id) | d(omit) }}" 21 | api_token_secret: "{{ api_token_secret | d(options.api_token_secret) | d(omit) }}" 22 | vmid: "{{ p.proxmox_template_vmid | d(p.template_vmid, true) | d(omit, true) }}" 23 | clone: "{{ p.proxmox_template_name | d(p.template_name, true) | d(options.template_name, true) | d(p.box, true) | d('molecule', true) }}" 24 | name: "{{ p.name }}" 25 | node: "{{ options.node }}" 26 | full: "{{ options.full | d(omit) }}" 27 | timeout: "{{ options.timeout | d(omit) }}" 28 | pool: "{{ options.pool | d(omit) }}" 29 | newid: "{{ p.newid | d(p.newid, true) | d(omit, true) }}" 30 | loop: "{{ molecule_yml.platforms }}" 31 | loop_control: 32 | loop_var: p 33 | label: "{{ p.name }}" 34 | register: proxmox_clone 35 | 36 | - name: "Update molecule instance config(s)" 37 | community.general.proxmox_kvm: 38 | state: present 39 | update: true 40 | api_host: "{{ api_host | d(options.api_host) | d(omit) }}" 41 | api_port: "{{ api_port | d(options.api_port) | d(omit) }}" 42 | api_user: "{{ api_user | d(options.api_user) | d(omit) }}" 43 | api_password: "{{ api_password | d(options.api_password) | d(omit) }}" 44 | api_token_id: "{{ api_token_id | d(options.api_token_id) | d(omit) }}" 45 | api_token_secret: "{{ api_token_secret | d(options.api_token_secret) | d(omit) }}" 46 | vmid: "{{ rc.vmid }}" 47 | node: "{{ options.node }}" 48 | timeout: "{{ options.timeout | d(omit) }}" 49 | ciuser: "{{ rc.p.ciuser | d(omit, true) }}" 50 | cipassword: "{{ rc.p.cipassword | d(omit, true) }}" 51 | citype: "{{ rc.p.citype | d(omit, true) }}" 52 | ipconfig: "{{ rc.p.ipconfig | d(omit, true) }}" 53 | nameservers: "{{ rc.p.nameservers | d(omit, true) }}" 54 | searchdomains: "{{ rc.p.searchdomains | d(omit, true) }}" 55 | sshkeys: "{{ rc.p.sshkeys | d(omit, true) }}" 56 | when: > 57 | rc.p.ciuser is defined or 58 | rc.p.cipassword is defined or 59 | rc.p.citype is defined or 60 | rc.p.ipconfig is defined or 61 | rc.p.nameservers is defined or 62 | rc.p.searchdomains is defined or 63 | rc.p.sshkeys is defined 64 | loop: "{{ proxmox_clone.results }}" 65 | loop_control: 66 | loop_var: rc 67 | label: "{{ rc.p.name, rc.vmid }}" 68 | 69 | - name: "Start molecule instance(s)." 70 | proxmox_qemu_agent: 71 | api_host: "{{ api_host | d(options.api_host) | d(omit) }}" 72 | api_port: "{{ api_port | d(options.api_port) | d(omit) }}" 73 | api_user: "{{ api_user | d(options.api_user) | d(omit) }}" 74 | api_password: "{{ api_password | d(options.api_password) | d(omit) }}" 75 | api_token_id: "{{ api_token_id | d(options.api_token_id) | d(omit) }}" 76 | api_token_secret: "{{ api_token_secret | d(options.api_token_secret) | d(omit) }}" 77 | vmid: "{{ rc.vmid }}" 78 | timeout: "{{ options.timeout | d(omit) }}" 79 | loop: "{{ proxmox_clone.results }}" 80 | loop_control: 81 | loop_var: rc 82 | label: "{{ rc.p.name, rc.vmid }}" 83 | register: proxmox_qemu_agent 84 | 85 | - name: "Populate instance configs." 86 | ansible.builtin.set_fact: 87 | instance_config: 88 | instance: "{{ ra.rc.p.name }}" 89 | address: "{{ ra.addresses[0] }}" 90 | user: "{{ options.ssh_user | d('molecule') }}" 91 | port: "{{ options.ssh_port | d(22) }}" 92 | identity_file: "{{ options.ssh_identity_file }}" 93 | vmid: "{{ ra.vmid }}" 94 | loop: "{{ proxmox_qemu_agent.results }}" 95 | loop_control: 96 | loop_var: ra 97 | label: "{{ ra.rc.p.name, ra.vmid, ra.addresses[0] }}" 98 | register: instance_configs 99 | 100 | - name: "Set instance_config fact." 101 | ansible.builtin.set_fact: 102 | instance_configs: "{{ instance_configs.results | map(attribute='ansible_facts.instance_config') | list }}" 103 | 104 | - name: "Write instance configs." 105 | ansible.builtin.copy: 106 | content: "{{ instance_configs | to_nice_yaml }}" 107 | dest: "{{ molecule_instance_config }}" 108 | mode: '0644' 109 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 4.11.4 3 | env_list = \ 4 | py3{10,11,12}-mol060000, \ 5 | py3{10,11,12}-mol060001, \ 6 | py3{10,11,12}-mol060002, \ 7 | py3{10,11,12}-mol060003, \ 8 | py3{10,11,12}-mol240200, \ 9 | py3{10,11,12}-mol240201, \ 10 | py3{10,11,12}-mol240600, \ 11 | py3{10,11,12}-mol240601, \ 12 | py3{10,11,12}-mol240700, \ 13 | py3{10,11,12}-mol240800, \ 14 | py3{10,11,12}-mol240900, \ 15 | py3{11,12,13}-mol241200, \ 16 | py3{11,12,13}-mol250100, \ 17 | py3{11,12,13}-mol250200, \ 18 | py3{11,12,13}-mol250300, \ 19 | py3{11,12,13}-mol250301, \ 20 | py3{11,12,13}-mol250400 21 | 22 | # 23 | # Usage: tox [-e ] [-- ] 24 | # tox list # to list environments 25 | # 26 | [testenv] 27 | description = Run the tests 28 | package = wheel 29 | wheel_build_env = .pkg 30 | deps = 31 | pytest==7.4.4 32 | # Ansible versions 33 | mol060000: ansible==10.4.0 34 | mol060001: ansible==10.4.0 35 | mol060002: ansible==10.4.0 36 | mol060003: ansible==10.4.0 37 | mol240200: ansible==10.4.0 38 | mol240201: ansible==10.4.0 39 | mol240600: ansible==10.4.0 40 | mol240601: ansible==10.4.0 41 | mol240700: ansible==10.4.0 42 | mol240800: ansible==10.4.0 43 | mol240900: ansible==10.4.0 44 | mol241200: ansible==11.4.0 45 | mol250100: ansible==11.4.0 46 | mol250200: ansible==11.4.0 47 | mol250300: ansible==11.4.0 48 | mol250301: ansible==11.4.0 49 | mol250400: ansible==11.4.0 50 | # Molecule versions 51 | mol060000: molecule==6.0.0 52 | mol060001: molecule==6.0.1 53 | mol060002: molecule==6.0.2 54 | mol060003: molecule==6.0.3 55 | mol240200: molecule==24.2.0 56 | mol240201: molecule==24.2.1 57 | mol240600: molecule==24.6.0 58 | mol240601: molecule==24.6.1 59 | mol240700: molecule==24.7.0 60 | mol240800: molecule==24.8.0 61 | mol240900: molecule==24.9.0 62 | mol241200: molecule==24.12.0 63 | mol250100: molecule==25.1.0 64 | mol250200: molecule==25.2.0 65 | mol250300: molecule==25.3.0 66 | mol250301: molecule==25.3.1 67 | mol250400: molecule==25.4.0 68 | passenv = 69 | SSH_* 70 | TEST_PROXMOX_* 71 | commands = 72 | pytest -v tests {posargs} 73 | 74 | # 75 | # Usage: tox -e latest [-- ] 76 | # 77 | # Examples: 78 | # tox -e latest -- --co # list tests 79 | # tox -e latest -- -s -k default # run the default scenario test 80 | # 81 | [testenv:latest] 82 | description = Run the tests with the latest versions 83 | basepython = python3.13 84 | package = wheel 85 | wheel_build_env = .pkg 86 | deps = 87 | pytest==7.4.4 88 | ansible==11.4.0 89 | molecule==25.4.0 90 | passenv = 91 | SSH_* 92 | TEST_PROXMOX_* 93 | commands = 94 | pytest -v tests {posargs} 95 | 96 | # 97 | # Usage: tox -e dev 98 | # 99 | # To activate the development environment: 100 | # 101 | # deactivate 102 | # source .tox/dev/bin/activate 103 | # 104 | # Then run molecule in the tests directory: 105 | # 106 | # cd tests 107 | # BOX= BOX_VERSION= molecule test [-s ] 108 | # cd .. 109 | # 110 | # Or run the tests with pytest: 111 | # 112 | # pytest --co tests # list tests 113 | # pytest -v [-k ] tests # run tests 114 | # 115 | [testenv:dev] 116 | description = Development environment 117 | basepython = python3.12 118 | usedevelop = True 119 | deps = 120 | pytest==7.4.4 121 | ansible==10.4.0 122 | molecule==24.9.0 123 | passenv = 124 | SSH_* 125 | TEST_PROXMOX_* 126 | commands = 127 | 128 | # 129 | # Usage: tox -e lint 130 | # 131 | [testenv:lint] 132 | description = Run static checks 133 | basepython = python3.12 134 | setenv = 135 | ANSIBLE_LIBRARY = src/molecule_proxmox 136 | deps = 137 | ansible==10.4.0 138 | ansible-lint==6.22.2 139 | collective.checkdocs==0.2 140 | flake8==7.0.0 141 | pyflakes==3.2.0 142 | pylint==3.0.3 143 | setuptools==69.0.3 144 | yamllint==1.33.0 145 | commands = 146 | pyflakes src tests 147 | flake8 src tests 148 | yamllint src tests 149 | ansible-lint src/molecule_proxmox/playbooks 150 | python setup.py -q checkdocs 151 | 152 | # 153 | # Usage: tox -e docs 154 | # 155 | [testenv:docs] 156 | description = Build documentation 157 | basepython = python3.12 158 | changedir = docs 159 | deps = 160 | Sphinx==7.2.6 161 | sphinx-rtd-theme==2.0.0 162 | commands = 163 | sphinx-build -M html source build 164 | 165 | # 166 | # Usage: tox -e build 167 | # 168 | [testenv:build] 169 | description = Build python package 170 | basepython = python3 171 | deps = 172 | build==1.2.2 173 | commands = 174 | python -m build 175 | 176 | # 177 | # Usage: tox -e release 178 | # 179 | # Note: Set TWINE env vars or ~/.pypirc before running. 180 | # 181 | [testenv:release] 182 | description = Upload release to pypi 183 | basepython = python3 184 | passenv = 185 | TWINE_USERNAME 186 | TWINE_PASSWORD 187 | TWINE_REPOSITORY_URL 188 | deps = 189 | build==1.2.2 190 | twine==6.1.0 191 | commands = 192 | python -m build 193 | twine check dist/* 194 | twine upload --repository molecule-proxmox --skip-existing dist/* 195 | -------------------------------------------------------------------------------- /src/molecule_proxmox/modules/proxmox_qemu_agent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright (c) 2022, Sine Nomine Associates 4 | # BSD 2-Clause License 5 | 6 | ANSIBLE_METADATA = { 7 | 'metadata_version': '1.1.', 8 | 'status': ['preview'], 9 | 'supported_by': 'community', 10 | } 11 | 12 | DOCUMENTATION = r""" 13 | --- 14 | module: proxmox_qemu_agent 15 | 16 | short_description: Query the QEMU guest agent to find the IP addresses 17 | of a running vm. 18 | 19 | description: 20 | - Start the vm if it is currently not running and wait until at least 21 | one non-loopback IP address is detected. 22 | 23 | - Fails when an IP address is not found within the timeout value. 24 | 25 | author: 26 | - Michael Meffie (@meffie) 27 | """ 28 | 29 | EXAMPLES = r""" 30 | - name: Start instance 31 | proxmox_qemu_agent: 32 | api_host: pve 33 | api_user: admin 34 | api_password: ******** 35 | vmid: 100 36 | timeout: 300 37 | """ 38 | 39 | RETURN = r""" 40 | vmid: 41 | description: vmid of target virtual machine 42 | returned: always 43 | type: int 44 | sample: 100 45 | 46 | addresses: 47 | decription: list of one or more IPv4 addresses 48 | returned: always 49 | type: list 50 | sample: ['192.168.136.123'] 51 | """ 52 | 53 | import time # noqa: E402 54 | import syslog # noqa: E402 55 | 56 | from proxmoxer import ProxmoxAPI # noqa: E402 57 | from proxmoxer.core import ResourceException # noqa: E402 58 | from ansible.module_utils.basic import AnsibleModule # noqa: E402 59 | 60 | 61 | def get_vm(module, proxmox, vmid): 62 | """ 63 | Look up a vm by id, and fail if not found. 64 | """ 65 | vm_list = [vm for vm in proxmox.cluster.resources.get(type='vm') if vm['vmid'] == int(vmid)] # noqa: E501 66 | if len(vm_list) == 0: 67 | module.fail_json(vmid=vmid, msg='VM with vmid = %s not found' % vmid) 68 | if len(vm_list) > 1: 69 | module.fail_json(vmid=vmid, msg='Multiple VMs with vmid = %s found' % vmid) # noqa: E501 70 | return vm_list[0] 71 | 72 | 73 | def start_vm(module, proxmox, vm): 74 | """ 75 | Start the vm and wait until the start task completes. 76 | """ 77 | vmid = vm['vmid'] 78 | proxmox_node = proxmox.nodes(vm['node']) 79 | timeout = module.params['timeout'] 80 | 81 | syslog.syslog('Starting vmid {0}'.format(vmid)) 82 | taskid = proxmox_node.qemu(vm['vmid']).status.start.post() 83 | while timeout: 84 | task = proxmox_node.tasks(taskid).status.get() 85 | if task['status'] == 'stopped' and task['exitstatus'] == 'OK': 86 | time.sleep(1) # Delay for API 87 | return 88 | timeout = timeout - 1 89 | if timeout == 0: 90 | break 91 | time.sleep(1) 92 | lastlog = proxmox_node.tasks(taskid).log.get()[:1] 93 | msg = 'Timeout while starting vmid {0}: {1}'.format(vmid, lastlog) 94 | syslog.syslog(msg) 95 | module.fail_json(msg=msg) 96 | 97 | 98 | def query_vm(module, proxmox, vm): 99 | """ 100 | Query the QEMU guest agent to get the current IP address(es). 101 | """ 102 | vmid = vm['vmid'] 103 | proxmox_node = proxmox.nodes(vm['node']) 104 | timeout = module.params['timeout'] 105 | 106 | syslog.syslog('Waiting for vmid {0} IP address'.format(vmid)) 107 | while timeout: 108 | reply = None 109 | try: 110 | reply = proxmox_node.qemu(vmid).agent.get('network-get-interfaces') # noqa: E501 111 | # syslog.syslog('network-get-interfaces: {0}'.format(reply)) 112 | except ResourceException as e: 113 | if e.status_code == 500 and 'VM {0} is not running'.format(vmid) in e.content: # noqa: 501 114 | start_vm(module, proxmox, vm) 115 | elif e.status_code == 500 and 'QEMU guest agent is not running' in e.content: # noqa: 501 116 | pass # Waiting for guest agent to start. 117 | else: 118 | module.fail_json(msg=str(e)) 119 | if reply and 'result' in reply: 120 | addresses = i2a(reply['result']) 121 | if len(addresses) > 0: 122 | return addresses # Found at least one address. 123 | timeout = timeout - 1 124 | if timeout == 0: 125 | break 126 | time.sleep(1) 127 | 128 | msg = 'Timeout while waiting for vmid {0} IP address'.format(vmid) 129 | syslog.syslog(msg) 130 | module.fail_json(msg=msg) 131 | 132 | 133 | def i2a(interfaces): 134 | """ 135 | Extract the non-loopback IPv4 addresses from 136 | network-get-interfaces results. 137 | 138 | Example: 139 | 140 | reply = {'results': [ 141 | { 142 | 'name': 'ens18', 143 | 'hardware-address': '6e:25:bb:c7:4b:76', 144 | 'ip-addresses': [ 145 | { 146 | 'ip-address-type': 'ipv4', 147 | 'ip-address': '192.168.136.176', 148 | ... 149 | }, 150 | { 151 | 'ip-address-type': 'ipv6', 152 | 'ip-address': 'fe80::6c25:bbff:fec7:4b76', 153 | ... 154 | } 155 | ] 156 | ... 157 | }, 158 | { 159 | 'name': 'lo', 160 | 'hardware-address': '00:00:00:00:00:00', 161 | ... 162 | } 163 | 164 | i2a(reply['results']) 165 | ['192.168.136.176'] 166 | 167 | """ 168 | addrs = [] 169 | for interface in interfaces: 170 | if 'ip-addresses' in interface: 171 | for ip_address in interface['ip-addresses']: 172 | atype = ip_address.get('ip-address-type', '') 173 | aip = ip_address.get('ip-address', '') 174 | if aip and atype == 'ipv4' and not aip.startswith('127.'): 175 | addrs.append(aip) 176 | return addrs 177 | 178 | 179 | def run_module(): 180 | """ 181 | Lookup the IP addresses on a running vm with the qemu guest agent. 182 | Since the guest may still be booting and acquiring an address with 183 | DHCP, retry until we find at least one address, or timeout. 184 | """ 185 | result = dict( 186 | changed=False, 187 | vmid=0, 188 | addresses=[], 189 | ) 190 | module = AnsibleModule( 191 | argument_spec=dict( 192 | api_host=dict(type='str', required=True), 193 | api_port=dict(type='int', default=None), 194 | api_user=dict(type='str', required=True), 195 | api_password=dict(type='str', no_log=True), 196 | api_token_id=dict(type='str', no_log=True), 197 | api_token_secret=dict(type='str', no_log=True), 198 | validate_certs=dict(type='bool', default=False), 199 | vmid=dict(type='int', required=True), 200 | timeout=dict(type='int', default=300), 201 | ), 202 | required_together=[('api_token_id', 'api_token_secret')], 203 | required_one_of=[('api_password', 'api_token_id')], 204 | ) 205 | api_host = module.params['api_host'] 206 | api_port = module.params['api_port'] 207 | validate_certs = module.params['validate_certs'] 208 | api_user = module.params['api_user'] 209 | api_password = module.params['api_password'] 210 | api_token_id = module.params['api_token_id'] 211 | api_token_secret = module.params['api_token_secret'] 212 | vmid = module.params['vmid'] 213 | 214 | auth_args = {'user': api_user} 215 | if not (api_token_id and api_token_secret): 216 | auth_args['password'] = api_password 217 | else: 218 | auth_args['token_name'] = api_token_id 219 | auth_args['token_value'] = api_token_secret 220 | 221 | # API Login 222 | proxmox = ProxmoxAPI( 223 | api_host, 224 | port=api_port, 225 | verify_ssl=validate_certs, 226 | **auth_args) 227 | 228 | # Lookup the vm by id. 229 | time.sleep(1) # Delay for API since we just cloned this instance. 230 | vm = get_vm(module, proxmox, vmid) 231 | 232 | # Wait for at least one IP address. 233 | addresses = query_vm(module, proxmox, vm) 234 | result['vmid'] = vmid 235 | result['addresses'] = addresses 236 | 237 | module.exit_json(**result) 238 | 239 | 240 | def main(): 241 | run_module() 242 | 243 | 244 | if __name__ == '__main__': 245 | main() 246 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | *********************** 2 | Molecule Proxmox Plugin 3 | *********************** 4 | 5 | This is an Ansible Molecule Driver plugin to manage instances on a 6 | `Proxmox VE`_ hypervisor cluster. Only virtual machines are supported at this 7 | time. 8 | 9 | Requirements 10 | ============ 11 | 12 | * Access to a `Proxmox VE`_ cluster 13 | * One or more virtual machine templates with required setup 14 | * Python package `proxmoxer`_ 15 | * Ansible module `community.general.proxmox_kvm`_ 16 | 17 | The required Python packages are automatically installed when 18 | ``molecule-proxmox`` is installed with ``pip``. 19 | 20 | The ``proxmox_kvm`` module is included with the Community.General collection 21 | and is automatically installed when Ansible is installed with ``pip``. 22 | 23 | If you choose to run a newer version of the ``community.general`` collection, 24 | be aware that Molecule Proxmox is not compatible with ``community.general`` 25 | version >=10.7.0 due ``proxmox_kvm`` being moved to its own 26 | collection, ``community.proxmox``. These changes which will probably be 27 | picked up in Ansible 12 and, at that point, in Molecule Proxmox as well. 28 | 29 | Virtual machine template requirements 30 | ------------------------------------- 31 | 32 | The molecule instances are created by cloning Proxmox virtual machine 33 | templates. You will need to create one or more templates. 34 | 35 | Templates have the following requirements. 36 | 37 | * A cloud-init drive if any cloud-init settings are used 38 | * networking configured 39 | * Python installed for Ansible 40 | * qemu-guest-agent installed and enabled in Proxmox 41 | * ssh server installed 42 | * user account for Ansible 43 | * An ssh public key must be added to the ``authorized_keys`` for the Ansible user account. 44 | * If a non-root user is used for the Ansible user (recommended), that user should be 45 | added to the sudoers. (This is not needed for the driver, but will likely be needed 46 | for the ``converge`` playbook.) 47 | 48 | Installation 49 | ============ 50 | 51 | The ``molecule-proxmox`` plugin may be installed with Python ``pip``. A virtualenv 52 | is recommended. The following commands install Ansible, Molecule, and the 53 | Molecule Proxmox plugin in a virtualenv called ``venv``. 54 | 55 | .. code-block:: bash 56 | 57 | $ python3 -m venv venv 58 | $ . venv/bin/activate 59 | $ pip3 install ansible-core molecule molecule-proxmox 60 | 61 | Examples 62 | ======== 63 | 64 | .. code-block:: yaml 65 | 66 | driver: 67 | name: molecule-proxmox 68 | options: 69 | api_host: # e.g. pve01.example.com 70 | api_user: @ # e.g. root@pam 71 | api_password: "********" 72 | node: pve01 73 | ssh_user: tester 74 | ssh_port: 22022 # default to 22 75 | ssh_identity_file: /path/to/id_rsa 76 | platforms: 77 | - name: test01 78 | template_name: debian11 79 | - name: test02 80 | template_name: alma8 81 | 82 | .. code-block:: yaml 83 | 84 | driver: 85 | name: molecule-proxmox 86 | options: 87 | api_host: # e.g. pve01.example.com 88 | api_port: 18006 # custom proxmox port number 89 | api_user: @ # e.g. root@pam 90 | # Optional: Use an API token for Proxmox authentication. 91 | api_token_id: "********" 92 | api_token_secret: "*******************************" 93 | node: pve01 94 | ssh_user: tester 95 | ssh_port: 22022 # default to 22 96 | ssh_identity_file: /path/to/id_rsa 97 | # Optional: The default template name. 98 | template_name: debian11 99 | # Optional: Set the hostname after cloning. 100 | sethostname: yes 101 | # Optional: Create the VMs in the pool. 102 | pool: test 103 | # Optional: Create Linked clone instead of Full clone. 104 | full: false 105 | platforms: 106 | - name: test01 107 | # Optional: Specify the VM id of the clone. 108 | newid: 216 109 | - name: test02 110 | # Optional: Specify the VM id of the clone. 111 | newid: 217 112 | 113 | .. code-block:: yaml 114 | 115 | driver: 116 | name: molecule-proxmox 117 | options: 118 | proxmox_secrets: /path/to/proxmox_secrets.yml 119 | node: pve01 120 | ssh_user: tester 121 | ssh_port: 22022 # default to 22 122 | ssh_identity_file: /path/to/id_rsa 123 | template_name: debian11 124 | platforms: 125 | - name: test01 126 | - name: test02 127 | 128 | The ``proxmox_secrets`` setting specifies the path to an external file with 129 | settings for the proxmox API connection, such as api_password. If this is a regular 130 | file, it should be a yaml file with the settings to be included. If the file is 131 | an executable, the file will be run and the stdout will be combined with the 132 | driver options. The output of the script needs to be valid yaml 133 | consisting of dictionary keys and values (e.g. ``api_password: foobar``). 134 | 135 | The value of ``proxmox_secrets`` will be passed into ``ansible.builtin.cmd``. 136 | Therefore, any additional argument values will be passed to the script as well. 137 | 138 | This allows you to use an external password manager to store 139 | the Proxmox API connection settings. For example with a script: 140 | 141 | .. code-block:: yaml 142 | 143 | driver: 144 | name: molecule-proxmox 145 | options: 146 | debug: true # Enable logging proxmox_secrets tasks for troubleshooting 147 | proxmox_secrets: /usr/local/bin/proxmox_secrets.sh 148 | node: pve01 149 | 150 | .. code-block:: bash 151 | 152 | $ cat /usr/local/bin/proxmox_secrets.sh 153 | #!/bin/sh 154 | pass proxmox/pve01 155 | 156 | Or with a file (which **must** not be executable): 157 | 158 | .. code-block:: yaml 159 | 160 | driver: 161 | name: molecule-proxmox 162 | options: 163 | debug: true # Enable logging proxmox_secrets tasks for troubleshooting 164 | proxmox_secrets: $HOME/proxmox_secrets.yaml 165 | node: pve01 166 | 167 | .. code-block:: yaml 168 | 169 | $ cat $HOME/proxmox_secrets.yaml 170 | --- 171 | api_host: my-proxmox-host 172 | api_user: my-proxmox-user@pam 173 | api_password: my-secret-password 174 | 175 | Finally, a configuration example with many features enabled: 176 | 177 | .. code-block:: yaml 178 | 179 | driver: 180 | name: molecule-proxmox 181 | options: 182 | proxmox_secrets: /path/to/proxmox_secrets.yml 183 | node: pve01 184 | ssh_user: tester 185 | ssh_port: 22022 # default to 22 186 | ssh_identity_file: /path/to/id_rsa 187 | template_name: debian11 188 | platforms: 189 | - name: test01 190 | newid: 1000 191 | template_name: debian11 192 | # See https://docs.ansible.com/ansible/latest/collections/community/general/proxmox_kvm_module.html 193 | # for cloud-init options. 194 | ciuser: some_user 195 | cipassword: some_password 196 | ipconfig: 197 | ipconfig0: 'ip=192.168.0.2/24,gw=192.168.0.1' 198 | nameservers: 199 | - 192.169.0.245 200 | 201 | Development 202 | =========== 203 | 204 | To checkout the source code: 205 | 206 | .. code-block:: bash 207 | 208 | git clone https://github.com/meffie/molecule-proxmox 209 | cd molecule-proxmox 210 | 211 | Install `tox` with `pipx`, your system package manager, or create 212 | a virtualenv. 213 | 214 | .. code-block:: bash 215 | 216 | pipx install tox 217 | 218 | Copy the `envrc.sample` file to `.envrc` and edit the `.envrc` for your local 219 | proxmox site. Source the `.envrc` file to to export the environment variables 220 | to the current shell. 221 | 222 | To run the tests with the latest supported molucule version: 223 | 224 | .. code-block:: bash 225 | 226 | tox -e latest 227 | 228 | To list the tox test environments 229 | 230 | .. code-block:: bash 231 | 232 | tox list 233 | 234 | To run tests with other versions: 235 | 236 | .. code-block:: bash 237 | 238 | tox -e -- [] 239 | 240 | 241 | Authors 242 | ======= 243 | 244 | Molecule Proxmox Plugin was created by Michael Meffie based on code from 245 | Molecule. 246 | 247 | License 248 | ======= 249 | 250 | The `MIT`_ License. 251 | 252 | 253 | .. _`Proxmox VE`: https://www.proxmox.com/en/proxmox-ve 254 | .. _`proxmoxer`: https://pypi.org/project/proxmoxer/ 255 | .. _`community.general.proxmox_kvm`: https://docs.ansible.com/ansible/11/collections/community/general/proxmox_kvm_module.html 256 | .. _`MIT`: https://github.com/meffie/molecule-proxmox/blob/master/LICENSE 257 | --------------------------------------------------------------------------------