-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add New Module file_remove #11032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add New Module file_remove #11032
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,297 @@ | ||||||||||||||||||||||
| #!/usr/bin/python | ||||||||||||||||||||||
| # -*- coding: utf-8 -*- | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # 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) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| attributes: | ||||||||||||||||||||||
| check_mode: | ||||||||||||||||||||||
| description: Can run in check_mode and return changed status without modifying the target. | ||||||||||||||||||||||
| support: full | ||||||||||||||||||||||
| diff_mode: | ||||||||||||||||||||||
| description: Will return details on what has changed (or possibly needs changing in check_mode). | ||||||||||||||||||||||
| support: full | ||||||||||||||||||||||
|
Comment on lines
+28
to
+33
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please remove the descriptions and use the appropriate doc fragment (see basically every other module in this collection). |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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 (*, ?, [seq], [!seq]) for glob-style matching. | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| - 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. | ||||||||||||||||||||||
| - V(file) - remove only regular files. | ||||||||||||||||||||||
| - V(link) - remove only symbolic links. | ||||||||||||||||||||||
| - V(any) - remove both files and symbolic links. | ||||||||||||||||||||||
| type: str | ||||||||||||||||||||||
| choices: ['file', 'link', 'any'] | ||||||||||||||||||||||
|
Comment on lines
+68
to
+72
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| 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: always | ||||||||||||||||||||||
| sample: 2 | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| msg: | ||||||||||||||||||||||
| description: Status message. | ||||||||||||||||||||||
| type: str | ||||||||||||||||||||||
| returned: always | ||||||||||||||||||||||
| sample: "Removed 2 files matching pattern '*.log'" | ||||||||||||||||||||||
|
Comment on lines
+125
to
+130
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We generally don't document standard return values.
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| path: | ||||||||||||||||||||||
| description: The directory path that was searched. | ||||||||||||||||||||||
| type: str | ||||||||||||||||||||||
| returned: always | ||||||||||||||||||||||
| sample: /var/log | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import os | ||||||||||||||||||||||
| import re | ||||||||||||||||||||||
| import glob | ||||||||||||||||||||||
| from ansible.module_utils.basic import AnsibleModule | ||||||||||||||||||||||
| from ansible.module_utils.common.text.converters import to_native | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def find_matching_files(path, pattern, use_regex, recursive, file_type): | ||||||||||||||||||||||
| """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: | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| 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) | ||||||||||||||||||||||
| except OSError as e: | ||||||||||||||||||||||
| raise AssertionError(f"Failed to list directory {path}: {to_native(e)}") | ||||||||||||||||||||||
| 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, file_type): | ||||||||||||||||||||||
| """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": | ||||||||||||||||||||||
| # Only regular files, not symlinks | ||||||||||||||||||||||
| return is_file and not is_link | ||||||||||||||||||||||
| elif file_type == "link": | ||||||||||||||||||||||
| # Only symbolic links | ||||||||||||||||||||||
| return is_link | ||||||||||||||||||||||
| elif file_type == "any": | ||||||||||||||||||||||
| # Both files and symlinks | ||||||||||||||||||||||
| return is_file or is_link | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return False | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def remove_files(module, files): | ||||||||||||||||||||||
| """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: | ||||||||||||||||||||||
| # Actually remove the file | ||||||||||||||||||||||
| os.remove(file_path) | ||||||||||||||||||||||
| removed_files.append(file_path) | ||||||||||||||||||||||
| except OSError as e: | ||||||||||||||||||||||
| failed_files.append((file_path, to_native(e))) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return removed_files, failed_files | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def main(): | ||||||||||||||||||||||
| 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 = module.params["path"] | ||||||||||||||||||||||
| pattern = module.params["pattern"] | ||||||||||||||||||||||
| use_regex = module.params["use_regex"] | ||||||||||||||||||||||
| recursive = module.params["recursive"] | ||||||||||||||||||||||
| file_type = 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: {to_native(e)}") | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is redundant, since we no longer support Python 2, more occurrences around the code should be adjusted.
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Find matching files | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| matching_files = find_matching_files(path, pattern, use_regex, recursive, file_type) | ||||||||||||||||||||||
| except AssertionError as e: | ||||||||||||||||||||||
| module.fail_json(msg=to_native(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() | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Copyright (c) Ansible Project | ||
| # 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 | ||
|
|
||
| azp/posix/3 | ||
| destructive | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Arguably not, since everything is created and destroyed within a temporary directory. @felixfontein what do you think?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this isn't needed. The tests aren't installing/removing/modifying packages, system config files, services etc. or doing similar destructive things, which this aliases entry is for. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| # Copyright (c) Ansible Project | ||
| # 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 | ||
|
|
||
| file_remove_testdir: "{{ remote_tmp_dir }}/file_remove_tests" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| # Copyright (c) Ansible Project | ||
| # 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 | ||
|
|
||
| dependencies: | ||
| - setup_remote_tmp_dir |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| --- | ||
| #################################################################### | ||
| # WARNING: These are designed specifically for Ansible tests # | ||
| # and should not be used as examples of how to write Ansible roles # | ||
| #################################################################### | ||
|
|
||
| # Test code for the file_remove module | ||
| # Copyright (c) Ansible Project | ||
| # 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 | ||
|
|
||
| - name: Ensure the test directory is absent before starting | ||
| ansible.builtin.file: | ||
| path: "{{ file_remove_testdir }}" | ||
| state: absent | ||
|
|
||
| - name: Create the test directory | ||
| ansible.builtin.file: | ||
| path: "{{ file_remove_testdir }}" | ||
| state: directory | ||
| mode: '0755' | ||
|
|
||
| - name: Run all tests and clean up afterwards | ||
| block: | ||
| - name: Include tasks to test error handling | ||
| include_tasks: test_errors.yml | ||
|
|
||
| - name: Include tasks to test glob pattern matching | ||
| include_tasks: test_glob.yml | ||
|
|
||
| - name: Include tasks to test regex pattern matching | ||
| include_tasks: test_regex.yml | ||
|
|
||
| - name: Include tasks to test recursive removal | ||
| include_tasks: test_recursive.yml | ||
|
|
||
| - name: Include tasks to test different file types | ||
| include_tasks: test_file_types.yml | ||
|
|
||
| - name: Include tasks to test check mode and diff mode | ||
| include_tasks: test_check_diff.yml | ||
|
|
||
| always: | ||
| - name: Remove test directory | ||
| ansible.builtin.file: | ||
| path: "{{ file_remove_testdir }}" | ||
| state: absent |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think some fewer paragraphs would be better here: