From 0aba4024deb8977db6454dbae7ba369c2d1ed74c Mon Sep 17 00:00:00 2001 From: transcaffeine Date: Sun, 9 Mar 2025 13:07:03 +0100 Subject: [PATCH] feat(restic): migrate to systemd template units --- .../defaults/{main.yml => main/config.yml} | 24 ++--- roles/restic/defaults/main/main.yml | 16 +++ roles/restic/defaults/main/systemd.yml | 20 ++++ ...backup-directories.sh => restic-backup.sh} | 18 ++-- ...-metrics.sh => restic-snapshot-metrics.sh} | 2 +- roles/restic/handlers/main.yml | 9 +- roles/restic/tasks/check.yml | 35 +++++++ roles/restic/tasks/configure.yml | 30 ++++++ roles/restic/tasks/install.yml | 48 +++++++++ roles/restic/tasks/main.yml | 98 ++++++------------- roles/restic/templates/restic.conf.j2 | 3 + roles/restic/templates/restic.service.j2 | 51 ---------- roles/restic/templates/restic@.service.j2 | 15 +++ roles/restic/vars/main.yml | 9 ++ 14 files changed, 231 insertions(+), 147 deletions(-) rename roles/restic/defaults/{main.yml => main/config.yml} (71%) create mode 100644 roles/restic/defaults/main/main.yml create mode 100644 roles/restic/defaults/main/systemd.yml rename roles/restic/files/{restic-backup-directories.sh => restic-backup.sh} (69%) rename roles/restic/files/{restic-generate-snapshot-metrics.sh => restic-snapshot-metrics.sh} (93%) create mode 100644 roles/restic/tasks/check.yml create mode 100644 roles/restic/tasks/configure.yml create mode 100644 roles/restic/tasks/install.yml create mode 100644 roles/restic/templates/restic.conf.j2 delete mode 100644 roles/restic/templates/restic.service.j2 create mode 100644 roles/restic/templates/restic@.service.j2 create mode 100644 roles/restic/vars/main.yml diff --git a/roles/restic/defaults/main.yml b/roles/restic/defaults/main/config.yml similarity index 71% rename from roles/restic/defaults/main.yml rename to roles/restic/defaults/main/config.yml index c36c501..e09615a 100644 --- a/roles/restic/defaults/main.yml +++ b/roles/restic/defaults/main/config.yml @@ -1,5 +1,4 @@ --- - restic_repo_url: ~ restic_repo_password: ~ restic_s3_key_id: ~ @@ -8,6 +7,8 @@ restic_s3_access_key: ~ restic_backup_paths: [] restic_backup_stdin_command: ~ restic_backup_stdin_command_filename: ~ +restic_backup_generate_metrics_command: >-2 + {{ restic_script_generate_snapshot_metrics }} restic_policy_keep_all_within: 1d restic_policy_keep_hourly: 12 @@ -18,13 +19,16 @@ restic_policy_keep_yearly: 5 restic_policy_backup_frequency: hourly restic_base_environment: - RESTIC_JOBNAME: "{{ restic_job_name | default('unknown') }}" + RESTIC_REPOSITORY: "{{ restic_repo_url }}" + RESTIC_PASSWORD: "{{ restic_repo_password }}" + RESTIC_JOBNAME: "{{ restic_job_name }}" RESTIC_FORGET_KEEP_WITHIN: "{{ restic_policy_keep_all_within }}" RESTIC_FORGET_KEEP_HOURLY: "{{ restic_policy_keep_hourly }}" RESTIC_FORGET_KEEP_DAILY: "{{ restic_policy_keep_daily }}" RESTIC_FORGET_KEEP_WEEKLY: "{{ restic_policy_keep_weekly }}" RESTIC_FORGET_KEEP_MONTHLY: "{{ restic_policy_keep_monthly }}" RESTIC_FORGET_KEEP_YEARLY: "{{ restic_policy_keep_yearly }}" + RESTIC_GENERATE_SNAPSHOT_METRICS_COMMAND: "{{ restic_backup_generate_metrics_command }}" restic_s3_environment: AWS_ACCESS_KEY_ID: "{{ restic_s3_key_id }}" @@ -33,8 +37,8 @@ restic_s3_environment: restic_complete_environment: >- {{ restic_base_environment - | combine((restic_s3_environment - if (restic_s3_key_id and restic_s3_access_key) else {}) | default({})) + | combine((restic_s3_environment | default({})) + if (restic_s3_key_id and restic_s3_access_key) else {}) | combine(restic_environment | default({})) }} @@ -46,15 +50,3 @@ restic_policy: monthly: "{{ restic_policy_keep_monthly }}" yearly: "{{ restic_policy_keep_yearly }}" frequency: "{{ restic_policy_backup_frequency }}" - -restic_user: root -restic_create_user: false -restic_start_job_on_unit_change: false - -restic_job_name: ~ -restic_job_description: "Restic backup job for {{ restic_job_name }}" -restic_systemd_unit_naming_scheme: "restic.{{ restic_job_name }}" -restic_systemd_working_directory: /tmp -restic_systemd_syslog_identifier: "restic-{{ restic_job_name }}" - -restic_package_name: restic diff --git a/roles/restic/defaults/main/main.yml b/roles/restic/defaults/main/main.yml new file mode 100644 index 0000000..f7f5d37 --- /dev/null +++ b/roles/restic/defaults/main/main.yml @@ -0,0 +1,16 @@ +--- +restic_user: root +restic_user_create: false +restic_create_user: "{{ restic_user_create }}" +restic_user_create_home: false +restic_user_system: false + +restic_state: present +restic_version: "0.17.3" +restic_job_name: default +restic_job_state: "{{ restic_state }}" +restic_job_directory: "/etc/restic" + +restic_package_name: restic +restic_script_generate_snapshot_metrics: "/opt/restic-generate-snapshot-metrics.sh" +restic_start_job_on_unit_change: true diff --git a/roles/restic/defaults/main/systemd.yml b/roles/restic/defaults/main/systemd.yml new file mode 100644 index 0000000..ec0e96c --- /dev/null +++ b/roles/restic/defaults/main/systemd.yml @@ -0,0 +1,20 @@ +--- +restic_systemd_job_description: "Restic backup service" +restic_systemd_unit_naming_scheme: "restic-{{ restic_job_name }}" +restic_systemd_timer_naming_scheme: >-2 + {{ restic_systemd_unit_naming_scheme }}.timer +restic_systemd_timer_state_map: + present: "started" + absent: "stopped" + masked: "started" +restic_systemd_timer_state: >-2 + {{ restic_systemd_timer_state_map[restic_job_state] }} + +restic_systemd_syslog_identifier: "restic@%i" +restic_systemd_working_directory: /tmp +restic_systemd_install_wanted_by: "basic.target" +restic_systemd_install_default_instance: "default" + +restic_systemd_start_job_on_unit_change: false +restic_systemd_service_exec_start: "/opt/restic-backup.sh" + diff --git a/roles/restic/files/restic-backup-directories.sh b/roles/restic/files/restic-backup.sh similarity index 69% rename from roles/restic/files/restic-backup-directories.sh rename to roles/restic/files/restic-backup.sh index a39ff3b..51d40f4 100644 --- a/roles/restic/files/restic-backup-directories.sh +++ b/roles/restic/files/restic-backup.sh @@ -8,15 +8,19 @@ fi echo "List existing snapshots or initialize repository" restic snapshots || restic init -sleep 2; +sleep 1; echo "Attempting to remove lock if present" restic unlock -sleep 2 +sleep 1; echo "Start backup on ${@:1}" restic --verbose --retry-lock=${RESTIC_RETRY_LOCK:-5m} backup "${@:1}" -sleep 2 +sleep 1; + +if [[ -n ${RESTIC_POIST_BACKUP_HOOK-} ]]; then + /bin/bash -c "$RESTIC_POST_BACKUP_HOOK" +fi echo "Forget and prune old snapshots" restic forget --prune --retry-lock=${RESTIC_RETRY_LOCK:-5m} \ @@ -29,9 +33,11 @@ restic forget --prune --retry-lock=${RESTIC_RETRY_LOCK:-5m} \ sleep 2 echo "Generate snapshot metrics" -restic --json snapshots | /opt/restic-generate-snapshot-metrics.sh \ - > /var/lib/node_exporter/restic-snapshots-${RESTIC_JOBNAME:-unknown}.prom-src -sleep 2 +if [[ -n ${RESTIC_GENERATE_SNAPSHOT_METRICS_COMMAND-} ]]; then + restic --json snapshots | ${RESTIC_GENERATE_SNAPSHOT_METRICS_COMMAND} \ + > /var/lib/node_exporter/restic-snapshots-${RESTIC_JOBNAME:-unknown}.prom-src + sleep 2; +fi echo "Check repository" restic check diff --git a/roles/restic/files/restic-generate-snapshot-metrics.sh b/roles/restic/files/restic-snapshot-metrics.sh similarity index 93% rename from roles/restic/files/restic-generate-snapshot-metrics.sh rename to roles/restic/files/restic-snapshot-metrics.sh index c0568dd..64471d7 100644 --- a/roles/restic/files/restic-generate-snapshot-metrics.sh +++ b/roles/restic/files/restic-snapshot-metrics.sh @@ -6,7 +6,7 @@ echo $RESTIC_JSON | jq -r '.[] | { "hostname": .hostname, "username": .username, - "short_id": .short_id, + "short_id": .short_id, "time": ((((.time | split(".")[0]) + "Z") | fromdate) - (3600 * (.time | split("+")[1] | split(":")[0] | tonumber + 1))), "paths": .paths[] } | "restic_snapshots{hostname=\"\(.hostname)\",username=\"\(.username)\",short_id=\"\(.short_id)\",paths=\"\(.paths)\"} \(.time)"' diff --git a/roles/restic/handlers/main.yml b/roles/restic/handlers/main.yml index e6c4867..300e3f1 100644 --- a/roles/restic/handlers/main.yml +++ b/roles/restic/handlers/main.yml @@ -1,13 +1,12 @@ --- - - name: Ensure system daemon is reloaded listen: reload-systemd - systemd: + ansible.builtin.systemd: daemon_reload: true - name: Ensure systemd service for '{{ restic_job_name }}' is started immediately listen: trigger-restic - systemd: - name: "{{ restic_systemd_unit_naming_scheme }}.service" + ansible.builtin.systemd: + name: "{{ restic_systemd_timer_naming_scheme }}" state: started - when: restic_start_job_on_unit_change + when: (not ansible_check_mode) and restic_start_job_on_unit_change diff --git a/roles/restic/tasks/check.yml b/roles/restic/tasks/check.yml new file mode 100644 index 0000000..94e28da --- /dev/null +++ b/roles/restic/tasks/check.yml @@ -0,0 +1,35 @@ +--- +- name: Check if 'restic_state' is valid + ansible.builtin.fail: + msg: >-2 + Unknown value '{{ restic_state }}' for 'restic_state'! + Supported values are {{ restic_states | join(', ') }} + when: restic_state not in restic_states + +- name: Ensure 'restic_job_name' is properly populated + ansible.builtin.fail: + msg: >-2 + Unsupported restic_job_name '{{ restic_job_name | string }}'! + when: + - not (restic_job_name | string | length > 0) + +- name: Ensure either backup_paths or backup_stdin_command is populated + ansible.builtin.fail: + msg: >-2 + Setting both `restic_backup_paths` and `restic_backup_stdin_command` + is not supported! + when: restic_backup_paths|length > 0 and restic_backup_stdin_command and false + +- name: Ensure a filename for stdin_command backup is given + ansible.builtin.fail: + msg: >-2 + `restic_backup_stdin_command` was set but no filename for the resulting + output was supplied in `restic_backup_stdin_command_filename`. + when: restic_backup_stdin_command and not restic_backup_stdin_command_filename + +- name: Ensure backup frequency adheres to systemd's OnCalender syntax + command: + cmd: "systemd-analyze calendar {{ restic_policy.frequency }}" + register: systemd_calender_parse_res + failed_when: systemd_calender_parse_res.rc != 0 + changed_when: false diff --git a/roles/restic/tasks/configure.yml b/roles/restic/tasks/configure.yml new file mode 100644 index 0000000..617982b --- /dev/null +++ b/roles/restic/tasks/configure.yml @@ -0,0 +1,30 @@ +--- +- name: Ensure systemd timer file for '{{ restic_job_name }}' is {{ restic_state }}' + ansible.builtin.template: + dest: "/etc/systemd/system/{{ restic_systemd_unit_naming_scheme }}.timer" + src: restic.timer.j2 + owner: root + group: root + mode: "0640" + when: restic_state == 'present' + register: restic_systemd_timer_info + notify: + - reload-systemd + +- name: Ensure restic configuration for '{{ restic_job_name }}' is {{ restic_job_state }} + ansible.builtin.template: + src: "restic.conf.j2" + dest: "{{ restic_job_directory }}/{{ restic_job_name }}.conf" + mode: "0640" + when: restic_job_state in ['present', 'masked'] + notify: + - trigger-restic + +- name: Ensure restic configuration for '{{ restic_job_name }}' is {{ restic_job_state }} + ansible.builtin.file: + path: "{{ restic_job_directory }}/{{ restic_job_name }}.conf" + state: "{{ restic_job_state }}" + when: restic_job_state not in ['present', 'masked'] + +- name: Flush handlers to ensure systemd knows about '{{ restic_job_name }}' + meta: flush_handlers diff --git a/roles/restic/tasks/install.yml b/roles/restic/tasks/install.yml new file mode 100644 index 0000000..6d2b364 --- /dev/null +++ b/roles/restic/tasks/install.yml @@ -0,0 +1,48 @@ +--- +- name: Ensure restic is installed + block: + - name: Ensure restic is installed via apt + apt: + package: restic + state: latest + when: ansible_os_family == 'Debian' + - name: Ensure restic is installed via dnf + dnf: + name: restic + state: latest + when: ansible_os_family == 'RedHat' + - name: Ensure restic is installed using the auto-detected package-manager + package: + name: "{{ restic_package_name }}" + state: present + when: ansible_os_family not in ['RedHat', 'Debian'] + +- name: Ensure restic backup scripts are {{ restic_state }} + ansible.builtin.copy: + src: "{{ script.source }}" + dest: "{{ script.destination }}" + mode: "{{ script.mode }}" + loop: + - source: restic-backup.sh + destination: "{{ restic_systemd_service_exec_start }}" + mode: "0510" + - source: restic-snapshot-metrics.sh + destination: "{{ restic_script_generate_snapshot_metrics }}" + mode: "0510" + loop_control: + loop_var: script + label: "{{ script.source }}" + +- name: Ensure systemd service file for restic template unit is {{ restic_state }} + ansible.builtin.template: + dest: "/etc/systemd/system/restic@.service" + src: "restic@.service.j2" + owner: root + group: root + mode: "0640" + when: restic_state == 'present' + notify: + - reload-systemd + - trigger-restic + + diff --git a/roles/restic/tasks/main.yml b/roles/restic/tasks/main.yml index a4d5eba..58e0d45 100644 --- a/roles/restic/tasks/main.yml +++ b/roles/restic/tasks/main.yml @@ -1,77 +1,39 @@ --- +- name: Check if role input is valid + ansible.builtin.include_tasks: + file: check.yml -- name: Ensure {{ restic_user }} system user exists - user: +- name: Ensure restic is {{ restic_state }} + ansible.builtin.include_tasks: + file: install.yml + +- name: Ensure restic user '{{ restic_user }}' is {{ restic_state }} + ansible.builtin.user: name: "{{ restic_user }}" - state: present - system: true + state: "{{ restic_state }}" + system: "{{ restic_user_system }}" + create_home: "{{ restic_user_create_home }}" when: restic_create_user -- name: Ensure either backup_paths or backup_stdin_command is populated - when: restic_backup_paths|length > 0 and restic_backup_stdin_command and false - fail: - msg: "Setting both `restic_backup_paths` and `restic_backup_stdin_command` is not supported" - -- name: Ensure a filename for stdin_command backup is given - when: restic_backup_stdin_command and not restic_backup_stdin_command_filename - fail: - msg: "`restic_backup_stdin_command` was set but no filename for the resulting output was supplied in `restic_backup_stdin_command_filename`" - -- name: Ensure backup frequency adheres to systemd's OnCalender syntax - command: - cmd: "systemd-analyze calendar {{ restic_policy.frequency }}" - register: systemd_calender_parse_res - failed_when: systemd_calender_parse_res.rc != 0 - changed_when: false - -- name: Ensure restic is installed - block: - - name: Ensure restic is installed via apt - apt: - package: restic - state: latest - when: ansible_os_family == 'Debian' - - name: Ensure restic is installed via dnf - dnf: - name: restic - state: latest - when: ansible_os_family == 'RedHat' - - name: Ensure restic is installed using the auto-detected package-manager - package: - name: "{{ restic_package_name }}" - state: present - when: ansible_os_family not in ['RedHat', 'Debian'] - -- name: Ensure systemd service file for '{{ restic_job_name }}' is templated - template: - dest: "/etc/systemd/system/{{ restic_systemd_unit_naming_scheme }}.service" - src: restic.service.j2 - owner: root - group: root - mode: 0640 - notify: - - reload-systemd - - trigger-restic - -- name: Ensure systemd service file for '{{ restic_job_name }}' is templated - template: - dest: "/etc/systemd/system/{{ restic_systemd_unit_naming_scheme }}.timer" - src: restic.timer.j2 - owner: root - group: root - mode: 0640 - notify: - - reload-systemd - -- name: Flush handlers to ensure systemd knows about '{{ restic_job_name }}' - meta: flush_handlers +- name: Ensure restic configuration for job is {{ restic_job_state }} + ansible.builtin.include_tasks: + file: "configure.yml" - name: Ensure systemd timer for '{{ restic_job_name }}' is activated - systemd: - name: "{{ restic_systemd_unit_naming_scheme }}.timer" + ansible.builtin.systemd: + name: "{{ restic_systemd_timer_naming_scheme }}" enabled: true + when: + - restic_systemd_timer_info.changed + - not restic_systemd_timer_info.failed + - not ansible_check_mode -- name: Ensure systemd timer for '{{ restic_job_name }}' is started - systemd: - name: "{{ restic_systemd_unit_naming_scheme }}.timer" - state: started +- name: Ensure systemd timer for '{{ restic_job_name }}' is {{ restic_job_state }} + ansible.builtin.systemd: + name: "{{ restic_systemd_timer_naming_scheme }}" + state: "{{ restic_job_state }}" + masked: "{{ (restic_job_state == 'masked') | ternary('true', omit) }}" + when: + - restic_systemd_timer_info.changed + - not restic_systemd_timer_info.failed + - not ansible_check_mode diff --git a/roles/restic/templates/restic.conf.j2 b/roles/restic/templates/restic.conf.j2 new file mode 100644 index 0000000..8ec246e --- /dev/null +++ b/roles/restic/templates/restic.conf.j2 @@ -0,0 +1,3 @@ +{% for kv in restic_complete_environment | dict2items %} +{{ kv.key }}={{ kv.value }} +{% endfor %} diff --git a/roles/restic/templates/restic.service.j2 b/roles/restic/templates/restic.service.j2 deleted file mode 100644 index 7169689..0000000 --- a/roles/restic/templates/restic.service.j2 +++ /dev/null @@ -1,51 +0,0 @@ -[Unit] -Description={{ restic_job_description }} - -[Service] -Type=simple -User={{ restic_user }} -WorkingDirectory={{ restic_systemd_working_directory }} -SyslogIdentifier={{ restic_systemd_syslog_identifier }} - -Environment=RESTIC_REPOSITORY={{ restic_repo_url }} -Environment=RESTIC_PASSWORD={{ restic_repo_password }} -{% for kv in restic_complete_environment | dict2items %} -Environment={{ kv.key }}={{ kv.value }} -{% endfor %} - -{% if restic_init | default(true) %} -ExecStartPre=-/bin/sh -c '/usr/bin/restic snapshots || /usr/bin/restic init' -{% endif %} -{% if restic_unlock_before_backup | default(false) %} -ExecStartPre=-/bin/sh -c 'sleep 3 && /usr/bin/restic unlock' -{% endif %} -{% if restic_backup_pre_hook | default(false) %} -ExecStartPre=-{{ restic_backup_pre_hook }} -{% endif %} -{% if restic_backup_stdin_command %} -ExecStart=/bin/sh -c '{{ restic_backup_stdin_command }} | /usr/bin/restic backup \ - --retry-lock {{ restic_retry_lock | default('5m') }} \ - --verbose --stdin \ - --stdin-filename {{ restic_backup_stdin_command_filename }}' -{% else %} -ExecStart=/opt/restic-backup-directories.sh {{ restic_backup_paths | join(' ') }} -{% endif %} -{% if restic_forget_prune | default(true) %} -ExecStartPost=/usr/bin/restic forget --prune \ - --retry-lock {{ restic_retry_lock | default('5m') }} \ - --keep-within={{ restic_policy.keep_within }} \ - --keep-hourly={{ restic_policy.hourly }} \ - --keep-daily={{ restic_policy.daily }} \ - --keep-weekly={{ restic_policy.weekly }} \ - --keep-monthly={{ restic_policy.monthly }} \ - --keep-yearly={{ restic_policy.yearly }} -{% endif %} -{% if restic_list_snapshots | default(true) %} -ExecStartPost=-/usr/bin/restic snapshots --retry-lock {{ restic_retry_lock | default('5m') }} -{% endif %} -{% if restic_backup_post_hook | default(false) %} -ExecStartPost=-{{ restic_backup_post_hook }} -{% endif %} -{% if restic_check | default(true) %} -ExecStartPost=/usr/bin/restic check --retry-lock {{ restic_retry_lock | default('5m') }} -{% endif %} diff --git a/roles/restic/templates/restic@.service.j2 b/roles/restic/templates/restic@.service.j2 new file mode 100644 index 0000000..f4a3e23 --- /dev/null +++ b/roles/restic/templates/restic@.service.j2 @@ -0,0 +1,15 @@ +[Unit] +Description={{ restic_systemd_job_description }} + +[Service] +Type=simple +EnvironmentFile={{ restic_job_directory }}/%i.conf +User={{ restic_user }} +WorkingDirectory={{ restic_systemd_working_directory }} +SyslogIdentifier={{ restic_systemd_syslog_identifier }} + +ExecStart={{ restic_systemd_service_exec_start }} + +[Install] +WantedBy={{ restic_systemd_install_wanted_by }} +DefaultInstance={{ restic_systemd_install_default_instance }} diff --git a/roles/restic/vars/main.yml b/roles/restic/vars/main.yml new file mode 100644 index 0000000..c645e2d --- /dev/null +++ b/roles/restic/vars/main.yml @@ -0,0 +1,9 @@ +--- +restic_states: + - "present" + - "absent" + +restic_job_states: + - "present" + - "masked" + - "absent"