refactor: rework ci and testing (#3)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing

This commit is contained in:
Robert Kaussow 2023-01-31 20:09:29 +01:00
parent 579a9713c1
commit dcf04af784
20 changed files with 2090 additions and 592 deletions

View File

@ -5,9 +5,30 @@ local PythonVersion(pyversion='3.8') = {
PY_COLORS: 1,
},
commands: [
'pip install -r dev-requirements.txt -qq',
'pip install -r test/unit/requirements.txt -qq',
'python -m pytest --cov --cov-append --no-cov-on-fail',
'pip install poetry -qq',
'poetry config experimental.new-installer false',
'poetry install --all-extras',
'poetry run pytest',
],
depends_on: [
'clone',
],
};
local AnsibleVersion(version='devel') = {
local gitversion = if version == 'devel' then 'devel' else 'stable-' + version,
name: 'ansible-' + std.strReplace(version, '.', ''),
image: 'python:3.9',
environment: {
PY_COLORS: 1,
},
commands: [
'pip install poetry -qq',
'poetry config experimental.new-installer false',
'poetry install',
'poetry run pip install https://github.com/ansible/ansible/archive/' + gitversion + '.tar.gz --disable-pip-version-check',
'poetry run ansible --version',
'poetry run ansible-test sanity --exclude .chglog/ --exclude .drone.yml --python 3.9',
],
depends_on: [
'clone',
@ -23,14 +44,31 @@ local PipelineLint = {
},
steps: [
{
name: 'flake8',
image: 'python:3.10',
name: 'check-format',
image: 'python:3.11',
environment: {
PY_COLORS: 1,
},
commands: [
'pip install -r dev-requirements.txt -qq',
'flake8',
'git fetch -tq',
'pip install poetry -qq',
'poetry config experimental.new-installer false',
'poetry install --all-extras',
'poetry run yapf -dr ./plugins',
],
},
{
name: 'check-coding',
image: 'python:3.11',
environment: {
PY_COLORS: 1,
},
commands: [
'git fetch -tq',
'pip install poetry -qq',
'poetry config experimental.new-installer false',
'poetry install --all-extras',
'poetry run ruff ./plugins',
],
},
],
@ -39,9 +77,9 @@ local PipelineLint = {
},
};
local PipelineTest = {
local PipelineUnitTest = {
kind: 'pipeline',
name: 'test',
name: 'unit-test',
platform: {
os: 'linux',
arch: 'amd64',
@ -50,6 +88,7 @@ local PipelineTest = {
PythonVersion(pyversion='3.8'),
PythonVersion(pyversion='3.9'),
PythonVersion(pyversion='3.10'),
PythonVersion(pyversion='3.11'),
],
depends_on: [
'lint',
@ -59,6 +98,29 @@ local PipelineTest = {
},
};
local PipelineSanityTest = {
kind: 'pipeline',
name: 'sanity-test',
platform: {
os: 'linux',
arch: 'amd64',
},
workspace: {
path: '/drone/src/ansible_collections/${DRONE_REPO_NAME/./\\/}',
},
steps: [
AnsibleVersion(version='devel'),
AnsibleVersion(version='2.14'),
AnsibleVersion(version='2.13'),
],
depends_on: [
'unit-test',
],
trigger: {
ref: ['refs/heads/main', 'refs/tags/**', 'refs/pull/**'],
},
};
local PipelineBuild = {
kind: 'pipeline',
name: 'build',
@ -69,12 +131,14 @@ local PipelineBuild = {
steps: [
{
name: 'build',
image: 'python:3.9',
image: 'python:3.11',
commands: [
'GALAXY_VERSION=${DRONE_TAG##v}',
"sed -i 's/version: 0.0.0/version: '\"$${GALAXY_VERSION:-0.0.0}\"'/g' galaxy.yml",
'pip install ansible -qq',
'ansible-galaxy collection build --output-path dist/',
'pip install poetry -qq',
'poetry config experimental.new-installer false',
'poetry install --all-extras',
'poetry run ansible-galaxy collection build --output-path dist/',
],
},
{
@ -117,7 +181,7 @@ local PipelineBuild = {
},
],
depends_on: [
'test',
'sanity-test',
],
trigger: {
ref: ['refs/heads/main', 'refs/tags/**', 'refs/pull/**'],
@ -188,7 +252,8 @@ local PipelineNotifications = {
[
PipelineLint,
PipelineTest,
PipelineUnitTest,
PipelineSanityTest,
PipelineBuild,
PipelineDocumentation,
PipelineNotifications,

View File

@ -7,11 +7,25 @@ platform:
arch: amd64
steps:
- name: flake8
image: python:3.10
- name: check-format
image: python:3.11
commands:
- pip install -r dev-requirements.txt -qq
- flake8
- git fetch -tq
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run yapf -dr ./plugins
environment:
PY_COLORS: 1
- name: check-coding
image: python:3.11
commands:
- git fetch -tq
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run ruff ./plugins
environment:
PY_COLORS: 1
@ -23,7 +37,7 @@ trigger:
---
kind: pipeline
name: test
name: unit-test
platform:
os: linux
@ -33,9 +47,10 @@ steps:
- name: python38-pytest
image: python:3.8
commands:
- pip install -r dev-requirements.txt -qq
- pip install -r test/unit/requirements.txt -qq
- python -m pytest --cov --cov-append --no-cov-on-fail
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run pytest
environment:
PY_COLORS: 1
depends_on:
@ -44,9 +59,10 @@ steps:
- name: python39-pytest
image: python:3.9
commands:
- pip install -r dev-requirements.txt -qq
- pip install -r test/unit/requirements.txt -qq
- python -m pytest --cov --cov-append --no-cov-on-fail
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run pytest
environment:
PY_COLORS: 1
depends_on:
@ -55,9 +71,22 @@ steps:
- name: python310-pytest
image: python:3.10
commands:
- pip install -r dev-requirements.txt -qq
- pip install -r test/unit/requirements.txt -qq
- python -m pytest --cov --cov-append --no-cov-on-fail
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run pytest
environment:
PY_COLORS: 1
depends_on:
- clone
- name: python311-pytest
image: python:3.11
commands:
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run pytest
environment:
PY_COLORS: 1
depends_on:
@ -72,6 +101,69 @@ trigger:
depends_on:
- lint
---
kind: pipeline
name: sanity-test
platform:
os: linux
arch: amd64
workspace:
path: /drone/src/ansible_collections/${DRONE_REPO_NAME/./\/}
steps:
- name: ansible-devel
image: python:3.9
commands:
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install
- poetry run pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check
- poetry run ansible --version
- poetry run ansible-test sanity --exclude .chglog/ --exclude .drone.yml --python 3.9
environment:
PY_COLORS: 1
depends_on:
- clone
- name: ansible-214
image: python:3.9
commands:
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install
- poetry run pip install https://github.com/ansible/ansible/archive/stable-2.14.tar.gz --disable-pip-version-check
- poetry run ansible --version
- poetry run ansible-test sanity --exclude .chglog/ --exclude .drone.yml --python 3.9
environment:
PY_COLORS: 1
depends_on:
- clone
- name: ansible-213
image: python:3.9
commands:
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install
- poetry run pip install https://github.com/ansible/ansible/archive/stable-2.13.tar.gz --disable-pip-version-check
- poetry run ansible --version
- poetry run ansible-test sanity --exclude .chglog/ --exclude .drone.yml --python 3.9
environment:
PY_COLORS: 1
depends_on:
- clone
trigger:
ref:
- refs/heads/main
- refs/tags/**
- refs/pull/**
depends_on:
- unit-test
---
kind: pipeline
name: build
@ -82,12 +174,14 @@ platform:
steps:
- name: build
image: python:3.9
image: python:3.11
commands:
- GALAXY_VERSION=${DRONE_TAG##v}
- "sed -i 's/version: 0.0.0/version: '\"$${GALAXY_VERSION:-0.0.0}\"'/g' galaxy.yml"
- pip install ansible -qq
- ansible-galaxy collection build --output-path dist/
- pip install poetry -qq
- poetry config experimental.new-installer false
- poetry install --all-extras
- poetry run ansible-galaxy collection build --output-path dist/
- name: checksum
image: alpine
@ -129,7 +223,7 @@ trigger:
- refs/pull/**
depends_on:
- test
- sanity-test
---
kind: pipeline
@ -195,6 +289,6 @@ depends_on:
---
kind: signature
hmac: 93f735c3d2fbaf499fd96b79301f6de2455349051dc320c511e6e62c8ba04a4d
hmac: 407d145ab4483651c579eea4e1e9375536caee1aa0579abcdf57b5653e595cb5
...

1
.gitignore vendored
View File

@ -108,3 +108,4 @@ docs/public/
resources/_gen/
CHANGELOG.md
tests/output

View File

@ -1,18 +0,0 @@
pydocstyle
flake8
flake8-blind-except
flake8-builtins
flake8-docstrings
flake8-isort
flake8-logging-format
flake8-polyfill
flake8-quotes
flake8-pep3101
flake8-eradicate
pep8-naming
wheel
pytest
pytest-mock
pytest-cov
bandit
yapf

View File

@ -1,11 +1,15 @@
"""Filter to prefix all itams from a list."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
def prefix(value, prefix="--"):
return [prefix + x for x in value]
class FilterModule(object):
class FilterModule(object): # noqa
def filters(self):
return {"prefix": prefix}

View File

@ -1,11 +1,15 @@
"""Filter to wrap all items from a list."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
def wrap(value, wrapper="'"):
return [wrapper + x + wrapper for x in value]
class FilterModule(object):
class FilterModule(object): # noqa
def filters(self):
return {"wrap": wrap}

View File

@ -1,19 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2014, Mathieu GAUTHIER-LAFAYE <gauthierl@lapth.cnrs.fr>
# Copyright (c) 2016, Matt Harris <matthaeus.harris@gmail.com>
# Copyright (c) 2020, Robert Kaussow <mail@thegeeklab.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
"""Dynamic inventory plugin for Proxmox VE."""
from __future__ import absolute_import, division, print_function
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = """
---
name: proxmox
plugin_type: inventory
short_description: Proxmox VE inventory source
version_added: 1.0.0
version_added: 1.1.0
description:
- Get inventory hosts from the proxmox service.
- "Uses a configuration file as an inventory source, it must end in C(.proxmox.yml) or C(.proxmox.yaml) and has a C(plugin: xoxys.general.proxmox) entry."
@ -24,29 +23,35 @@ DOCUMENTATION = """
description: The name of this plugin, it should always be set to C(xoxys.general.proxmox) for this plugin to recognize it as it's own.
required: yes
choices: ["xoxys.general.proxmox"]
server:
description: Proxmox VE server url.
default: "pve.example.com"
type: string
required: yes
env:
- name: PROXMOX_SERVER
user:
description: Proxmox VE authentication user.
type: string
required: yes
env:
- name: PROXMOX_USER
password:
description: Proxmox VE authentication password.
type: string
required: yes
env:
- name: PROXMOX_PASSWORD
api_host:
description:
- Specify the target host of the Proxmox VE cluster.
type: str
required: true
api_user:
description:
- Specify the user to authenticate with.
type: str
required: true
api_password:
description:
- Specify the password to authenticate with.
- You can use C(PROXMOX_PASSWORD) environment variable.
type: str
api_token_id:
description:
- Specify the token ID.
type: str
api_token_secret:
description:
- Specify the token secret.
type: str
verify_ssl:
description: Skip SSL certificate verification.
type: boolean
default: yes
description:
- If C(false), SSL certificates will not be validated.
- This should only be used on personally controlled sites using self-signed certificates.
type: bool
default: True
auth_timeout:
description: Proxmox VE authentication timeout.
type: int
@ -68,28 +73,30 @@ DOCUMENTATION = """
want_facts:
description: Toggle, if C(true) the plugin will retrieve host facts from the server
type: boolean
default: yes
default: True
requirements:
- "proxmoxer"
""" # noqa
EXAMPLES = """
# proxmox.yml
plugin: xoxys.general.proxmox
server: pve.example.com
user: admin@pve
password: secure
api_user: root@pam
api_password: secret
api_host: helldorado
"""
import json
import re
import socket
from collections import defaultdict
from distutils.version import LooseVersion
from ansible.errors import AnsibleError
from ansible.module_utils._text import to_native
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils.six import iteritems
from ansible.plugins.inventory import BaseInventoryPlugin
from collections import defaultdict
from distutils.version import LooseVersion
try:
from proxmoxer import ProxmoxAPI
@ -97,17 +104,39 @@ try:
except ImportError:
HAS_PROXMOXER = False
try:
from requests.packages import urllib3
HAS_URLLIB3 = True
except ImportError:
try:
import urllib3
HAS_URLLIB3 = True
except ImportError:
HAS_URLLIB3 = False
class InventoryModule(BaseInventoryPlugin):
"""Provide Proxmox VE inventory."""
NAME = "xoxys.general.proxmox"
def _auth(self):
auth_args = {"user": self.get_option("api_user")}
if not (self.get_option("api_token_id") and self.get_option("api_token_secret")):
auth_args["password"] = self.get_option("api_password")
else:
auth_args["token_name"] = self.get_option("api_token_id")
auth_args["token_value"] = self.get_option("api_token_secret")
verify_ssl = boolean(self.get_option("verify_ssl"), strict=False)
if not verify_ssl and HAS_URLLIB3:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
return ProxmoxAPI(
self.get_option("server"),
user=self.get_option("user"),
password=self.get_option("password"),
verify_ssl=boolean(self.get_option("password"), strict=False),
timeout=self.get_option("auth_timeout")
self.get_option("api_host"),
verify_ssl=verify_ssl,
timeout=self.get_option("auth_timeout"),
**auth_args
)
def _get_version(self):
@ -117,14 +146,12 @@ class InventoryModule(BaseInventoryPlugin):
return LooseVersion(self.client.version.get()["release"])
def _get_names(self, pve_list, pve_type):
names = []
if pve_type == "node":
names = [node["node"] for node in pve_list]
elif pve_type == "pool":
names = [pool["poolid"] for pool in pve_list]
return [node["node"] for node in pve_list]
if pve_type == "pool":
return [pool["poolid"] for pool in pve_list]
return names
return []
def _get_variables(self, pve_list, pve_type):
variables = {}
@ -143,11 +170,9 @@ class InventoryModule(BaseInventoryPlugin):
def validate(address):
try:
# IP address validation
if socket.inet_aton(address):
# Ignore localhost
if address != "127.0.0.1":
if socket.inet_aton(address) and address != "127.0.0.1":
return address
except socket.error:
except OSError:
return False
address = False
@ -159,11 +184,10 @@ class InventoryModule(BaseInventoryPlugin):
networks = self.client.nodes(pve_node).get(
"qemu", vmid, "agent", "network-get-interfaces"
)["result"]
except Exception:
except Exception: # noqa
pass
if networks:
if type(networks) is list:
if networks and isinstance(networks, list):
for network in networks:
for ip_address in network["ip-addresses"]:
address = validate(ip_address["ip-address"])
@ -171,7 +195,7 @@ class InventoryModule(BaseInventoryPlugin):
try:
config = self.client.nodes(pve_node).get(pve_type, vmid, "config")
address = re.search(r"ip=(\d*\.\d*\.\d*\.\d*)", config["net0"]).group(1)
except Exception:
except Exception: # noqa
pass
return address
@ -197,8 +221,8 @@ class InventoryModule(BaseInventoryPlugin):
try:
qemu_list = self._exclude(self.client.nodes(node).qemu.get())
container_list = self._exclude(self.client.nodes(node).lxc.get())
except Exception as e:
raise AnsibleError("Proxmoxer API error: {0}".format(to_native(e)))
except Exception as e: # noqa
raise AnsibleError(f"Proxmoxer API error: {to_native(e)}") from e
# Merge QEMU and Containers lists from this node
instances = self._get_variables(qemu_list, "qemu").copy()
@ -217,8 +241,8 @@ class InventoryModule(BaseInventoryPlugin):
"config")["description"]
except KeyError:
description = None
except Exception as e:
raise AnsibleError("Proxmoxer API error: {0}".format(to_native(e)))
except Exception as e: # noqa
raise AnsibleError(f"Proxmoxer API error: {to_native(e)}") from e
try:
metadata = json.loads(description)
@ -252,8 +276,8 @@ class InventoryModule(BaseInventoryPlugin):
for pool in self._get_names(self.client.pools.get(), "pool"):
try:
pool_list = self._exclude(self.client.pool(pool).get()["members"])
except Exception as e:
raise AnsibleError("Proxmoxer API error: {0}".format(to_native(e)))
except Exception as e: # noqa
raise AnsibleError(f"Proxmoxer API error: {to_native(e)}") from e
members = [
member["name"]
@ -266,13 +290,13 @@ class InventoryModule(BaseInventoryPlugin):
def verify_file(self, path):
"""Verify the Proxmox VE configuration file."""
if super(InventoryModule, self).verify_file(path):
if super().verify_file(path):
endings = ("proxmox.yaml", "proxmox.yml")
if any((path.endswith(ending) for ending in endings)):
if any(path.endswith(ending) for ending in endings):
return True
return False
def parse(self, inventory, loader, path, cache=True):
def parse(self, inventory, loader, path, cache=True): # noqa
"""Dynamically parse the Proxmox VE cloud inventory."""
if not HAS_PROXMOXER:
raise AnsibleError(
@ -280,7 +304,7 @@ class InventoryModule(BaseInventoryPlugin):
"https://pypi.org/project/proxmoxer/"
)
super(InventoryModule, self).parse(inventory, loader, path)
super().parse(inventory, loader, path)
self._read_config_data(path)
self.client = self._auth()

View File

@ -1,3 +1,4 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
IPtables raw module.
@ -21,13 +22,17 @@ You should have received a copy of the GNU General Public License
along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
ANSIBLE_METADATA = {'status': ['preview'], 'supported_by': 'community', 'metadata_version': '1.0'}
DOCUMENTATION = r'''
---
module: iptables_raw
short_description: Manage iptables rules
version_added: "2.4"
version_added: 1.1.0
description:
- Add/remove iptables rules while keeping state.
options:
@ -35,13 +40,14 @@ options:
description:
- Create a backup of the iptables state file before overwriting it.
required: false
choices: ["yes", "no"]
default: "no"
type: bool
default: False
ipversion:
description:
- Target the IP version this rule is for.
required: false
default: "4"
type: str
choices: ["4", "6"]
keep_unmanaged:
description:
@ -54,8 +60,8 @@ options:
first time, since if you don't specify correct rules, you can block
yourself out of the managed host."
required: false
choices: ["yes", "no"]
default: "yes"
type: bool
default: True
name:
description:
- Name that will be used as an identifier for these rules. It can contain
@ -64,17 +70,21 @@ options:
C(state=absent) to flush all rules in the selected table, or even all
tables with C(table=*).
required: true
type: str
rules:
description:
- The rules that we want to add. Accepts multiline values.
- "Note: You can only use C(-A)/C(--append), C(-N)/C(--new-chain), and
C(-P)/C(--policy) to specify rules."
required: false
type: str
default: ""
state:
description:
- The state this rules fragment should be in.
choices: ["present", "absent"]
required: false
type: str
default: present
table:
description:
@ -82,12 +92,13 @@ options:
with C(name=*) and C(state=absent) to flush all rules in all tables.
choices: ["filter", "nat", "mangle", "raw", "security", "*"]
required: false
type: str
default: filter
weight:
description:
- Determines the order of the rules. Lower C(weight) means higher
priority. Supported range is C(0 - 99)
choices: ["0 - 99"]
type: int
required: false
default: 40
notes:
@ -116,7 +127,7 @@ EXAMPLES = '''
- iptables_raw:
name: default_rules
weight: 10
keep_unmanaged: no
keep_unmanaged: false
rules: |
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i lo -j ACCEPT
@ -156,12 +167,12 @@ RETURN = '''
state:
description: state of the rules
returned: success
type: string
type: str
sample: present
name:
description: name of the rules
returned: success
type: string
type: str
sample: open_tcp_80
weight:
description: weight of the rules
@ -176,22 +187,22 @@ ipversion:
rules:
description: passed rules
returned: success
type: string
type: str
sample: "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT"
table:
description: iptables table used
returned: success
type: string
type: str
sample: filter
backup:
description: if the iptables file should backed up
returned: success
type: boolean
type: bool
sample: False
keep_unmanaged:
description: if it should keep unmanaged rules
returned: success
type: boolean
type: bool
sample: True
'''

View File

@ -1,25 +1,36 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
"""OpenSSL PKCS12 module."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
ANSIBLE_METADATA = {"metadata_version": "1.0", "status": ["preview"], "supported_by": "community"}
DOCUMENTATION = """
---
module: openssl_pkcs12
author: "Guillaume Delpierre (@gdelpierre)"
version_added: "2.4"
version_added: 1.1.0
short_description: Generate OpenSSL pkcs12 archive.
description:
- "This module allows one to (re-)generate PKCS#12."
requirements:
- "python-pyOpenSSL"
extends_documentation_fragment: files
options:
ca_certificates:
required: False
type: list
elements: str
description:
- List of CA certificate to include.
cert_path:
required: False
type: path
description:
- The path to read certificates and private keys from.
Must be in PEM format.
@ -27,61 +38,70 @@ options:
required: False
default: "export"
choices: ["parse", "export"]
type: str
description:
- Create (export) or parse a PKCS#12.
src:
required: False
type: path
description:
- PKCS#12 file path to parse.
path:
required: True
default: null
type: path
description:
- Filename to write the PKCS#12 file to.
force:
required: False
default: False
type: bool
description:
- Should the file be regenerated even it it already exists.
friendly_name:
required: False
default: null
aliases: "name"
type: str
aliases:
- "name"
description:
- Specifies the friendly name for the certificate and private key.
iter_size:
required: False
default: 2048
type: int
description:
- Number of times to repeat the encryption step.
maciter_size:
required: False
default: 1
type: int
description:
- Number of times to repeat the MAC step.
mode:
required: False
default: 0400
default: "0400"
type: str
description:
- Default mode for the generated PKCS#12 file.
passphrase:
required: False
default: null
type: str
description:
- The PKCS#12 password.
privatekey_path:
required: False
type: path
description:
- File to read private key from.
privatekey_passphrase:
required: False
default: null
type: str
description:
- Passphrase source to decrypt any input private keys with.
state:
required: False
default: "present"
choices: ["present", "absent"]
type: str
description:
- Whether the file should exist or not.
"""
@ -133,7 +153,7 @@ RETURN = """
filename:
description: Path to the generate PKCS#12 file.
returned: changed or success
type: string
type: str
sample: /opt/certs/ansible.p12
"""
@ -151,11 +171,11 @@ else:
pyopenssl_found = True
class PkcsError(Exception):
class PkcsError(Exception): # noqa
pass
class Pkcs(object):
class Pkcs(object): # noqa
def __init__(self, module):
self.path = module.params["path"]
@ -181,36 +201,37 @@ class Pkcs(object):
def load_privatekey(self, path, passphrase=None):
"""Load the specified OpenSSL private key."""
try:
if passphrase:
privatekey = crypto.load_privatekey(
crypto.FILETYPE_PEM,
open(path, "rb").read(), passphrase
open(path, "rb").read(), # noqa
passphrase
) if passphrase else crypto.load_privatekey(
crypto.FILETYPE_PEM,
open(path, "rb").read() # noqa
)
else:
privatekey = crypto.load_privatekey(crypto.FILETYPE_PEM, open(path, "rb").read())
return privatekey
except (IOError, OSError) as exc:
raise PkcsError(exc)
except OSError as exc:
raise PkcsError(exc) from exc
def load_certificate(self, path):
"""Load the specified certificate."""
try:
cert_content = open(path, "rb").read()
cert_content = open(path, "rb").read() # noqa
cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_content)
return cert
except (IOError, OSError) as exc:
raise PkcsError(exc)
except OSError as exc:
raise PkcsError(exc) from exc
def load_pkcs12(self, path, passphrase=None):
"""Load pkcs12 file."""
try:
if passphrase:
return crypto.load_pkcs12(open(path, "rb").read(), passphrase)
else:
return crypto.load_pkcs12(open(path, "rb").read())
except (IOError, OSError) as exc:
raise PkcsError(exc)
return crypto.load_pkcs12(open(path, "rb").read(), passphrase) # noqa
return crypto.load_pkcs12(open(path, "rb").read()) # noqa
except OSError as exc:
raise PkcsError(exc) from exc
def dump_privatekey(self, path):
"""Dump the specified OpenSSL private key."""
@ -219,8 +240,8 @@ class Pkcs(object):
crypto.FILETYPE_PEM,
self.load_pkcs12(path).get_privatekey()
)
except (IOError, OSError) as exc:
raise PkcsError(exc)
except OSError as exc:
raise PkcsError(exc) from exc
def dump_certificate(self, path):
"""Dump the specified certificate."""
@ -229,8 +250,8 @@ class Pkcs(object):
crypto.FILETYPE_PEM,
self.load_pkcs12(path).get_certificate()
)
except (IOError, OSError) as exc:
raise PkcsError(exc)
except OSError as exc:
raise PkcsError(exc) from exc
def generate(self, module):
"""Generate PKCS#12 file archive."""
@ -264,9 +285,9 @@ class Pkcs(object):
)
module.set_mode_if_different(self.path, self.mode, False)
self.changed = True
except (IOError, OSError) as exc:
except OSError as exc:
self.remove()
raise PkcsError(exc)
raise PkcsError(exc) from exc
file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False):
@ -281,14 +302,12 @@ class Pkcs(object):
with open(self.path, "wb") as content:
content.write(
"{0}{1}".format(
self.dump_privatekey(self.src), self.dump_certificate(self.src)
)
f"{self.dump_privatekey(self.src)}{self.dump_certificate(self.src)}"
)
module.set_mode_if_different(self.path, self.mode, False)
self.changed = True
except IOError as exc:
raise PkcsError(exc)
except OSError as exc:
raise PkcsError(exc) from exc
file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False):
@ -302,11 +321,11 @@ class Pkcs(object):
self.changed = True
except OSError as exc:
if exc.errno != errno.ENOENT:
raise PkcsError(exc)
else:
raise PkcsError(exc) from exc
pass
def check(self, module, perms_required=True):
def check(self, module, perms_required=True): # noqa
def _check_pkey_passphrase():
if self.privatekey_passphrase:
@ -337,19 +356,20 @@ class Pkcs(object):
def main():
argument_spec = dict(
action=dict(default="export", choices=["parse", "export"], type="str"),
ca_certificates=dict(type="list"),
action=dict(default="export", choices=["parse", "export"], type="str", required=False),
ca_certificates=dict(type="list", elements="str", required=False),
cert_path=dict(type="path"),
force=dict(default=False, type="bool"),
friendly_name=dict(type="str", aliases=["name"]),
iter_size=dict(default=2048, type="int"),
maciter_size=dict(default=1, type="int"),
passphrase=dict(type="str", no_log=True),
path=dict(required=True, type="path"),
path=dict(type="path", required=True),
privatekey_path=dict(type="path"),
privatekey_passphrase=dict(type="str", no_log=True),
state=dict(default="present", choices=["present", "absent"], type="str"),
src=dict(type="path"),
mode=dict(default="0400", type="str", required=False)
)
required_if = [
@ -376,8 +396,7 @@ def main():
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg="The directory {0} does not exist or "
"the file is not a directory".format(base_dir)
msg=f"The directory {base_dir} does not exist or the file is not a directory"
)
pkcs12 = Pkcs(module)

View File

@ -1,20 +1,69 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2016, Abdoul Bah (@helldorado) <bahabdoul at gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
"""Module to control Proxmox KVM machines."""
"""Control Proxmox KVM machines."""
from __future__ import absolute_import, division, print_function
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = """
DOCUMENTATION = r"""
---
module: proxmox_kvm
short_description: Management of Qemu(KVM) Virtual Machines in Proxmox VE cluster.
description:
- Allows you to create/delete/stop Qemu(KVM) Virtual Machines in Proxmox VE cluster.
author: "Abdoul Bah (@helldorado) <bahabdoul at gmail.com>"
version_added: 1.1.0
options:
vmid:
description:
- Specifies the instance ID.
- If not set the next available ID will be fetched from ProxmoxAPI.
type: int
node:
description:
- Proxmox VE node on which to operate.
- Only required for I(state=present).
- For every other states it will be autodiscovered.
type: str
pool:
description:
- Add the new VM to the specified pool.
type: str
api_host:
description:
- Specify the target host of the Proxmox VE cluster.
type: str
required: true
api_user:
description:
- Specify the user to authenticate with.
type: str
required: true
api_password:
description:
- Specify the password to authenticate with.
- You can use C(PROXMOX_PASSWORD) environment variable.
type: str
api_token_id:
description:
- Specify the token ID.
type: str
version_added: 1.3.0
api_token_secret:
description:
- Specify the token secret.
type: str
version_added: 1.3.0
verify_ssl:
description:
- If C(false), SSL certificates will not be validated.
- This should only be used on personally controlled sites using self-signed certificates.
type: bool
default: True
acpi:
description:
- Specify if ACPI should be enabled/disabled.
@ -49,7 +98,7 @@ options:
description:
- Specify the BIOS implementation.
type: str
choices: ['seabios', 'ovmf']
choices: ["seabios", "ovmf"]
boot:
description:
- Specify the boot order -> boot on floppy C(a), hard disk C(c), CD-ROM C(d), or network C(n).
@ -63,27 +112,23 @@ options:
type: str
cicustom:
description:
- 'cloud-init: Specify custom files to replace the automatically generated ones at start.'
- "cloud-init: Specify custom files to replace the automatically generated ones at start."
type: str
version_added: 1.3.0
cipassword:
description:
- 'cloud-init: password of default user to create.'
- "cloud-init: password of default user to create."
type: str
version_added: 1.3.0
citype:
description:
- 'cloud-init: Specifies the cloud-init configuration format.'
- "cloud-init: Specifies the cloud-init configuration format."
- The default depends on the configured operating system type (C(ostype)).
- We use the C(nocloud) format for Linux, and C(configdrive2) for Windows.
type: str
choices: ['nocloud', 'configdrive2']
version_added: 1.3.0
choices: ["nocloud", "configdrive2"]
ciuser:
description:
- 'cloud-init: username of default user to create.'
- "cloud-init: username of default user to create."
type: str
version_added: 1.3.0
clone:
description:
- Name of VM to be cloned. If C(vmid) is setted, C(clone) can take arbitrary value but required for initiating the clone.
@ -142,7 +187,8 @@ options:
option has a default of C(qcow2). If I(proxmox_default_behavior) is set to C(no_defaults),
not specifying this option is equivalent to setting it to C(unspecified).
type: str
choices: [ "cloop", "cow", "qcow", "qcow2", "qed", "raw", "vmdk", "unspecified" ]
choices:
["cloop", "cow", "qcow", "qcow2", "qed", "raw", "vmdk", "unspecified"]
freeze:
description:
- Specify if PVE should freeze CPU at startup (use 'c' monitor command to start execution).
@ -153,7 +199,7 @@ options:
- For VM templates, we try to create a linked clone by default.
- Used only with clone
type: bool
default: 'yes'
default: "yes"
hostpci:
description:
- Specify a hash/dictionary of map host pci devices into guest. C(hostpci='{"key":"value", "key":"value"}').
@ -175,7 +221,7 @@ options:
description:
- Enable/disable hugepages memory.
type: str
choices: ['any', '2', '1024']
choices: ["any", "2", "1024"]
ide:
description:
- A hash/dictionary of volume used as IDE hard disk or CD-ROM. C(ide='{"key":"value", "key":"value"}').
@ -187,17 +233,16 @@ options:
type: dict
ipconfig:
description:
- 'cloud-init: Set the IP configuration.'
- "cloud-init: Set the IP configuration."
- A hash/dictionary of network ip configurations. C(ipconfig='{"key":"value", "key":"value"}').
- Keys allowed are - C(ipconfig[n]) where 0 n network interfaces.
- Values allowed are - C("[gw=<GatewayIPv4>] [,gw6=<GatewayIPv6>] [,ip=<IPv4Format/CIDR>] [,ip6=<IPv6Format/CIDR>]").
- 'cloud-init: Specify IP addresses and gateways for the corresponding interface.'
- "cloud-init: Specify IP addresses and gateways for the corresponding interface."
- IP addresses use CIDR notation, gateways are optional but they should be in the same subnet of specified IP address.
- The special string 'dhcp' can be used for IP addresses to use DHCP, in which case no explicit gateway should be provided.
- For IPv6 the special string 'auto' can be used to use stateless autoconfiguration.
- If cloud-init is enabled and neither an IPv4 nor an IPv6 address is specified, it defaults to using dhcp on IPv4.
type: dict
version_added: 1.3.0
keyboard:
description:
- Sets the keyboard layout for VNC server.
@ -217,7 +262,7 @@ options:
description:
- Lock/unlock the VM.
type: str
choices: ['migrate', 'backup', 'snapshot', 'rollback']
choices: ["migrate", "backup", "snapshot", "rollback"]
machine:
description:
- Specifies the Qemu machine type.
@ -245,11 +290,10 @@ options:
type: str
nameservers:
description:
- 'cloud-init: DNS server IP address(es).'
- "cloud-init: DNS server IP address(es)."
- If unset, PVE host settings are used.
type: list
elements: str
version_added: 1.3.0
net:
description:
- A hash/dictionary of network interfaces for the VM. C(net='{"key":"value", "key":"value"}').
@ -293,7 +337,21 @@ options:
- If I(proxmox_default_behavior) is set to C(compatiblity) (the default value), this
option has a default of C(l26).
type: str
choices: ['other', 'wxp', 'w2k', 'w2k3', 'w2k8', 'wvista', 'win7', 'win8', 'win10', 'l24', 'l26', 'solaris']
choices:
[
"other",
"wxp",
"w2k",
"w2k3",
"w2k8",
"wvista",
"win7",
"win8",
"win10",
"l24",
"l26",
"solaris",
]
parallel:
description:
- A hash/dictionary of map host parallel devices. C(parallel='{"key":"value", "key":"value"}').
@ -334,14 +392,21 @@ options:
description:
- Specifies the SCSI controller model.
type: str
choices: ['lsi', 'lsi53c810', 'virtio-scsi-pci', 'virtio-scsi-single', 'megasas', 'pvscsi']
choices:
[
"lsi",
"lsi53c810",
"virtio-scsi-pci",
"virtio-scsi-single",
"megasas",
"pvscsi",
]
searchdomains:
description:
- 'cloud-init: Sets DNS search domain(s).'
- "cloud-init: Sets DNS search domain(s)."
- If unset, PVE host settings are used.
type: list
elements: str
version_added: 1.3.0
serial:
description:
- A hash/dictionary of serial device to create inside the VM. C('{"key":"value", "key":"value"}').
@ -377,9 +442,8 @@ options:
type: int
sshkeys:
description:
- 'cloud-init: SSH key to assign to the default user. NOT TESTED with multiple keys but a multi-line value should work.'
- "cloud-init: SSH key to assign to the default user. NOT TESTED with multiple keys but a multi-line value should work."
type: str
version_added: 1.3.0
startdate:
description:
- Sets the initial date of the real time clock.
@ -396,7 +460,7 @@ options:
- Indicates desired state of the instance.
- If C(current), the current state of the VM will be fetched. You can access it with C(results.status)
type: str
choices: ['present', 'started', 'absent', 'stopped', 'restarted','current']
choices: ["present", "started", "absent", "stopped", "restarted", "current"]
default: present
storage:
description:
@ -415,7 +479,6 @@ options:
- Tags are only available in Proxmox 6+.
type: list
elements: str
version_added: 2.3.0
target:
description:
- Target node. Only allowed if the original VM is on shared storage.
@ -441,7 +504,7 @@ options:
- If C(yes), the VM will be updated with new value.
- Update of C(pool) is disabled. It needs an additional API endpoint not covered by this module.
type: bool
default: 'no'
default: "no"
vcpus:
description:
- Sets number of hotplugged vcpus.
@ -452,7 +515,20 @@ options:
- If I(proxmox_default_behavior) is set to C(compatiblity) (the default value), this
option has a default of C(std).
type: str
choices: ['std', 'cirrus', 'vmware', 'qxl', 'serial0', 'serial1', 'serial2', 'serial3', 'qxl2', 'qxl3', 'qxl4']
choices:
[
"std",
"cirrus",
"vmware",
"qxl",
"serial0",
"serial1",
"serial2",
"serial3",
"qxl2",
"qxl3",
"qxl4",
]
virtio:
description:
- A hash/dictionary of volume used as VIRTIO hard disk. C(virtio='{"key":"value", "key":"value"}').
@ -477,10 +553,6 @@ options:
choices:
- compatibility
- no_defaults
version_added: "1.3.0"
extends_documentation_fragment:
- xoxys.general.proxmox.documentation
- xoxys.general.proxmox.selection
""" # noqa
EXAMPLES = """
@ -509,8 +581,8 @@ EXAMPLES = """
name: spynal
node: sabrewulf
net:
net0: 'virtio,bridge=vmbr1,rate=200'
net1: 'e1000,bridge=vmbr2'
net0: "virtio,bridge=vmbr1,rate=200"
net1: "e1000,bridge=vmbr2"
- name: Create new VM with one network interface, three virto hard disk, 4 cores, and 2 vcpus
xoxys.general.proxmox_kvm:
@ -520,11 +592,11 @@ EXAMPLES = """
name: spynal
node: sabrewulf
net:
net0: 'virtio,bridge=vmbr1,rate=200'
net0: "virtio,bridge=vmbr1,rate=200"
virtio:
virtio0: 'VMs_LVM:10'
virtio1: 'VMs:2,format=qcow2'
virtio2: 'VMs:5,format=raw'
virtio0: "VMs_LVM:10"
virtio1: "VMs:2,format=qcow2"
virtio2: "VMs:5,format=raw"
cores: 4
vcpus: 2
@ -599,15 +671,15 @@ EXAMPLES = """
api_host: helldorado
name: spynal
ide:
ide2: 'local:cloudinit,format=qcow2'
ide2: "local:cloudinit,format=qcow2"
ciuser: mylinuxuser
cipassword: supersecret
searchdomains: 'mydomain.internal'
searchdomains: "mydomain.internal"
nameservers: 1.1.1.1
net:
net0: 'virtio,bridge=vmbr1,tag=77'
net0: "virtio,bridge=vmbr1,tag=77"
ipconfig:
ipconfig0: 'ip=192.168.1.1/24,gw=192.168.1.1'
ipconfig0: "ip=192.168.1.1/24,gw=192.168.1.1"
- name: Create new VM using Cloud-Init with an ssh key
xoxys.general.proxmox_kvm:
@ -617,16 +689,16 @@ EXAMPLES = """
api_host: helldorado
name: spynal
ide:
ide2: 'local:cloudinit,format=qcow2'
sshkeys: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILJkVm98B71lD5XHfihwcYHE9TVpsJmK1vR1JcaU82L+'
searchdomains: 'mydomain.internal'
ide2: "local:cloudinit,format=qcow2"
sshkeys: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILJkVm98B71lD5XHfihwcYHE9TVpsJmK1vR1JcaU82L+"
searchdomains: "mydomain.internal"
nameservers:
- '1.1.1.1'
- '8.8.8.8'
- "1.1.1.1"
- "8.8.8.8"
net:
net0: 'virtio,bridge=vmbr1,tag=77'
net0: "virtio,bridge=vmbr1,tag=77"
ipconfig:
ipconfig0: 'ip=192.168.1.1/24'
ipconfig0: "ip=192.168.1.1/24"
- name: Start VM
xoxys.general.proxmox_kvm:
@ -701,7 +773,7 @@ EXAMPLES = """
api_host: helldorado
name: spynal
node: sabrewulf
delete: 'args,template,cpulimit'
delete: "args,template,cpulimit"
- name: Revert a pending change
xoxys.general.proxmox_kvm:
@ -710,8 +782,9 @@ EXAMPLES = """
api_host: helldorado
name: spynal
node: sabrewulf
revert: 'template,cpulimit'
""" # noqa
revert: "template,cpulimit"
"""
RETURN = """
vmid:
@ -735,9 +808,10 @@ import re
import string
import time
import traceback
from distutils.version import LooseVersion
from ansible.module_utils.six.moves.urllib.parse import quote
from collections import defaultdict
from distutils.version import LooseVersion
from ansible.module_utils.six.moves.urllib.parse import quote
try:
from proxmoxer import ProxmoxAPI
@ -745,17 +819,27 @@ try:
except ImportError:
HAS_PROXMOXER = False
from ansible.module_utils.basic import AnsibleModule, env_fallback
try:
from requests.packages import urllib3
HAS_URLLIB3 = True
except ImportError:
try:
import urllib3
HAS_URLLIB3 = True
except ImportError:
HAS_URLLIB3 = False
from ansible.module_utils._text import to_native
from ansible.module_utils.basic import AnsibleModule, env_fallback
def get_nextvmid(module, proxmox):
try:
vmid = proxmox.cluster.nextid.get()
return vmid
except Exception as e:
except Exception as e: # noqa
module.fail_json(
msg="Unable to get next vmid. Failed with exception: {}".format(to_native(e)),
msg=f"Unable to get next vmid. Failed with exception: {to_native(e)}",
exception=traceback.format_exc()
)
@ -775,14 +859,13 @@ def node_check(proxmox, node):
def get_vminfo(module, proxmox, node, vmid, **kwargs):
global results # noqa
global results
results = {}
try:
vm = proxmox.nodes(node).qemu(vmid).config.get()
except Exception as e:
except Exception as e: # noqa
module.fail_json(
msg="Getting information for VM with vmid = {} failed with exception: {}".
format(vmid, e)
msg=f"Getting information for VM with vmid = {vmid} failed with exception: {e}"
)
# Sanitize kwargs. Remove not defined args and ensure True and False converted to int.
@ -800,16 +883,13 @@ def get_vminfo(module, proxmox, node, vmid, **kwargs):
results["_raw"] = vm
def settings(module, proxmox, vmid, node, name, **kwargs):
def settings(module, proxmox, vmid, node, name, **kwargs): # noqa
proxmox_node = proxmox.nodes(node)
# Sanitize kwargs. Remove not defined args and ensure True and False converted to int.
kwargs = dict((k, v) for k, v in kwargs.items() if v is not None)
if proxmox_node.qemu(vmid).config.set(**kwargs) is None:
return True
else:
return False
return (proxmox_node.qemu(vmid).config.set(**kwargs) is None)
def wait_for_task(module, proxmox, node, taskid):
@ -840,7 +920,7 @@ def create_vm(
clone_params = {}
# Default args for vm. Note: -args option is for experts only. It allows you
# to pass arbitrary arguments to kvm.
vm_args = "-serial unix:/var/run/qemu-server/{0}.serial,server,nowait".format(vmid)
vm_args = f"-serial unix:/var/run/qemu-server/{vmid}.serial,server,nowait"
proxmox_node = proxmox.nodes(node)
@ -849,13 +929,13 @@ def create_vm(
kwargs.update(dict([k, int(v)] for k, v in kwargs.items() if isinstance(v, bool)))
# The features work only on PVE 4+
if PVE_MAJOR_VERSION < 4: # noqa
if PVE_MAJOR_VERSION < 4:
for p in only_v4:
if p in kwargs:
del kwargs[p]
# The features work only on PVE 6
if PVE_MAJOR_VERSION < 6: # noqa
if PVE_MAJOR_VERSION < 6:
for p in only_v6:
if p in kwargs:
del kwargs[p]
@ -877,17 +957,17 @@ def create_vm(
if update:
for k, v in disks.items():
if results["disks"].get(k):
kwargs[k.rstrip(string.digits)][k] = "{0}:{1},{2}".format(
results["disks"][k]["storage_id"], results["disks"][k]["storage_opts"],
",".join(disks[k]["opts"])
)
storage_id = results["disks"][k]["storage_id"]
storage_opts = results["disks"][k]["storage_opts"]
opts = ",".join(v["opts"])
kwargs[k.rstrip(string.digits)][k] = f"{storage_id}:{storage_opts},{opts}"
for k, v in nets.items():
if results["nets"].get(k):
kwargs[k.rstrip(string.digits)][k] = "{0}={1},{2}".format(
results["nets"][k]["net_id"], results["nets"][k]["net_opts"],
",".join(nets[k]["opts"])
)
net_id = results["nets"][k]["net_id"]
net_opts = results["nets"][k]["net_opts"]
opts = ",".join(v["opts"])
kwargs[k.rstrip(string.digits)][k] = f"{net_id}={net_opts},{opts}"
# Convert all dict in kwargs to elements.
for k in list(kwargs.keys()):
@ -914,12 +994,14 @@ def create_vm(
if "tags" in kwargs:
for tag in kwargs["tags"]:
if not re.match(r"^[a-z0-9_][a-z0-9_\-\+\.]*$", tag):
module.fail_json(msg="{} is not a valid tag".format(tag))
module.fail_json(msg=f"{tag} is not a valid tag")
kwargs["tags"] = ",".join(kwargs["tags"])
# -args and skiplock require root@pam user - but can not use api tokens
if module.params["api_user"] == "root@pam" and module.params["args"] is None:
if not update and module.params["proxmox_default_behavior"] == "compatibility":
if (
module.params["api_user"] == "root@pam" and module.params["args"] is None and not update
and module.params["proxmox_default_behavior"] == "compatibility"
):
kwargs["args"] = vm_args
elif module.params["api_user"] == "root@pam" and module.params["args"] is not None:
kwargs["args"] = module.params["args"]
@ -930,13 +1012,12 @@ def create_vm(
module.fail_json(msg="skiplock parameter require root@pam user. ")
if update:
if proxmox_node.qemu(vmid).config.set(
name=name, memory=memory, cpu=cpu, cores=cores, sockets=sockets, **kwargs
) is None:
return True
else:
return False
elif module.params["clone"] is not None:
return (
proxmox_node.qemu(vmid).config.
set(name=name, memory=memory, cpu=cpu, cores=cores, sockets=sockets, **kwargs) is None
)
if module.params["clone"] is not None:
for param in valid_clone_params:
if module.params[param] is not None:
clone_params[param] = module.params[param]
@ -952,8 +1033,7 @@ def create_vm(
if not wait_for_task(module, proxmox, node, taskid):
module.fail_json(
msg="Reached timeout while waiting for creating VM."
"Last line in task before timeout: {}".
format(proxmox_node.tasks(taskid).log.get()[:1])
f"Last line in task before timeout: {proxmox_node.tasks(taskid).log.get()[:1]}"
)
return False
return True
@ -966,8 +1046,7 @@ def start_vm(module, proxmox, vm):
if not wait_for_task(module, proxmox, vm[0]["node"], taskid):
module.fail_json(
msg="Reached timeout while waiting for starting VM."
"Last line in task before timeout: {}".
format(proxmox_node.tasks(taskid).log.get()[:1])
f"Last line in task before timeout: {proxmox_node.tasks(taskid).log.get()[:1]}"
)
return False
return True
@ -980,8 +1059,7 @@ def stop_vm(module, proxmox, vm, force):
if not wait_for_task(module, proxmox, vm[0]["node"], taskid):
module.fail_json(
msg="Reached timeout while waiting for stopping VM."
"Last line in task before timeout: {}".
format(proxmox_node.tasks(taskid).log.get()[:1])
f"Last line in task before timeout: {proxmox_node.tasks(taskid).log.get()[:1]}"
)
return False
return True
@ -998,7 +1076,7 @@ def main():
acpi=dict(type="bool"),
agent=dict(type="bool"),
args=dict(type="str"),
api_host=dict(required=True),
api_host=dict(required=True, type="str"),
api_password=dict(no_log=True, fallback=(env_fallback, ["PROXMOX_PASSWORD"])),
api_token_id=dict(no_log=True),
api_token_secret=dict(no_log=True),
@ -1044,7 +1122,7 @@ def main():
nameservers=dict(type="list", elements="str"),
net=dict(type="dict"),
newid=dict(type="int"),
node=dict(),
node=dict(type="str"),
numa=dict(type="dict"),
numa_enabled=dict(type="bool"),
onboot=dict(type="bool"),
@ -1076,7 +1154,7 @@ def main():
sockets=dict(type="int"),
sshkeys=dict(type="str", no_log=False),
startdate=dict(type="str"),
startup=dict(),
startup=dict(type="str"),
state=dict(
default="present",
choices=["present", "absent", "stopped", "started", "restarted", "current"]
@ -1089,7 +1167,7 @@ def main():
template=dict(type="bool"),
timeout=dict(type="int", default=30),
update=dict(type="bool", default=False),
validate_certs=dict(type="bool", default=False),
verify_ssl=dict(type="bool", default=True),
vcpus=dict(type="int"),
vga=dict(
choices=[
@ -1099,7 +1177,7 @@ def main():
),
virtio=dict(type="dict"),
vmid=dict(type="int"),
watchdog=dict(),
watchdog=dict(type="str"),
proxmox_default_behavior=dict(type="str", choices=["compatibility", "no_defaults"]),
),
mutually_exclusive=[("delete", "revert"), ("delete", "update"), ("revert", "update"),
@ -1130,7 +1208,7 @@ def main():
state = module.params["state"]
update = bool(module.params["update"])
vmid = module.params["vmid"]
validate_certs = module.params["validate_certs"]
verify_ssl = module.params["verify_ssl"]
if module.params["proxmox_default_behavior"] is None:
module.params["proxmox_default_behavior"] = "compatibility"
@ -1166,15 +1244,16 @@ def main():
auth_args["token_name"] = api_token_id
auth_args["token_value"] = api_token_secret
if not verify_ssl and HAS_URLLIB3:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
try:
proxmox = ProxmoxAPI(api_host, verify_ssl=validate_certs, **auth_args)
global PVE_MAJOR_VERSION # noqa
proxmox = ProxmoxAPI(api_host, verify_ssl=verify_ssl, **auth_args)
global PVE_MAJOR_VERSION
version = proxmox_version(proxmox)
PVE_MAJOR_VERSION = 3 if version < LooseVersion("4.0") else version.version[0]
except Exception as e:
module.fail_json(
msg="authorization on proxmox cluster failed with exception: {}".format(e)
)
except Exception as e: # noqa
module.fail_json(msg=f"authorization on proxmox cluster failed with exception: {e}")
# If vmid is not defined then retrieve its value from the vm name,
# the cloned vm name or retrieve the next free VM id from ProxmoxAPI.
@ -1182,16 +1261,16 @@ def main():
if state == "present" and not update and not clone and not delete and not revert:
try:
vmid = get_nextvmid(module, proxmox)
except Exception:
except Exception: # noqa
module.fail_json(
msg="Can't get the next vmid for VM {0} automatically."
"Ensure your cluster state is good".format(name)
msg=f"Can't get the next vmid for VM {name} automatically."
"Ensure your cluster state is good"
)
else:
clone_target = clone or name
try:
vmid = get_vmid(proxmox, clone_target)[0]
except Exception:
except Exception: # noqa
vmid = -1
if clone is not None:
@ -1199,50 +1278,40 @@ def main():
if not newid:
try:
newid = get_nextvmid(module, proxmox)
except Exception:
except Exception: # noqa
module.fail_json(
msg="Can't get the next vmid for VM {0} automatically."
"Ensure your cluster state is good".format(name)
msg=f"Can't get the next vmid for VM {name} automatically."
"Ensure your cluster state is good"
)
# Ensure source VM name exists when cloning
if -1 == vmid:
module.fail_json(msg="VM with name = {} does not exist in cluster".format(clone))
module.fail_json(msg=f"VM with name = {clone} does not exist in cluster")
# Ensure source VM id exists when cloning
if not get_vm(proxmox, vmid):
module.fail_json(
vmid=vmid, msg="VM with vmid = {} does not exist in cluster".format(vmid)
)
module.fail_json(vmid=vmid, msg=f"VM with vmid = {vmid} does not exist in cluster")
# Ensure the choosen VM name doesn't already exist when cloning
if get_vmid(proxmox, name):
module.exit_json(
changed=False, vmid=vmid, msg="VM with name <{}> already exists".format(name)
)
module.exit_json(changed=False, vmid=vmid, msg=f"VM with name <{name}> already exists")
# Ensure the choosen VM id doesn't already exist when cloning
if get_vm(proxmox, newid):
module.exit_json(
changed=False,
vmid=vmid,
msg="vmid {} with VM name {} already exists".format(newid, name)
changed=False, vmid=vmid, msg=f"vmid {newid} with VM name {name} already exists"
)
if delete is not None:
try:
settings(module, proxmox, vmid, node, name, delete=delete)
module.exit_json(
changed=True,
vmid=vmid,
msg="Settings has deleted on VM {0} with vmid {1}".format(name, vmid)
changed=True, vmid=vmid, msg=f"Settings has deleted on VM {name} with vmid {vmid}"
)
except Exception as e:
except Exception as e: # noqa
module.fail_json(
vmid=vmid,
msg="Unable to delete settings on VM {0} with vmid {1}: {2}".format(
name, vmid, str(e)
)
msg=f"Unable to delete settings on VM {name} with vmid {vmid}: {str(e)}"
)
if revert is not None:
@ -1251,29 +1320,29 @@ def main():
module.exit_json(
changed=True,
vmid=vmid,
msg="Settings has reverted on VM {0} with vmid {1}".format(name, vmid)
msg=f"Settings has reverted on VM {name} with vmid {vmid}"
)
except Exception as e:
except Exception as e: # noqa
module.fail_json(
vmid=vmid,
msg="Unable to revert settings on VM {0} with vmid {1}:"
"Maybe is not a pending task...".format(name, vmid) + str(e)
msg=f"Unable to revert settings on VM {name} with vmid {vmid}:"
f"Maybe is not a pending task...{str(e)}"
)
if state == "present":
try:
if get_vm(proxmox, vmid) and not (update or clone):
module.exit_json(
changed=False, vmid=vmid, msg="VM with vmid <{}> already exists".format(vmid)
changed=False, vmid=vmid, msg=f"VM with vmid <{vmid}> already exists"
)
elif get_vmid(proxmox, name) and not (update or clone):
module.exit_json(
changed=False, vmid=vmid, msg="VM with name <{}> already exists".format(name)
changed=False, vmid=vmid, msg=f"VM with name <{name}> already exists"
)
elif not (node, name):
module.fail_json(msg="node, name is mandatory for creating/updating vm")
elif not node_check(proxmox, node):
module.fail_json(msg="node '{}' does not exist in cluster".format(node))
module.fail_json(msg=f"node '{node}' does not exist in cluster")
if not clone:
get_vminfo(
@ -1362,130 +1431,100 @@ def main():
if update:
module.exit_json(
changed=True, vmid=vmid, msg="VM {} with vmid {} updated".format(name, vmid)
changed=True, vmid=vmid, msg=f"VM {name} with vmid {vmid} updated"
)
elif clone is not None:
module.exit_json(
changed=True,
vmid=vmid,
msg="VM {} with newid {} cloned from vm with vmid {}".format(
name, newid, vmid
)
msg=f"VM {name} with newid {newid} cloned from vm with vmid {vmid}"
)
else:
module.exit_json(
changed=True,
msg="VM {} with vmid {} deployed".format(name, vmid),
**results # noqa
changed=True, msg=f"VM {name} with vmid {vmid} deployed", **results
)
except Exception as e:
except Exception as e: # noqa
if update:
module.fail_json(
vmid=vmid,
msg="Unable to update vm {0} with vmid {1}=".format(name, vmid) + str(e)
vmid=vmid, msg=f"Unable to update vm {name} with vmid {vmid}=" + str(e)
)
elif clone is not None:
module.fail_json(
vmid=vmid,
msg="Unable to clone vm {0} from vmid {1}=".format(name, vmid) + str(e)
vmid=vmid, msg=f"Unable to clone vm {name} from vmid {vmid}=" + str(e)
)
else:
module.fail_json(
vmid=vmid,
msg="creation of qemu VM {} with vmid {} failed with exception={}".format(
name, vmid, e
)
msg=f"creation of qemu VM {name} with vmid {vmid} failed with exception={e}"
)
elif state == "started":
status = {}
try:
if -1 == vmid:
module.fail_json(msg="VM with name = {} does not exist in cluster".format(name))
module.fail_json(msg=f"VM with name = {name} does not exist in cluster")
vm = get_vm(proxmox, vmid)
if not vm:
module.fail_json(
vmid=vmid, msg="VM with vmid <{}> does not exist in cluster".format(vmid)
)
module.fail_json(vmid=vmid, msg=f"VM with vmid <{vmid}> does not exist in cluster")
status["status"] = vm[0]["status"]
if vm[0]["status"] == "running":
module.exit_json(
changed=False,
vmid=vmid,
msg="VM {} is already running".format(vmid),
**status
changed=False, vmid=vmid, msg=f"VM {vmid} is already running", **status
)
if start_vm(module, proxmox, vm):
module.exit_json(
changed=True, vmid=vmid, msg="VM {} started".format(vmid), **status
)
except Exception as e:
module.exit_json(changed=True, vmid=vmid, msg=f"VM {vmid} started", **status)
except Exception as e: # noqa
module.fail_json(
vmid=vmid,
msg="starting of VM {} failed with exception: {}".format(vmid, e),
**status
vmid=vmid, msg=f"starting of VM {vmid} failed with exception: {e}", **status
)
elif state == "stopped":
status = {}
try:
if -1 == vmid:
module.fail_json(msg="VM with name = {} does not exist in cluster".format(name))
module.fail_json(msg=f"VM with name = {name} does not exist in cluster")
vm = get_vm(proxmox, vmid)
if not vm:
module.fail_json(
vmid=vmid, msg="VM with vmid = {} does not exist in cluster".format(vmid)
)
module.fail_json(vmid=vmid, msg=f"VM with vmid = {vmid} does not exist in cluster")
status["status"] = vm[0]["status"]
if vm[0]["status"] == "stopped":
module.exit_json(
changed=False,
vmid=vmid,
msg="VM {} is already stopped".format(vmid),
**status
changed=False, vmid=vmid, msg=f"VM {vmid} is already stopped", **status
)
if stop_vm(module, proxmox, vm, force=module.params["force"]):
module.exit_json(
changed=True, vmid=vmid, msg="VM {} is shutting down".format(vmid), **status
changed=True, vmid=vmid, msg=f"VM {vmid} is shutting down", **status
)
except Exception as e:
except Exception as e: # noqa
module.fail_json(
vmid=vmid,
msg="stopping of VM {} failed with exception: {}".format(vmid, e),
**status
vmid=vmid, msg=f"stopping of VM {vmid} failed with exception: {e}", **status
)
elif state == "restarted":
status = {}
try:
if -1 == vmid:
module.fail_json(msg="VM with name = {} does not exist in cluster".format(name))
module.fail_json(msg=f"VM with name = {name} does not exist in cluster")
vm = get_vm(proxmox, vmid)
if not vm:
module.fail_json(
vmid=vmid, msg="VM with vmid = {} does not exist in cluster".format(vmid)
)
module.fail_json(vmid=vmid, msg=f"VM with vmid = {vmid} does not exist in cluster")
status["status"] = vm[0]["status"]
if vm[0]["status"] == "stopped":
module.exit_json(
changed=False, vmid=vmid, msg="VM {} is not running".format(vmid), **status
changed=False, vmid=vmid, msg=f"VM {vmid} is not running", **status
)
if stop_vm(module, proxmox, vm,
force=module.params["force"]) and start_vm(module, proxmox, vm):
module.exit_json(
changed=True, vmid=vmid, msg="VM {} is restarted".format(vmid), **status
)
except Exception as e:
module.exit_json(changed=True, vmid=vmid, msg=f"VM {vmid} is restarted", **status)
except Exception as e: # noqa
module.fail_json(
vmid=vmid,
msg="restarting of VM {} failed with exception: {}".format(vmid, e),
**status
vmid=vmid, msg=f"restarting of VM {vmid} failed with exception: {e}", **status
)
elif state == "absent":
@ -1504,28 +1543,26 @@ def main():
module.exit_json(
changed=False,
vmid=vmid,
msg="VM {} is running. Stop it before deletion or use force=yes.".
format(vmid)
msg=f"VM {vmid} is running. Stop it before deletion or use force=yes."
)
taskid = proxmox_node.qemu.delete(vmid)
if not wait_for_task(module, proxmox, vm[0]["node"], taskid):
module.fail_json(
msg="Reached timeout while waiting for removing VM."
"Last line in task before timeout: {}".
format(proxmox_node.tasks(taskid).log.get()[:1])
f"Last line in task before timeout: {proxmox_node.tasks(taskid).log.get()[:1]}"
)
else:
module.exit_json(changed=True, vmid=vmid, msg="VM {} removed".format(vmid))
except Exception as e:
module.fail_json(msg="deletion of VM {} failed with exception: {}".format(vmid, e))
module.exit_json(changed=True, vmid=vmid, msg=f"VM {vmid} removed")
except Exception as e: # noqa
module.fail_json(msg=f"deletion of VM {vmid} failed with exception: {e}")
elif state == "current":
status = {}
if -1 == vmid:
module.fail_json(msg="VM with name = {} does not exist in cluster".format(name))
module.fail_json(msg=f"VM with name = {name} does not exist in cluster")
vm = get_vm(proxmox, vmid)
if not vm:
module.fail_json(msg="VM with vmid = {} does not exist in cluster".format(vmid))
module.fail_json(msg=f"VM with vmid = {vmid} does not exist in cluster")
if not name:
name = vm[0]["name"]
current = proxmox.nodes(vm[0]["node"]).qemu(vmid).status.current.get()["status"]
@ -1534,7 +1571,7 @@ def main():
module.exit_json(
changed=False,
vmid=vmid,
msg="VM {} with vmid = {} is {}".format(name, vmid, current),
msg=f"VM {name} with vmid = {vmid} is {current}",
**status
)
@ -1561,8 +1598,10 @@ def _extract_nets(item):
if re.match(r"net[0-9]", k):
nets[k]["opts"] = []
for val in v.split(","):
if any(val.startswith(s) for s in ["e1000", "rtl8139", "virtio", "vmxnet3"]):
if len(val.split("=")) == 2:
if (
any(val.startswith(s) for s in ["e1000", "rtl8139", "virtio", "vmxnet3"])
and len(val.split("=")) == 2
):
net = val.split("=")
nets[k]["net_id"] = net[0]
nets[k]["net_opts"] = net[1]

View File

@ -1,5 +1,12 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""Module to control Univention Corporate Registry."""
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
"""Control Univention Corporate Registry."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"}
@ -7,7 +14,7 @@ DOCUMENTATION = """
---
module: ucr
short_description: Manage variables in univention configuration registry.
version_added: "2.6"
version_added: 1.1.0
description:
- "This module allows to manage variables inside the univention configuration registry
on a univention corporate server (UCS)."
@ -15,16 +22,21 @@ options:
path:
description:
- Path for the variable
aliases:
- name
required: True
default: null
type: str
value:
description:
- New value of the variable
required: False
type: str
default: ""
state:
required: False
default: "present"
choices: ["present", "absent"]
type: str
description:
- Whether the variable should be exist or not.
author:
@ -49,39 +61,41 @@ RETURN = """
original_message:
description: The original name param that was passed in
type: str
returned: success
message:
description: The output message that the sample module generates
type: str
returned: success
"""
from ansible.module_utils.basic import AnsibleModule
from univention.config_registry import ConfigRegistry # noqa
from univention.config_registry.frontend import ucr_update # noqa
try:
from univention.config_registry import ConfigRegistry
from univention.config_registry.frontend import ucr_update
HAS_UNIVENTION = True
except ImportError:
HAS_UNIVENTION = False
def get_variable(ucr, path):
ucr.load()
if path in ucr:
value = ucr.get(path)
else:
value = None
return value
return ucr.get(path) if path in ucr else None
def set_variable(ucr, path, value, result):
def set_variable(ucr, path, value, result): # noqa
org_value = get_variable(ucr, path)
ucr_update(ucr, {path: value})
new_value = get_variable(ucr, path)
return not org_value == new_value
return org_value != new_value
def dry_variable(ucr, path, value, result):
def dry_variable(ucr, path, value, result): # noqa
org_value = get_variable(ucr, path)
return not org_value == value
return org_value != value
def main():
ucr = ConfigRegistry()
module_args = dict(
path=dict(type="str", required=True, aliases=["name"]),
value=dict(type="str", required=False, default=""),
@ -94,12 +108,16 @@ def main():
argument_spec=module_args, supports_check_mode=True, required_if=required_if
)
if not HAS_UNIVENTION:
module.fail_json(msg="univention required for this module")
ucr = ConfigRegistry()
result = dict(changed=False, original_message="", message="")
path = module.params["path"]
value = module.params["value"]
if module.params["state"] == "present":
if value is None or value == "None":
if module.params["state"] == "present" and (value is None or value == "None"):
value = ""
elif module.params["state"] == "absent":
value = None

1143
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

149
pyproject.toml Normal file
View File

@ -0,0 +1,149 @@
[tool.poetry]
authors = ["Robert Kaussow <mail@thegeeklab.de>"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"License :: OSI Approved :: MIT License",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators",
"Natural Language :: English",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Utilities",
"Topic :: Software Development",
"Topic :: Software Development :: Documentation",
]
description = "Build environment for Ansible Collection."
license = "MIT"
name = "xoxys.general"
readme = "README.md"
repository = "https://gitea.rknet.org/ansible/xoxys.general"
version = "0.0.0"
[tool.poetry.dependencies]
python = "^3.8.0"
ansible-core = { version = "<=2.14.0", optional = true }
pyopenssl = "23.0.0"
proxmoxer = "2.0.1"
hcloud = "1.18.2"
[tool.poetry.extras]
ansible = ["ansible-core"]
[tool.poetry.group.dev.dependencies]
ruff = "0.0.230"
pytest = "7.2.1"
pytest-mock = "3.10.0"
pytest-cov = "4.0.0"
toml = "0.10.2"
yapf = "0.32.0"
pycodestyle = "2.10.0"
yamllint = "1.29.0"
pylint = "2.15.0"
voluptuous = "0.13.1"
[tool.pytest.ini_options]
addopts = "--cov --cov-report=xml:coverage.xml --cov-report=term --cov-append --no-cov-on-fail"
pythonpath = [
"."
]
testpaths = [
"tests",
]
filterwarnings = [
"ignore::FutureWarning",
"ignore::DeprecationWarning",
"ignore:.*pep8.*:FutureWarning",
]
[tool.coverage.run]
omit = ["**/tests/*"]
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
[tool.ruff]
exclude = [
".git",
"__pycache__",
"build",
"dist",
"tests",
"*.pyc",
"*.egg-info",
".cache",
".eggs",
"env*",
"iptables_raw.py",
]
# Explanation of errors
#
# D102: Missing docstring in public method
# D103: Missing docstring in public function
# D105: Missing docstring in magic method
# D107: Missing docstring in __init__
# D202: No blank lines allowed after function docstring
# D203: One blank line required before class docstring
# E402: Module level import not at top of file
# SIM105: Use `contextlib.suppress(Exception)` instead of try-except-pass
# C402: Unnecessary generator (rewrite as a `dict` comprehension)
# C408: Unnecessary `dict` call (rewrite as a literal)
# I001: Import block is un-sorted or un-formatted
# UP001: `__metaclass__ = type` is implied
# UP009: UTF-8 encoding declaration is unnecessary
# UP010: Unnecessary `__future__` imports `absolute_import`, `division`, `print_function` for target Python version
ignore = [
"D102",
"D103",
"D105",
"D107",
"D202",
"D203",
"D212",
"E402",
"SIM105",
"C402",
"C408",
"I001",
"UP001",
"UP009",
"UP010",
]
line-length = 99
select = [
"D",
"E",
"F",
"Q",
"W",
"I",
"S",
"BLE",
"N",
"UP",
"B",
"A",
"C4",
"T20",
"SIM",
"RET",
"ARG",
"ERA",
"RUF",
]
[tool.ruff.flake8-quotes]
inline-quotes = "double"
[tool.yapf]
based_on_style = "google"
column_limit = 99
dedent_closing_brackets = true
coalesce_brackets = true
split_before_logical_operator = true

View File

@ -1,4 +0,0 @@
ansible-core
pyopenssl
proxmoxer
hcloud

View File

@ -1,42 +0,0 @@
[isort]
default_section = THIRDPARTY
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
force_single_line = true
line_length = 99
skip_glob = **/.venv*,**/venv/*,**/docs/*,**/inventory/*,**/modules/*
[yapf]
based_on_style = google
column_limit = 99
dedent_closing_brackets = true
coalesce_brackets = true
split_before_logical_operator = true
[tool:pytest]
filterwarnings =
ignore::FutureWarning
ignore:.*collections.*:DeprecationWarning
ignore:.*pep8.*:FutureWarning
[coverage:run]
omit =
**/test/*
**/.venv/*
[flake8]
ignore = D101, D102, D103, D105, D107, D202, E402, W503, B902
max-line-length = 99
inline-quotes = double
exclude =
.git
.tox
__pycache__
build
dist
test
*.pyc
*.egg-info
.cache
.eggs
env*
iptables_raw.py

View File

@ -1 +0,0 @@
# noqa

View File

@ -1,11 +0,0 @@
ansible
# requirement for the proxmox modules
proxmoxer
requests
# requirement for the corenetworks modules
corenetworks
# requirement for the openssl_pkcs12 module
pyOpenSSL

2
tests/config.yml Normal file
View File

@ -0,0 +1,2 @@
modules:
python_requires: ">=3.8"

View File

View File

@ -1,10 +1,11 @@
"""Test inventory plugin proxmox."""
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Robert Kaussow <mail@thegeeklab.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import pytest
proxmox = pytest.importorskip("proxmoxer")
@ -58,7 +59,7 @@ def test_get_ip_address(inventory, mocker):
inventory.client = mocker.MagicMock()
inventory.client.nodes.return_value.get.return_value = networks
assert "10.0.0.1" == inventory._get_ip_address("qemu", None, None)
assert inventory._get_ip_address("qemu", None, None) == "10.0.0.1"
def test_exclude(inventory, mocker):