From bac4e25a862191271bfc7111c3cffca7156252ee Mon Sep 17 00:00:00 2001 From: munchtoast Date: Thu, 4 Jun 2026 10:40:58 -0400 Subject: [PATCH] feat: Add kopia_policy module - Manage Kopia snapshot policies (set, delete, list, show) via the Kopia CLI. - Extends community.general._kopia doc fragment for shared password and config options. - Uses fixed args for read-only _get() list_policies method. --- plugins/modules/kopia_policy.py | 402 ++++++++++++++++++ .../unit/plugins/modules/test_kopia_policy.py | 11 + .../plugins/modules/test_kopia_policy.yaml | 359 ++++++++++++++++ 3 files changed, 772 insertions(+) create mode 100644 plugins/modules/kopia_policy.py create mode 100644 tests/unit/plugins/modules/test_kopia_policy.py create mode 100644 tests/unit/plugins/modules/test_kopia_policy.yaml diff --git a/plugins/modules/kopia_policy.py b/plugins/modules/kopia_policy.py new file mode 100644 index 0000000000..e65cda2329 --- /dev/null +++ b/plugins/modules/kopia_policy.py @@ -0,0 +1,402 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Dexter Le +# 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: kopia_policy +short_description: Manage Kopia snapshot policies +author: + - Dexter Le (@munchtoast) +version_added: "13.1.0" +description: + - Manage Kopia snapshot policies using the Kopia CLI. + - Supports setting, deleting, showing, and listing policies. + - Policies control retention, scheduling, file exclusions, and compression for snapshots. +extends_documentation_fragment: + - community.general._attributes + - community.general._kopia +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + state: + description: + - Desired state of the Kopia policy. + - V(set) creates or updates a policy. At least one policy option must be provided. + - V(deleted) removes the policy for O(target) or the global policy when O(global_policy=true). + - V(listed) lists all defined policies. + - V(shown) displays the effective policy for O(target), including inherited values. + type: str + choices: [set, deleted, listed, shown] + default: set + target: + description: + - Policy target in one of the following forms. + - A per-path policy C(user@host:/path). + - A per-host policy C(@host). + - A per-user policy C(user@host). + - Required if O(state=set), O(state=deleted), or O(state=shown), unless O(global_policy=true). + type: str + global_policy: + description: + - When V(true), operate on the global policy instead of a per-target policy. + - When V(true), O(target) must not be set. + type: bool + default: false + retention: + description: + - Snapshot retention settings. All sub-options accept an integer or the string C(inherit) + to remove an override and fall back to the parent policy. + type: dict + suboptions: + keep_latest: + description: + - Number of most-recent snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + keep_hourly: + description: + - Number of most-recent hourly snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + keep_daily: + description: + - Number of most-recent daily snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + keep_weekly: + description: + - Number of most-recent weekly snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + keep_monthly: + description: + - Number of most-recent monthly snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + keep_annual: + description: + - Number of most-recent annual snapshots to keep. + - Pass C(inherit) to remove this override. + type: str + ignore_identical: + description: + - Do not save a new snapshot if its contents are identical to the previous one. + - Accepts V(true), V(false), or C(inherit). + type: str + scheduling: + description: + - Snapshot scheduling settings. + type: dict + suboptions: + interval: + description: + - Time between automatic snapshots, for example C(1h), C(30m), or C(24h). + type: str + times: + description: + - List of times of day at which to take snapshots, in C(HH:mm) format. + - Pass C(inherit) as the only list entry to remove this override. + type: list + elements: str + manual: + description: + - When V(true), only create snapshots manually and disable automatic scheduling. + type: bool + files: + description: + - File and directory exclusion settings. + type: dict + suboptions: + add_ignore: + description: + - List of path patterns to add to the ignore list. + type: list + elements: str + remove_ignore: + description: + - List of path patterns to remove from the ignore list. + type: list + elements: str + max_file_size: + description: + - Exclude files larger than this size. Accepts a byte count or human-readable string + such as C(100MB). + type: str + one_file_system: + description: + - When V(true), do not cross filesystem boundaries during backup. + - Accepts V(true), V(false), or C(inherit). + type: str + ignore_cache_dirs: + description: + - When V(true), ignore directories containing a C(CACHEDIR.TAG) file. + - Accepts V(true), V(false), or C(inherit). + type: str + compression: + description: + - Compression algorithm to apply to snapshot data. + - Refer to C(kopia policy set --compression help) for the list of supported algorithms. + type: str +""" + +EXAMPLES = r""" +- name: Set a retention policy for a path + community.general.kopia_policy: + state: set + target: "user@hostname:/home/user" + retention: + keep_latest: "10" + keep_daily: "7" + keep_weekly: "4" + keep_monthly: "6" + config: /etc/kopia/root.config + +- name: Set a scheduled snapshot policy + community.general.kopia_policy: + state: set + target: "user@hostname:/var/www" + scheduling: + times: + - "02:00" + - "14:00" + config: /etc/kopia/root.config + +- name: Set the global policy with compression and ignore rules + community.general.kopia_policy: + state: set + global_policy: true + compression: zstd + files: + add_ignore: + - "*.tmp" + - ".cache" + ignore_cache_dirs: "true" + config: /etc/kopia/root.config + +- name: Inherit keep_daily from the parent policy + community.general.kopia_policy: + state: set + target: "user@hostname:/home/user" + retention: + keep_daily: inherit + config: /etc/kopia/root.config + +- name: Show the effective policy for a target + community.general.kopia_policy: + state: shown + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + +- name: List all defined policies + community.general.kopia_policy: + state: listed + config: /etc/kopia/root.config + +- name: Delete a policy for a specific target + community.general.kopia_policy: + state: deleted + target: "user@hostname:/home/user" + config: /etc/kopia/root.config +""" + +RETURN = r""" +kopia_policy: + description: Output from the Kopia policy command. + type: str + sample: "" + returned: always +""" + +from ansible_collections.community.general.plugins.module_utils._cmd_runner import cmd_runner_fmt +from ansible_collections.community.general.plugins.module_utils._kopia import ( + KOPIA_COMMON_ARGUMENT_SPEC, + kopia_runner, +) +from ansible_collections.community.general.plugins.module_utils._module_helper import StateModuleHelper + + +def _fmt_retention(value): + """Expand the retention dict into --keep-* and --ignore-identical-snapshots flags.""" + if not value: + return [] + flag_map = { + "keep_latest": "--keep-latest", + "keep_hourly": "--keep-hourly", + "keep_daily": "--keep-daily", + "keep_weekly": "--keep-weekly", + "keep_monthly": "--keep-monthly", + "keep_annual": "--keep-annual", + "ignore_identical": "--ignore-identical-snapshots", + } + result = [] + for param, flag in flag_map.items(): + v = value.get(param) + if v is not None: + result.extend([flag, str(v)]) + return result + + +def _fmt_scheduling(value): + """Expand the scheduling dict into --snapshot-interval, --snapshot-time, --manual flags.""" + if not value: + return [] + result = [] + if value.get("interval") is not None: + result.extend(["--snapshot-interval", value["interval"]]) + if value.get("times") is not None: + result.extend(["--snapshot-time", ",".join(value["times"])]) + if value.get("manual"): + result.append("--manual") + return result + + +def _fmt_files(value): + """Expand the files dict into file-handling flags.""" + if not value: + return [] + result = [] + for path in value.get("add_ignore") or []: + result.extend(["--add-ignore", path]) + for path in value.get("remove_ignore") or []: + result.extend(["--remove-ignore", path]) + if value.get("max_file_size") is not None: + result.extend(["--max-file-size", value["max_file_size"]]) + if value.get("one_file_system") is not None: + result.extend(["--one-file-system", value["one_file_system"]]) + if value.get("ignore_cache_dirs") is not None: + result.extend(["--ignore-cache-dirs", value["ignore_cache_dirs"]]) + return result + + +class KopiaPolicy(StateModuleHelper): + module = dict( + supports_check_mode=True, + argument_spec=dict( + **KOPIA_COMMON_ARGUMENT_SPEC, + state=dict( + type="str", + default="set", + choices=["set", "deleted", "listed", "shown"], + ), + target=dict(type="str"), + global_policy=dict(type="bool", default=False), + retention=dict( + type="dict", + options=dict( + keep_latest=dict(type="str"), + keep_hourly=dict(type="str"), + keep_daily=dict(type="str"), + keep_weekly=dict(type="str"), + keep_monthly=dict(type="str"), + keep_annual=dict(type="str"), + ignore_identical=dict(type="str"), + ), + ), + scheduling=dict( + type="dict", + options=dict( + interval=dict(type="str"), + times=dict(type="list", elements="str"), + manual=dict(type="bool"), + ), + ), + files=dict( + type="dict", + options=dict( + add_ignore=dict(type="list", elements="str"), + remove_ignore=dict(type="list", elements="str"), + max_file_size=dict(type="str"), + one_file_system=dict(type="str"), + ignore_cache_dirs=dict(type="str"), + ), + ), + compression=dict(type="str"), + ), + required_if=[ + ("state", "set", ["target", "global_policy"], True), + ("state", "deleted", ["target", "global_policy"], True), + ("state", "shown", ["target", "global_policy"], True), + ], + ) + + def __init_module__(self): + self.runner = kopia_runner( + self.module, + extra_formats=dict( + list_policies=cmd_runner_fmt.as_fixed("policy", "list"), + target=cmd_runner_fmt.as_list(), + global_policy=cmd_runner_fmt.as_bool("--global"), + retention=cmd_runner_fmt.as_func(_fmt_retention), + scheduling=cmd_runner_fmt.as_func(_fmt_scheduling), + files=cmd_runner_fmt.as_func(_fmt_files), + compression=cmd_runner_fmt.as_opt_val("--compression"), + ), + ) + self.vars.set("previous_value", self._get()["out"]) + self.vars.set("value", self.vars.previous_value, change=True, diff=True) + + def __quit_module__(self): + self.vars.set("value", self._get()["out"]) + + def _get(self): + with self.runner("list_policies config") as ctx: + result = ctx.run() + return dict( + rc=result[0], + out=(result[1].rstrip() if result[1] else None), + err=result[2], + ) + + def _process_command_output(self, fail_on_err, ignore_err_msg=""): + def process(rc, out, err): + if fail_on_err and rc != 0 and err and ignore_err_msg not in err: + self.do_raise(f"kopia failed with error (rc={rc}): {err}") + out = out.rstrip() if out else "" + return None if out == "" else out + + return process + + def state_set(self): + with self.runner( + "cli_action state target global_policy retention scheduling files compression config", + output_process=self._process_command_output(True), + check_mode_skip=True, + ) as ctx: + ctx.run(cli_action="policy") + + def state_deleted(self): + with self.runner( + "cli_action state target global_policy config", + output_process=self._process_command_output(True, "no such policy"), + check_mode_skip=True, + ) as ctx: + ctx.run(cli_action="policy") + + def state_listed(self): + with self.runner( + "cli_action state config", + output_process=self._process_command_output(True), + ) as ctx: + ctx.run(cli_action="policy") + + def state_shown(self): + with self.runner( + "cli_action state target global_policy config", + output_process=self._process_command_output(True), + ) as ctx: + ctx.run(cli_action="policy") + + +def main(): + KopiaPolicy.execute() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_kopia_policy.py b/tests/unit/plugins/modules/test_kopia_policy.py new file mode 100644 index 0000000000..206bdfe242 --- /dev/null +++ b/tests/unit/plugins/modules/test_kopia_policy.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, Dexter Le +# 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 + +from ansible_collections.community.general.plugins.modules import kopia_policy + +from .uthelper import RunCommandMock, UTHelper + +UTHelper.from_module(kopia_policy, __name__, mocks=[RunCommandMock]) diff --git a/tests/unit/plugins/modules/test_kopia_policy.yaml b/tests/unit/plugins/modules/test_kopia_policy.yaml new file mode 100644 index 0000000000..064da822ea --- /dev/null +++ b/tests/unit/plugins/modules/test_kopia_policy.yaml @@ -0,0 +1,359 @@ +# Copyright (c) 2026, Dexter Le +# 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 + +--- +anchors: + environ: &env-def {environ_update: {LANGUAGE: C, LC_ALL: C}, check_rc: false} + list_ok: &list-ok + command: [/testbin/kopia, policy, list, --config-file=/etc/kopia/root.config] + environ: *env-def + rc: 0 + out: "user@host:/home/user" + err: '' + list_empty: &list-empty + command: [/testbin/kopia, policy, list, --config-file=/etc/kopia/root.config] + environ: *env-def + rc: 0 + out: '' + err: '' + +test_cases: + - id: set_retention_policy + input: + state: set + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + retention: + keep_latest: "10" + keep_daily: "7" + keep_weekly: "4" + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/home/user + - --keep-latest + - "10" + - --keep-daily + - "7" + - --keep-weekly + - "4" + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: set_global_policy_with_compression + input: + state: set + global_policy: true + config: /etc/kopia/root.config + compression: zstd + files: + add_ignore: + - "*.tmp" + - ".cache" + ignore_cache_dirs: "true" + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - --global + - --add-ignore + - "*.tmp" + - --add-ignore + - .cache + - --ignore-cache-dirs + - "true" + - --compression + - zstd + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: set_scheduling_policy + input: + state: set + target: "user@hostname:/var/www" + config: /etc/kopia/root.config + scheduling: + times: + - "02:00" + - "14:00" + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/var/www + - --snapshot-time + - "02:00,14:00" + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: show_policy + input: + state: shown + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + output: + changed: false + mocks: + run_command: + - *list-ok + - command: + - /testbin/kopia + - policy + - show + - user@hostname:/home/user + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: "Policy for user@hostname:/home/user" + err: '' + - *list-ok + + - id: list_policies + input: + state: listed + config: /etc/kopia/root.config + output: + changed: false + mocks: + run_command: + - *list-ok + - command: + - /testbin/kopia + - policy + - list + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: "user@host:/home/user" + err: '' + - *list-ok + + - id: delete_policy + input: + state: deleted + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + output: + changed: true + mocks: + run_command: + - *list-ok + - command: + - /testbin/kopia + - policy + - delete + - user@hostname:/home/user + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-empty + + - id: delete_policy_no_such_policy + input: + state: deleted + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + output: + changed: false + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - delete + - user@hostname:/home/user + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 1 + out: '' + err: 'no such policy' + - *list-empty + + - id: set_scheduling_interval + input: + state: set + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + scheduling: + interval: "1h" + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/home/user + - --snapshot-interval + - "1h" + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: set_scheduling_manual + input: + state: set + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + scheduling: + manual: true + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/home/user + - --manual + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: set_policy_files_remove_ignore + input: + state: set + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + files: + remove_ignore: + - "*.tmp" + max_file_size: "100MB" + one_file_system: "true" + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/home/user + - --remove-ignore + - "*.tmp" + - --max-file-size + - 100MB + - --one-file-system + - "true" + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: set_retention_policy_inherit + input: + state: set + target: "user@hostname:/home/user" + config: /etc/kopia/root.config + retention: + keep_daily: inherit + keep_weekly: inherit + output: + changed: true + mocks: + run_command: + - *list-empty + - command: + - /testbin/kopia + - policy + - set + - user@hostname:/home/user + - --keep-daily + - inherit + - --keep-weekly + - inherit + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-ok + + - id: delete_global_policy + input: + state: deleted + global_policy: true + config: /etc/kopia/root.config + output: + changed: true + mocks: + run_command: + - *list-ok + - command: + - /testbin/kopia + - policy + - delete + - --global + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: '' + err: '' + - *list-empty + + - id: show_global_policy + input: + state: shown + global_policy: true + config: /etc/kopia/root.config + output: + changed: false + mocks: + run_command: + - *list-ok + - command: + - /testbin/kopia + - policy + - show + - --global + - --config-file=/etc/kopia/root.config + environ: *env-def + rc: 0 + out: "Global policy settings" + err: '' + - *list-ok