From 4069b600ea6804df2fb120d7a47c0878cd3e01cb Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 22 Jul 2020 09:04:00 +0300 Subject: [PATCH] Create podman_volume module for volumes management (#87) --- .github/workflows/podman_volume.yml | 106 ++++ ci/playbooks/containers/podman_volume.yml | 6 + plugins/modules/podman_volume.py | 456 ++++++++++++++++++ .../targets/podman_volume/tasks/main.yml | 115 +++++ tests/sanity/ignore-2.10.txt | 8 + tests/sanity/ignore-2.11.txt | 8 + tests/sanity/ignore-2.9.txt | 7 + 7 files changed, 706 insertions(+) create mode 100644 .github/workflows/podman_volume.yml create mode 100644 ci/playbooks/containers/podman_volume.yml create mode 100644 plugins/modules/podman_volume.py create mode 100644 tests/integration/targets/podman_volume/tasks/main.yml diff --git a/.github/workflows/podman_volume.yml b/.github/workflows/podman_volume.yml new file mode 100644 index 0000000..36187c9 --- /dev/null +++ b/.github/workflows/podman_volume.yml @@ -0,0 +1,106 @@ +name: Podman volume + +on: + push: + paths: + - '.github/workflows/podman_volume.yml' + - 'ci/*.yml' + - 'ci/containers/podman_volume.yml' + - 'plugins/modules/podman_volume.py' + - 'tests/integration/targets/podman_volume/**' + branches: + - master + pull_request: + paths: + - '.github/workflows/podman_volume.yml' + - 'ci/*.yml' + - 'ci/containers/podman_volume.yml' + - 'plugins/modules/podman_volume.py' + - 'tests/integration/targets/podman_volume/**' + schedule: + - cron: 4 0 * * * # Run daily at 0:03 UTC + +jobs: + + test_podman_volume: + name: Podman volume ${{ matrix.ansible-version }}-${{ matrix.os || 'ubuntu-latest' }} + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + ansible-version: + - ansible<2.10 + # - git+https://github.com/ansible/ansible.git@stable-2.10 + os: + - ubuntu-latest + python-version: + - 3.7 + include: + - os: ubuntu-20.04 + ansible-version: git+https://github.com/ansible/ansible.git@devel + python-version: 3.7 + steps: + + - name: Check out repository + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip and display Python and PIP versions + run: | + python -m pip install --upgrade pip + python -V + pip --version + + - name: Set up pip cache + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ github.ref }}-units-VMs + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Install Ansible ${{ matrix.ansible-version }} + run: python3 -m pip install --user --force-reinstall --upgrade '${{ matrix.ansible-version }}' + + - name: Build and install the collection tarball + run: | + rm -rf /tmp/just_new_collection + ~/.local/bin/ansible-galaxy collection build --output-path /tmp/just_new_collection --force + ~/.local/bin/ansible-galaxy collection install -vvv --force /tmp/just_new_collection/*.tar.gz + + - name: Run collection tests for podman volume info + run: | + export PATH=~/.local/bin:$PATH + + echo "Run ansible version" + command -v ansible + ansible --version + + if [[ '${{ matrix.ansible-version }}' == 'git+https://github.com/ansible/ansible.git@devel' ]]; then + export ANSIBLE_CONFIG=$(pwd)/ci/ansible-dev.cfg + elif [[ '${{ matrix.ansible-version }}' == 'ansible<2.10' ]]; then + export ANSIBLE_CONFIG=$(pwd)/ci/ansible-2.9.cfg + fi + + echo $ANSIBLE_CONFIG + command -v ansible-playbook + pip --version + python --version + ansible-playbook --version + + ansible-playbook -vv ci/playbooks/pre.yml \ + -e host=localhost \ + -i localhost, \ + -e ansible_connection=local \ + -e setup_python=false + + TEST2RUN=podman_volume ./ci/run_containers_tests.sh + shell: bash diff --git a/ci/playbooks/containers/podman_volume.yml b/ci/playbooks/containers/podman_volume.yml new file mode 100644 index 0000000..28b80c1 --- /dev/null +++ b/ci/playbooks/containers/podman_volume.yml @@ -0,0 +1,6 @@ +--- +- hosts: all + gather_facts: true + tasks: + - include_role: + name: podman_volume diff --git a/plugins/modules/podman_volume.py b/plugins/modules/podman_volume.py new file mode 100644 index 0000000..c098e86 --- /dev/null +++ b/plugins/modules/podman_volume.py @@ -0,0 +1,456 @@ +#!/usr/bin/python +# Copyright (c) 2020 Red Hat +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# flake8: noqa: E501 +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: podman_volume +short_description: Manage Podman volumes +author: + - "Sagi Shnaidman (@sshnaidm)" +version_added: '1.1.0' +description: + - Manage Podman volumes +options: + state: + description: + - State of volume, default 'present' + type: str + default: present + choices: + - present + - absent + recreate: + description: + - Recreate volume even if exists. + type: bool + default: false + name: + description: + - Name of volume. + type: str + required: true + label: + description: + - Add metadata to a pod volume (e.g., label com.example.key=value). + type: dict + required: false + driver: + description: + - Specify volume driver name (default local). + type: str + required: false + options: + description: + - Set driver specific options. + type: str + required: false + executable: + description: + - Path to C(podman) executable if it is not in the C($PATH) on the + machine running C(podman) + default: 'podman' + type: str + debug: + description: + - Return additional information which can be helpful for investigations. + type: bool + default: False + +requirements: + - "podman" + +''' + +RETURN = ''' +volume: + description: Volume inspection results if exists. + returned: always + type: dict + sample: + CreatedAt: '2020-06-05T16:38:55.277628769+03:00' + Driver: local + Labels: + key.com: value + key.org: value2 + Mountpoint: /home/user/.local/share/containers/storage/volumes/test/_data + Name: test + Options: {} + Scope: local + +''' + +EXAMPLES = ''' +# What modules does for example +- podman_volume: + state: present + name: volume1 + label: + key: value + key2: value2 +''' +# noqa: F402 +import json # noqa: F402 +from distutils.version import LooseVersion # noqa: F402 +import yaml # noqa: F402 + +from ansible.module_utils.basic import AnsibleModule # noqa: F402 +from ansible.module_utils._text import to_bytes, to_native # noqa: F402 + + +class PodmanVolumeModuleParams: + """Creates list of arguments for podman CLI command. + + Arguments: + action {str} -- action type from 'create', 'delete' + params {dict} -- dictionary of module parameters + + """ + + def __init__(self, action, params, podman_version, module): + self.params = params + self.action = action + self.podman_version = podman_version + self.module = module + + def construct_command_from_params(self): + """Create a podman command from given module parameters. + + Returns: + list -- list of byte strings for Popen command + """ + if self.action in ['delete']: + return self._simple_action() + if self.action in ['create']: + return self._create_action() + + def _simple_action(self): + if self.action == 'delete': + cmd = ['rm', '-f', self.params['name']] + return [to_bytes(i, errors='surrogate_or_strict') for i in cmd] + + def _create_action(self): + cmd = [self.action, self.params['name']] + all_param_methods = [func for func in dir(self) + if callable(getattr(self, func)) + and func.startswith("addparam")] + params_set = (i for i in self.params if self.params[i] is not None) + for param in params_set: + func_name = "_".join(["addparam", param]) + if func_name in all_param_methods: + cmd = getattr(self, func_name)(cmd) + return [to_bytes(i, errors='surrogate_or_strict') for i in cmd] + + def check_version(self, param, minv=None, maxv=None): + if minv and LooseVersion(minv) > LooseVersion( + self.podman_version): + self.module.fail_json(msg="Parameter %s is supported from podman " + "version %s only! Current version is %s" % ( + param, minv, self.podman_version)) + if maxv and LooseVersion(maxv) < LooseVersion( + self.podman_version): + self.module.fail_json(msg="Parameter %s is supported till podman " + "version %s only! Current version is %s" % ( + param, minv, self.podman_version)) + + def addparam_label(self, c): + for label in self.params['label'].items(): + c += ['--label', b'='.join( + [to_bytes(l, errors='surrogate_or_strict') for l in label])] + return c + + def addparam_driver(self, c): + return c + ['--driver', self.params['driver']] + + def addparam_options(self, c): + return c + ['--opt', self.params['options']] + + +class PodmanVolumeDefaults: + def __init__(self, module, podman_version): + self.module = module + self.version = podman_version + self.defaults = { + 'driver': 'local', + 'label': {}, + 'options': {} + } + + def default_dict(self): + # make here any changes to self.defaults related to podman version + return self.defaults + + +class PodmanVolumeDiff: + def __init__(self, module, info, podman_version): + self.module = module + self.version = podman_version + self.default_dict = None + self.info = yaml.safe_load(json.dumps(info).lower()) + self.params = self.defaultize() + self.diff = {'before': {}, 'after': {}} + self.non_idempotent = {} + + def defaultize(self): + params_with_defaults = {} + self.default_dict = PodmanVolumeDefaults( + self.module, self.version).default_dict() + for p in self.module.params: + if self.module.params[p] is None and p in self.default_dict: + params_with_defaults[p] = self.default_dict[p] + else: + params_with_defaults[p] = self.module.params[p] + return params_with_defaults + + def _diff_update_and_compare(self, param_name, before, after): + if before != after: + self.diff['before'].update({param_name: before}) + self.diff['after'].update({param_name: after}) + return True + return False + + def diffparam_label(self): + before = self.info['labels'] if 'labels' in self.info else {} + after = self.params['label'] + return self._diff_update_and_compare('label', before, after) + + def diffparam_driver(self): + before = self.info['driver'] + after = self.params['driver'] + return self._diff_update_and_compare('driver', before, after) + + def diffparam_options(self): + before = self.info['options'] if 'options' in self.info else {} + after = self.params['options'] + return self._diff_update_and_compare('options', before, after) + + def is_different(self): + diff_func_list = [func for func in dir(self) + if callable(getattr(self, func)) and func.startswith( + "diffparam")] + fail_fast = not bool(self.module._diff) + different = False + for func_name in diff_func_list: + dff_func = getattr(self, func_name) + if dff_func(): + if fail_fast: + return True + else: + different = True + # Check non idempotent parameters + for p in self.non_idempotent: + if self.module.params[p] is not None and self.module.params[p] not in [{}, [], '']: + different = True + return different + + +class PodmanVolume: + """Perform volume tasks. + + Manages podman volume, inspects it and checks its current state + """ + + def __init__(self, module, name): + """Initialize PodmanVolume class. + + Arguments: + module {obj} -- ansible module object + name {str} -- name of volume + """ + + super(PodmanVolume, self).__init__() + self.module = module + self.name = name + self.stdout, self.stderr = '', '' + self.info = self.get_info() + self.version = self._get_podman_version() + self.diff = {} + self.actions = [] + + @property + def exists(self): + """Check if volume exists.""" + return bool(self.info != {}) + + @property + def different(self): + """Check if volume is different.""" + diffcheck = PodmanVolumeDiff( + self.module, + self.info, + self.version) + is_different = diffcheck.is_different() + diffs = diffcheck.diff + if self.module._diff and is_different and diffs['before'] and diffs['after']: + self.diff['before'] = "\n".join( + ["%s - %s" % (k, v) for k, v in sorted( + diffs['before'].items())]) + "\n" + self.diff['after'] = "\n".join( + ["%s - %s" % (k, v) for k, v in sorted( + diffs['after'].items())]) + "\n" + return is_different + + def get_info(self): + """Inspect volume and gather info about it.""" + # pylint: disable=unused-variable + rc, out, err = self.module.run_command( + [self.module.params['executable'], b'volume', b'inspect', self.name]) + return json.loads(out)[0] if rc == 0 else {} + + def _get_podman_version(self): + # pylint: disable=unused-variable + rc, out, err = self.module.run_command( + [self.module.params['executable'], b'--version']) + if rc != 0 or not out or "version" not in out: + self.module.fail_json(msg="%s run failed!" % + self.module.params['executable']) + return out.split("version")[1].strip() + + def _perform_action(self, action): + """Perform action with volume. + + Arguments: + action {str} -- action to perform - create, stop, delete + """ + b_command = PodmanVolumeModuleParams(action, + self.module.params, + self.version, + self.module, + ).construct_command_from_params() + full_cmd = " ".join([self.module.params['executable'], 'volume'] + + [to_native(i) for i in b_command]) + self.module.log("PODMAN-VOLUME-DEBUG: %s" % full_cmd) + self.actions.append(full_cmd) + if not self.module.check_mode: + rc, out, err = self.module.run_command( + [self.module.params['executable'], b'volume'] + b_command, + expand_user_and_vars=False) + self.stdout = out + self.stderr = err + if rc != 0: + self.module.fail_json( + msg="Can't %s volume %s" % (action, self.name), + stdout=out, stderr=err) + + def delete(self): + """Delete the volume.""" + self._perform_action('delete') + + def create(self): + """Create the volume.""" + self._perform_action('create') + + def recreate(self): + """Recreate the volume.""" + self.delete() + self.create() + + +class PodmanVolumeManager: + """Module manager class. + + Defines according to parameters what actions should be applied to volume + """ + + def __init__(self, module): + """Initialize PodmanManager class. + + Arguments: + module {obj} -- ansible module object + """ + + super(PodmanVolumeManager, self).__init__() + + self.module = module + self.results = { + 'changed': False, + 'actions': [], + 'volume': {}, + } + self.name = self.module.params['name'] + self.executable = \ + self.module.get_bin_path(self.module.params['executable'], + required=True) + self.state = self.module.params['state'] + self.recreate = self.module.params['recreate'] + self.volume = PodmanVolume(self.module, self.name) + + def update_volume_result(self, changed=True): + """Inspect the current container, update results with last info, exit. + + Keyword Arguments: + changed {bool} -- whether any action was performed + (default: {True}) + """ + facts = self.volume.get_info() if changed else self.volume.info + out, err = self.volume.stdout, self.volume.stderr + self.results.update({'changed': changed, 'volume': facts, + 'podman_actions': self.volume.actions}, + stdout=out, stderr=err) + if self.volume.diff: + self.results.update({'diff': self.volume.diff}) + if self.module.params['debug']: + self.results.update({'podman_version': self.volume.version}) + self.module.exit_json(**self.results) + + def execute(self): + """Execute the desired action according to map of actions & states.""" + states_map = { + 'present': self.make_present, + 'absent': self.make_absent, + } + process_action = states_map[self.state] + process_action() + self.module.fail_json(msg="Unexpected logic error happened, " + "please contact maintainers ASAP!") + + def make_present(self): + """Run actions if desired state is 'started'.""" + if not self.volume.exists: + self.volume.create() + self.results['actions'].append('created %s' % self.volume.name) + self.update_volume_result() + elif self.recreate or self.volume.different: + self.volume.recreate() + self.results['actions'].append('recreated %s' % + self.volume.name) + self.update_volume_result() + else: + self.update_volume_result(changed=False) + + def make_absent(self): + """Run actions if desired state is 'absent'.""" + if not self.volume.exists: + self.results.update({'changed': False}) + elif self.volume.exists: + self.volume.delete() + self.results['actions'].append('deleted %s' % self.volume.name) + self.results.update({'changed': True}) + self.results.update({'container': {}, + 'podman_actions': self.volume.actions}) + self.module.exit_json(**self.results) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(type='str', default="present", + choices=['present', 'absent']), + name=dict(type='str', required=True), + label=dict(type='dict', required=False), + driver=dict(type='str', required=False), + options=dict(type='str', required=False), + recreate=dict(type='bool', default=False), + executable=dict(type='str', required=False, default='podman'), + debug=dict(type='bool', default=False), + )) + + PodmanVolumeManager(module).execute() + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/podman_volume/tasks/main.yml b/tests/integration/targets/podman_volume/tasks/main.yml new file mode 100644 index 0000000..1c6b87c --- /dev/null +++ b/tests/integration/targets/podman_volume/tasks/main.yml @@ -0,0 +1,115 @@ +- name: Test podman_volume + block: + + - name: Print podman version + command: podman version + + - name: Generate random value for volume name + set_fact: + volume_name: "{{ 'ansible-test-podman-%0x' % ((2**32) | random) }}" + + - name: Make sure volume doesn't exist + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: absent + + - name: Get missing volume info + containers.podman.podman_volume_info: + name: "{{ volume_name }}" + register: info + ignore_errors: true + + - name: Check results + assert: + that: + - info is failed + + - name: Create volume + containers.podman.podman_volume: + name: "{{ volume_name }}" + register: vol + + - name: Get existing volume info + containers.podman.podman_volume_info: + name: "{{ volume_name }}" + register: info1 + + - name: Check info + assert: + that: + - info1 | length > 1 + - info1.volumes.0.Name == volume_name + - vol is changed + + - name: Create volume again + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: present + register: info2 + + - name: Check info + assert: + that: + - info2 is not changed + + - name: Create volume with labels + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: present + label: + key: val + nokey: noval + register: info3 + + - name: Check info + assert: + that: + - info3 is changed + + - name: Create volume with labels again + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: present + label: + key: val + nokey: noval + register: info4 + + - name: Check info + assert: + that: + - info4 is not changed + + - name: Create volume w/o labels + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: present + register: info5 + + - name: Check info + assert: + that: + - info5 is changed + + - name: Make sure volume doesn't exist + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: absent + + - name: Get existing volume info + containers.podman.podman_volume_info: + name: "{{ volume_name }}" + register: info10 + ignore_errors: true + + - name: Check results + assert: + that: + - info10 is failed + + always: + + - name: Make sure volume doesn't exist + containers.podman.podman_volume: + name: "{{ volume_name }}" + state: absent diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 9f031ac..9619e69 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -17,6 +17,14 @@ plugins/modules/podman_pod.py import-3.6!skip plugins/modules/podman_pod.py import-3.7!skip plugins/modules/podman_pod.py import-3.8!skip plugins/modules/podman_pod.py import-3.9!skip +plugins/modules/podman_volume.py import-2.6!skip +plugins/modules/podman_volume.py compile-2.6!skip +plugins/modules/podman_volume.py import-2.7!skip +plugins/modules/podman_volume.py import-3.5!skip +plugins/modules/podman_volume.py import-3.6!skip +plugins/modules/podman_volume.py import-3.7!skip +plugins/modules/podman_volume.py import-3.8!skip +plugins/modules/podman_volume.py import-3.9!skip tests/integration/targets/connection_buildah/runme.sh shellcheck:SC2086 tests/integration/targets/connection_podman/runme.sh shellcheck:SC2086 plugins/module_utils/podman/common.py shebang!skip diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 9f031ac..9619e69 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -17,6 +17,14 @@ plugins/modules/podman_pod.py import-3.6!skip plugins/modules/podman_pod.py import-3.7!skip plugins/modules/podman_pod.py import-3.8!skip plugins/modules/podman_pod.py import-3.9!skip +plugins/modules/podman_volume.py import-2.6!skip +plugins/modules/podman_volume.py compile-2.6!skip +plugins/modules/podman_volume.py import-2.7!skip +plugins/modules/podman_volume.py import-3.5!skip +plugins/modules/podman_volume.py import-3.6!skip +plugins/modules/podman_volume.py import-3.7!skip +plugins/modules/podman_volume.py import-3.8!skip +plugins/modules/podman_volume.py import-3.9!skip tests/integration/targets/connection_buildah/runme.sh shellcheck:SC2086 tests/integration/targets/connection_podman/runme.sh shellcheck:SC2086 plugins/module_utils/podman/common.py shebang!skip diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 0885c0e..80a5d6e 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -12,6 +12,13 @@ plugins/modules/podman_pod.py import-3.5!skip plugins/modules/podman_pod.py import-3.6!skip plugins/modules/podman_pod.py import-3.7!skip plugins/modules/podman_pod.py import-3.8!skip +plugins/modules/podman_volume.py import-2.6!skip +plugins/modules/podman_volume.py compile-2.6!skip +plugins/modules/podman_volume.py import-2.7!skip +plugins/modules/podman_volume.py import-3.5!skip +plugins/modules/podman_volume.py import-3.6!skip +plugins/modules/podman_volume.py import-3.7!skip +plugins/modules/podman_volume.py import-3.8!skip tests/integration/targets/connection_buildah/runme.sh shellcheck:SC2086 tests/integration/targets/connection_podman/runme.sh shellcheck:SC2086 plugins/module_utils/podman/common.py shebang!skip