From cdb95e848ea76cc3ad344b0d537c4cc6357de4eb Mon Sep 17 00:00:00 2001 From: Shahar Golshani Date: Wed, 5 Nov 2025 12:42:54 +0200 Subject: [PATCH] Add New Module file_remove --- .github/BOTMETA.yml | 2 + plugins/modules/file_remove.py | 297 ++++++++++++++++++ tests/integration/targets/file_remove/aliases | 7 + .../targets/file_remove/defaults/main.yml | 6 + .../targets/file_remove/meta/main.yml | 7 + .../targets/file_remove/tasks/main.yml | 47 +++ .../file_remove/tasks/test_check_diff.yml | 108 +++++++ .../targets/file_remove/tasks/test_errors.yml | 57 ++++ .../file_remove/tasks/test_file_types.yml | 132 ++++++++ .../targets/file_remove/tasks/test_glob.yml | 102 ++++++ .../file_remove/tasks/test_recursive.yml | 127 ++++++++ .../targets/file_remove/tasks/test_regex.yml | 97 ++++++ 12 files changed, 989 insertions(+) create mode 100644 plugins/modules/file_remove.py create mode 100644 tests/integration/targets/file_remove/aliases create mode 100644 tests/integration/targets/file_remove/defaults/main.yml create mode 100644 tests/integration/targets/file_remove/meta/main.yml create mode 100644 tests/integration/targets/file_remove/tasks/main.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_check_diff.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_errors.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_file_types.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_glob.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_recursive.yml create mode 100644 tests/integration/targets/file_remove/tasks/test_regex.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 5bb9bd7bc88..76cc75bdbdb 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -592,6 +592,8 @@ files: $modules/filesystem.py: labels: filesystem maintainers: pilou- abulimov quidame + $modules/file_remove.py: + maintainers: shahargolshani $modules/flatpak.py: maintainers: $team_flatpak $modules/flatpak_remote.py: diff --git a/plugins/modules/file_remove.py b/plugins/modules/file_remove.py new file mode 100644 index 00000000000..84f2d25f67e --- /dev/null +++ b/plugins/modules/file_remove.py @@ -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 + +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. + - 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'] + 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'" + +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)}") + + # 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() diff --git a/tests/integration/targets/file_remove/aliases b/tests/integration/targets/file_remove/aliases new file mode 100644 index 00000000000..a54c6489f43 --- /dev/null +++ b/tests/integration/targets/file_remove/aliases @@ -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 + diff --git a/tests/integration/targets/file_remove/defaults/main.yml b/tests/integration/targets/file_remove/defaults/main.yml new file mode 100644 index 00000000000..d41a4ba44f6 --- /dev/null +++ b/tests/integration/targets/file_remove/defaults/main.yml @@ -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" diff --git a/tests/integration/targets/file_remove/meta/main.yml b/tests/integration/targets/file_remove/meta/main.yml new file mode 100644 index 00000000000..982de6eb035 --- /dev/null +++ b/tests/integration/targets/file_remove/meta/main.yml @@ -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 diff --git a/tests/integration/targets/file_remove/tasks/main.yml b/tests/integration/targets/file_remove/tasks/main.yml new file mode 100644 index 00000000000..1b78148c11a --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/main.yml @@ -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 diff --git a/tests/integration/targets/file_remove/tasks/test_check_diff.yml b/tests/integration/targets/file_remove/tasks/test_check_diff.yml new file mode 100644 index 00000000000..bdbcbd0a4aa --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_check_diff.yml @@ -0,0 +1,108 @@ +--- +# 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 + +# Test check mode and diff mode + +- name: Create test files for check mode testing + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - check1.tmp + - check2.tmp + - check3.txt + +- name: Test removal in check mode + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + check_mode: true + register: check_mode_result + +- name: Verify check mode reported changes but didn't remove files + ansible.builtin.assert: + that: + - check_mode_result is changed + - check_mode_result.files_count == 2 + - "file_remove_testdir ~ '/check1.tmp' in check_mode_result.removed_files" + - "file_remove_testdir ~ '/check2.tmp' in check_mode_result.removed_files" + +- name: Verify files still exist after check mode + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: files_after_check + loop: + - check1.tmp + - check2.tmp + +- name: Assert files were not actually removed in check mode + ansible.builtin.assert: + that: + - item.stat.exists + loop: "{{ files_after_check.results }}" + +- name: Test removal in normal mode with diff + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + diff: true + register: diff_mode_result + +- name: Verify diff mode provides before/after information + ansible.builtin.assert: + that: + - diff_mode_result is changed + - diff_mode_result.files_count == 2 + - diff_mode_result.diff is defined + - diff_mode_result.diff.before is defined + - diff_mode_result.diff.after is defined + - diff_mode_result.diff.before.files | length == 2 + - diff_mode_result.diff.after.files | length == 0 + +- name: Verify files were actually removed + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: files_after_removal + loop: + - check1.tmp + - check2.tmp + +- name: Assert files were removed + ansible.builtin.assert: + that: + - not item.stat.exists + loop: "{{ files_after_removal.results }}" + +- name: Test idempotency - try to remove already removed files + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + register: idempotent_result + +- name: Verify idempotency (no changes when no files match) + ansible.builtin.assert: + that: + - idempotent_result is not changed + - idempotent_result.files_count == 0 + - idempotent_result.removed_files == [] + +- name: Test idempotency in check mode + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + check_mode: true + register: idempotent_check_result + +- name: Verify idempotency in check mode + ansible.builtin.assert: + that: + - idempotent_check_result is not changed + - idempotent_check_result.files_count == 0 + +- name: Clean up test directory + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: absent diff --git a/tests/integration/targets/file_remove/tasks/test_errors.yml b/tests/integration/targets/file_remove/tasks/test_errors.yml new file mode 100644 index 00000000000..5b677ba08b0 --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_errors.yml @@ -0,0 +1,57 @@ +--- +# 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 + +# Test error handling + +- name: Test with non-existent path + community.general.file_remove: + path: /this/path/does/not/exist + pattern: "*.txt" + register: error_result_1 + ignore_errors: true + +- name: Verify that non-existent path fails + ansible.builtin.assert: + that: + - error_result_1 is failed + - "'does not exist' in error_result_1.msg" + +- name: Create a test file to use as a non-directory path + ansible.builtin.file: + path: "{{ file_remove_testdir }}/testfile" + state: touch + mode: '0644' + +- name: Test with a file path instead of directory + community.general.file_remove: + path: "{{ file_remove_testdir }}/testfile" + pattern: "*.txt" + register: error_result_2 + ignore_errors: true + +- name: Verify that non-directory path fails + ansible.builtin.assert: + that: + - error_result_2 is failed + - "'not a directory' in error_result_2.msg" + +- name: Test with invalid regex pattern + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "[unclosed" + use_regex: true + register: error_result_3 + ignore_errors: true + +- name: Verify that invalid regex fails + ansible.builtin.assert: + that: + - error_result_3 is failed + - "'Invalid regular expression' in error_result_3.msg" + +- name: Remove test file + ansible.builtin.file: + path: "{{ file_remove_testdir }}/testfile" + state: absent diff --git a/tests/integration/targets/file_remove/tasks/test_file_types.yml b/tests/integration/targets/file_remove/tasks/test_file_types.yml new file mode 100644 index 00000000000..2f2fd11fa0b --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_file_types.yml @@ -0,0 +1,132 @@ +--- +# 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 + +# Test different file types + +- name: Create regular test files + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - file1.tmp + - file2.tmp + - target1.txt + - target2.txt + +- name: Create symbolic links + ansible.builtin.file: + src: "{{ file_remove_testdir }}/{{ item.src }}" + dest: "{{ file_remove_testdir }}/{{ item.dest }}" + state: link + loop: + - { src: target1.txt, dest: link1.tmp } + - { src: target2.txt, dest: link2.tmp } + +- name: Remove only regular files (file_type=file, default) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + file_type: file + register: file_type_result_1 + +- name: Verify only regular .tmp files were removed + ansible.builtin.assert: + that: + - file_type_result_1 is changed + - file_type_result_1.files_count == 2 + - "file_remove_testdir ~ '/file1.tmp' in file_type_result_1.removed_files" + - "file_remove_testdir ~ '/file2.tmp' in file_type_result_1.removed_files" + +- name: Verify symbolic links still exist + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + follow: false + register: links_exist + loop: + - link1.tmp + - link2.tmp + +- name: Assert symbolic links still exist + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.islnk + loop: "{{ links_exist.results }}" + +- name: Remove only symbolic links (file_type=link) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.tmp" + file_type: link + register: file_type_result_2 + +- name: Verify only symbolic links were removed + ansible.builtin.assert: + that: + - file_type_result_2 is changed + - file_type_result_2.files_count == 2 + - "file_remove_testdir ~ '/link1.tmp' in file_type_result_2.removed_files" + - "file_remove_testdir ~ '/link2.tmp' in file_type_result_2.removed_files" + +- name: Verify target files still exist + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: targets_exist + loop: + - target1.txt + - target2.txt + +- name: Assert target files still exist + ansible.builtin.assert: + that: + - item.stat.exists + loop: "{{ targets_exist.results }}" + +- name: Create more test files and links + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - data1.dat + - data2.dat + +- name: Create more symbolic links + ansible.builtin.file: + src: "{{ file_remove_testdir }}/{{ item.src }}" + dest: "{{ file_remove_testdir }}/{{ item.dest }}" + state: link + loop: + - { src: target1.txt, dest: link1.dat } + - { src: target2.txt, dest: link2.dat } + +- name: Remove both files and links (file_type=any) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.dat" + file_type: any + register: file_type_result_3 + +- name: Verify both files and links were removed + ansible.builtin.assert: + that: + - file_type_result_3 is changed + - file_type_result_3.files_count == 4 + - "file_remove_testdir ~ '/data1.dat' in file_type_result_3.removed_files" + - "file_remove_testdir ~ '/data2.dat' in file_type_result_3.removed_files" + - "file_remove_testdir ~ '/link1.dat' in file_type_result_3.removed_files" + - "file_remove_testdir ~ '/link2.dat' in file_type_result_3.removed_files" + +- name: Clean up for next test + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: absent + +- name: Recreate test directory + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: directory + mode: '0755' diff --git a/tests/integration/targets/file_remove/tasks/test_glob.yml b/tests/integration/targets/file_remove/tasks/test_glob.yml new file mode 100644 index 00000000000..85ed6df9697 --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_glob.yml @@ -0,0 +1,102 @@ +--- +# 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 + +# Test glob pattern matching + +- name: Create test files for glob testing + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - test1.txt + - test2.txt + - test3.log + - data.txt + - readme.md + - backup_file.bak + +- name: Remove files matching *.txt pattern + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.txt" + register: glob_result_1 + +- name: Verify *.txt files were removed + ansible.builtin.assert: + that: + - glob_result_1 is changed + - glob_result_1.files_count == 3 + - "file_remove_testdir ~ '/test1.txt' in glob_result_1.removed_files" + - "file_remove_testdir ~ '/test2.txt' in glob_result_1.removed_files" + - "file_remove_testdir ~ '/data.txt' in glob_result_1.removed_files" + - "'3 file(s)' in glob_result_1.msg" + +- name: Verify remaining files still exist + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: remaining_files + loop: + - test3.log + - readme.md + - backup_file.bak + +- name: Assert remaining files exist + ansible.builtin.assert: + that: + - item.stat.exists + loop: "{{ remaining_files.results }}" + +- name: Remove files matching test* pattern + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "test*" + register: glob_result_2 + +- name: Verify test* files were removed + ansible.builtin.assert: + that: + - glob_result_2 is changed + - glob_result_2.files_count == 1 + - "file_remove_testdir ~ '/test3.log' in glob_result_2.removed_files" + +- name: Remove files matching [rb]* pattern (character set) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "[rb]*" + register: glob_result_3 + +- name: Verify [rb]* files were removed + ansible.builtin.assert: + that: + - glob_result_3 is changed + - glob_result_3.files_count == 2 + - "file_remove_testdir ~ '/readme.md' in glob_result_3.removed_files" + - "file_remove_testdir ~ '/backup_file.bak' in glob_result_3.removed_files" + +- name: Try to remove with non-matching pattern + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.nonexistent" + register: glob_result_4 + +- name: Verify no files were removed (idempotent) + ansible.builtin.assert: + that: + - glob_result_4 is not changed + - glob_result_4.files_count == 0 + - glob_result_4.removed_files == [] + - "'0 file(s)' in glob_result_4.msg" + +- name: Clean up for next test + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: absent + +- name: Recreate test directory + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: directory + mode: '0755' diff --git a/tests/integration/targets/file_remove/tasks/test_recursive.yml b/tests/integration/targets/file_remove/tasks/test_recursive.yml new file mode 100644 index 00000000000..5b32b577cc6 --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_recursive.yml @@ -0,0 +1,127 @@ +--- +# 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 + +# Test recursive removal + +- name: Create nested directory structure + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: directory + mode: '0755' + loop: + - subdir1 + - subdir2 + - subdir1/nested1 + - subdir2/nested2 + - subdir2/nested2/deep + +- name: Create test files in nested directories + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - temp1.log + - temp2.log + - subdir1/temp3.log + - subdir1/data.txt + - subdir1/nested1/temp4.log + - subdir2/temp5.log + - subdir2/nested2/temp6.log + - subdir2/nested2/deep/temp7.log + - subdir2/nested2/deep/file.txt + +- name: Remove .log files non-recursively (should only remove root level) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.log" + recursive: false + register: recursive_result_1 + +- name: Verify only root level .log files were removed + ansible.builtin.assert: + that: + - recursive_result_1 is changed + - recursive_result_1.files_count == 2 + - "file_remove_testdir ~ '/temp1.log' in recursive_result_1.removed_files" + - "file_remove_testdir ~ '/temp2.log' in recursive_result_1.removed_files" + +- name: Verify nested .log files still exist + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: nested_files_exist + loop: + - subdir1/temp3.log + - subdir1/nested1/temp4.log + - subdir2/temp5.log + - subdir2/nested2/temp6.log + - subdir2/nested2/deep/temp7.log + +- name: Assert nested .log files still exist + ansible.builtin.assert: + that: + - item.stat.exists + loop: "{{ nested_files_exist.results }}" + +- name: Remove .log files recursively (should remove all .log files) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "*.log" + recursive: true + register: recursive_result_2 + +- name: Verify all .log files were removed recursively + ansible.builtin.assert: + that: + - recursive_result_2 is changed + - recursive_result_2.files_count == 5 + - "file_remove_testdir ~ '/subdir1/temp3.log' in recursive_result_2.removed_files" + - "file_remove_testdir ~ '/subdir1/nested1/temp4.log' in recursive_result_2.removed_files" + - "file_remove_testdir ~ '/subdir2/temp5.log' in recursive_result_2.removed_files" + - "file_remove_testdir ~ '/subdir2/nested2/temp6.log' in recursive_result_2.removed_files" + - "file_remove_testdir ~ '/subdir2/nested2/deep/temp7.log' in recursive_result_2.removed_files" + +- name: Verify .txt files still exist + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: txt_files_exist + loop: + - subdir1/data.txt + - subdir2/nested2/deep/file.txt + +- name: Assert .txt files still exist + ansible.builtin.assert: + that: + - item.stat.exists + loop: "{{ txt_files_exist.results }}" + +- name: Verify directories still exist (directories should never be removed) + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/{{ item }}" + register: dirs_exist + loop: + - subdir1 + - subdir2 + - subdir1/nested1 + - subdir2/nested2 + - subdir2/nested2/deep + +- name: Assert directories still exist + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.isdir + loop: "{{ dirs_exist.results }}" + +- name: Clean up for next test + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: absent + +- name: Recreate test directory + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: directory + mode: '0755' diff --git a/tests/integration/targets/file_remove/tasks/test_regex.yml b/tests/integration/targets/file_remove/tasks/test_regex.yml new file mode 100644 index 00000000000..3a0af370223 --- /dev/null +++ b/tests/integration/targets/file_remove/tasks/test_regex.yml @@ -0,0 +1,97 @@ +--- +# 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 + +# Test regex pattern matching + +- name: Create test files for regex testing + ansible.builtin.file: + path: "{{ file_remove_testdir }}/{{ item }}" + state: touch + mode: '0644' + loop: + - backup_20241101.tar.gz + - backup_20241102.tar.gz + - backup_20241103.tar.gz + - backup_old.tar.gz + - file123.txt + - file456.txt + - fileabc.txt + - test_file.log + +- name: Remove files matching regex backup_[0-9]{8}\.tar\.gz + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "backup_[0-9]{8}\\.tar\\.gz" + use_regex: true + register: regex_result_1 + +- name: Verify regex matched files were removed + ansible.builtin.assert: + that: + - regex_result_1 is changed + - regex_result_1.files_count == 3 + - "file_remove_testdir ~ '/backup_20241101.tar.gz' in regex_result_1.removed_files" + - "file_remove_testdir ~ '/backup_20241102.tar.gz' in regex_result_1.removed_files" + - "file_remove_testdir ~ '/backup_20241103.tar.gz' in regex_result_1.removed_files" + +- name: Verify backup_old.tar.gz still exists + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/backup_old.tar.gz" + register: backup_old_stat + +- name: Assert backup_old.tar.gz was not removed + ansible.builtin.assert: + that: + - backup_old_stat.stat.exists + +- name: Remove files matching regex file[0-9]+\.txt (digits only) + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "file[0-9]+\\.txt" + use_regex: true + register: regex_result_2 + +- name: Verify files with digits were removed + ansible.builtin.assert: + that: + - regex_result_2 is changed + - regex_result_2.files_count == 2 + - "file_remove_testdir ~ '/file123.txt' in regex_result_2.removed_files" + - "file_remove_testdir ~ '/file456.txt' in regex_result_2.removed_files" + +- name: Verify fileabc.txt still exists + ansible.builtin.stat: + path: "{{ file_remove_testdir }}/fileabc.txt" + register: fileabc_stat + +- name: Assert fileabc.txt was not removed + ansible.builtin.assert: + that: + - fileabc_stat.stat.exists + +- name: Remove files with regex using anchors ^test.* + community.general.file_remove: + path: "{{ file_remove_testdir }}" + pattern: "^test.*" + use_regex: true + register: regex_result_3 + +- name: Verify files starting with 'test' were removed + ansible.builtin.assert: + that: + - regex_result_3 is changed + - regex_result_3.files_count == 1 + - "file_remove_testdir ~ '/test_file.log' in regex_result_3.removed_files" + +- name: Clean up for next test + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: absent + +- name: Recreate test directory + ansible.builtin.file: + path: "{{ file_remove_testdir }}" + state: directory + mode: '0755'