diff --git a/changelogs/fragments/gem-custom-home.yaml b/changelogs/fragments/gem-custom-home.yaml new file mode 100644 index 00000000000..8ecc4f7086d --- /dev/null +++ b/changelogs/fragments/gem-custom-home.yaml @@ -0,0 +1,2 @@ +new_features: + - gem - add ability to specify a custom directory for installing gems (https://github.com/ansible/ansible/pull/38195) diff --git a/lib/ansible/modules/packaging/language/gem.py b/lib/ansible/modules/packaging/language/gem.py index 57ffc239df4..4ef651e0dfc 100644 --- a/lib/ansible/modules/packaging/language/gem.py +++ b/lib/ansible/modules/packaging/language/gem.py @@ -58,6 +58,13 @@ options: - Override the path to the gem executable required: false version_added: "1.4" + install_dir: + description: + - Install the gems into a specific directory. + These gems will be independant from the global installed ones. + Specifying this requires user_install to be false. + required: false + version_added: "2.6" env_shebang: description: - Rewrite the shebang line on installed scripts to use /usr/bin/env. @@ -133,6 +140,12 @@ def get_rubygems_version(module): return tuple(int(x) for x in match.groups()) +def get_rubygems_environ(module): + if module.params['install_dir']: + return {'GEM_HOME': module.params['install_dir']} + return None + + def get_installed_versions(module, remote=False): cmd = get_rubygems_path(module) @@ -143,7 +156,9 @@ def get_installed_versions(module, remote=False): cmd.extend(['--source', module.params['repository']]) cmd.append('-n') cmd.append('^%s$' % module.params['name']) - (rc, out, err) = module.run_command(cmd, check_rc=True) + + environ = get_rubygems_environ(module) + (rc, out, err) = module.run_command(cmd, environ_update=environ, check_rc=True) installed_versions = [] for line in out.splitlines(): match = re.match(r"\S+\s+\((.+)\)", line) @@ -155,7 +170,6 @@ def get_installed_versions(module, remote=False): def exists(module): - if module.params['state'] == 'latest': remoteversions = get_installed_versions(module, remote=True) if remoteversions: @@ -175,14 +189,18 @@ def uninstall(module): if module.check_mode: return cmd = get_rubygems_path(module) + environ = get_rubygems_environ(module) cmd.append('uninstall') + if module.params['install_dir']: + cmd.extend(['--install-dir', module.params['install_dir']]) + if module.params['version']: cmd.extend(['--version', module.params['version']]) else: cmd.append('--all') cmd.append('--executable') cmd.append(module.params['name']) - module.run_command(cmd, check_rc=True) + module.run_command(cmd, environ_update=environ, check_rc=True) def install(module): @@ -211,6 +229,8 @@ def install(module): cmd.append('--user-install') else: cmd.append('--no-user-install') + if module.params['install_dir']: + cmd.extend(['--install-dir', module.params['install_dir']]) if module.params['pre_release']: cmd.append('--pre') if not module.params['include_doc']: @@ -238,6 +258,7 @@ def main(): repository=dict(required=False, aliases=['source'], type='str'), state=dict(required=False, default='present', choices=['present', 'absent', 'latest'], type='str'), user_install=dict(required=False, default=True, type='bool'), + install_dir=dict(required=False, type='path'), pre_release=dict(required=False, default=False, type='bool'), include_doc=dict(required=False, default=False, type='bool'), env_shebang=dict(required=False, default=False, type='bool'), @@ -252,6 +273,8 @@ def main(): module.fail_json(msg="Cannot specify version when state=latest") if module.params['gem_source'] and module.params['state'] == 'latest': module.fail_json(msg="Cannot maintain state=latest when installing from local source") + if module.params['user_install'] and module.params['install_dir']: + module.fail_json(msg="install_dir requires user_install=false") if not module.params['gem_source']: module.params['gem_source'] = module.params['name'] diff --git a/test/integration/targets/gem/tasks/main.yml b/test/integration/targets/gem/tasks/main.yml index 77b93e663da..924daa89301 100644 --- a/test/integration/targets/gem/tasks/main.yml +++ b/test/integration/targets/gem/tasks/main.yml @@ -25,31 +25,104 @@ - 'default.yml' paths: '../vars' -- name: install dependencies for test - package: name={{ package_item }} state=present - with_items: "{{ test_packages }}" - loop_control: - loop_var: package_item +- name: Install dependencies for test + package: + name: "{{ item }}" + state: present + loop: "{{ test_packages }}" when: ansible_distribution != "MacOSX" -- name: remove a gem - gem: name=gist state=absent +- name: Install a gem + gem: + name: gist + state: present + register: install_gem_result -- name: verify gist is not installed - shell: gem list | egrep '^gist ' - register: uninstall - failed_when: "uninstall.rc != 1" +- name: List gems + command: gem list + register: current_gems -- name: install a gem - gem: name=gist state=present - register: gem_result +- name: Ensure gem was installed + assert: + that: + - install_gem_result is changed + - current_gems.stdout is search('gist\s+\([0-9.]+\)') + +- name: Remove a gem + gem: + name: gist + state: absent + register: remove_gem_results + +- name: List gems + command: gem list + register: current_gems + +- name: Verify gem is not installed + assert: + that: + - remove_gem_results is changed + - current_gems.stdout is not search('gist\s+\([0-9.]+\)') + + +# Check cutom gem directory +- name: Install gem in a custom directory with incorrect options + gem: + name: gist + state: present + install_dir: "{{ output_dir }}/gems" + ignore_errors: yes + register: install_gem_fail_result + +- debug: + var: install_gem_fail_result + tags: debug -- name: verify module output properties +- name: Ensure previous task failed assert: that: - - "'name' in gem_result" - - "'changed' in gem_result" - - "'state' in gem_result" + - install_gem_fail_result is failed + - install_gem_fail_result.msg == 'install_dir requires user_install=false' -- name: verify gist is installed - shell: gem list | egrep '^gist ' +- name: Install a gem in a custom directory + gem: + name: gist + state: present + user_install: no + install_dir: "{{ output_dir }}/gems" + register: install_gem_result + +- name: Find gems in custom directory + find: + paths: "{{ output_dir }}/gems/gems" + file_type: directory + contains: gist + register: gem_search + +- name: Ensure gem was installed in custom directory + assert: + that: + - install_gem_result is changed + - gem_search.files[0].path is search('gist-[0-9.]+') + ignore_errors: yes + +- name: Remove a gem in a custom directory + gem: + name: gist + state: absent + user_install: no + install_dir: "{{ output_dir }}/gems" + register: install_gem_result + +- name: Find gems in custom directory + find: + paths: "{{ output_dir }}/gems/gems" + file_type: directory + contains: gist + register: gem_search + +- name: Ensure gem was removed in custom directory + assert: + that: + - install_gem_result is changed + - gem_search.files | length == 0 diff --git a/test/units/modules/packaging/language/test_gem.py b/test/units/modules/packaging/language/test_gem.py new file mode 100644 index 00000000000..4dae9ece505 --- /dev/null +++ b/test/units/modules/packaging/language/test_gem.py @@ -0,0 +1,121 @@ +# Copyright (c) 2018 Antoine Catton +# MIT License (see licenses/MIT-license.txt or https://opensource.org/licenses/MIT) +import copy +import json + +import pytest + +from ansible.modules.packaging.language import gem +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + + +def get_command(run_command): + """Generate the command line string from the patched run_command""" + args = run_command.call_args[0] + command = args[0] + return ' '.join(command) + + +class TestGem(ModuleTestCase): + def setUp(self): + super(TestGem, self).setUp() + self.rubygems_path = ['/usr/bin/gem'] + self.mocker.patch( + 'ansible.modules.packaging.language.gem.get_rubygems_path', + lambda module: copy.deepcopy(self.rubygems_path), + ) + + @pytest.fixture(autouse=True) + def _mocker(self, mocker): + self.mocker = mocker + + def patch_installed_versions(self, versions): + """Mocks the versions of the installed package""" + + target = 'ansible.modules.packaging.language.gem.get_installed_versions' + + def new(module, remote=False): + return versions + + return self.mocker.patch(target, new) + + def patch_rubygems_version(self, version=None): + target = 'ansible.modules.packaging.language.gem.get_rubygems_version' + + def new(module): + return version + + return self.mocker.patch(target, new) + + def patch_run_command(self): + target = 'ansible.module_utils.basic.AnsibleModule.run_command' + return self.mocker.patch(target) + + def test_fails_when_user_install_and_install_dir_are_combined(self): + set_module_args({ + 'name': 'dummy', + 'user_install': True, + 'install_dir': '/opt/dummy', + }) + + with pytest.raises(AnsibleFailJson) as exc: + gem.main() + + result = exc.value.args[0] + assert result['failed'] + assert result['msg'] == "install_dir requires user_install=false" + + def test_passes_install_dir_to_gem(self): + # XXX: This test is extremely fragile, and makes assuptions about the module code, and how + # functions are run. + # If you start modifying the code of the module, you might need to modify what this + # test mocks. The only thing that matters is the assertion that this 'gem install' is + # invoked with '--install-dir'. + + set_module_args({ + 'name': 'dummy', + 'user_install': False, + 'install_dir': '/opt/dummy', + }) + + self.patch_rubygems_version() + self.patch_installed_versions([]) + run_command = self.patch_run_command() + + with pytest.raises(AnsibleExitJson) as exc: + gem.main() + + result = exc.value.args[0] + assert result['changed'] + assert run_command.called + + assert '--install-dir /opt/dummy' in get_command(run_command) + + def test_passes_install_dir_and_gem_home_when_uninstall_gem(self): + # XXX: This test is also extremely fragile because of mocking. + # If this breaks, the only that matters is to check whether '--install-dir' is + # in the run command, and that GEM_HOME is passed to the command. + set_module_args({ + 'name': 'dummy', + 'user_install': False, + 'install_dir': '/opt/dummy', + 'state': 'absent', + }) + + self.patch_rubygems_version() + self.patch_installed_versions(['1.0.0']) + + run_command = self.patch_run_command() + + with pytest.raises(AnsibleExitJson) as exc: + gem.main() + + result = exc.value.args[0] + + assert result['changed'] + assert run_command.called + + assert '--install-dir /opt/dummy' in get_command(run_command) + + update_environ = run_command.call_args[1].get('environ_update', {}) + assert update_environ.get('GEM_HOME') == '/opt/dummy'