This commit is contained in:
Sloane Hertel 2026-02-03 19:16:11 -06:00 committed by GitHub
commit fafbc88695
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 1 deletions

View file

@ -0,0 +1,6 @@
minor_changes:
- >-
Added simple ``uninstall`` functionality to ``ansible-galaxy``.
Unless ``--yes``/``-y`` is provided, paths will only be removed after user confirmation.
Provide a ``--requirements-file`` to uninstall roles and collections at the same time.
Dependencies are not uninstalled, only direct requests.

View file

@ -36,6 +36,7 @@ from ansible.galaxy.collection import (
find_existing_collections,
install_collections,
publish_collection,
uninstall_collections,
validate_collection_name,
validate_collection_path,
verify_collections,
@ -47,7 +48,7 @@ from ansible.galaxy.collection.concrete_artifact_manager import (
from ansible.galaxy.collection.gpg import GPG_ERROR_MAP
from ansible.galaxy.dependency_resolution.dataclasses import Requirement
from ansible.galaxy.role import GalaxyRole
from ansible.galaxy.role import GalaxyRole, uninstall_roles
from ansible.galaxy.token import BasicAuthToken, GalaxyToken, KeycloakToken, NoTokenSentinel
from ansible.module_utils.ansible_release import __version__ as ansible_version
from ansible.module_utils.common.collections import is_iterable
@ -294,6 +295,7 @@ class GalaxyCLI(CLI):
self.add_publish_options(collection_parser, parents=[common])
self.add_install_options(collection_parser, parents=[common, force, cache_options])
self.add_list_options(collection_parser, parents=[common, collections_path])
self.add_uninstall_options(collection_parser, parents=[common])
self.add_verify_options(collection_parser, parents=[common, collections_path])
# Add sub parser for the Galaxy role actions
@ -311,6 +313,7 @@ class GalaxyCLI(CLI):
self.add_info_options(role_parser, parents=[common, roles_path, offline])
self.add_install_options(role_parser, parents=[common, force, roles_path])
self.add_uninstall_options(role_parser, parents=[common])
def add_download_options(self, parser, parents=None):
download_parser = parser.add_parser('download', parents=parents,
@ -387,6 +390,50 @@ class GalaxyCLI(CLI):
list_parser.add_argument('--format', dest='output_format', choices=('human', 'yaml', 'json'), default='human',
help="Format to display the list of collections in.")
def add_uninstall_options(self, parser, parents=None):
galaxy_type = "role" if parser.metavar == "ROLE_ACTION" else "collection"
if self._implicit_role:
description = (
f"Uninstall roles by name, or roles and collections together by providing a --requirements-file. "
f"Roles will be uninstalled from the configured role paths: {', '.join(C.DEFAULT_ROLES_PATH)}. "
f"Collections will be uninstalled from the configured collection paths: {', '.join(C.COLLECTIONS_PATHS)}. "
)
else:
if galaxy_type == "role":
description = f"Uninstall roles from the configured role paths: {', '.join(C.DEFAULT_ROLES_PATH)}. "
else:
description = f"Uninstall collections from the configured collection paths: {', '.join(C.COLLECTIONS_PATHS)}. "
description += f"To remove all versions of a {galaxy_type}, only specify a {galaxy_type} name. "
uninstall_parser = parser.add_parser("uninstall", parents=parents, description=description +
"This is a destructive operation and cannot be reversed.",
help=f"Uninstall {galaxy_type}s.")
if galaxy_type == "role":
uninstall_parser.add_argument("--roles-path", dest="roles_path", default=C.DEFAULT_ROLES_PATH,
type=opt_help.unfrack_path(pathsep=True), action=opt_help.PrependListAction,
help="Remove roles from additional paths.")
if self._implicit_role or galaxy_type == "collection":
uninstall_parser.add_argument("--collections-path", dest="collections_path", default=C.COLLECTIONS_PATHS,
type=opt_help.unfrack_path(pathsep=True), action=opt_help.PrependListAction,
help="Remove collections from additional paths.")
if self._implicit_role:
galaxy_types = "roles and collections"
else:
galaxy_types = f"{galaxy_type}s"
# one required, mutually exclusive
uninstall_parser.add_argument("args", metavar=f"{galaxy_type}_name", nargs="*", help=f"{galaxy_type.upper()} to uninstall.")
uninstall_parser.add_argument("-r", "--requirements-file", dest="requirements", help=f"A file containing a list of {galaxy_types} to uninstall.")
# bypass prompting
uninstall_parser.add_argument("-y", "--yes", dest="yes", action="store_true", default=False,
help=f"Remove all {galaxy_types} matching the requirements without prompting.")
uninstall_parser.set_defaults(func=self.execute_uninstall)
def add_search_options(self, parser, parents=None):
search_parser = parser.add_parser('search', parents=parents,
help='Search the Galaxy database by tags, platforms, author and multiple '
@ -1616,6 +1663,55 @@ class GalaxyCLI(CLI):
return 0
@with_collection_artifacts_manager
def execute_uninstall(self, artifacts_manager=None):
"""Locate collections/roles that match any requirement, and get user confirmation on which paths to remove."""
if artifacts_manager is not None:
artifacts_manager.require_build_metadata = False
if context.CLIARGS["args"] and context.CLIARGS["requirements"]:
raise AnsibleOptionsError(f"Positional {context.CLIARGS['type']} requirements and --requirements-file are mutually exclusive.")
if not context.CLIARGS["args"] and not context.CLIARGS["requirements"]:
raise AnsibleOptionsError(f"Positional {context.CLIARGS['type']} requirements or --requirements-file is required.")
collection_requirements = []
role_requirements = []
if context.CLIARGS["type"] == "role" and context.CLIARGS["args"]:
for role_requirement in context.CLIARGS["args"]:
role_spec = RoleRequirement.role_yaml_parse(role_requirement)
if "role" in role_spec:
name = role_spec["role"]
elif "name" in role_spec:
name = role_spec["name"]
elif "src" in role_spec:
name = RoleRequirement.repo_url_to_role_name(role_spec["src"])
else:
raise AnsibleError("Invalid role requirement: '{role_requirement}'. A role name is required.")
role_requirements.append(GalaxyRole(self.galaxy, self.lazy_role_api, name, version=role_spec.get("version")))
elif context.CLIARGS["args"]:
for collection_requirement in context.CLIARGS["args"]:
collection_requirements.append(
Requirement.from_string(collection_requirement, artifacts_manager, None)
)
else:
requirements_file = GalaxyCLI._resolve_path(context.CLIARGS["requirements"])
requirements = self._parse_requirements_file(
requirements_file, allow_old_format=False, artifacts_manager=artifacts_manager, validate_signature_options=False
)
collection_requirements = requirements["collections"]
role_requirements = requirements["roles"]
rc = 0
if self._implicit_role or context.CLIARGS["type"] == "collection":
rc = uninstall_collections(
collection_requirements, context.CLIARGS["collections_path"], context.CLIARGS["yes"], artifacts_manager=artifacts_manager
)
if self._implicit_role or context.CLIARGS["type"] == "role":
rc = uninstall_roles(role_requirements, context.CLIARGS["roles_path"], context.CLIARGS["yes"]) or rc
return rc
@with_collection_artifacts_manager
def execute_list_collection(self, artifacts_manager=None):
"""

View file

@ -22,16 +22,56 @@
from __future__ import annotations
import shutil
import os
import ansible.constants as C
from ansible import context
from ansible.utils.display import Display
from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.common.yaml import yaml_load
# default_readme_template
# default_meta_template
display = Display()
def _ask_uninstall(label: str, paths: list[str], collection: bool = True, skip_prompt: bool = False) -> bool:
"""Confirm whether the list of paths should be removed and return a boolean."""
if skip_prompt:
return True
prompt = f"Uninstalling {'collection' if collection else 'role'} {label}:"
prompt += (
"\n Would remove:\n " +
"\n ".join(paths)
)
display.display(prompt)
while (response := input("Proceed (y/n)? ").lower()) not in ("y", "n"):
display.display(f"Your response ('{response}') was not one of the expected responses: y, n,")
return response == "y"
def _uninstall_paths(label: str, paths: list[str], collection: bool = True) -> int:
"""Remove a list of paths, depth first."""
rc = 0
for path in reversed(sorted(paths)):
try:
if os.path.islink(path):
os.unlink(path)
else:
shutil.rmtree(path)
except OSError as e:
display.error(f"Unable to remove {'collection' if collection else 'role'} {label} path {path}: {e}")
rc = 1
else:
display.vvvv(f"Removed {path}")
if rc == 0:
display.display(f"Uninstalled {'collection' if collection else 'role'} {label}")
return rc
def get_collections_galaxy_meta_info():
meta_path = os.path.join(os.path.dirname(__file__), 'data', 'collections_galaxy_meta.yml')

View file

@ -83,6 +83,7 @@ if t.TYPE_CHECKING:
import ansible.constants as C
from ansible.errors import AnsibleError
from ansible.galaxy import _ask_uninstall, _uninstall_paths
from ansible.galaxy.api import GalaxyAPI
from ansible.galaxy.collection.concrete_artifact_manager import (
_consume_file,
@ -944,6 +945,54 @@ def verify_collections(
return results
def uninstall_collections(requirements: list[Requirement], search_paths: list[str], skip_prompt: bool, artifacts_manager: ConcreteArtifactsManager = None):
"""Uninstall collections in any of the search paths that match a requirement."""
collections = []
for requirement in requirements:
namespace, name = requirement.fqcn.split(".")
for installed in find_existing_collections(
search_paths,
artifacts_manager,
namespace_filter=namespace,
collection_filter=name,
dedupe=False
):
if requirement.ver in (None, "*") or meets_requirements(installed.ver, requirement.ver):
collections.append(installed)
if not collections or collections[-1].fqcn != requirement.fqcn or (
requirement.ver not in (None, "*") and not meets_requirements(collections[-1].ver, requirement.ver)
):
display.warning(f"Skipping collection {requirement} as it is not installed.")
display.display(f"Found {len(collections)} collections to remove.")
remove = set()
keep = set()
collection_paths = {}
for collection in collections:
collection_path = to_text(collection.src, errors="surrogate_or_strict")
if collection_path in remove or collection_path in keep:
continue
content = [collection_path]
namespace = os.path.dirname(collection_path)
for other_collection in os.listdir(namespace):
if other_collection != collection.name and os.path.join(namespace, other_collection) not in remove:
break
else:
content.append(namespace)
content.extend(glob.glob(f"{os.path.dirname(namespace)}/{collection.fqcn}-*.info"))
if _ask_uninstall(f"{collection}", content, skip_prompt=skip_prompt):
remove.add(collection_path)
collection_paths[f"{collection}"] = content
else:
keep.add(collection_path)
rc = 0
for label, paths in collection_paths.items():
rc = _uninstall_paths(label, paths) or rc
return rc
@contextmanager
def _tempdir():
b_temp_path = tempfile.mkdtemp(dir=to_bytes(C.DEFAULT_LOCAL_TMP, errors='surrogate_or_strict'))

View file

@ -32,6 +32,7 @@ from shutil import rmtree
from ansible import context
from ansible.errors import AnsibleError, AnsibleParserError
from ansible.galaxy import _ask_uninstall, _uninstall_paths
from ansible.galaxy.api import GalaxyAPI
from ansible.galaxy.user_agent import user_agent
from ansible.module_utils.common.text.converters import to_native, to_text
@ -447,3 +448,39 @@ class GalaxyRole(object):
raise AnsibleParserError(f"Expected role dependencies to be a list. Role {self} has meta/requirements.yml {self._requirements}")
return self._requirements
def uninstall_roles(requirements: list[GalaxyRole], search_paths: list[str], skip_prompt: bool) -> int:
"""Uninstall all roles that match each requirement name (and optional version)."""
roles = []
for requirement in requirements:
for path in search_paths:
for role_name in os.listdir(path):
if requirement.name != role_name:
continue
gr = GalaxyRole(requirement.galaxy, requirement.api, role_name, requirement.src, requirement.version, requirement.scm, path)
if requirement.version is None or (gr.install_info or {}).get("version") == requirement.version:
roles.append(gr)
if not roles or roles[-1].name != requirement.name or (
requirement.version is not None and (roles[-1].install_info or {}).get("version") != requirement.version
):
display.warning(f"Skipping role {requirement.name} ({requirement.version or 'unknown version'}) as it is not installed.")
display.display(f"Found {len(roles)} roles to remove.")
remove = set()
keep = set()
role_paths = {}
for role in roles:
if role.path in remove or role.path in keep:
continue
label = f"{role.name} (unknown version)" if not role.version else f"{role.name} ({role.version})"
if _ask_uninstall(label, [role.path], collection=False, skip_prompt=skip_prompt):
remove.add(role.path)
role_paths[label] = [role.path]
else:
keep.add(role.path)
rc = 0
for label, paths in role_paths.items():
rc = _uninstall_paths(label, paths, collection=False) or rc
return rc

View file

@ -216,3 +216,11 @@
environment:
ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}'
ANSIBLE_CONFIG: '{{ galaxy_dir }}/ansible.cfg'
- include_tasks: uninstall.yml
args:
apply:
environment:
ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}'
ANSIBLE_CONFIG: '{{ galaxy_dir }}/ansible.cfg'
ANSIBLE_ROLES_PATH: '{{ galaxy_dir }}/roles'

View file

@ -0,0 +1,84 @@
- name: Uninstall a collection which isn't installed
command: ansible-galaxy collection uninstall namespace1.name1 -y {{ galaxy_verbosity }}
register: not_installed
- name: Install a collection
command: ansible-galaxy collection install namespace1.name1 {{ galaxy_verbosity }}
- name: Uninstall the collection
command: ansible-galaxy collection uninstall namespace1.name1 -y {{ galaxy_verbosity }}
register: uninstalled
- name: Install a specific version
command: ansible-galaxy collection install namespace1.name1:1.1.0-beta.1 {{ galaxy_verbosity }}
- name: Uninstall a different version
command: ansible-galaxy collection uninstall -y namespace1.name1:1.0.0 -y {{ galaxy_verbosity }}
register: not_installed_2
- name: Uninstall a compatible requirement
command: ansible-galaxy collection uninstall -y namespace1.name1:>1.0.0 -y {{ galaxy_verbosity }}
register: uninstalled_beta
- assert:
that:
- '"Uninstalled collection" not in not_installed.stdout'
- '"Uninstalled collection namespace1.name1:1.0.9" in uninstalled.stdout'
- '"Uninstalled collection" not in not_installed_2.stdout'
- '"Uninstalled collection namespace1.name1:1.1.0-beta.1" in uninstalled_beta.stdout'
- name: Download a collection
command: ansible-galaxy collection download namespace1.name1 -p {{ galaxy_dir }}/artifacts {{ galaxy_verbosity }}
- name: Initialize a role
command: ansible-galaxy role init test.role --init-path {{ galaxy_dir }}/roles_src {{ galaxy_verbosity }}
- name: Create installable role
command: tar -czvf test.role.tar.gz test.role
args:
chdir: "{{ galaxy_dir }}/roles_src"
- name: Create a requirements file to test uninstalling roles and collections
copy:
dest: "{{ galaxy_dir }}/uninstall_requirements.yml"
content: |
collections:
- name: parent_dep.parent_collection
version: <=2.0.0
- name: {{ galaxy_dir }}/artifacts/namespace1-name1-1.0.9.tar.gz
roles:
- src: "file://{{ galaxy_dir }}/roles_src/test.role.tar.gz"
- name: Install requirements file
command: ansible-galaxy install -r {{ galaxy_dir }}/uninstall_requirements.yml {{ galaxy_verbosity }}
- name: Uninstall requirements file
command: ansible-galaxy uninstall -r {{ galaxy_dir }}/uninstall_requirements.yml -y {{ galaxy_verbosity }}
register: uninstall_requirements
- assert:
that:
- '"Uninstalled collection parent_dep.parent_collection:2.0.0" in uninstall_requirements.stdout'
- '"Uninstalled collection namespace1.name1:1.0.9" in uninstall_requirements.stdout'
- '"Uninstalled role test.role (unknown version)" in uninstall_requirements.stdout'
# Dependencies are not removed
- '"Uninstalled collection child_dep.child_collection:1.0.0" not in uninstall_requirements.stdout'
- '"Uninstalled collection child_dep.child_dep2:1.2.2" not in uninstall_requirements.stdout'
- name: Uninstall remaining dependencies
command: ansible-galaxy collection uninstall child_dep.child_collection child_dep.child_dep2 -y {{ galaxy_verbosity }}
register: uninstall_dependencies
- assert:
that:
- '"Uninstalled collection child_dep.child_collection:1.0.0" in uninstall_dependencies.stdout'
- '"Uninstalled collection child_dep.child_dep2:1.2.2" in uninstall_dependencies.stdout'
- name: Remove uninstall test files
file:
path: "{{ galaxy_dir }}/{{ item }}"
state: absent
loop:
- uninstall_requirements.yml
- roles_src
- roles