Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 6 commits into from
May 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than adding an explicit check, you could just set user_install and install_dir as mutually exclusive.

Copy link
Contributor Author

@acatton acatton Apr 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tell me if I'm wrong, but I'm not sure this is possible.

In order for this feature to work, the user MUST specify:

gem:
  user_install: no
  install_dir: /some/other/directory

The default of user_install being True.

Unless I'm mistaking, mutually_exclusive means that "the user CAN specify
one, but MUST NOT specify the other." However, here, the user MUST specify both.

If I'm right, there are two solutions:

  • Keep the current code (= the manual check)
  • Change the default of user_install to no, thus loosing backward compatibility.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're correct. After more careful reading, I see what you're trying to accomplish. Changing the default would not be a good idea.


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'