Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/BOTMETA.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
297 changes: 297 additions & 0 deletions plugins/modules/file_remove.py
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.
Comment on lines +17 to +20
Copy link
Collaborator

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:

Suggested change
- 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.
- 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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Supports wildcards (*, ?, [seq], [!seq]) for glob-style matching.
- 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.
- 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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']
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: always
sample: 2
msg:
description: Status message.
type: str
returned: always
sample: "Removed 2 files matching pattern '*.log'"
Comment on lines +125 to +130
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally don't document standard return values.

Suggested change
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)}")
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
module.fail_json(msg=f"Invalid regular expression pattern: {to_native(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 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()
7 changes: 7 additions & 0 deletions tests/integration/targets/file_remove/aliases
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.


6 changes: 6 additions & 0 deletions tests/integration/targets/file_remove/defaults/main.yml
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"
7 changes: 7 additions & 0 deletions tests/integration/targets/file_remove/meta/main.yml
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
47 changes: 47 additions & 0 deletions tests/integration/targets/file_remove/tasks/main.yml
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
Loading