aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.ansible-lint2
-rw-r--r--README.md77
-rw-r--r--defaults/main.yml15
-rw-r--r--meta/argument_specs.yml16
-rw-r--r--molecule/.gitignore2
-rw-r--r--molecule/Dockerfile.j2 (renamed from molecule/default/Dockerfile.j2)0
-rw-r--r--molecule/default/converge.yml11
-rw-r--r--molecule/default/molecule.yml14
-rw-r--r--molecule/delete/converge.yml143
-rw-r--r--molecule/delete/molecule.yml68
-rw-r--r--molecule/delete/prepare.yml153
-rw-r--r--molecule/delete/tests/test_delete.py318
-rw-r--r--shell.nix2
-rw-r--r--tasks/absent.yml298
-rw-r--r--tasks/client_setup.yml21
-rw-r--r--tasks/main.yml7
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
diff --git a/README.md b/README.md
index 6dff957..9d21794 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/shell.nix b/shell.nix
index b15061e..268654c 100644
--- a/shell.nix
+++ b/shell.nix
@@ -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"