diff options
| author | Colin Wilk <colin@wilk.cx> | 2026-07-02 18:10:04 +0200 |
|---|---|---|
| committer | Colin Wilk <colin@wilk.cx> | 2026-07-02 18:10:04 +0200 |
| commit | 50b914e77695fd1cdb294653f20143747e7e3b01 (patch) | |
| tree | b667145cd04e39abb52d1680fc61a17513ae0af6 | |
| parent | 66e46df3d2a840bf62ef8084ad171041772db65e (diff) | |
| download | ansible-role-borgbackup-50b914e77695fd1cdb294653f20143747e7e3b01.tar.gz ansible-role-borgbackup-50b914e77695fd1cdb294653f20143747e7e3b01.zip | |
Add state: absent variable
Allow removing configuration for repositories.
| -rw-r--r-- | .ansible-lint | 2 | ||||
| -rw-r--r-- | README.md | 77 | ||||
| -rw-r--r-- | defaults/main.yml | 15 | ||||
| -rw-r--r-- | meta/argument_specs.yml | 16 | ||||
| -rw-r--r-- | molecule/.gitignore | 2 | ||||
| -rw-r--r-- | molecule/Dockerfile.j2 (renamed from molecule/default/Dockerfile.j2) | 0 | ||||
| -rw-r--r-- | molecule/default/converge.yml | 11 | ||||
| -rw-r--r-- | molecule/default/molecule.yml | 14 | ||||
| -rw-r--r-- | molecule/delete/converge.yml | 143 | ||||
| -rw-r--r-- | molecule/delete/molecule.yml | 68 | ||||
| -rw-r--r-- | molecule/delete/prepare.yml | 153 | ||||
| -rw-r--r-- | molecule/delete/tests/test_delete.py | 318 | ||||
| -rw-r--r-- | shell.nix | 2 | ||||
| -rw-r--r-- | tasks/absent.yml | 298 | ||||
| -rw-r--r-- | tasks/client_setup.yml | 21 | ||||
| -rw-r--r-- | tasks/main.yml | 7 |
16 files changed, 1118 insertions, 29 deletions
diff --git a/.ansible-lint b/.ansible-lint index ab574e1..37144aa 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -2,6 +2,8 @@ --- exclude_paths: - molecule/default/decryption_keys.yml + - molecule/delete/decryption_keys_delete.yml + - molecule/delete/decryption_keys_empty_delete.yml use_default_rules: true @@ -420,10 +420,81 @@ For hosts with multiple repos, each repository has its own entry: ### Deprovisioning Borg Repositories -This role does not currently handle deprovisioning or removing repositories. -Deprovisioning must be done manually or via separate playbooks. +Remove a repository's configuration without deleting the backup data: -### Removing a Repository +```yaml +- role: kliwniloc.borgbackup + vars: + borg_server_host: borg-server + borg_repo_name: my-backup + state: absent +``` + +This will not delete the repository data on the backup server. + +#### Deleting Repository Data + +To also delete the repository data on the backup server: + +```yaml +- role: kliwniloc.borgbackup + vars: + borg_server_host: borg-server + borg_repo_name: my-backup + state: absent + borg_dangerously_delete_backups: true # WARNING destructive! +``` + +> [!WARNING] +> `borg_dangerously_delete_backups: true` permanently deletes all backup data +> from the server. Use with caution. + +#### Multi-Instance Considerations + +When removing one repository from a multi-instance setup: + +- Shared SSH keys (`borg_ssh_key_per_repo: false`) are kept +- `authorized_keys` is updated to remove only the deleted repo restriction +- Other repositories remain unaffected + +Example - Remove one repo from multi-instance: + +```yaml +- role: kliwniloc.borgbackup + vars: + borg_server_host: borg-server + borg_repo_name: configs + borg_backup_argument: configs + state: absent + # Other repos using same SSH key will continue to work +``` + +#### Limitations + +The following components are **NOT** removed by `state: absent`: + +| Component | Reason | +| -------------------------------- | ------------------------------------------ | +| `borgbackup` package | May be required by other backups or system | +| Server user (`borg_server_user`) | May be used by other backup clients | +| Server home directory | Contains other repositories | +| Shared SSH keys | Other repos still depend on them | +| SSH known_hosts entries | May be used for other purposes | + +#### Per-Repo SSH Key Behavior + +When `borg_ssh_key_per_repo: true`: + +- The specific SSH key for the repo is deleted +- The corresponding `authorized_keys` line is removed + +When `borg_ssh_key_per_repo: false`: + +- Shared SSH key is kept +- `authorized_keys` line is updated to remove repo restriction +- If the deleted repo is the last one, the entire line is removed + +### Manual Deprovisioning To remove a specific repository (applies to both single and multi-instance setups): diff --git a/defaults/main.yml b/defaults/main.yml index c508975..1c9d05d 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,5 +1,20 @@ --- ################################################################################ +# State Configuration +################################################################################ + +# Desired state of the backup configuration. +# - present: Create/update backup configuration (default) +# - absent: Remove backup configuration +state: present + +# DANGEROUS: Delete repository data on server when state: absent. +# When false (default), only configuration is removed, repository data is preserved. +# When true, repository data is permanently deleted from the server. +# This is destructive and cannot be undone. +borg_dangerously_delete_backups: false + +################################################################################ # Borg Client User Configuration ################################################################################ diff --git a/meta/argument_specs.yml b/meta/argument_specs.yml index 518b881..52e85cb 100644 --- a/meta/argument_specs.yml +++ b/meta/argument_specs.yml @@ -15,6 +15,22 @@ argument_specs: required: true ################################################################################ + # State Configuration + ################################################################################ + state: + type: str + required: false + default: present + choices: + - present + - absent + + borg_dangerously_delete_backups: + type: bool + required: false + default: false + + ################################################################################ # Optional ################################################################################ borg_server_user: diff --git a/molecule/.gitignore b/molecule/.gitignore index dd2354f..477f7f1 100644 --- a/molecule/.gitignore +++ b/molecule/.gitignore @@ -1,2 +1,2 @@ __pycache__ -decryption_keys.yml +decryption_keys*.yml diff --git a/molecule/default/Dockerfile.j2 b/molecule/Dockerfile.j2 index cc459e3..cc459e3 100644 --- a/molecule/default/Dockerfile.j2 +++ b/molecule/Dockerfile.j2 diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 504758d..90b3258 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -47,17 +47,6 @@ | split(" ") | reject("search", borg_server_host) | join(" ") }} - when: borg_server_host_ssh_key is not defined - -- name: Setup - Create custom borg user on borg-server-2 - hosts: borg-server-2 - tasks: - - name: Create backupserver user - ansible.builtin.user: - name: backupserver - home: /var/backups - shell: /bin/bash - become: true - name: Converge - Default borg-client hosts: borg-client diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index 7b8a349..0ea5c96 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -8,7 +8,7 @@ driver: platforms: - name: borg-client image: ${MOLECULE_DISTRO_CLIENT:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true docker_networks: @@ -21,7 +21,7 @@ platforms: - name: borg-client-2 image: ${MOLECULE_DISTRO_CLIENT:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: @@ -29,7 +29,7 @@ platforms: - name: borg-client-multi image: ${MOLECULE_DISTRO_CLIENT:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: @@ -37,7 +37,7 @@ platforms: - name: borg-client-nonroot image: ${MOLECULE_DISTRO_CLIENT:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: @@ -45,7 +45,7 @@ platforms: - name: borg-client-multi-keys image: ${MOLECULE_DISTRO_CLIENT:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: @@ -53,7 +53,7 @@ platforms: - name: borg-server image: ${MOLECULE_DISTRO_SERVER:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: @@ -61,7 +61,7 @@ platforms: - name: borg-server-2 image: ${MOLECULE_DISTRO_SERVER:-debian:12} - dockerfile: Dockerfile.j2 + dockerfile: ../Dockerfile.j2 pre_build_image: false privileged: true networks: diff --git a/molecule/delete/converge.yml b/molecule/delete/converge.yml new file mode 100644 index 0000000..00de93d --- /dev/null +++ b/molecule/delete/converge.yml @@ -0,0 +1,143 @@ +--- +- name: Setup - Fetch SSH key for server + hosts: + - borg-client-single-delete + - borg-client-multi-delete + - borg-client-per-repo-delete + - borg-client-nonroot-delete + - borg-client-never-delete + + pre_tasks: + - name: Fetch ssh_key for borg-server-delete + ansible.builtin.command: >- + ssh-keyscan -t rsa borg-server-delete | sed "s/^[^ ]* //" + register: borg_server_ssh_keyscan + changed_when: false + + - name: Set ssh_key for borg-server-delete + ansible.builtin.set_fact: + borg_server_host_ssh_key: >- + {{ borg_server_ssh_keyscan.stdout + | split(" ") + | reject("search", "borg-server-delete") + | join(" ") }} + +- name: Delete single repo backup (with data deletion) + hosts: borg-client-single-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_repo_name: single-backup + borg_backup_argument: single-backup + borg_dangerously_delete_backups: true + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + +- name: Delete one repo from multi-instance (keep data) + hosts: borg-client-multi-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_repo_name: multi-repo-a + borg_backup_argument: multi-repo-a + borg_dangerously_delete_backups: false + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + +- name: Delete one repo from per-repo (with data deletion) + hosts: borg-client-per-repo-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_repo_name: per-repo-a + borg_backup_argument: per-repo-a + borg_ssh_key_per_repo: true + borg_ssh_key_type: ed25519 + borg_dangerously_delete_backups: true + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + +- name: Delete one repo from non-root shared-key setup + hosts: borg-client-nonroot-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_client_user: backupuser + borg_repo_name: nonroot-a + borg_backup_argument: nonroot-a + borg_dangerously_delete_backups: false + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + +- name: Delete never-configured backup with empty key file + hosts: borg-client-never-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_repo_name: never-configured + borg_backup_argument: never-configured + borg_dangerously_delete_backups: false + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_empty_delete.yml" + +- name: Re-run absent for idempotency + hosts: + - borg-client-single-delete + - borg-client-multi-delete + - borg-client-per-repo-delete + - borg-client-nonroot-delete + - borg-client-never-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: absent + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ borg_server_host_ssh_key }}" + borg_repo_name: >- + {{ + { + 'borg-client-single-delete': 'single-backup', + 'borg-client-multi-delete': 'multi-repo-a', + 'borg-client-per-repo-delete': 'per-repo-a', + 'borg-client-nonroot-delete': 'nonroot-a', + 'borg-client-never-delete': 'never-configured' + }[inventory_hostname] + }} + borg_backup_argument: >- + {{ + { + 'borg-client-single-delete': 'single-backup', + 'borg-client-multi-delete': 'multi-repo-a', + 'borg-client-per-repo-delete': 'per-repo-a', + 'borg-client-nonroot-delete': 'nonroot-a', + 'borg-client-never-delete': 'never-configured' + }[inventory_hostname] + }} + borg_client_user: >- + {{ 'backupuser' if inventory_hostname == 'borg-client-nonroot-delete' else 'root' }} + borg_ssh_key_per_repo: "{{ inventory_hostname == 'borg-client-per-repo-delete' }}" + borg_ssh_key_type: "{{ 'ed25519' if inventory_hostname == 'borg-client-per-repo-delete' else 'rsa' }}" + borg_dangerously_delete_backups: >- + {{ inventory_hostname in ['borg-client-single-delete', 'borg-client-per-repo-delete'] }} + borg_decryption_keys_yaml_path: >- + {{ + (playbook_dir ~ '/decryption_keys_empty_delete.yml') + if inventory_hostname == 'borg-client-never-delete' + else (playbook_dir ~ '/decryption_keys_delete.yml') + }} diff --git a/molecule/delete/molecule.yml b/molecule/delete/molecule.yml new file mode 100644 index 0000000..bc75ff0 --- /dev/null +++ b/molecule/delete/molecule.yml @@ -0,0 +1,68 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: borg-server-delete + image: ${MOLECULE_DISTRO_SERVER:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + docker_networks: + - name: molecule-delete-net + driver_options: + com.docker.network.driver.mtu: 1420 + networks: + - name: molecule-delete-net + + - name: borg-client-single-delete + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-delete-net + + - name: borg-client-multi-delete + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-delete-net + + - name: borg-client-per-repo-delete + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-delete-net + + - name: borg-client-nonroot-delete + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-delete-net + + - name: borg-client-never-delete + image: ${MOLECULE_DISTRO_CLIENT:-debian:12} + dockerfile: ../Dockerfile.j2 + pre_build_image: false + privileged: true + networks: + - name: molecule-delete-net + +provisioner: + name: ansible + playbooks: + prepare: prepare.yml + converge: ${MOLECULE_PLAYBOOK:-converge.yml} + +verifier: + name: testinfra diff --git a/molecule/delete/prepare.yml b/molecule/delete/prepare.yml new file mode 100644 index 0000000..98a94a1 --- /dev/null +++ b/molecule/delete/prepare.yml @@ -0,0 +1,153 @@ +--- +- name: Prepare - Configure SSH keys for delete setup + hosts: + - borg-client-single-delete + - borg-client-multi-delete + - borg-client-per-repo-delete + - borg-client-nonroot-delete + - borg-client-never-delete + + pre_tasks: + - name: Start ssh on borg-server-delete + ansible.builtin.systemd: + name: sshd + state: started + become: true + delegate_to: borg-server-delete + + - name: Fetch ssh_key for borg-server-delete + ansible.builtin.command: >- + ssh-keyscan -t rsa borg-server-delete | sed "s/^[^ ]* //" + register: borg_server_ssh_keyscan + changed_when: false + + - name: Set ssh_key for borg-server-delete + ansible.builtin.set_fact: + borg_server_host_ssh_key: >- + {{ borg_server_ssh_keyscan.stdout + | split(" ") + | reject("search", "borg-server-delete") + | join(" ") }} + +- name: Create single repo backup + hosts: borg-client-single-delete + + roles: + - role: kliwniloc.borgbackup + vars: + state: present + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ hostvars[inventory_hostname].borg_server_host_ssh_key }}" + borg_repo_name: single-backup + borg_backup_argument: single-backup + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + borg_included_dirs: + - /etc + borg_excluded_dirs: [] + +- name: Create multi-instance backup (shared key) + hosts: borg-client-multi-delete + + vars: + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ hostvars[inventory_hostname].borg_server_host_ssh_key }}" + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + borg_ssh_key_per_repo: false + + roles: + - role: kliwniloc.borgbackup + vars: + state: present + borg_repo_name: multi-repo-a + borg_backup_argument: multi-repo-a + borg_included_dirs: + - /etc + borg_systemd_oncalendar: "*-*-* 02:00:00" + + - role: kliwniloc.borgbackup + vars: + state: present + borg_repo_name: multi-repo-b + borg_backup_argument: multi-repo-b + borg_included_dirs: + - /home + borg_systemd_oncalendar: "*-*-* 04:00:00" + +- name: Create per-repo SSH key backup + hosts: borg-client-per-repo-delete + + vars: + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ hostvars[inventory_hostname].borg_server_host_ssh_key }}" + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + borg_ssh_key_per_repo: true + + roles: + - role: kliwniloc.borgbackup + vars: + state: present + borg_repo_name: per-repo-a + borg_backup_argument: per-repo-a + borg_ssh_key_type: ed25519 + borg_included_dirs: + - /etc + borg_systemd_oncalendar: "*-*-* 02:00:00" + + - role: kliwniloc.borgbackup + vars: + state: present + borg_repo_name: per-repo-b + borg_backup_argument: per-repo-b + borg_ssh_key_type: ed25519 + borg_included_dirs: + - /home + borg_systemd_oncalendar: "*-*-* 04:00:00" + +- name: Create non-root shared-key backup + hosts: borg-client-nonroot-delete + + pre_tasks: + - name: Create backup user for non-root delete test + ansible.builtin.user: + name: backupuser + home: /home/backupuser + shell: /bin/bash + state: present + become: true + + roles: + - role: kliwniloc.borgbackup + vars: + state: present + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ hostvars[inventory_hostname].borg_server_host_ssh_key }}" + borg_client_user: backupuser + borg_repo_name: nonroot-a + borg_backup_argument: nonroot-a + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + borg_included_dirs: + - /etc + borg_excluded_dirs: [] + + - role: kliwniloc.borgbackup + vars: + state: present + borg_server_host: borg-server-delete + borg_server_host_ssh_key: "{{ hostvars[inventory_hostname].borg_server_host_ssh_key }}" + borg_client_user: backupuser + borg_repo_name: nonroot-b + borg_backup_argument: nonroot-b + borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys_delete.yml" + borg_included_dirs: + - /home + borg_excluded_dirs: [] + +- name: Prepare empty decryption keys file for never-configured host + hosts: localhost + gather_facts: false + tasks: + - name: Create empty decryption keys file + ansible.builtin.copy: + content: "" + dest: "{{ playbook_dir }}/decryption_keys_empty_delete.yml" + mode: "0600" diff --git a/molecule/delete/tests/test_delete.py b/molecule/delete/tests/test_delete.py new file mode 100644 index 0000000..4c0d859 --- /dev/null +++ b/molecule/delete/tests/test_delete.py @@ -0,0 +1,318 @@ +"""Tests for state: absent functionality.""" + +import os + + +testinfra_hosts = [ + "borg-client-single-delete", + "borg-client-multi-delete", + "borg-client-per-repo-delete", + "borg-client-nonroot-delete", + "borg-client-never-delete", + "borg-server-delete", +] + + +def _scenario_file(filename): + scenario_dir = os.environ.get("MOLECULE_SCENARIO_DIRECTORY") + if scenario_dir: + return os.path.join(scenario_dir, filename) + test_dir = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(test_dir, "..", filename) + + +def _read_local_file(filename): + with open(_scenario_file(filename), "r", encoding="utf-8") as file_handle: + return file_handle.read() + + +class TestSingleRepoDelete: + def test_systemd_units_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-single-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@single-backup.timer") + service = host.file("/etc/systemd/system/borg_backup@single-backup.service") + assert not timer.exists + assert not service.exists + + def test_backup_scripts_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-single-delete": + return + + repo_script = host.file("/usr/local/bin/run_borg_backup@single-backup") + base_script = host.file("/usr/local/bin/run_borg_backup") + assert not repo_script.exists + assert not base_script.exists + + def test_shared_ssh_key_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-single-delete": + return + + key = host.file("/root/.ssh/id_rsa") + assert key.exists + + def test_repository_data_deleted(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + repo = host.file("/opt/borg/single-backup") + assert not repo.exists + + def test_authorized_keys_entry_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + auth_keys = host.file("/opt/borg/.ssh/authorized_keys") + assert auth_keys.exists + assert "root@borg-client-single-delete" not in auth_keys.content_string + + +class TestMultiInstanceDelete: + def test_removed_repo_units_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-multi-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@multi-repo-a.timer") + service = host.file("/etc/systemd/system/borg_backup@multi-repo-a.service") + script = host.file("/usr/local/bin/run_borg_backup@multi-repo-a") + assert not timer.exists + assert not service.exists + assert not script.exists + + def test_remaining_repo_artifacts_exist(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-multi-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@multi-repo-b.timer") + service = host.file("/etc/systemd/system/borg_backup@multi-repo-b.service") + script = host.file("/usr/local/bin/run_borg_backup@multi-repo-b") + assert timer.exists + assert service.exists + assert script.exists + + def test_base_script_keeps_only_remaining_block(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-multi-delete": + return + + base_script = host.file("/usr/local/bin/run_borg_backup") + assert base_script.exists + content = base_script.content_string + assert "/opt/borg/multi-repo-b" in content + assert "/opt/borg/multi-repo-a" not in content + assert content.count("borg create") == 1 + + def test_shared_ssh_key_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-multi-delete": + return + + key = host.file("/root/.ssh/id_rsa") + assert key.exists + + def test_repository_data_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + removed_repo = host.file("/opt/borg/multi-repo-a") + remaining_repo = host.file("/opt/borg/multi-repo-b") + assert removed_repo.exists + assert removed_repo.is_directory + assert remaining_repo.exists + assert remaining_repo.is_directory + + def test_authorized_keys_keeps_only_remaining_repo(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + auth_keys = host.file("/opt/borg/.ssh/authorized_keys") + content = auth_keys.content_string + host_entries = [ + line + for line in content.splitlines() + if "root@borg-client-multi-delete" in line + ] + assert len(host_entries) == 1 + assert "--restrict-to-repository /opt/borg/multi-repo-b" in host_entries[0] + assert "--restrict-to-repository /opt/borg/multi-repo-a" not in host_entries[0] + + +class TestPerRepoKeyDelete: + def test_removed_repo_artifacts_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-per-repo-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@per-repo-a.timer") + service = host.file("/etc/systemd/system/borg_backup@per-repo-a.service") + script = host.file("/usr/local/bin/run_borg_backup@per-repo-a") + assert not timer.exists + assert not service.exists + assert not script.exists + + def test_remaining_repo_untouched(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-per-repo-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@per-repo-b.timer") + service = host.file("/etc/systemd/system/borg_backup@per-repo-b.service") + script = host.file("/usr/local/bin/run_borg_backup@per-repo-b") + base_script = host.file("/usr/local/bin/run_borg_backup") + assert timer.exists + assert service.exists + assert script.exists + assert base_script.exists + assert "/opt/borg/per-repo-b" in base_script.content_string + assert "/opt/borg/per-repo-a" not in base_script.content_string + + def test_per_repo_ssh_key_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-per-repo-delete": + return + + key = host.file( + "/root/.ssh/id_ed25519_borgbackup_borg_server_delete_per_repo_a" + ) + key_pub = host.file( + "/root/.ssh/id_ed25519_borgbackup_borg_server_delete_per_repo_a.pub" + ) + assert not key.exists + assert not key_pub.exists + + def test_other_repo_ssh_key_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-per-repo-delete": + return + + key = host.file( + "/root/.ssh/id_ed25519_borgbackup_borg_server_delete_per_repo_b" + ) + key_pub = host.file( + "/root/.ssh/id_ed25519_borgbackup_borg_server_delete_per_repo_b.pub" + ) + assert key.exists + assert key_pub.exists + + def test_repository_data_deleted_and_remaining_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + removed_repo = host.file("/opt/borg/per-repo-a") + remaining_repo = host.file("/opt/borg/per-repo-b") + assert not removed_repo.exists + assert remaining_repo.exists + + def test_authorized_keys_per_repo_entry_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + auth_keys = host.file("/opt/borg/.ssh/authorized_keys") + content = auth_keys.content_string + assert "--restrict-to-repository /opt/borg/per-repo-a" not in content + assert "--restrict-to-repository /opt/borg/per-repo-b" in content + + +class TestNonRootDelete: + def test_removed_repo_artifacts_removed(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-nonroot-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@nonroot-a.timer") + service = host.file("/etc/systemd/system/borg_backup@nonroot-a.service") + script = host.file("/usr/local/bin/run_borg_backup@nonroot-a") + assert not timer.exists + assert not service.exists + assert not script.exists + + def test_remaining_repo_and_shared_key_kept(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-nonroot-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@nonroot-b.timer") + service = host.file("/etc/systemd/system/borg_backup@nonroot-b.service") + script = host.file("/usr/local/bin/run_borg_backup@nonroot-b") + key = host.file("/home/backupuser/.ssh/id_rsa") + assert timer.exists + assert service.exists + assert script.exists + assert key.exists + + def test_authorized_keys_preserves_nonroot_comment(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + auth_keys = host.file("/opt/borg/.ssh/authorized_keys") + content = auth_keys.content_string + host_entries = [ + line + for line in content.splitlines() + if "backupuser@borg-client-nonroot-delete" in line + ] + assert len(host_entries) == 1 + assert "root@borg-client-nonroot-delete" not in host_entries[0] + assert "--restrict-to-repository /opt/borg/nonroot-b" in host_entries[0] + assert "--restrict-to-repository /opt/borg/nonroot-a" not in host_entries[0] + + +class TestDecryptionKeyCleanup: + def test_deleted_entries_removed_and_remaining_kept(self): + content = _read_local_file("decryption_keys_delete.yml") + assert "borg-client-single-delete_single-backup:" not in content + assert "borg-client-multi-delete_multi-repo-a:" not in content + assert "borg-client-per-repo-delete_per-repo-a:" not in content + assert "borg-client-nonroot-delete_nonroot-a:" not in content + assert "borg-client-multi-delete_multi-repo-b:" in content + assert "borg-client-per-repo-delete_per-repo-b:" in content + assert "borg-client-nonroot-delete_nonroot-b:" in content + + def test_empty_decryption_keys_file_stays_valid(self): + content = _read_local_file("decryption_keys_empty_delete.yml") + assert content.strip() == "{}" + + +class TestNeverConfiguredDelete: + def test_no_artifacts_created(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-client-never-delete": + return + + timer = host.file("/etc/systemd/system/borg_backup@never-configured.timer") + service = host.file("/etc/systemd/system/borg_backup@never-configured.service") + script = host.file("/usr/local/bin/run_borg_backup@never-configured") + base_script = host.file("/usr/local/bin/run_borg_backup") + assert not timer.exists + assert not service.exists + assert not script.exists + assert not base_script.exists + + def test_server_repo_not_created(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + repo = host.file("/opt/borg/never-configured") + assert not repo.exists + + def test_no_authorized_keys_entry_created(self, host): + hostname = host.backend.get_hostname() + if hostname != "borg-server-delete": + return + + auth_keys = host.file("/opt/borg/.ssh/authorized_keys") + assert "borg-client-never-delete" not in auth_keys.content_string @@ -40,5 +40,7 @@ pkgs.mkShell { molecule --version | head -n 1 echo "============================================" echo "Run: molecule test -s default" + echo "Run: molecule test -s delete" + echo "Run: molecule test --all" ''; } diff --git a/tasks/absent.yml b/tasks/absent.yml new file mode 100644 index 0000000..b1a5592 --- /dev/null +++ b/tasks/absent.yml @@ -0,0 +1,298 @@ +--- +- name: Ensure borg_client_user exists + ansible.builtin.getent: + database: passwd + key: "{{ borg_client_user }}" + become: true + failed_when: false + +- name: Compute borg_client_user_home if not set + ansible.builtin.set_fact: + borg_client_user_home: "{{ ansible_facts.getent_passwd[borg_client_user][4] }}" + when: + - borg_client_user_home is not defined + - ansible_facts.getent_passwd[borg_client_user] is defined + +- name: Compute SSH key identifier + ansible.builtin.set_fact: + borg_ssh_key_identifier: >- + {{ (borg_server_host_url ~ '_' ~ borg_repo_name) | regex_replace('[^a-zA-Z0-9]', '_') }} + +- name: Compute SSH key path + ansible.builtin.set_fact: + borg_ssh_key_path: >- + {{ borg_client_user_home }}/.ssh/id_{{ borg_ssh_key_type }}{%- if borg_ssh_key_per_repo -%}_borgbackup_{{ borg_ssh_key_identifier }}{%- endif -%} + when: borg_client_user_home is defined + +- name: Read SSH public key + ansible.builtin.slurp: + src: "{{ borg_ssh_key_path }}.pub" + register: ssh_pubkey_slurp + become: true + when: borg_ssh_key_path is defined + failed_when: false + +- name: Check if systemd timer exists + ansible.builtin.stat: + path: /etc/systemd/system/{{ borg_backup_timer_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.timer + register: timer_stat + become: true + +- name: Stop systemd timer + ansible.builtin.systemd: + name: "{{ borg_backup_timer_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.timer" + state: stopped + enabled: false + become: true + when: timer_stat.stat.exists + +- name: Check if systemd service exists + ansible.builtin.stat: + path: /etc/systemd/system/{{ borg_backup_service_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.service + register: service_stat + become: true + +- name: Stop systemd service + ansible.builtin.systemd: + name: "{{ borg_backup_service_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.service" + state: stopped + enabled: false + become: true + when: service_stat.stat.exists + +- name: Remove systemd timer file + ansible.builtin.file: + path: /etc/systemd/system/{{ borg_backup_timer_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.timer + state: absent + become: true + +- name: Remove systemd service file + ansible.builtin.file: + path: /etc/systemd/system/{{ borg_backup_service_name }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}.service + state: absent + become: true + +- name: Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: true + become: true + +- name: Check if base backup script exists + ansible.builtin.stat: + path: "{{ borg_backup_script_location }}" + register: base_script_stat + become: true + +- name: Remove repo-specific backup script + ansible.builtin.file: + path: "{{ borg_backup_script_location }}{{ '@' if borg_backup_argument != '' }}{{ borg_backup_argument }}" + state: absent + become: true + when: borg_backup_argument | length > 0 + +- name: Remove block from base backup script + ansible.builtin.blockinfile: + path: "{{ borg_backup_script_location }}" + marker: "## {mark} ANSIBLE MANAGED BLOCK for {{ borg_server_host_url }}/{{ borg_repo_name }}" + state: absent + become: true + when: base_script_stat.stat.exists + +- name: Read base script content + ansible.builtin.slurp: + src: "{{ borg_backup_script_location }}" + register: base_script_content + become: true + when: base_script_stat.stat.exists + +- name: Remove empty base script + ansible.builtin.file: + path: "{{ borg_backup_script_location }}" + state: absent + become: true + when: + - base_script_stat.stat.exists + - ('ANSIBLE MANAGED BLOCK' not in (base_script_content.content | b64decode)) + +- name: Remove per-repo SSH private key + ansible.builtin.file: + path: "{{ borg_ssh_key_path }}" + state: absent + become: true + when: + - borg_ssh_key_per_repo + - borg_ssh_key_path is defined + +- name: Remove per-repo SSH public key + ansible.builtin.file: + path: "{{ borg_ssh_key_path }}.pub" + state: absent + become: true + when: + - borg_ssh_key_per_repo + - borg_ssh_key_path is defined + +- name: Read existing authorized_keys on server + ansible.builtin.slurp: + src: "{{ borg_server_user_home }}/.ssh/authorized_keys" + become: true + delegate_to: "{{ borg_server_host }}" + register: auth_keys_slurp + ignore_errors: true + +- name: Update authorized_keys on server + when: + - auth_keys_slurp.content is defined + - ssh_pubkey_slurp.content is defined + block: + - name: Get public key content + ansible.builtin.set_fact: + ssh_public_key: "{{ ssh_pubkey_slurp.content | b64decode | trim }}" + + - name: Normalize SSH public key for matching + ansible.builtin.set_fact: + ssh_public_key_material: >- + {{ + ssh_public_key + | regex_search('^[^ ]+ [^ ]+') + | default(ssh_public_key) + }} + + - name: Parse authorized_keys lines + ansible.builtin.set_fact: + auth_keys_lines: "{{ (auth_keys_slurp.content | b64decode).splitlines() }}" + + - name: Find line matching this host's public key + ansible.builtin.set_fact: + matching_line: >- + {{ + auth_keys_lines + | select('search', ssh_public_key_material | regex_escape) + | first + | default('') + }} + + - name: Extract authorized_keys comment for shared key + ansible.builtin.set_fact: + authorized_keys_comment: "{{ matching_line | regex_search('([^\" ]+@[^\" ]+)$') | default(borg_client_user ~ '@' ~ inventory_hostname) }}" + when: + - not borg_ssh_key_per_repo + - matching_line | length > 0 + + - name: Remove entire authorized_keys line (per-repo key) + ansible.builtin.lineinfile: + path: "{{ borg_server_user_home }}/.ssh/authorized_keys" + regexp: "{{ ssh_public_key_material | regex_escape }}" + state: absent + become: true + delegate_to: "{{ borg_server_host }}" + when: + - borg_ssh_key_per_repo + - matching_line | length > 0 + + - name: Extract repos from authorized_keys line (shared key) + ansible.builtin.set_fact: + existing_repos: >- + {{ + (matching_line | regex_findall('--restrict-to-repository ([^\s"]+)')) + }} + repo_to_delete: "{{ borg_server_user_home }}/{{ borg_repo_name }}" + remaining_repos: >- + {{ + (matching_line | regex_findall('--restrict-to-repository ([^\s"]+)')) + | reject('eq', borg_server_user_home ~ '/' ~ borg_repo_name) + | list + }} + when: + - not borg_ssh_key_per_repo + - matching_line | length > 0 + + - name: Remove entire authorized_keys line (shared key, last repo) + ansible.builtin.lineinfile: + path: "{{ borg_server_user_home }}/.ssh/authorized_keys" + regexp: "{{ ssh_public_key_material | regex_escape }}" + state: absent + become: true + delegate_to: "{{ borg_server_host }}" + when: + - not borg_ssh_key_per_repo + - matching_line | length > 0 + - existing_repos is defined + - repo_to_delete in existing_repos + - remaining_repos is defined + - remaining_repos | length == 0 + + - name: Update authorized_keys keeping other repos (shared key, multiple repos) + ansible.builtin.lineinfile: + path: "{{ borg_server_user_home }}/.ssh/authorized_keys" + search_string: "{{ ssh_public_key_material }}" + line: >- + restrict,command="borg serve + {{ ' --append-only' if ('--append-only' in matching_line) }} + {{ ' --storage-quota ' ~ (matching_line | regex_findall('--storage-quota[= ](\S+)') | first) if (matching_line | regex_search('--storage-quota')) }} + {{ (remaining_repos | map('regex_replace', '^', '--restrict-to-repository ')) | join(' ') }}" + {{ ssh_public_key }} {{ authorized_keys_comment }} + state: present + become: true + delegate_to: "{{ borg_server_host }}" + when: + - not borg_ssh_key_per_repo + - matching_line | length > 0 + - authorized_keys_comment is defined + - existing_repos is defined + - repo_to_delete in existing_repos + - remaining_repos is defined + - remaining_repos | length > 0 + +- name: Delete repository data on server + ansible.builtin.file: + path: "{{ borg_server_user_home }}/{{ borg_repo_name }}" + state: absent + become: true + delegate_to: "{{ borg_server_host }}" + when: + - borg_dangerously_delete_backups | default(false) | bool + - borg_server_user_home | default('') | length > 0 + - borg_repo_name | default('') | length > 0 + +- name: Remove decryption key entry + when: borg_decryption_keys_yaml_path | default('') | length > 0 + block: + - name: Check if decryption keys file exists + ansible.builtin.stat: + path: "{{ borg_decryption_keys_yaml_path }}" + delegate_to: localhost + become: false + register: decryption_keys_stat + + - name: Read existing decryption keys file + ansible.builtin.slurp: + src: "{{ borg_decryption_keys_yaml_path }}" + delegate_to: localhost + become: false + register: decryption_keys_slurp + when: decryption_keys_stat.stat.exists + + - name: Remove entry for this host+repo + vars: + existing_keys: >- + {{ + ((decryption_keys_slurp.content | b64decode | from_yaml) | default({}, true)) + if decryption_keys_slurp.content is defined + else {} + }} + key_name: "{{ inventory_hostname ~ '_' ~ borg_repo_name }}" + ansible.builtin.copy: + content: >- + {{ + existing_keys + | dict2items + | rejectattr('key', '==', key_name) + | items2dict + | to_nice_yaml(indent=2, width=2048) + }} + dest: "{{ borg_decryption_keys_yaml_path }}" + mode: "0600" + delegate_to: localhost + become: false + when: decryption_keys_slurp.content is defined diff --git a/tasks/client_setup.yml b/tasks/client_setup.yml index 5d162ed..ab09926 100644 --- a/tasks/client_setup.yml +++ b/tasks/client_setup.yml @@ -39,10 +39,7 @@ - name: Compute SSH key path ansible.builtin.set_fact: borg_ssh_key_path: >- - {{ borg_client_user_home }}/.ssh/id_{{ borg_ssh_key_type }} - {%- if borg_ssh_key_per_repo -%} - _borgbackup_{{ borg_ssh_key_identifier }} - {%- endif -%} + {{ borg_client_user_home }}/.ssh/id_{{ borg_ssh_key_type }}{%- if borg_ssh_key_per_repo -%}_borgbackup_{{ borg_ssh_key_identifier }}{%- endif -%} - name: Create SSH Directory ansible.builtin.file: @@ -73,6 +70,16 @@ become: true register: ssh_key +- name: Normalize SSH public key for matching + ansible.builtin.set_fact: + ssh_public_key: "{{ ssh_key.public_key | trim }}" + ssh_public_key_material: >- + {{ + (ssh_key.public_key | trim) + | regex_search('^[^ ]+ [^ ]+') + | default(ssh_key.public_key | trim) + }} + - name: Ensure authorized_keys file exists on borg server ansible.builtin.file: path: "{{ borg_server_user_home }}/.ssh/authorized_keys" @@ -97,7 +104,7 @@ existing_line: >- {{ (auth_keys_content.content | b64decode).splitlines() - | select("search", ssh_key.public_key | trim | regex_escape) + | select("search", ssh_public_key_material | regex_escape) | first | default("") }} @@ -149,11 +156,11 @@ - name: Update authorized_keys entry for this host ansible.builtin.lineinfile: path: "{{ borg_server_user_home }}/.ssh/authorized_keys" - search_string: "{{ ssh_key.public_key | trim }}" + search_string: "{{ ssh_public_key_material }}" line: >- restrict,command="borg serve{{ " --append-only" if borg_mode_append_only }}{{ " --storage-quota " ~ borg_storage_quota if borg_storage_quota }} {{ all_repos | map('regex_replace', '^', '--restrict-to-repository ') | join(' ') }}" - {{ ssh_key.public_key | trim }} {{ borg_client_user }}@{{ inventory_hostname }} + {{ ssh_public_key }} {{ borg_client_user }}@{{ inventory_hostname }} state: present become: true delegate_to: "{{ borg_server_host }}" diff --git a/tasks/main.yml b/tasks/main.yml index 7ffef06..6feca79 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -1,9 +1,16 @@ --- - name: Install dependencies ansible.builtin.include_tasks: installation.yml + when: state == "present" - name: Run setup on server ansible.builtin.include_tasks: server_setup.yml + when: state == "present" - name: Run setup on client ansible.builtin.include_tasks: client_setup.yml + when: state == "present" + +- name: Remove borg backup configuration + ansible.builtin.include_tasks: absent.yml + when: state == "absent" |