From a833281e228a5038f8c57bd67055e3c619ce1ce5 Mon Sep 17 00:00:00 2001 From: David Gunter Date: Wed, 18 Apr 2018 06:19:38 -0700 Subject: [PATCH] Add Yarn module (#19026) * Add yarn module based off of NPM module, adjust syntax for install cmd * Update author list * Add Return docbloc * Remove extra var assignment * Always return output without emojis, small changes for yarn 0.16.1 * Move import line, add ANSIBLE_METADATA, bump version_added * Updating module format to meet newest lint requirements. Update options and example docs. * Bring back RETURN block and main() execution. * All trailing whitespace removed. * Remove json try/except. * Add initial pass at setting up Yarn integration tests. * Add better handling for latest and removal states. Add tests for upgrading a single package. * Fix issue where state=latest for installing all packages caused failure. * Set yarn bin to latest version for tests. Fix sanity tests. * Switch template task to copy task in yarn integration tests. --- .../modules/packaging/language/yarn.py | 381 ++++++++++++++++++ test/integration/targets/yarn/aliases | 3 + test/integration/targets/yarn/tasks/main.yml | 28 ++ test/integration/targets/yarn/tasks/run.yml | 114 ++++++ .../targets/yarn/templates/package.j2 | 8 + 5 files changed, 534 insertions(+) create mode 100644 lib/ansible/modules/packaging/language/yarn.py create mode 100644 test/integration/targets/yarn/aliases create mode 100644 test/integration/targets/yarn/tasks/main.yml create mode 100644 test/integration/targets/yarn/tasks/run.yml create mode 100644 test/integration/targets/yarn/templates/package.j2 diff --git a/lib/ansible/modules/packaging/language/yarn.py b/lib/ansible/modules/packaging/language/yarn.py new file mode 100644 index 00000000000..cd1a234a327 --- /dev/null +++ b/lib/ansible/modules/packaging/language/yarn.py @@ -0,0 +1,381 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2017 David Gunter +# Copyright (c) 2017 Chris Hoffman +# 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 + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: yarn +short_description: Manage node.js packages with Yarn +description: + - Manage node.js packages with the Yarn package manager (https://yarnpkg.com/) +version_added: "2.6" +author: + - "David Gunter (@verkaufer)" + - "Chris Hoffman (@chrishoffman, creator of NPM Ansible module)" +options: + name: + description: + - The name of a node.js library to install + - If omitted all packages in package.json are installed. + required: false + path: + description: + - The base path where Node.js libraries will be installed. + - This is where the node_modules folder lives. + required: false + version: + description: + - The version of the library to be installed. + - Must be in semver format. If "latest" is desired, use "state" arg instead + required: false + global: + description: + - Install the node.js library globally + required: false + default: no + type: bool + executable: + description: + - The executable location for yarn. + required: false + ignore_scripts: + description: + - Use the --ignore-scripts flag when installing. + required: false + type: bool + default: no + production: + description: + - Install dependencies in production mode. + - Yarn will ignore any dependencies under devDependencies in package.json + required: false + type: bool + default: no + registry: + description: + - The registry to install modules from. + required: false + state: + description: + - Installation state of the named node.js library + - If absent is selected, a name option must be provided + required: false + default: present + choices: [ "present", "absent", "latest" ] +requirements: + - Yarn installed in bin path (typically /usr/local/bin) +''' + +EXAMPLES = ''' +- name: Install "imagemin" node.js package. + yarn: + name: imagemin + path: /app/location +- name: Install "imagemin" node.js package on version 5.3.1 + yarn: + name: imagemin + version: '5.3.1' + path: /app/location +- name: Install "imagemin" node.js package globally. + yarn: + name: imagemin + global: yes +- name: Remove the globally-installed package "imagemin". + yarn: + name: imagemin + global: yes + state: absent +- name: Install "imagemin" node.js package from custom registry. + yarn: + name: imagemin + registry: 'http://registry.mysite.com' +- name: Install packages based on package.json. + yarn: + path: /app/location +- name: Update all packages in package.json to their latest version. + yarn: + path: /app/location + state: latest +''' + +RETURN = ''' +changed: + description: Whether Yarn changed any package data + returned: always + type: boolean + sample: true +msg: + description: Provides an error message if Yarn syntax was incorrect + returned: failure + type: string + sample: "Package must be explicitly named when uninstalling." +invocation: + description: Parameters and values used during execution + returned: success + type: dictionary + sample: { + "module_args": { + "executable": null, + "globally": false, + "ignore_scripts": false, + "name": null, + "path": "/some/path/folder", + "production": false, + "registry": null, + "state": "present", + "version": null + } + } +out: + description: Output generated from Yarn with emojis removed. + returned: always + type: string + sample: "yarn add v0.16.1[1/4] Resolving packages...[2/4] Fetching packages...[3/4] Linking dependencies...[4/4] + Building fresh packages...success Saved lockfile.success Saved 1 new dependency..left-pad@1.1.3 Done in 0.59s." +''' + +import os +import re +import json + +from ansible.module_utils.basic import AnsibleModule + + +class Yarn(object): + + DEFAULT_GLOBAL_INSTALLATION_PATH = '~/.config/yarn/global' + + def __init__(self, module, **kwargs): + self.module = module + self.globally = kwargs['globally'] + self.name = kwargs['name'] + self.version = kwargs['version'] + self.path = kwargs['path'] + self.registry = kwargs['registry'] + self.production = kwargs['production'] + self.ignore_scripts = kwargs['ignore_scripts'] + + # Specify a version of package if version arg passed in + self.name_version = None + + if kwargs['executable']: + self.executable = kwargs['executable'].split(' ') + else: + self.executable = [module.get_bin_path('yarn', True)] + + if kwargs['version'] and self.name is not None: + self.name_version = self.name + '@' + str(self.version) + + def _exec(self, args, run_in_check_mode=False, check_rc=True): + if not self.module.check_mode or (self.module.check_mode and run_in_check_mode): + + if self.globally: + # Yarn global arg is inserted before the command (e.g. `yarn global {some-command}`) + args.insert(0, 'global') + + cmd = self.executable + args + + if self.production: + cmd.append('--production') + if self.ignore_scripts: + cmd.append('--ignore-scripts') + if self.registry: + cmd.append('--registry') + cmd.append(self.registry) + + # always run Yarn without emojis when called via Ansible + cmd.append('--no-emoji') + + # If path is specified, cd into that path and run the command. + cwd = None + if self.path and not self.globally: + if not os.path.exists(self.path): + # Module will make directory if not exists. + os.makedirs(self.path) + if not os.path.isdir(self.path): + self.module.fail_json(msg="Path provided %s is not a directory" % self.path) + cwd = self.path + + if not os.path.isfile(os.path.join(self.path, 'package.json')): + self.module.fail_json(msg="Package.json does not exist in provided path.") + + rc, out, err = self.module.run_command(cmd, check_rc=check_rc, cwd=cwd) + return out, err + + return '' + + def list(self): + cmd = ['list', '--depth=0', '--json'] + + installed = list() + missing = list() + + if not os.path.isfile(os.path.join(self.path, 'yarn.lock')): + missing.append(self.name) + return installed, missing + + result, error = self._exec(cmd, True, False) + + if error: + self.module.fail_json(msg=error) + + data = json.loads(result) + try: + dependencies = data['data']['trees'] + except KeyError: + missing.append(self.name) + return installed, missing + + for dep in dependencies: + name, version = dep['name'].split('@') + installed.append(name) + + if self.name not in installed: + missing.append(self.name) + + return installed, missing + + def install(self): + if self.name_version: + # Yarn has a separate command for installing packages by name... + return self._exec(['add', self.name_version]) + # And one for installing all packages in package.json + return self._exec(['install', '--non-interactive']) + + def update(self): + return self._exec(['upgrade', '--latest']) + + def uninstall(self): + return self._exec(['remove', self.name]) + + def list_outdated(self): + outdated = list() + + if not os.path.isfile(os.path.join(self.path, 'yarn.lock')): + return outdated + + cmd_result, err = self._exec(['outdated', '--json'], True, False) + if err: + self.module.fail_json(msg=err) + + outdated_packages_data = cmd_result.splitlines()[1] + + data = json.loads(outdated_packages_data) + + try: + outdated_dependencies = data['data']['body'] + except KeyError: + return outdated + + for dep in outdated_dependencies: + # Outdated dependencies returned as a list of lists, where + # item at index 0 is the name of the dependency + outdated.append(dep[0]) + return outdated + + +def main(): + arg_spec = dict( + name=dict(default=None), + path=dict(default=None, type='path'), + version=dict(default=None), + production=dict(default='no', type='bool'), + executable=dict(default=None, type='path'), + registry=dict(default=None), + state=dict(default='present', choices=['present', 'absent', 'latest']), + ignore_scripts=dict(default=False, type='bool'), + ) + arg_spec['global'] = dict(default='no', type='bool') + module = AnsibleModule( + argument_spec=arg_spec, + supports_check_mode=True + ) + + name = module.params['name'] + path = module.params['path'] + version = module.params['version'] + globally = module.params['global'] + production = module.params['production'] + executable = module.params['executable'] + registry = module.params['registry'] + state = module.params['state'] + ignore_scripts = module.params['ignore_scripts'] + + # When installing globally, users should not be able to define a path for installation. + # Require a path if global is False, though! + if path is None and globally is False: + module.fail_json(msg='Path must be specified when not using global arg') + elif path and globally is True: + module.fail_json(msg='Cannot specify path if doing global installation') + + if state == 'absent' and not name: + module.fail_json(msg='Package must be explicitly named when uninstalling.') + if state == 'latest': + version = 'latest' + + # When installing globally, use the defined path for global node_modules + if globally: + path = Yarn.DEFAULT_GLOBAL_INSTALLATION_PATH + + yarn = Yarn(module, + name=name, + path=path, + version=version, + globally=globally, + production=production, + executable=executable, + registry=registry, + ignore_scripts=ignore_scripts) + + changed = False + out = '' + err = '' + if state == 'present': + + if not name: + changed = True + out, err = yarn.install() + else: + installed, missing = yarn.list() + if len(missing): + changed = True + out, err = yarn.install() + + elif state == 'latest': + + if not name: + changed = True + out, err = yarn.install() + else: + installed, missing = yarn.list() + outdated = yarn.list_outdated() + if len(missing): + changed = True + out, err = yarn.install() + if len(outdated): + changed = True + out, err = yarn.update() + else: + # state == absent + installed, missing = yarn.list() + if name in installed: + changed = True + out, err = yarn.uninstall() + + module.exit_json(changed=changed, out=out, err=err) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/yarn/aliases b/test/integration/targets/yarn/aliases new file mode 100644 index 00000000000..426f4c88531 --- /dev/null +++ b/test/integration/targets/yarn/aliases @@ -0,0 +1,3 @@ +posix/ci/group1 +destructive +skip/freebsd \ No newline at end of file diff --git a/test/integration/targets/yarn/tasks/main.yml b/test/integration/targets/yarn/tasks/main.yml new file mode 100644 index 00000000000..6c1762d6d26 --- /dev/null +++ b/test/integration/targets/yarn/tasks/main.yml @@ -0,0 +1,28 @@ +# Yarn package manager integration tests +# (c) 2018 David Gunter, + +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# ============================================================ + +- include: run.yml + vars: + nodejs_version: '{{ item.node_version }}' + nodejs_path: 'node-v{{ nodejs_version }}-{{ ansible_system|lower }}-x{{ ansible_userspace_bits }}' + yarn_version: '{{ item.yarn_version }}' + with_items: + - {node_version: 4.8.0, yarn_version: 1.6.0} # Lowest compatible nodejs version + - {node_version: 8.0.0, yarn_version: 1.6.0} \ No newline at end of file diff --git a/test/integration/targets/yarn/tasks/run.yml b/test/integration/targets/yarn/tasks/run.yml new file mode 100644 index 00000000000..b72e519aae0 --- /dev/null +++ b/test/integration/targets/yarn/tasks/run.yml @@ -0,0 +1,114 @@ +- name: 'Create directory for Node' + file: + path: /usr/local/lib/nodejs + state: directory + +- name: 'Download Nodejs' + unarchive: + src: 'https://nodejs.org/dist/v{{ nodejs_version }}/{{ nodejs_path }}.tar.gz' + dest: '{{ output_dir }}' + remote_src: yes + creates: '{{ output_dir }}/{{ nodejs_path }}.tar.gz' + +- name: 'Download Yarn' + unarchive: + src: 'https://yarnpkg.com/downloads/{{yarn_version}}/yarn-v{{yarn_version}}.tar.gz' + dest: '{{ output_dir }}' + remote_src: yes + creates: '{{ output_dir }}/yarn-v{{yarn_version}}_pkg.tar.gz' + +- name: 'Copy node to directory created earlier' + command: "mv {{ output_dir }}/{{ nodejs_path }} /usr/local/lib/nodejs/{{nodejs_path}}" + +# Clean up before running tests +- name: Remove any previous Nodejs modules + file: + path: '{{output_dir}}/node_modules' + state: absent + +# Set vars for our test harness +- vars: + #node_bin_path: "/usr/local/lib/nodejs/node-v{{nodejs_version}}/bin" + node_bin_path: "/usr/local/lib/nodejs/{{ nodejs_path }}/bin" + yarn_bin_path: "{{ output_dir }}/yarn-v{{ yarn_version }}/bin" + package: 'iconv-lite' + environment: + PATH: "{{ node_bin_path }}:{{ansible_env.PATH}}" + block: + + # Get the version of Yarn and register to a variable + - shell: '{{ yarn_bin_path }}/yarn --version' + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + register: yarn_version + + - name: 'Create dummy package.json' + copy: + src: templates/package.j2 + dest: '{{ output_dir }}/package.json' + + - name: 'Install all packages.' + yarn: + path: '{{ output_dir }}' + executable: '{{ yarn_bin_path }}/yarn' + state: present + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + + - name: 'Install the same package from package.json again.' + yarn: + path: '{{ output_dir }}' + executable: '{{ yarn_bin_path }}/yarn' + name: '{{ package }}' + state: present + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + register: yarn_install + + - assert: + that: + - not (yarn_install is changed) + + - name: 'Install package with explicit version (older version of package)' + yarn: + path: '{{ output_dir }}' + executable: '{{ yarn_bin_path }}/yarn' + name: left-pad + version: 1.1.0 + state: present + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + register: yarn_install_old_package + + - assert: + that: + - yarn_install_old_package is changed + + - name: 'Upgrade old package' + yarn: + path: '{{ output_dir }}' + executable: '{{ yarn_bin_path }}/yarn' + name: left-pad + state: latest + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + register: yarn_update_old_package + + - assert: + that: + - yarn_update_old_package is changed + + - name: 'Remove a package' + yarn: + path: '{{ output_dir }}' + executable: '{{ yarn_bin_path }}/yarn' + name: '{{ package }}' + state: absent + environment: + PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}' + register: yarn_uninstall_package + + - name: 'Assert package removed' + assert: + that: + - yarn_uninstall_package is changed diff --git a/test/integration/targets/yarn/templates/package.j2 b/test/integration/targets/yarn/templates/package.j2 new file mode 100644 index 00000000000..5af8b4bab6c --- /dev/null +++ b/test/integration/targets/yarn/templates/package.j2 @@ -0,0 +1,8 @@ +{ + "name": "ansible-yarn-testing", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.4.21" + } +} \ No newline at end of file