Add the ability to specify an install_dir to the gem module (#38195)

* Add the ability to specify an install_dir to the gem module

* Add GEM_HOME when installing a non-global gem

* Add tests for custom gem path

* Fix sanity tests

* Add changelog entry

* Rebase and add tests for incorrect options

Co-authored by: Antoine Catton <devel@antoine.catton.fr>
This commit is contained in:
Antoine Catton 2018-05-21 15:55:43 +02:00 committed by Sam Doran
parent fc8663edc0
commit 39f9d3e4a6
4 changed files with 243 additions and 24 deletions

View file

@ -0,0 +1,2 @@
new_features:
- gem - add ability to specify a custom directory for installing gems (https://github.com/ansible/ansible/pull/38195)

View file

@ -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']

View file

@ -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: verify module output properties
- name: Ensure gem was installed
assert:
that:
- "'name' in gem_result"
- "'changed' in gem_result"
- "'state' in gem_result"
- install_gem_result is changed
- current_gems.stdout is search('gist\s+\([0-9.]+\)')
- name: verify gist is installed
shell: gem list | egrep '^gist '
- 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: Ensure previous task failed
assert:
that:
- install_gem_fail_result is failed
- install_gem_fail_result.msg == 'install_dir requires user_install=false'
- 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

View file

@ -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'