gem - fix --user-install conflict with OS-injected --install-dir (#11873)

* gem - fix --user-install conflict with OS-injected --install-dir

Some distributions (e.g. Fedora) inject --install-dir via operating_system.rb
as a platform default. Combining that with --user-install causes a gem CLI
parser error. Resolve the user install directory at install time and pass
--install-dir instead, which is semantically equivalent and avoids the conflict.
Uninstall is intentionally left unscoped so gem can find gems regardless of
where they were originally installed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* gem - add changelog fragment for #11873

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* gem - fix user_install handling for install and uninstall

Two issues found in CI:

1. `gem environment user_gemhome` is not supported on older RubyGems (e.g.
   Ubuntu 20.04 ships 3.1.2). Simplify get_user_install_dir() to always parse
   the full `gem environment` output for "USER INSTALLATION DIRECTORY", which
   is stable across all supported versions.

2. On Fedora, `gem uninstall` without flags only searches the system gem path
   (set by operating_system.rb), so it cannot find gems installed to the user
   dir via --install-dir. Add user_install to the uninstall args_order so that
   gem uninstall --user-install is passed when user_install=True. The OS
   defaults conflict only applies to gem install, not gem uninstall.
   The integration test is updated to be consistent: the user_install:false
   install/remove block now also specifies user_install:false on removal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* gem - use --install-dir for both install and uninstall of user gems

gem uninstall --user-install does not reliably find gems on Fedora/RHEL when
running as root, because those systems may disable user gem home for root and
Gem.user_dir may differ from the path resolved via 'gem environment'.

Use --install-dir <user_dir> for uninstall as well, since that is the exact
path used during install, making the operation consistent across platforms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* gem - add override_platform_install_dir option and type hints

- add type hints to all functions
- fix misleading comment about --install-dir scoping for uninstall
- add override_platform_install_dir option (default=false) to opt in to
  resolving and passing the user gem dir explicitly to both gem install
  and gem uninstall, working around OS-injected platform defaults on
  distributions such as Fedora
- reclassify changelog fragment as minor_changes (new parameter, not
  backport-eligible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(gem): add integration test for override_platform_install_dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(gem): skip default user_install test on RedHat family

OS-injected --install-dir on RHEL/Fedora makes the default user_install: true
case fail. The override_platform_install_dir block already covers the correct
path on those platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alexei Znamensky
2026-05-04 07:28:56 +12:00
committed by GitHub
parent 2aa6fc2cf7
commit f5da5c9681
3 changed files with 114 additions and 30 deletions

View File

@@ -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).

View File

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

View File

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