/
/
/
Ansible role that can run restic backups and push it to a remote storage server.
1---
2# Appliance backups (non-Docker): Home Assistant and OPNsense.
3# Runs on the collector host (homeassistant). Each appliance records its
4# outcome in backup_results like a service does.
5
6# Each appliance runs when it is enabled and either nothing specific was
7# selected, or it was selected by name (homeassistant / opnsense).
8- name: Decide which appliances to back up
9 ansible.builtin.set_fact:
10 _ha_selected: "{{ backup_ha_enabled | bool and (_backup_selection | length == 0 or 'homeassistant' in _backup_selection) }}"
11 _opn_selected: "{{ backup_opnsense_enabled | bool and (_backup_selection | length == 0 or 'opnsense' in _backup_selection) }}"
12 _ha_error: ""
13 _ha_summary: {}
14 _opn_error: ""
15 _opn_summary: {}
16
17# ==============================================================================
18# HOME ASSISTANT
19# ==============================================================================
20# A full backup through the Supervisor, without an HA password: the restic
21# repository encrypts it anyway, and a password would be one more secret a
22# restore depends on. HA's own (encrypted, partial) automatic backups in the
23# same directory are snapshotted too, but never pruned by this role.
24
25- name: Home Assistant backup
26 when: _ha_selected | bool
27 block:
28 - name: Home Assistant - Create a full backup
29 ansible.builtin.command:
30 argv:
31 - ha
32 - backups
33 - new
34 - --name
35 - "{{ backup_ha_name_prefix }}{{ now().strftime('%Y-%m-%d') }}"
36 - --no-progress
37 - --raw-json
38 register: _ha_new
39 changed_when: true
40 failed_when: (_ha_new.stdout | from_json).result != 'ok'
41
42 - name: Home Assistant - restic backup of {{ backup_ha_backup_path }}
43 ansible.builtin.command:
44 argv: >-
45 {{ [backup_restic_bin, 'backup', '--json', '--quiet',
46 '--host', inventory_hostname, '--tag', 'homeassistant', '--tag', 'appliance']
47 + restic_backup_args + [backup_ha_backup_path] }}
48 environment:
49 RESTIC_REPOSITORY: "{{ restic_repo }}"
50 RESTIC_PASSWORD: "{{ restic_password }}"
51 register: _ha_restic
52 changed_when: true
53
54 - name: Home Assistant - List local backups
55 ansible.builtin.command: ha backups list --raw-json
56 register: _ha_list
57 changed_when: false
58
59 - name: Home Assistant - Remove all but the newest {{ backup_ha_keep_local }} backups made by this role
60 ansible.builtin.command:
61 argv: [ha, backups, remove, "{{ item.slug }}"]
62 loop: >-
63 {{ ((_ha_list.stdout | from_json).data.backups
64 | selectattr('name', 'match', backup_ha_name_prefix ~ '[0-9]')
65 | sort(attribute='date', reverse=true) | list)[backup_ha_keep_local:] }}
66 loop_control:
67 label: "{{ item.name }} ({{ item.slug }})"
68 changed_when: true
69
70 - name: Home Assistant - Record the result
71 ansible.builtin.set_fact:
72 _ha_summary: >-
73 {{ (_ha_restic.stdout_lines | select('search', '"message_type":"summary"') | list | last | default('{}')) | from_json }}
74 _ha_error: ""
75
76 rescue:
77 - name: Home Assistant - Record the failure
78 ansible.builtin.set_fact:
79 _ha_summary: {}
80 _ha_error: >-
81 {{ (ansible_failed_result.stderr | default(ansible_failed_result.msg | default('unknown error')))
82 | regex_replace('rest:[^ ]*', 'rest:<repo>') | truncate(300) }}
83
84- name: Home Assistant - Add to the results
85 ansible.builtin.set_fact:
86 backup_results: >-
87 {{ backup_results + [{
88 'name': 'homeassistant', 'kind': 'appliance',
89 'status': 'failed' if _ha_error else 'ok', 'error': _ha_error,
90 'snapshot': _ha_summary.snapshot_id | default(''),
91 'added_bytes': _ha_summary.data_added | default(0),
92 'processed_bytes': _ha_summary.total_bytes_processed | default(0),
93 'files': _ha_summary.total_files_processed | default(0),
94 'seconds': (_ha_summary.total_duration | default(0)) | round(1),
95 'stopped': [] }] }}
96 when: _ha_selected | bool
97
98# ==============================================================================
99# OPNSENSE
100# ==============================================================================
101# The API user needs the "Diagnostics: Configuration History" privilege
102# (it covers api/core/backup/*). "System: Configuration: Backups" only
103# grants the legacy diag_backup.php page and is not enough.
104
105- name: OPNsense backup
106 when: _opn_selected | bool
107 block:
108 - name: OPNsense - Create the staging directory
109 ansible.builtin.file:
110 path: "{{ backup_appliance_staging_dir }}/opnsense"
111 state: directory
112 owner: root
113 group: root
114 mode: '0700' # config.xml contains secrets
115
116 - name: OPNsense - Download config.xml through the API
117 ansible.builtin.uri:
118 url: "https://{{ backup_opnsense_host }}/api/core/backup/download/this"
119 user: "{{ backup_opnsense_api_key }}"
120 password: "{{ backup_opnsense_api_secret }}"
121 force_basic_auth: true
122 validate_certs: "{{ backup_opnsense_validate_certs }}"
123 dest: "{{ backup_appliance_staging_dir }}/opnsense/config-{{ now().strftime('%Y-%m-%d') }}.xml"
124 mode: '0600'
125
126 - name: OPNsense - List downloaded configs
127 ansible.builtin.find:
128 paths: "{{ backup_appliance_staging_dir }}/opnsense"
129 patterns: "config-*.xml"
130 register: _opn_files
131
132 - name: OPNsense - Keep the newest {{ backup_opnsense_keep_local }} configs locally
133 ansible.builtin.file:
134 path: "{{ item.path }}"
135 state: absent
136 loop: "{{ (_opn_files.files | sort(attribute='mtime', reverse=true) | list)[backup_opnsense_keep_local:] }}"
137 loop_control:
138 label: "{{ item.path | basename }}"
139
140 - name: OPNsense - restic backup
141 ansible.builtin.command:
142 argv: >-
143 {{ [backup_restic_bin, 'backup', '--json', '--quiet',
144 '--host', inventory_hostname, '--tag', 'opnsense', '--tag', 'appliance']
145 + restic_backup_args + [backup_appliance_staging_dir ~ '/opnsense'] }}
146 environment:
147 RESTIC_REPOSITORY: "{{ restic_repo }}"
148 RESTIC_PASSWORD: "{{ restic_password }}"
149 register: _opn_restic
150 changed_when: true
151
152 - name: OPNsense - Record the result
153 ansible.builtin.set_fact:
154 _opn_summary: >-
155 {{ (_opn_restic.stdout_lines | select('search', '"message_type":"summary"') | list | last | default('{}')) | from_json }}
156 _opn_error: ""
157
158 rescue:
159 - name: OPNsense - Record the failure
160 ansible.builtin.set_fact:
161 _opn_summary: {}
162 _opn_error: >-
163 {{ (ansible_failed_result.msg | default(ansible_failed_result.stderr | default('unknown error')))
164 | regex_replace('rest:[^ ]*', 'rest:<repo>') | truncate(300) }}
165
166- name: OPNsense - Add to the results
167 ansible.builtin.set_fact:
168 backup_results: >-
169 {{ backup_results + [{
170 'name': 'opnsense', 'kind': 'appliance',
171 'status': 'failed' if _opn_error else 'ok', 'error': _opn_error,
172 'snapshot': _opn_summary.snapshot_id | default(''),
173 'added_bytes': _opn_summary.data_added | default(0),
174 'processed_bytes': _opn_summary.total_bytes_processed | default(0),
175 'files': _opn_summary.total_files_processed | default(0),
176 'seconds': (_opn_summary.total_duration | default(0)) | round(1),
177 'stopped': [] }] }}
178 when: _opn_selected | bool
179