diff --git a/restic/files/restic.service.j2 b/restic/files/restic.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3cf5b23ee32fb35ef9d014559f9ed7855e4be7d7 --- /dev/null +++ b/restic/files/restic.service.j2 @@ -0,0 +1,37 @@ +{% set restic = pillar.get('restic', {}) -%} +{% if accumulator is not defined -%} + {% set accumulator = {} -%} +{% endif -%} +{% set cmds = accumulator.get('restic.cmd', []) | json_query('[][]') -%} +{% set paths = accumulator.get('restic.path', []) | json_query('[][]') -%} +{% set excludes = accumulator.get('restic.exclude', []) | json_query('[][]') -%} + +[Unit] +Description=Create a backup +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +Environment="RESTIC_REPOSITORY=rest:https://{{ restic.get('http_user') }}:{{ restic.get('http_password') }}@{{ restic.get('http_host') }}/{{ restic.get('http_user') }}" +Environment="RESTIC_PASSWORD_FILE=/etc/restic.password" +Environment="RESTIC_CACHE_DIR=/tmp/restic" +{% for cmd in cmds | sort -%} +{% if cmd[0] != '/' -%} + {% set cmd = cmd.split(' ') -%} + {% do cmd.insert(0, cmd[0] | which) -%} + {% do cmd.pop(1) -%} + {% set cmd = cmd|join(' ') -%} +{% endif -%} +ExecStartPre={{ cmd }} +{% endfor -%} +ExecStart={{ 'restic' | which }} backup \ + {% for path in paths | sort -%} + "{{ path }}"{% if not loop.last or excludes %} \{%endif %} + {% else -%} + /dev/null{% if excludes %} \{%endif %} + {% endfor -%} + {% for path in excludes | sort -%} + --exclude "{{ path }}"{% if not loop.last %} \{%endif %} + {% endfor -%} + diff --git a/restic/files/restic.timer.j2 b/restic/files/restic.timer.j2 new file mode 100644 index 0000000000000000000000000000000000000000..187f0f154d25c77b588a73fc6a416f710bf5035a --- /dev/null +++ b/restic/files/restic.timer.j2 @@ -0,0 +1,14 @@ +{% set restic = pillar.get('restic', {}) -%} +[Unit] +Description=Create a backup +Wants=network-online.target +After=network-online.target + +[Timer] +OnCalendar={{ restic.get('time', '02:00') }} +RandomizedDelaySec={{ restic.get('delay', '7200') }} +Persistent=true + +[Install] +WantedBy=timers.target + diff --git a/restic/init.sls b/restic/init.sls new file mode 100644 index 0000000000000000000000000000000000000000..075b59133ae33c35c459189d31fb795dbb07c934 --- /dev/null +++ b/restic/init.sls @@ -0,0 +1,86 @@ +{% set restic = pillar.get('restic', {}) -%} +{% set backup = restic.get('backup', {}) %} + +restic: + pkg.installed: [] + +restic password: + file.managed: + - name: /etc/restic.password + - user: root + - group: root + - mode: '0600' + - contents: {{ restic.get('password') }} + +{% set repository = ['rest:https://', restic.get('http_user'), ':', restic.get('http_password'), '@', restic.get('http_host'), '/', restic.get('http_user')] | join %} +restic init: + cmd.run: + - env: + - RESTIC_REPOSITORY: {{ repository }} + - RESTIC_PASSWORD_FILE: /etc/restic.password + - unless: restic snapshots -r '{{ repository }}' -p /etc/restic.password + - require: + - file: restic password + - pkg: restic + +/etc/systemd/system/restic.service: + file.managed: + - source: salt://restic/files/restic.service.j2 + - template: jinja + service.enabled: + - name: restic + - require: + - file: /etc/systemd/system/restic.service + +/etc/systemd/system/restic.timer: + file.managed: + - source: salt://restic/files/restic.timer.j2 + - template: jinja + service.running: + - name: restic.timer + - enable: True + - require: + - service: restic + - file: restic password + - cmd: restic init + +{% macro cmd(name) %} +restic run {{ name }}: + file.accumulated: + - name: restic.cmd + - text: {{ varargs | list | tojson }} + - filename: /etc/systemd/system/restic.service + - require_in: + - file: /etc/systemd/system/restic.service +{% endmacro %} + +{% macro path(name) %} +restic backup {{ name }}: + file.accumulated: + - name: restic.path + - filename: /etc/systemd/system/restic.service + - text: {{ varargs | list | tojson }} + - require_in: + - file: /etc/systemd/system/restic.service +{% endmacro %} + +{% macro exclude(name) %} +restic exclude {{ name }}: + file.accumulated: + - name: restic.exclude + - filename: /etc/systemd/system/restic.service + - text: {{ varargs | list | tojson }} + - require_in: + - file: /etc/systemd/system/restic.service +{% endmacro %} + +{% if backup.get('cmd', []) %} +{{ cmd('defaults', *backup.get('cmd', [])) }} +{% endif %} +{% if backup.get('path', []) %} +{{ path('defaults', *backup.get('path', [])) }} +{% endif %} +{% if backup.get('exclude', []) %} +{{ exclude('defaults', *backup.get('exclude', [])) }} +{% endif %} + diff --git a/restic/readme.md b/restic/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..8810187962b63a755d72a44ebb1d7f2cb247ae06 --- /dev/null +++ b/restic/readme.md @@ -0,0 +1,49 @@ +# Restic +This module backups the provided data to a [Restic](https://restic.readthedocs.io/en/latest/) +[REST Server](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#rest-server) over https. + +When no backup paths are specified, `/dev/null` is added to allow for the same rollout on every system. + + +## Requirements +* Python + * `jmespath` +* Parameters + * The `cmd` command must start with a command which has to be an absolute path to a binary or exist in `$PATH`. + + +## Pillar +Pillar configuration to configure the backup target + +```yaml +restic: + http_user: {{ grains['id'] }} + http_password: 1anDoMpAs5Wo1D + http_host: restic.host.tld + password: rAnD0MpA5SWoRd # The repository password + backup: + cmd: # Optional, commands to run before starting the backup + - echo 'I should be in the backup' > /tmp/note + path: # Paths to include in the backup + - /tmp/note + - /etc/ + exclude: # Paths to exclude from backup + - /etc/foo/ + time: 5:42 # Optional, time to start the backup, default 02:00 + delay: 300 # Optional, in seconds, defaults 2h +``` + + +## States +Include backups by other states + +```jinja +{% import 'restic/init.sls' as restic %} + +[...] + +{{ restic.cmd('cmd name', '/usr/local/bin/dump-data > /some/file') }} +{{ restic.path('path name', '/some/file') }} +{{ restic.exclude('exclude name', '/another/path') }} +``` +