#!/usr/bin/python # Copyright (c) 2025, Shahar Golshani (@shahargolshani) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations DOCUMENTATION = r""" module: file_remove short_description: Remove files matching a pattern from a directory description: - This module removes files from a specified directory that match a given pattern. The pattern can include wildcards and regular expressions. - By default, only files in the specified directory are removed (non-recursive). Use the O(recursive) option to search and remove files in subdirectories. version_added: "12.1.0" author: - Shahar Golshani (@shahargolshani) extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: full options: path: description: - Path to the directory where files should be removed. - This must be an existing directory. type: path required: true pattern: description: - Pattern to match files for removal. - Supports wildcards (V(*), V(?), V([seq]), V([!seq])) for glob-style matching. - Use O(use_regex=true) to interpret this as a regular expression instead. type: str required: true use_regex: description: - If V(true), O(pattern) is interpreted as a regular expression. - If V(false), O(pattern) is interpreted as a glob-style wildcard pattern. type: bool default: false recursive: description: - If V(true), search for files recursively in subdirectories. - If V(false), only files in the specified directory are removed. type: bool default: false file_type: description: - Type of files to remove. type: str choices: file: remove only regular files. link: remove only symbolic links. any: remove both files and symbolic links. default: file notes: - Directories are never removed by this module, only files and optionally symbolic links. - This module will not follow symbolic links when O(recursive=true). - Be careful with patterns that might match many files, especially with O(recursive=true). """ EXAMPLES = r""" - name: Remove all log files from /var/log community.general.file_remove: path: /var/log pattern: "*.log" - name: Remove all temporary files recursively community.general.file_remove: path: /tmp/myapp pattern: "*.tmp" recursive: true - name: Remove files matching a regex pattern community.general.file_remove: path: /data/backups pattern: "backup_[0-9]{8}\\.tar\\.gz" use_regex: true - name: Remove both files and symbolic links community.general.file_remove: path: /opt/app/cache pattern: "cache_*" file_type: any - name: Remove all files starting with 'test_' (check mode) community.general.file_remove: path: /home/user/tests pattern: "test_*" check_mode: true """ RETURN = r""" removed_files: description: List of files that were removed. type: list elements: str returned: always sample: ['/var/log/app.log', '/var/log/error.log'] files_count: description: Number of files removed. type: int returned: success sample: 2 path: description: The directory path that was searched. type: str returned: success sample: /var/log """ import os import re import glob import typing as t from ansible.module_utils.basic import AnsibleModule def find_matching_files( path: str, pattern: str, use_regex: bool, recursive: bool, file_type: t.Literal["file", "link", "any"] ) -> list[str]: """Find all files matching the pattern in the given path.""" matching_files = [] if use_regex: # Use regular expression matching regex = re.compile(pattern) if recursive: for root, _dirs, files in os.walk(path, followlinks=False): for filename in files: if regex.match(filename) or regex.search(filename): full_path = os.path.join(root, filename) if should_include_file(full_path, file_type): matching_files.append(full_path) else: for filename in os.listdir(path): if regex.match(filename) or regex.search(filename): full_path = os.path.join(path, filename) if should_include_file(full_path, file_type): matching_files.append(full_path) else: # Use glob pattern matching if recursive: glob_pattern = os.path.join(path, "**", pattern) matching_files = [f for f in glob.glob(glob_pattern, recursive=True) if should_include_file(f, file_type)] else: glob_pattern = os.path.join(path, pattern) matching_files = [f for f in glob.glob(glob_pattern) if should_include_file(f, file_type)] return sorted(matching_files) def should_include_file(file_path: str, file_type: t.Literal["file", "link", "any"]) -> bool: """Determine if a file should be included based on its type.""" # Never include directories if os.path.isdir(file_path): return False is_link = os.path.islink(file_path) is_file = os.path.isfile(file_path) if file_type == "file": return is_file and not is_link elif file_type == "link": return is_link else: return is_file or is_link def remove_files(module: AnsibleModule, files: list[str]) -> tuple[list[str], list[tuple[str, str]]]: """Remove the specified files and return results.""" removed_files = [] failed_files = [] for file_path in files: try: if module.check_mode: # In check mode, just verify the file exists if os.path.exists(file_path): removed_files.append(file_path) else: raise FileNotFoundError() else: # Actually remove the file os.remove(file_path) removed_files.append(file_path) except FileNotFoundError: module.warn(f"File not found (likely removed by other process): {file_path}") except OSError as e: failed_files.append((file_path, str(e))) return removed_files, failed_files def main() -> None: module = AnsibleModule( argument_spec=dict( path=dict(type="path", required=True), pattern=dict(type="str", required=True), use_regex=dict(type="bool", default=False), recursive=dict(type="bool", default=False), file_type=dict(type="str", default="file", choices=["file", "link", "any"]), ), supports_check_mode=True, ) path: str = module.params["path"] pattern: str = module.params["pattern"] use_regex: bool = module.params["use_regex"] recursive: bool = module.params["recursive"] file_type: t.Literal["file", "link", "any"] = module.params["file_type"] # Validate that the path exists and is a directory if not os.path.exists(path): module.fail_json(msg=f"Path does not exist: {path}") if not os.path.isdir(path): module.fail_json(msg=f"Path is not a directory: {path}") # Validate regex pattern if use_regex is true if use_regex: try: re.compile(pattern) except re.error as e: module.fail_json(msg=f"Invalid regular expression pattern: {e}") # Find matching files try: matching_files = find_matching_files(path, pattern, use_regex, recursive, file_type) except OSError as e: module.fail_json(msg=str(e)) # Prepare diff information diff = dict(before=dict(files=matching_files), after=dict(files=[])) # Remove the files removed_files, failed_files = remove_files(module, matching_files) # Prepare result changed = len(removed_files) > 0 result = dict( changed=changed, removed_files=removed_files, files_count=len(removed_files), path=path, msg=f"Removed {len(removed_files)} file(s) matching pattern '{pattern}'", ) # Add diff if in diff mode if module._diff: result["diff"] = diff # Report any failures if failed_files: failure_msg = "; ".join([f"{f}: {e}" for f, e in failed_files]) module.fail_json( msg=f"Failed to remove some files: {failure_msg}", removed_files=removed_files, failed_files=[f for f, e in failed_files], ) module.exit_json(**result) if __name__ == "__main__": main()