diff --git a/roles/borgmatic/.ansible-lint b/roles/borgmatic/.ansible-lint new file mode 100644 index 0000000..f865478 --- /dev/null +++ b/roles/borgmatic/.ansible-lint @@ -0,0 +1,16 @@ +--- +profile: production +strict: true + +# Enable checking of loop variable prefixes in roles +loop_var_prefix: "^(__|{role}_)" + +skip_list: + - var-naming[no-role-prefix] + +warn_list: + - role-name[path] + - var-naming[no-role-prefix] + +exclude_paths: + - .github/ diff --git a/roles/borgmatic/.gitignore b/roles/borgmatic/.gitignore new file mode 100644 index 0000000..9451651 --- /dev/null +++ b/roles/borgmatic/.gitignore @@ -0,0 +1,3 @@ +.vscode +.idea +.lock diff --git a/roles/borgmatic/.yamllint b/roles/borgmatic/.yamllint new file mode 100644 index 0000000..611db54 --- /dev/null +++ b/roles/borgmatic/.yamllint @@ -0,0 +1,8 @@ +--- +rules: + brackets: + forbid: false + min-spaces-inside: 0 + max-spaces-inside: 2 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: 2 diff --git a/roles/borgmatic/README.md b/roles/borgmatic/README.md new file mode 100644 index 0000000..ce5f940 --- /dev/null +++ b/roles/borgmatic/README.md @@ -0,0 +1,40 @@ +ansible-borgmatic +========= + +This is a wrapper around the `borgmatic` role from the `maxhoesel.borgbackup` collection. + +The wrapper solve the most outstading issue with the current implementation of the original role: inability to install latest (or arbitrary) versions of `borgmatic` and `borg`. + +In the case of Borg we are fetching a release from Github. + +In the case of Borgmatic we are installing it via `pipx`, as recommended by their official documentation found here: https://torsion.org/borgmatic/docs/how-to/set-up-backups/. + +Requirements +------------ + +- `maxhoesel.borgbackup` collection installed (see `requirements.yml`); + +Role Variables +-------------- + +None + +Dependencies +------------ + +None + +Example Playbook +---------------- + +See `molecule/default/converge.yml` + +License +------- + +BSD + +Author Information +------------------ + +corvus-migratorius@proton.me diff --git a/roles/borgmatic/conda.dev.yml b/roles/borgmatic/conda.dev.yml new file mode 100644 index 0000000..f8272ac --- /dev/null +++ b/roles/borgmatic/conda.dev.yml @@ -0,0 +1,11 @@ +--- +name: ansible-borgmatic +channels: + - conda-forge +dependencies: + - python~=3.12.0 + - pip>=24.2 + - actionlint + - pip: + - -r requirements.txt + - -r requirements.ci.txt diff --git a/roles/borgmatic/conda.prod.yml b/roles/borgmatic/conda.prod.yml new file mode 100644 index 0000000..2e7d5bd --- /dev/null +++ b/roles/borgmatic/conda.prod.yml @@ -0,0 +1,9 @@ +--- +name: ansible-borgmatic +channels: + - conda-forge +dependencies: + - python~=3.12.0 + - pip>=24.2 + - pip: + - -r requirements.txt diff --git a/roles/borgmatic/defaults/main.yml b/roles/borgmatic/defaults/main.yml new file mode 100644 index 0000000..6ae71ef --- /dev/null +++ b/roles/borgmatic/defaults/main.yml @@ -0,0 +1,17 @@ +--- +borg_version: "1.4.0" +glibc_version: "2.36" +pipx_version: "1.7.1" +borg_binary_url: "https://github.com/borgbackup/borg/releases/download/{{ borg_version }}/borg-linux-glibc{{ glibc_version | replace('.', '') }}.tgz" +borgmatic_pipx_bin_dir: "/opt/borgmatic/bin" +borgmatic_schedule_oncalendar: "daily" +push_pubkey: true +ssh_key_path: "/root/borgmatic/id_ed25519" +borg_compression: "lz4" +borg_keep_hourly: 0 +borg_keep_daily: 3 +borg_keep_weekly: 3 +borg_keep_monthly: 1 +borg_keep_yearly: 0 +borg_uptime_kuma: +borg_loki: diff --git a/roles/borgmatic/handlers/main.yml b/roles/borgmatic/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/borgmatic/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/borgmatic/meta/main.yml b/roles/borgmatic/meta/main.yml new file mode 100644 index 0000000..c99f58c --- /dev/null +++ b/roles/borgmatic/meta/main.yml @@ -0,0 +1,17 @@ +--- +galaxy_info: + role_name: "borgmatic" + namespace: genlab + author: "Alexander Gorelyshev" + company: "Genlab, LLC" + description: "" + license: "MIT" + min_ansible_version: "2.1" + + platforms: + - name: "Ubuntu" + versions: [ "focal", "jammy", "noble" ] + + galaxy_tags: [ ] + +dependencies: [] diff --git a/roles/borgmatic/molecule/default/converge.yml b/roles/borgmatic/molecule/default/converge.yml new file mode 100644 index 0000000..eabe4f7 --- /dev/null +++ b/roles/borgmatic/molecule/default/converge.yml @@ -0,0 +1,48 @@ +--- +- name: Converge + hosts: all + vars: + repo_path: "/home/borg/test-repo" + + pre_tasks: + - name: "Create a user for borg" + ansible.builtin.user: + name: borg + shell: /bin/bash + create_home: true + + - name: "Generate test data file" + ansible.builtin.copy: + dest: "/tmp/data" + content: "This is a test file!" + owner: "{{ ansible_user_id }}" + group: "{{ ansible_user_id }}" + mode: "0644" + + - name: "Ensure the repo path exists" + ansible.builtin.file: + path: "{{ repo_path }}" + state: directory + owner: "borg" + mode: "0700" + + - name: "Install openssh-server" + ansible.builtin.apt: + name: openssh-server + state: present + update_cache: true + + - name: "Start an SSH openssh-server" + ansible.builtin.systemd: + name: ssh + state: started + + roles: + - role: genlab.borgmatic + borg_source_directories: + - "/tmp/data" + borg_repo_path: "ssh://borg@localhost/./test-repo" + borg_repo_label: "test-repo" + borg_encryption_passphrase: "secret" + repo_server_inventory_hostname: ubuntu # in production this should be an Ansible inventory hostname + repo_server_user: borg diff --git a/roles/borgmatic/molecule/default/molecule.yml b/roles/borgmatic/molecule/default/molecule.yml new file mode 100644 index 0000000..2910ca3 --- /dev/null +++ b/roles/borgmatic/molecule/default/molecule.yml @@ -0,0 +1,31 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: ubuntu + image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest + pre_build_image: true + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + +provisioner: + name: ansible + +verifier: + name: ansible + +scenario: + name: default + test_sequence: + - destroy + - create + - converge + # - idempotence + - verify diff --git a/roles/borgmatic/molecule/default/verify.yml b/roles/borgmatic/molecule/default/verify.yml new file mode 100644 index 0000000..67cdad1 --- /dev/null +++ b/roles/borgmatic/molecule/default/verify.yml @@ -0,0 +1,48 @@ +--- +- name: Verify + hosts: all + gather_facts: false + any_errors_fatal: true + + vars: + repo_path: "/home/borg/test-repo" + + tasks: + - name: "Include default vars" + ansible.builtin.include_vars: + dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/" + extensions: [ 'yml' ] + + - name: "Check if Borg is installed" + changed_when: false + register: borg_installed_version + ansible.builtin.command: "/usr/bin/borg --version" + + - name: "Check Borg version" + ansible.builtin.assert: + that: borg_installed_version.stdout.find(borg_version) + success_msg: "borg version {{ borg_version }} is installed and working" + fail_msg: "borg version {{ borg_version }} is not installed or not working correctly" + + - name: "Check if Borgmatic is installed" + changed_when: false + register: borgmatic_installed_version + ansible.builtin.command: + cmd: "/usr/bin/borgmatic --version" + + - name: "Check that the test repo was created" + register: test_repo_readme + failed_when: test_repo_readme.stat.exists is false + ansible.builtin.stat: + path: "{{ repo_path }}" + + - name: "Check that the systemd timer for Borgmatic is up and running" + register: timer + ansible.builtin.systemd: + name: borgmatic.timer + + - name: "Assert that the timer is running" + ansible.builtin.assert: + that: timer.status.ActiveState == "active" + success_msg: "Timer is running" + fail_msg: "Unexpected timer state: '{{ timer.status.ActiveState }}'" diff --git a/roles/borgmatic/renovate.json b/roles/borgmatic/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/roles/borgmatic/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/roles/borgmatic/requirements.ci.txt b/roles/borgmatic/requirements.ci.txt new file mode 100644 index 0000000..f99c76a --- /dev/null +++ b/roles/borgmatic/requirements.ci.txt @@ -0,0 +1,6 @@ +ansible-lint +molecule==24.12.0 +molecule-plugins[docker] +docker~=7.1.0 +requests==2.31.0 # pinned to the latest version not breaking Docker SDK +yamllint diff --git a/roles/borgmatic/requirements.txt b/roles/borgmatic/requirements.txt new file mode 100644 index 0000000..92f8677 --- /dev/null +++ b/roles/borgmatic/requirements.txt @@ -0,0 +1 @@ +ansible~=11.3.0 diff --git a/roles/borgmatic/requirements.yml b/roles/borgmatic/requirements.yml new file mode 100644 index 0000000..e2cdc9f --- /dev/null +++ b/roles/borgmatic/requirements.yml @@ -0,0 +1,6 @@ +# requirements file +--- +collections: + - name: https://github.com/maxhoesel/ansible-collection-borgbackup + type: git + version: "v2.0.1" diff --git a/roles/borgmatic/tasks/config.yml b/roles/borgmatic/tasks/config.yml new file mode 100644 index 0000000..f030017 --- /dev/null +++ b/roles/borgmatic/tasks/config.yml @@ -0,0 +1,34 @@ +--- +- name: "Compose basic configuration for Borgmatic" + ansible.builtin.set_fact: + borgmatic_composite_config: + source_directories: "{{ borg_source_directories }}" + repositories: + - path: "{{ borg_repo_path }}" + label: "{{ borg_repo_label }}" + encryption_passphrase: "{{ borg_encryption_passphrase }}" + compression: "{{ borg_compression }}" + # CLI output configuration + list_details: true + statistics: true + exclude_caches: true + # logging verbosity: + verbosity: 1 + syslog_verbosity: 1 + monitoring_verbosity: 1 + # backup depth + keep_hourly: "{{ borg_keep_hourly }}" + keep_daily: "{{ borg_keep_daily }}" + keep_weekly: "{{ borg_keep_weekly }}" + keep_monthly: "{{ borg_keep_monthly }}" + keep_yearly: "{{ borg_keep_yearly }}" + +- name: "Add Uptime Kuma configuration" + when: borg_uptime_kuma + ansible.builtin.set_fact: + borgmatic_composite_config: "{{ borgmatic_composite_config | combine({'uptime_kuma': borg_uptime_kuma}) }}" + +- name: "Add Loki configuration" + when: borg_loki + ansible.builtin.set_fact: + borgmatic_composite_config: "{{ borgmatic_composite_config | combine({'loki': borg_loki}) }}" diff --git a/roles/borgmatic/tasks/handle-ssh-keys.yml b/roles/borgmatic/tasks/handle-ssh-keys.yml new file mode 100644 index 0000000..126349b --- /dev/null +++ b/roles/borgmatic/tasks/handle-ssh-keys.yml @@ -0,0 +1,25 @@ +--- +- name: "Ensure the path for SSH keys exists" + ansible.builtin.file: + path: "{{ ssh_key_path | dirname }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: "Generate an ed25519 SSH key pair with 100 KDF rounds" + register: ssh_key_pair + community.crypto.openssh_keypair: + type: ed25519 + path: "{{ ssh_key_path }}" + comment: "Generated by Ansible for Borgmatic" + force: false + mode: '0600' + +- name: "Push the SSH key pair to the Borg repo host" + when: push_pubkey + delegate_to: "{{ repo_server_inventory_hostname }}" + ansible.posix.authorized_key: + user: "{{ repo_server_user }}" + key: "{{ ssh_key_pair.public_key }}" + state: present diff --git a/roles/borgmatic/tasks/install.yml b/roles/borgmatic/tasks/install.yml new file mode 100644 index 0000000..98cf198 --- /dev/null +++ b/roles/borgmatic/tasks/install.yml @@ -0,0 +1,66 @@ +--- +- name: "Ensure that system dependencies are installed" + ansible.builtin.apt: + name: + - openssh-client + - python3-pip + - python3-venv + state: present + update_cache: true + cache_valid_time: 3600 + +- name: "Install pipx" + retries: 3 + delay: 1 + ansible.builtin.pip: + name: "pipx=={{ pipx_version }}" + executable: pip3 + break_system_packages: true + +- name: "Ensure pipx binary is available in PATH" + changed_when: false + ansible.builtin.command: + cmd: pipx ensurepath + +- name: "Install borgmatic via pipx" + retries: 3 + delay: 1 + environment: + PIPX_BIN_DIR: "{{ borgmatic_pipx_bin_dir }}" + community.general.pipx: + name: borgmatic + state: present + install_deps: true + +- name: "Install Borg if the correct version is not available" + block: + # we are looking for Borg installed in a directory that Max Hoesel's role exects to find it + - name: "Get the currently installed version of Borg" + changed_when: false + register: borg_version_installed + ansible.builtin.command: + cmd: /usr/bin/borg --version + + - name: "Check that the correct version of Borg is installed" + ansible.builtin.assert: + that: borg_version_installed.stdout.find(borg_version) + fail_msg: "The expected Borg version was not found: {{ borg_version_installed }}" + success_msg: "Found the expected Borg version ({{ borg_version }})" + + rescue: + - name: "Download Borg from a custom URL: '{{ borg_binary_url }}'" + retries: 3 + delay: 1 + ansible.builtin.unarchive: + src: "{{ borg_binary_url }}" + dest: "/opt/" + remote_src: true + owner: root + group: root + mode: "0755" + + - name: "Create a symbolic link for Borg" + ansible.builtin.file: + state: link + src: "/opt/borg-dir/borg.exe" + dest: "/usr/bin/borg" diff --git a/roles/borgmatic/tasks/integrations.yml b/roles/borgmatic/tasks/integrations.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/borgmatic/tasks/integrations.yml @@ -0,0 +1 @@ +--- diff --git a/roles/borgmatic/tasks/main.yml b/roles/borgmatic/tasks/main.yml new file mode 100644 index 0000000..0207ec6 --- /dev/null +++ b/roles/borgmatic/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: "Include tool installation tasks" + ansible.builtin.include_tasks: "install.yml" + +- name: "Include SSH key handling tasks" + ansible.builtin.include_tasks: "handle-ssh-keys.yml" + +- name: "Include configuration tasks" + ansible.builtin.include_tasks: "config.yml" + +- name: "Include tasks for third-party integrations" + ansible.builtin.include_tasks: "integrations.yml" + +- name: "Include tasks for running borgmatic" + ansible.builtin.include_tasks: "run.yml" diff --git a/roles/borgmatic/tasks/run.yml b/roles/borgmatic/tasks/run.yml new file mode 100644 index 0000000..182aa05 --- /dev/null +++ b/roles/borgmatic/tasks/run.yml @@ -0,0 +1,22 @@ +--- +# A workaround for maxhoesel.borgbackup.borgmatic that does not support custom paths +- name: "Create symbolic links for Borgmatic executables" + loop: + - borgmatic + - generate-borgmatic-config + - validate-borgmatic-config + ansible.builtin.file: + state: link + src: "{{ borgmatic_pipx_bin_dir }}/{{ item }}" + dest: /usr/bin/{{ item }} + +- name: "Configure and run Borgmatic" + ansible.builtin.include_role: + name: maxhoesel.borgbackup.borgmatic + vars: + borgmatic_install: false # we handle installation separately to get the recent version + # borgmatic_ssh_key_gen_options: "-t ed25519 -a 100" + borgmatic_ssh_key_path: "{{ ssh_key_path }}" + borgmatic_schedule_on: "{{ borgmatic_schedule_oncalendar }}" + borgmatic_schedule_max_random_delay: 600 + borgmatic_config: "{{ borgmatic_composite_config }}" diff --git a/roles/borgmatic/vars/main.yml b/roles/borgmatic/vars/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/borgmatic/vars/main.yml @@ -0,0 +1 @@ +---