Skip to content

Commit

Permalink
Add the ability to specify an install_dir to the gem module (ansible#…
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
acatton authored and ilicmilan committed Aug 15, 2018
1 parent 3ea72a2 commit 0eccf87
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 23 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/gem-custom-home.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
new_features:
- gem - add ability to specify a custom directory for installing gems (https://github.com/ansible/ansible/pull/38195)
29 changes: 26 additions & 3 deletions lib/ansible/modules/packaging/language/gem.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@
- 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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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']:
Expand Down Expand Up @@ -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'),
Expand All @@ -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']
Expand Down
113 changes: 93 additions & 20 deletions test/integration/targets/gem/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
121 changes: 121 additions & 0 deletions test/units/modules/packaging/language/test_gem.py
Original file line number Diff line number Diff line change
@@ -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'

0 comments on commit 0eccf87

Please sign in to comment.