|
| 1 | +#!/usr/bin/python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +# Copyright (c) 2025, Shahar Golshani (@shahargolshani) |
| 5 | +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) |
| 6 | +# SPDX-License-Identifier: GPL-3.0-or-later |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | + |
| 11 | +DOCUMENTATION = r""" |
| 12 | +module: file_remove |
| 13 | +
|
| 14 | +short_description: Remove files matching a pattern from a directory |
| 15 | +
|
| 16 | +description: |
| 17 | + - This module removes files from a specified directory that match a given pattern. |
| 18 | + - The pattern can include wildcards and regular expressions. |
| 19 | + - By default, only files in the specified directory are removed (non-recursive). |
| 20 | + - Use the O(recursive) option to search and remove files in subdirectories. |
| 21 | +
|
| 22 | +version_added: "12.1.0" |
| 23 | +
|
| 24 | +author: |
| 25 | + - Shahar Golshani (@shahargolshani) |
| 26 | +
|
| 27 | +attributes: |
| 28 | + check_mode: |
| 29 | + description: Can run in check_mode and return changed status without modifying the target. |
| 30 | + support: full |
| 31 | + diff_mode: |
| 32 | + description: Will return details on what has changed (or possibly needs changing in check_mode). |
| 33 | + support: full |
| 34 | +
|
| 35 | +options: |
| 36 | + path: |
| 37 | + description: |
| 38 | + - Path to the directory where files should be removed. |
| 39 | + - This must be an existing directory. |
| 40 | + type: path |
| 41 | + required: true |
| 42 | +
|
| 43 | + pattern: |
| 44 | + description: |
| 45 | + - Pattern to match files for removal. |
| 46 | + - Supports wildcards (*, ?, [seq], [!seq]) for glob-style matching. |
| 47 | + - Use O(use_regex=true) to interpret this as a regular expression instead. |
| 48 | + type: str |
| 49 | + required: true |
| 50 | +
|
| 51 | + use_regex: |
| 52 | + description: |
| 53 | + - If V(true), O(pattern) is interpreted as a regular expression. |
| 54 | + - If V(false), O(pattern) is interpreted as a glob-style wildcard pattern. |
| 55 | + type: bool |
| 56 | + default: false |
| 57 | +
|
| 58 | + recursive: |
| 59 | + description: |
| 60 | + - If V(true), search for files recursively in subdirectories. |
| 61 | + - If V(false), only files in the specified directory are removed. |
| 62 | + type: bool |
| 63 | + default: false |
| 64 | +
|
| 65 | + file_type: |
| 66 | + description: |
| 67 | + - Type of files to remove. |
| 68 | + - V(file) - remove only regular files. |
| 69 | + - V(link) - remove only symbolic links. |
| 70 | + - V(any) - remove both files and symbolic links. |
| 71 | + type: str |
| 72 | + choices: ['file', 'link', 'any'] |
| 73 | + default: file |
| 74 | +
|
| 75 | +notes: |
| 76 | + - Directories are never removed by this module, only files and optionally symbolic links. |
| 77 | + - This module will not follow symbolic links when O(recursive=true). |
| 78 | + - Be careful with patterns that might match many files, especially with O(recursive=true). |
| 79 | +""" |
| 80 | + |
| 81 | +EXAMPLES = r""" |
| 82 | +- name: Remove all log files from /var/log |
| 83 | + community.general.file_remove: |
| 84 | + path: /var/log |
| 85 | + pattern: "*.log" |
| 86 | +
|
| 87 | +- name: Remove all temporary files recursively |
| 88 | + community.general.file_remove: |
| 89 | + path: /tmp/myapp |
| 90 | + pattern: "*.tmp" |
| 91 | + recursive: true |
| 92 | +
|
| 93 | +- name: Remove files matching a regex pattern |
| 94 | + community.general.file_remove: |
| 95 | + path: /data/backups |
| 96 | + pattern: 'backup_[0-9]{8}\.tar\.gz' |
| 97 | + use_regex: true |
| 98 | +
|
| 99 | +- name: Remove both files and symbolic links |
| 100 | + community.general.file_remove: |
| 101 | + path: /opt/app/cache |
| 102 | + pattern: "cache_*" |
| 103 | + file_type: any |
| 104 | +
|
| 105 | +- name: Remove all files starting with 'test_' (check mode) |
| 106 | + community.general.file_remove: |
| 107 | + path: /home/user/tests |
| 108 | + pattern: "test_*" |
| 109 | + check_mode: true |
| 110 | +""" |
| 111 | + |
| 112 | +RETURN = r""" |
| 113 | +removed_files: |
| 114 | + description: List of files that were removed. |
| 115 | + type: list |
| 116 | + elements: str |
| 117 | + returned: always |
| 118 | + sample: ['/var/log/app.log', '/var/log/error.log'] |
| 119 | +
|
| 120 | +files_count: |
| 121 | + description: Number of files removed. |
| 122 | + type: int |
| 123 | + returned: always |
| 124 | + sample: 2 |
| 125 | +
|
| 126 | +msg: |
| 127 | + description: Status message. |
| 128 | + type: str |
| 129 | + returned: always |
| 130 | + sample: "Removed 2 files matching pattern '*.log'" |
| 131 | +
|
| 132 | +path: |
| 133 | + description: The directory path that was searched. |
| 134 | + type: str |
| 135 | + returned: always |
| 136 | + sample: /var/log |
| 137 | +""" |
| 138 | + |
| 139 | + |
| 140 | +import os |
| 141 | +import re |
| 142 | +import glob |
| 143 | +from ansible.module_utils.basic import AnsibleModule |
| 144 | +from ansible.module_utils.common.text.converters import to_native |
| 145 | + |
| 146 | + |
| 147 | +def find_matching_files(path, pattern, use_regex, recursive, file_type): |
| 148 | + """Find all files matching the pattern in the given path.""" |
| 149 | + matching_files = [] |
| 150 | + |
| 151 | + if use_regex: |
| 152 | + # Use regular expression matching |
| 153 | + regex = re.compile(pattern) |
| 154 | + if recursive: |
| 155 | + for root, dirs, files in os.walk(path, followlinks=False): |
| 156 | + for filename in files: |
| 157 | + if regex.match(filename) or regex.search(filename): |
| 158 | + full_path = os.path.join(root, filename) |
| 159 | + if should_include_file(full_path, file_type): |
| 160 | + matching_files.append(full_path) |
| 161 | + else: |
| 162 | + try: |
| 163 | + for filename in os.listdir(path): |
| 164 | + if regex.match(filename) or regex.search(filename): |
| 165 | + full_path = os.path.join(path, filename) |
| 166 | + if should_include_file(full_path, file_type): |
| 167 | + matching_files.append(full_path) |
| 168 | + except OSError as e: |
| 169 | + raise AssertionError(f"Failed to list directory {path}: {to_native(e)}") |
| 170 | + else: |
| 171 | + # Use glob pattern matching |
| 172 | + if recursive: |
| 173 | + glob_pattern = os.path.join(path, "**", pattern) |
| 174 | + matching_files = [f for f in glob.glob(glob_pattern, recursive=True) if should_include_file(f, file_type)] |
| 175 | + else: |
| 176 | + glob_pattern = os.path.join(path, pattern) |
| 177 | + matching_files = [f for f in glob.glob(glob_pattern) if should_include_file(f, file_type)] |
| 178 | + |
| 179 | + return sorted(matching_files) |
| 180 | + |
| 181 | + |
| 182 | +def should_include_file(file_path, file_type): |
| 183 | + """Determine if a file should be included based on its type.""" |
| 184 | + # Never include directories |
| 185 | + if os.path.isdir(file_path): |
| 186 | + return False |
| 187 | + |
| 188 | + is_link = os.path.islink(file_path) |
| 189 | + is_file = os.path.isfile(file_path) |
| 190 | + |
| 191 | + if file_type == "file": |
| 192 | + # Only regular files, not symlinks |
| 193 | + return is_file and not is_link |
| 194 | + elif file_type == "link": |
| 195 | + # Only symbolic links |
| 196 | + return is_link |
| 197 | + elif file_type == "any": |
| 198 | + # Both files and symlinks |
| 199 | + return is_file or is_link |
| 200 | + |
| 201 | + return False |
| 202 | + |
| 203 | + |
| 204 | +def remove_files(module, files): |
| 205 | + """Remove the specified files and return results.""" |
| 206 | + removed_files = [] |
| 207 | + failed_files = [] |
| 208 | + |
| 209 | + for file_path in files: |
| 210 | + try: |
| 211 | + if module.check_mode: |
| 212 | + # In check mode, just verify the file exists |
| 213 | + if os.path.exists(file_path): |
| 214 | + removed_files.append(file_path) |
| 215 | + else: |
| 216 | + # Actually remove the file |
| 217 | + os.remove(file_path) |
| 218 | + removed_files.append(file_path) |
| 219 | + except OSError as e: |
| 220 | + failed_files.append((file_path, to_native(e))) |
| 221 | + |
| 222 | + return removed_files, failed_files |
| 223 | + |
| 224 | + |
| 225 | +def main(): |
| 226 | + module = AnsibleModule( |
| 227 | + argument_spec=dict( |
| 228 | + path=dict(type="path", required=True), |
| 229 | + pattern=dict(type="str", required=True), |
| 230 | + use_regex=dict(type="bool", default=False), |
| 231 | + recursive=dict(type="bool", default=False), |
| 232 | + file_type=dict(type="str", default="file", choices=["file", "link", "any"]), |
| 233 | + ), |
| 234 | + supports_check_mode=True, |
| 235 | + ) |
| 236 | + |
| 237 | + path = module.params["path"] |
| 238 | + pattern = module.params["pattern"] |
| 239 | + use_regex = module.params["use_regex"] |
| 240 | + recursive = module.params["recursive"] |
| 241 | + file_type = module.params["file_type"] |
| 242 | + |
| 243 | + # Validate that the path exists and is a directory |
| 244 | + if not os.path.exists(path): |
| 245 | + module.fail_json(msg=f"Path does not exist: {path}") |
| 246 | + |
| 247 | + if not os.path.isdir(path): |
| 248 | + module.fail_json(msg=f"Path is not a directory: {path}") |
| 249 | + |
| 250 | + # Validate regex pattern if use_regex is true |
| 251 | + if use_regex: |
| 252 | + try: |
| 253 | + re.compile(pattern) |
| 254 | + except re.error as e: |
| 255 | + module.fail_json(msg=f"Invalid regular expression pattern: {to_native(e)}") |
| 256 | + |
| 257 | + # Find matching files |
| 258 | + try: |
| 259 | + matching_files = find_matching_files(path, pattern, use_regex, recursive, file_type) |
| 260 | + except AssertionError as e: |
| 261 | + module.fail_json(msg=to_native(e)) |
| 262 | + |
| 263 | + # Prepare diff information |
| 264 | + diff = dict(before=dict(files=matching_files), after=dict(files=[])) |
| 265 | + |
| 266 | + # Remove the files |
| 267 | + removed_files, failed_files = remove_files(module, matching_files) |
| 268 | + |
| 269 | + # Prepare result |
| 270 | + changed = len(removed_files) > 0 |
| 271 | + |
| 272 | + result = dict( |
| 273 | + changed=changed, |
| 274 | + removed_files=removed_files, |
| 275 | + files_count=len(removed_files), |
| 276 | + path=path, |
| 277 | + msg=f"Removed {len(removed_files)} file(s) matching pattern '{pattern}'", |
| 278 | + ) |
| 279 | + |
| 280 | + # Add diff if in diff mode |
| 281 | + if module._diff: |
| 282 | + result["diff"] = diff |
| 283 | + |
| 284 | + # Report any failures |
| 285 | + if failed_files: |
| 286 | + failure_msg = "; ".join([f"{f}: {e}" for f, e in failed_files]) |
| 287 | + module.fail_json( |
| 288 | + msg=f"Failed to remove some files: {failure_msg}", |
| 289 | + removed_files=removed_files, |
| 290 | + failed_files=[f for f, e in failed_files], |
| 291 | + ) |
| 292 | + |
| 293 | + module.exit_json(**result) |
| 294 | + |
| 295 | + |
| 296 | +if __name__ == "__main__": |
| 297 | + main() |
0 commit comments