diff --git a/changelogs/fragments/11873-gem-user-install-os-defaults.yml b/changelogs/fragments/11873-gem-user-install-os-defaults.yml new file mode 100644 index 0000000000..fda37a7fa3 --- /dev/null +++ b/changelogs/fragments/11873-gem-user-install-os-defaults.yml @@ -0,0 +1,5 @@ +minor_changes: + - gem - add ``override_platform_install_dir`` option to work around OS-injected platform install + dir defaults on distributions such as Fedora + (https://github.com/ansible-collections/community.general/issues/3259, + https://github.com/ansible-collections/community.general/pull/11873). diff --git a/plugins/modules/gem.py b/plugins/modules/gem.py index f3e3567a50..0405dd2ff2 100644 --- a/plugins/modules/gem.py +++ b/plugins/modules/gem.py @@ -98,6 +98,17 @@ options: - Force gem to (un-)install, bypassing dependency checks. default: false type: bool + override_platform_install_dir: + description: + - Resolve the user gem installation directory via C(gem environment) and pass it explicitly + as C(--install-dir) to both C(gem install) and C(gem uninstall), instead of using C(--user-install). + - This is needed on distributions (such as Fedora) where a platform-specific C(operating_system.rb) + injects C(--install-dir) as a default for all gem commands, which conflicts with C(--user-install) + and causes C(gem uninstall) to search the wrong directory. + - Cannot be combined with O(user_install=false) or O(install_dir). + default: false + type: bool + version_added: 13.0.0 author: - "Ansible Core Team" - "Johan Wiren (@johanwiren)" @@ -122,6 +133,7 @@ EXAMPLES = r""" state: present """ +import os import re from ansible.module_utils.basic import AnsibleModule @@ -133,13 +145,23 @@ RE_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)") RE_INSTALLED = re.compile(r"\S+\s+\((?:default: )?(.+)\)") -def get_rubygems_path(module): +def get_rubygems_path(module: AnsibleModule) -> list[str]: if module.params["executable"]: return module.params["executable"].split() return [module.get_bin_path("gem", True)] -def get_rubygems_version(module): +def get_user_install_dir(module: AnsibleModule) -> str | None: + cmd = get_rubygems_path(module) + rc, out, err = module.run_command(cmd + ["environment"], check_rc=True) + for line in out.splitlines(): + match = re.search(r"USER INSTALLATION DIRECTORY:\s*(.+)", line) + if match: + return match.group(1).strip() + return None + + +def get_rubygems_version(module: AnsibleModule) -> tuple[int, ...] | None: cmd = get_rubygems_path(module) + ["--version"] rc, out, err = module.run_command(cmd, check_rc=True) match = RE_VERSION.match(out) @@ -148,7 +170,7 @@ def get_rubygems_version(module): return tuple(int(x) for x in match.groups()) -def make_runner(module, ver): +def make_runner(module: AnsibleModule, ver: tuple[int, ...] | None) -> CmdRunner: command = get_rubygems_path(module) environ_update = {} @@ -195,7 +217,7 @@ def make_runner(module, ver): ) -def get_installed_versions(runner, remote=False): +def get_installed_versions(runner: CmdRunner, remote: bool = False) -> list[str]: name = runner.module.params["name"] if remote: args_order = ["_list_subcmd", "norc", "_remote_flag", "repository", "_name_pattern"] @@ -213,7 +235,7 @@ def get_installed_versions(runner, remote=False): return installed_versions -def exists(runner): +def exists(runner: CmdRunner) -> bool: module = runner.module if module.params["state"] == "latest": remoteversions = get_installed_versions(runner, remote=True) @@ -225,7 +247,7 @@ def exists(runner): return bool(installed_versions) -def install(runner): +def install(runner: CmdRunner, user_dir: str | None = None) -> None: args_order = [ "_install_subcmd", "norc", @@ -243,10 +265,14 @@ def install(runner): "force", ] with runner(args_order, check_mode_skip=True) as ctx: - ctx.run() + if user_dir: + bindir = runner.module.params["bindir"] or os.path.join(user_dir, "bin") + ctx.run(user_install=False, install_dir=user_dir, bindir=bindir) + else: + ctx.run() -def uninstall(runner): +def uninstall(runner: CmdRunner, user_dir: str | None = None) -> tuple[int, str, str] | None: args_order = [ "_uninstall_subcmd", "norc", @@ -258,7 +284,10 @@ def uninstall(runner): "name", ] with runner(args_order, check_mode_skip=True) as ctx: - return ctx.run(_uninstall_version=runner.module.params["version"]) + kwargs = {"_uninstall_version": runner.module.params["version"]} + if user_dir: + kwargs["install_dir"] = user_dir + return ctx.run(**kwargs) def main(): @@ -280,6 +309,7 @@ def main(): version=dict(type="str"), build_flags=dict(type="str"), force=dict(default=False, type="bool"), + override_platform_install_dir=dict(default=False, type="bool"), ), supports_check_mode=True, mutually_exclusive=[["gem_source", "repository"], ["gem_source", "version"]], @@ -291,22 +321,32 @@ def main(): 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 module.params["override_platform_install_dir"]: + if not module.params["user_install"]: + module.fail_json(msg="override_platform_install_dir requires user_install=true") + if module.params["install_dir"]: + module.fail_json(msg="override_platform_install_dir cannot be combined with install_dir") if not module.params["gem_source"]: module.params["gem_source"] = module.params["name"] ver = get_rubygems_version(module) + + user_dir = None + if module.params["override_platform_install_dir"]: + user_dir = get_user_install_dir(module) + runner = make_runner(module, ver) changed = False if module.params["state"] in ["present", "latest"]: if not exists(runner): - install(runner) + install(runner, user_dir) changed = True elif module.params["state"] == "absent": if exists(runner): - command_output = uninstall(runner) + command_output = uninstall(runner, user_dir) if command_output is not None and exists(runner): rc, out, err = command_output module.fail_json( diff --git a/tests/integration/targets/gem/tasks/main.yml b/tests/integration/targets/gem/tasks/main.yml index 2f1363ee85..3b7913b9b0 100644 --- a/tests/integration/targets/gem/tasks/main.yml +++ b/tests/integration/targets/gem/tasks/main.yml @@ -30,23 +30,15 @@ loop: "{{ test_packages }}" when: ansible_facts.distribution != "MacOSX" - - name: Install a gem - gem: - name: gist - state: present - register: install_gem_result - ignore_errors: true + # default user_install: skip on RedHat/Fedora where OS-injected --install-dir conflicts + - when: ansible_facts.os_family != "RedHat" + block: + - name: Install a gem (default user_install) + gem: + name: gist + state: present + register: install_gem_result - # when running as root on Fedora, '--install-dir' is set in the os defaults which is - # incompatible with '--user-install', we ignore this error for this case only - - name: fail if failed to install gem - fail: - msg: "failed to install gem: {{ install_gem_result.msg }}" - when: - - install_gem_result is failed - - not (ansible_facts.user_uid == 0 and "User --install-dir or --user-install but not both" not in install_gem_result.msg) - - - block: - name: List gems command: gem list register: current_gems @@ -72,7 +64,6 @@ that: - remove_gem_results is changed - current_gems.stdout is not search('gist\s+\([0-9.]+\)') - when: not install_gem_result is failed # install gem in --no-user-install - block: @@ -97,6 +88,7 @@ gem: name: gist state: absent + user_install: false register: remove_gem_results - name: List gems @@ -179,7 +171,7 @@ state: present bindir: "{{ remote_tmp_dir }}/custom_bindir" norc: true - user_install: false # Avoid conflicts between --install-dir and --user-install when running as root on CentOS / Fedora / RHEL + user_install: false register: install_gem_result - name: Get stats of gem executable @@ -199,7 +191,7 @@ state: absent bindir: "{{ remote_tmp_dir }}/custom_bindir" norc: true - user_install: false # Avoid conflicts between --install-dir and --user-install when running as root on CentOS / Fedora / RHEL + user_install: false register: install_gem_result - name: Get stats of gem executable @@ -213,6 +205,53 @@ - install_gem_result is changed - not gem_bindir_stat.stat.exists + # override_platform_install_dir: install and remove using explicit user gem dir + - name: Install a gem with override_platform_install_dir + gem: + name: gist + state: present + override_platform_install_dir: true + register: install_gem_result + + - name: List gems + command: gem list + register: current_gems + + - name: Ensure gem was installed with override_platform_install_dir + assert: + that: + - install_gem_result is changed + - current_gems.stdout is search('gist\s+\([0-9.]+\)') + + - name: Install a gem with override_platform_install_dir (idempotency) + gem: + name: gist + state: present + override_platform_install_dir: true + register: install_gem_result + + - name: Ensure install is idempotent + assert: + that: + - install_gem_result is not changed + + - name: Remove a gem with override_platform_install_dir + gem: + name: gist + state: absent + override_platform_install_dir: true + register: remove_gem_result + + - name: List gems + command: gem list + register: current_gems + + - name: Verify gem was removed with override_platform_install_dir + assert: + that: + - remove_gem_result is changed + - current_gems.stdout is not search('gist\s+\([0-9.]+\)') + - name: Attempt to uninstall default gem 'json' community.general.gem: name: json