Skip to content

Commit cdb95e8

Browse files
Add New Module file_remove
1 parent 3c42ec7 commit cdb95e8

File tree

12 files changed

+989
-0
lines changed

12 files changed

+989
-0
lines changed

.github/BOTMETA.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,8 @@ files:
592592
$modules/filesystem.py:
593593
labels: filesystem
594594
maintainers: pilou- abulimov quidame
595+
$modules/file_remove.py:
596+
maintainers: shahargolshani
595597
$modules/flatpak.py:
596598
maintainers: $team_flatpak
597599
$modules/flatpak_remote.py:

plugins/modules/file_remove.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright (c) Ansible Project
2+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
azp/posix/3
6+
destructive
7+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
# Copyright (c) Ansible Project
3+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
4+
# SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
file_remove_testdir: "{{ remote_tmp_dir }}/file_remove_tests"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
# Copyright (c) Ansible Project
3+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
4+
# SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
dependencies:
7+
- setup_remote_tmp_dir
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
####################################################################
3+
# WARNING: These are designed specifically for Ansible tests #
4+
# and should not be used as examples of how to write Ansible roles #
5+
####################################################################
6+
7+
# Test code for the file_remove module
8+
# Copyright (c) Ansible Project
9+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
10+
# SPDX-License-Identifier: GPL-3.0-or-later
11+
12+
- name: Ensure the test directory is absent before starting
13+
ansible.builtin.file:
14+
path: "{{ file_remove_testdir }}"
15+
state: absent
16+
17+
- name: Create the test directory
18+
ansible.builtin.file:
19+
path: "{{ file_remove_testdir }}"
20+
state: directory
21+
mode: '0755'
22+
23+
- name: Run all tests and clean up afterwards
24+
block:
25+
- name: Include tasks to test error handling
26+
include_tasks: test_errors.yml
27+
28+
- name: Include tasks to test glob pattern matching
29+
include_tasks: test_glob.yml
30+
31+
- name: Include tasks to test regex pattern matching
32+
include_tasks: test_regex.yml
33+
34+
- name: Include tasks to test recursive removal
35+
include_tasks: test_recursive.yml
36+
37+
- name: Include tasks to test different file types
38+
include_tasks: test_file_types.yml
39+
40+
- name: Include tasks to test check mode and diff mode
41+
include_tasks: test_check_diff.yml
42+
43+
always:
44+
- name: Remove test directory
45+
ansible.builtin.file:
46+
path: "{{ file_remove_testdir }}"
47+
state: absent

0 commit comments

Comments
 (0)