From 7c21a27c5d70897a592da0256d50b99901db733d Mon Sep 17 00:00:00 2001 From: radek-sprta Date: Sat, 8 Jan 2022 15:40:13 +0100 Subject: [PATCH] New module for cargo command (#3712) * New module for cargo command * Resolve CI errors * Update plugins/modules/packaging/language/cargo.py Co-authored-by: Felix Fontein * Update plugins/modules/packaging/language/cargo.py Co-authored-by: Felix Fontein * Update plugins/modules/packaging/language/cargo.py Co-authored-by: Felix Fontein * Add maintainer * Change installed_packages from property to function * Allow cargo to install list of of packages * Remove period at the end of task names * Pass only the list of packages to take action on to cargo * Add integration tests for cargo * Update plugins/modules/packaging/language/cargo.py Co-authored-by: Felix Fontein * Apply suggestions from code review * Update tests/integration/targets/cargo/tasks/setup.yml * Update tests/integration/targets/cargo/tasks/setup.yml Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/cargo.py | 1 + plugins/modules/packaging/language/cargo.py | 205 ++++++++++++++++++ tests/integration/targets/cargo/aliases | 3 + .../integration/targets/cargo/tasks/main.yml | 5 + .../integration/targets/cargo/tasks/setup.yml | 14 ++ .../targets/cargo/tasks/test_general.yml | 31 +++ .../targets/cargo/tasks/test_version.yml | 25 +++ 8 files changed, 286 insertions(+) create mode 120000 plugins/modules/cargo.py create mode 100644 plugins/modules/packaging/language/cargo.py create mode 100644 tests/integration/targets/cargo/aliases create mode 100644 tests/integration/targets/cargo/tasks/main.yml create mode 100644 tests/integration/targets/cargo/tasks/setup.yml create mode 100644 tests/integration/targets/cargo/tasks/test_general.yml create mode 100644 tests/integration/targets/cargo/tasks/test_version.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 3e319e200d..333c741b8f 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -728,6 +728,8 @@ files: maintainers: mwarkentin $modules/packaging/language/bundler.py: maintainers: thoiberg + $modules/packaging/language/cargo.py: + maintainers: radek-sprta $modules/packaging/language/composer.py: maintainers: dmtrs ignore: resmo diff --git a/plugins/modules/cargo.py b/plugins/modules/cargo.py new file mode 120000 index 0000000000..4cfbb5066b --- /dev/null +++ b/plugins/modules/cargo.py @@ -0,0 +1 @@ +packaging/language/cargo.py \ No newline at end of file diff --git a/plugins/modules/packaging/language/cargo.py b/plugins/modules/packaging/language/cargo.py new file mode 100644 index 0000000000..b7251887aa --- /dev/null +++ b/plugins/modules/packaging/language/cargo.py @@ -0,0 +1,205 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Radek Sprta +# 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 + + +DOCUMENTATION = r""" +--- +module: cargo +short_description: Manage Rust packages with cargo +version_added: 4.3.0 +description: + - Manage Rust packages with cargo. +author: "Radek Sprta (@radek-sprta)" +options: + name: + description: + - The name of a Rust package to install. + type: list + elements: str + required: true + path: + description: + -> + The base path where to install the Rust packages. Cargo automatically appends + C(/bin). In other words, C(/usr/local) will become C(/usr/local/bin). + type: path + version: + description: + -> + The version to install. If I(name) contains multiple values, the module will + try to install all of them in this version. + type: str + required: false + state: + description: + - The state of the Rust package. + required: false + type: str + default: present + choices: [ "present", "absent", "latest" ] +requirements: + - cargo installed in bin path (recommended /usr/local/bin) +""" + +EXAMPLES = r""" +- name: Install "ludusavi" Rust package + community.general.cargo: + name: ludusavi + +- name: Install "ludusavi" Rust package in version 0.10.0 + community.general.cargo: + name: ludusavi + version: '0.10.0' + +- name: Install "ludusavi" Rust package to global location + community.general.cargo: + name: ludusavi + path: /usr/local + +- name: Remove "ludusavi" Rust package + community.general.cargo: + name: ludusavi + state: absent + +- name: Update "ludusavi" Rust package its latest version + community.general.cargo: + name: ludusavi + state: latest +""" + +import os +import re + +from ansible.module_utils.basic import AnsibleModule + + +class Cargo(object): + def __init__(self, module, **kwargs): + self.module = module + self.name = kwargs["name"] + self.path = kwargs["path"] + self.state = kwargs["state"] + self.version = kwargs["version"] + + self.executable = [module.get_bin_path("cargo", True)] + + @property + def path(self): + return self._path + + @path.setter + def path(self, path): + if path is not None and not os.path.isdir(path): + self.module.fail_json(msg="Path %s is not a directory" % path) + self._path = path + + def _exec( + self, args, run_in_check_mode=False, check_rc=True, add_package_name=True + ): + if not self.module.check_mode or (self.module.check_mode and run_in_check_mode): + cmd = self.executable + args + rc, out, err = self.module.run_command(cmd, check_rc=check_rc) + return out, err + return "", "" + + def get_installed(self): + cmd = ["install", "--list"] + data, dummy = self._exec(cmd, True, False, False) + + package_regex = re.compile(r"^(\w+) v(.+):$") + installed = {} + for line in data.splitlines(): + package_info = package_regex.match(line) + if package_info: + installed[package_info.group(1)] = package_info.group(2) + + return installed + + def install(self, packages=None): + cmd = ["install"] + cmd.extend(packages or self.name) + if self.path: + cmd.append("--root") + cmd.append(self.path) + if self.version: + cmd.append("--version") + cmd.append(self.version) + return self._exec(cmd) + + def is_outdated(self, name): + installed_version = self.get_installed().get(name) + + cmd = ["search", name, "--limit", "1"] + data = self._exec(cmd, True, False, False) + + match = re.search(r'"(.+)"', data) + if match: + latest_version = match[1] + + return installed_version != latest_version + + def uninstall(self, packages=None): + cmd = ["uninstall"] + cmd.extend(packages or self.name) + return self._exec(cmd) + + +def main(): + arg_spec = dict( + name=dict(required=True, type="list", elements="str"), + path=dict(default=None, type="path"), + state=dict(default="present", choices=["present", "absent", "latest"]), + version=dict(default=None, type="str"), + ) + module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True) + + name = module.params["name"] + path = module.params["path"] + state = module.params["state"] + version = module.params["version"] + + if not name: + module.fail_json(msg="Package name must be specified") + + # Set LANG env since we parse stdout + module.run_command_environ_update = dict( + LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C" + ) + + cargo = Cargo(module, name=name, path=path, state=state, version=version) + changed, out, err = False, None, None + installed_packages = cargo.get_installed() + if state == "present": + to_install = [ + n + for n in name + if (n not in installed_packages) + or (version and version != installed_packages[n]) + ] + if to_install: + changed = True + out, err = cargo.install(to_install) + elif state == "latest": + to_update = [ + n for n in name if n not in installed_packages or cargo.is_outdated(n) + ] + if to_update: + changed = True + out, err = cargo.install(to_update) + else: # absent + to_uninstall = [n for n in name if n in installed_packages] + if to_uninstall: + changed = True + out, err = cargo.uninstall(to_uninstall) + + module.exit_json(changed=changed, stdout=out, stderr=err) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/cargo/aliases b/tests/integration/targets/cargo/aliases new file mode 100644 index 0000000000..9a80b36fe0 --- /dev/null +++ b/tests/integration/targets/cargo/aliases @@ -0,0 +1,3 @@ +destructive +shippable/posix/group2 +skip/aix diff --git a/tests/integration/targets/cargo/tasks/main.yml b/tests/integration/targets/cargo/tasks/main.yml new file mode 100644 index 0000000000..e8a11ea9f3 --- /dev/null +++ b/tests/integration/targets/cargo/tasks/main.yml @@ -0,0 +1,5 @@ +- import_tasks: setup.yml +- block: + - import_tasks: test_general.yml + - import_tasks: test_version.yml + when: has_cargo | default(false) diff --git a/tests/integration/targets/cargo/tasks/setup.yml b/tests/integration/targets/cargo/tasks/setup.yml new file mode 100644 index 0000000000..77104f6dfd --- /dev/null +++ b/tests/integration/targets/cargo/tasks/setup.yml @@ -0,0 +1,14 @@ +--- +- block: + - name: Install cargo + package: + name: cargo + state: present + - set_fact: + has_cargo: true + when: + - ansible_system != 'FreeBSD' or ansible_distribution_version is version('13.0', '>') + - ansible_distribution != 'MacOSX' + - ansible_distribution != 'RedHat' or ansible_distribution_version is version('8.0', '>=') + - ansible_distribution != 'CentOS' or ansible_distribution_version is version('7.0', '>=') + - ansible_distribution != 'Ubuntu' or ansible_distribution_version is version('18', '>=') diff --git a/tests/integration/targets/cargo/tasks/test_general.yml b/tests/integration/targets/cargo/tasks/test_general.yml new file mode 100644 index 0000000000..dfc699a88c --- /dev/null +++ b/tests/integration/targets/cargo/tasks/test_general.yml @@ -0,0 +1,31 @@ +--- +- name: Ensure application helloworld is uninstalled + community.general.cargo: + state: absent + name: helloworld + register: uninstall_absent_helloworld + +- name: Install application helloworld + community.general.cargo: + name: helloworld + register: install_absent_helloworld + +- name: Install application helloworld again + community.general.cargo: + name: helloworld + register: install_present_helloworld + ignore_errors: yes + +- name: Uninstall application helloworld + community.general.cargo: + state: absent + name: helloworld + register: uninstall_present_helloworld + +- name: Check assertions helloworld + assert: + that: + - uninstall_absent_helloworld is not changed + - install_absent_helloworld is changed + - install_present_helloworld is not changed + - uninstall_present_helloworld is changed diff --git a/tests/integration/targets/cargo/tasks/test_version.yml b/tests/integration/targets/cargo/tasks/test_version.yml new file mode 100644 index 0000000000..a59bd72e0a --- /dev/null +++ b/tests/integration/targets/cargo/tasks/test_version.yml @@ -0,0 +1,25 @@ +--- +- name: Install application helloworld-yliu 0.1.0 + community.general.cargo: + name: helloworld-yliu + version: 0.1.0 + register: install_helloworld_010 + +- name: Upgrade helloworld-yliu 0.1.0 + community.general.cargo: + name: helloworld-yliu + state: latest + register: upgrade_helloworld_010 + +- name: Downgrade helloworld-yliu 0.1.0 + community.general.cargo: + name: helloworld-yliu + version: 0.1.0 + register: downgrade_helloworld_010 + +- name: Check assertions helloworld-yliu 0.1.0 + assert: + that: + - install_helloworld_010 is changed + - upgrade_helloworld_010 is changed + - downgrade_helloworld_010 is changed