Files
computer-playbook/library/directory_batch.py
T
kevinveenbirkenbachandClaude Opus 5.5 76b16a8abb perf(library): create a whole directory list in one module call
Directory loops over ansible.builtin.file cost one SSH round trip per path: the openresty config tree (20 paths) took up to 12.9 s, the webserver core directories (16) up to 13.2 s, every app's compose directories (5) 2.5-5 s per app, the shared CDN roots 5.8 s and the OpenLDAP LDIF dirs 2.2 s on echoserver.

directory_batch takes entries (path plus its own mode, owner, group) or paths with one shared set, and handles each path like file with state: directory: symlinks are followed, missing parents get the same attributes, an attribute left out stays as found, and it never recurses. It reports changed_paths, supports check and diff mode, and fails on a relative path, an existing non-directory or a failed mkdir. It replaces the five loops and the earlier install -d of the CDN role directories, which now report changed honestly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:51:18 +02:00

151 lines
4.4 KiB
Python

"""Ensure many directories exist in one module call.
Args:
entries: list of ``{path, mode, owner, group}``, each path with its own
attributes; mutually exclusive with ``paths``.
paths: list of paths that all receive ``mode``, ``owner`` and ``group``.
mode: mode of every path in ``paths``.
owner: owner of every path in ``paths``.
group: group of every path in ``paths``.
Every path is handled like ``ansible.builtin.file`` with
``state: directory``: a symlink is followed, a missing path is created
together with its missing parents, which receive the same attributes, an
existing directory gets every given attribute that differs, and an
attribute left out stays as found.
Returns:
changed_paths: the paths that were created or whose attributes changed.
diff: one ``before``/``after`` record per changed path, in diff mode.
"""
import os
from pathlib import Path
from ansible.module_utils.basic import AnsibleModule
ATTRIBUTES = ("mode", "owner", "group")
ATTRIBUTE_SPEC = {
"mode": {"type": "raw"},
"owner": {"type": "str"},
"group": {"type": "str"},
}
ARGUMENT_SPEC = {
"entries": {
"type": "list",
"elements": "dict",
"options": {"path": {"type": "path", "required": True}, **ATTRIBUTE_SPEC},
},
"paths": {"type": "list", "elements": "path"},
**ATTRIBUTE_SPEC,
}
MUTUALLY_EXCLUSIVE = [["entries", name] for name in ("paths", *ATTRIBUTES)]
REQUIRED_ONE_OF = [["entries", "paths"]]
class DirectoryError(Exception):
pass
def requested_entries(params):
if params["entries"] is not None:
return params["entries"]
return [
{"path": path, **{name: params[name] for name in ATTRIBUTES}}
for path in params["paths"]
]
def path_state(path):
target = Path(path)
if target.is_symlink():
return "link"
if target.is_dir():
return "directory"
if target.exists():
return "file"
return "absent"
def apply_attributes(module, path, entry, changed, diff):
file_args = module.load_file_common_arguments(
{"path": path, **{name: entry[name] for name in ATTRIBUTES}}
)
return module.set_fs_attributes_if_different(file_args, changed, diff, expand=False)
def create(module, path, entry, diff):
changed = False
current = ""
for part in path.strip("/").split("/"):
current = f"{current}/{part}"
candidate = Path(current)
if candidate.exists():
continue
try:
candidate.mkdir()
changed = True
except FileExistsError:
if not candidate.is_dir():
raise
changed = apply_attributes(module, current, entry, changed, diff)
return changed
def ensure(module, entry):
path = entry["path"]
if not Path(path).is_absolute():
raise DirectoryError(f"{path!r} is not an absolute path")
if Path(path).is_symlink():
path = os.path.realpath(path)
state = path_state(path)
diff = {"before": {"path": path}, "after": {"path": path}}
if state == "directory":
return apply_attributes(module, path, entry, False, diff), diff
if state != "absent":
raise DirectoryError(f"{path} already exists as a {state}")
diff["before"]["state"] = state
diff["after"]["state"] = "directory"
if module.check_mode:
return True, diff
try:
return create(module, path, entry, diff), diff
except OSError as error:
raise DirectoryError(f"Could not create {path}: {error}") from error
def main():
module = AnsibleModule(
argument_spec=ARGUMENT_SPEC,
mutually_exclusive=MUTUALLY_EXCLUSIVE,
required_one_of=REQUIRED_ONE_OF,
supports_check_mode=True,
)
changed_paths = []
diffs = []
for entry in requested_entries(module.params):
try:
changed, diff = ensure(module, entry)
except DirectoryError as error:
module.fail_json(
msg=str(error),
path=entry["path"],
changed=bool(changed_paths),
changed_paths=changed_paths,
)
if changed:
changed_paths.append(entry["path"])
diffs.append(diff)
result = {"changed": bool(changed_paths), "changed_paths": changed_paths}
if module._diff:
result["diff"] = diffs
module.exit_json(**result)
if __name__ == "__main__":
main()