mirror of
https://github.com/ansible/ansible.git
synced 2026-02-03 20:40:24 -05:00
Merge 5f1422ab31 into 7f17759bfe
This commit is contained in:
commit
fafbc88695
7 changed files with 321 additions and 1 deletions
6
changelogs/fragments/add-ansible-galaxy-uninstall.yml
Normal file
6
changelogs/fragments/add-ansible-galaxy-uninstall.yml
Normal 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.
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue