Last active 2 months ago

As shown on https://youtu.be/P-Pr7Dy6mP8

MrGeneration's Avatar MrGeneration revised this gist 10 months ago. Go to revision

No changes

MrGeneration's Avatar MrGeneration revised this gist 10 months ago. Go to revision

No changes

MrGeneration's Avatar MrGeneration revised this gist 10 months ago. Go to revision

3 files changed, 426 insertions, 1 deletion

README(file created)

@@ -0,0 +1,16 @@
1 + Last change: 2025-09-10 Marcel Herrguth
2 + As shown on https://youtu.be/P-Pr7Dy6mP8
3 + Attribution back to this GIST is required!
4 +
5 + Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup
6 + Only PostGreSQL is supported; No symlinks are supported
7 +
8 + Restoration only works on a pre-installed Zammad installation of the same version (or higher).
9 + During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty.
10 +
11 +
12 + COMMAND REFERENCE
13 + - backup: creates a new snapshot and backs up database and storage.
14 + - restore <snapshot>: Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used.
15 + - list_snapshots: List all available snapshot IDs.
16 + - verify_health <percentage>: Verify the health of your backup repository. If you omit percentage, 100% will be assumed.

init (file deleted)

@@ -1 +0,0 @@
1 - TODO

restic_zammad.sh(file created)

@@ -0,0 +1,410 @@
1 + #!/usr/bin/env bash
2 + #
3 + # 2025-09-06 Marcel Herrguth
4 + # As shown on https://youtu.be/P-Pr7Dy6mP8
5 + # Attribution back to this GIST is required!
6 + #
7 + # Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup
8 + # Only PostGreSQL is supported; No symlinks are supported
9 + #
10 + # Restoration only works on a pre-installed Zammad installation of the same version (or higher).
11 + # During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty.
12 +
13 + #----- CONFIG -------------------------------------------------------------------------------#
14 + # Use either a directory or a supported repository type (like s3:https//domain.tld/bucket)
15 + BACKUP_REPOSITORY='s3:https://s3.domain.tld/resticsample'
16 +
17 + # Zammad Installation directory
18 + ZAMMAD_DIR='/opt/zammad'
19 +
20 + # Backup either the whole Zammad directory (yes) or just the storage folder (attachments; no)
21 + # Storage folder only is best practice as per documentation
22 + # https://docs.zammad.org/en/latest/appendix/backup-and-restore/index.html
23 + FULL_FS_DUMP='no'
24 +
25 + # How many daily and hourly snapshot should stay available?
26 + HOLD_DAYS=14
27 + HOLD_HOURLY=1
28 +
29 + # Do you want to cause downtime during backup? (This is the safest backup way)
30 + STOP_DURING_BACKUP='yes'
31 + #----- CONFIG END ---------------------------------------------------------------------------#
32 +
33 + # ---- Magic --------------------------------------------------------------------------------#
34 + function pre_flight () {
35 + # Verify that we have all we need.
36 + if [[ ""$BACKUP_REPOSITORY"" == s3:* ]]; then
37 + if [[ -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
38 + echo "You have chosen to backup to S3 (it seems), but either AWS_ACCESS_KEY_ID and/or AWS_SECRET_ACCESS_KEY."
39 + exit 1
40 + fi
41 + else
42 + echo "This script has been tested with S3 only. This should be fine."
43 + echo "Ensure that the required environment information for restic are available as per their documentation:"
44 + echo "https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html"
45 + fi
46 +
47 + if [[ -z "${RESTIC_PASSWORD}" ]]; then
48 + echo "You forgot to set RESTIC_PASSWORD which is mandatory for your backup repository!"
49 + exit 1
50 + fi
51 + }
52 +
53 + function start_zammad () {
54 + echo "# Starting Zammad"
55 + systemctl start zammad
56 + }
57 +
58 + function stop_zammad () {
59 + echo "# Stopping Zammad"
60 + systemctl stop zammad
61 + }
62 +
63 + function get_db_credentials () {
64 + DB_HOST="$(grep -m 1 '^[[:space:]]*host:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*host:[[:space:]]*//g')"
65 + DB_PORT="$(grep -m 1 '^[[:space:]]*port:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*port:[[:space:]]*//g')"
66 + DB_NAME="$(grep -m 1 '^[[:space:]]*database:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*database:[[:space:]]* //g')"
67 + DB_USER="$(grep -m 1 '^[[:space:]]*username:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*username:[[:space:]]*//g')"
68 + DB_PASS="$(grep -m 1 '^[[:space:]]*password:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*password:[[:space:]]*//g')"
69 +
70 + # Ensure that HOST and PORT are not empty, provide defaults if needed.
71 + if [ "${DB_HOST}x" == "x" ]; then
72 + DB_HOST="localhost"
73 + fi
74 + if [ "${DB_PORT}x" == "x" ]; then
75 + DB_PORT="5432"
76 + fi
77 + }
78 +
79 + function create_pgpassfile() {
80 + PGPASSFILE=$(mktemp)
81 + export PGPASSFILE
82 + chmod 600 "$PGPASSFILE"
83 + cat <<EOT > "$PGPASSFILE"
84 + *:*:${DB_NAME}:${DB_USER}:${DB_PASS}
85 + EOT
86 + trap 'rm "$PGPASSFILE"' EXIT
87 + }
88 +
89 + function kind_exit () {
90 + # We're nice to our admin and bring Zammad back up before exiting
91 + start_zammad
92 + exit 1
93 + }
94 +
95 + function db_helper_alter_user () {
96 + # Get DB credentials
97 + get_db_credentials
98 +
99 + if [ "${DB_PASS}x" == "x" ]; then
100 + echo "# Found an empty password - I'll be fixing this for you ..."
101 +
102 + DB_PASS="$(tr -dc A-Za-z0-9 < /dev/urandom | head -c10)"
103 +
104 + sed -e "s/.*username:.*/ username: ${DB_USER}/" \
105 + -e "s/.*password:.*/ password: ${DB_PASS}/" \
106 + -e "s/.*database:.*/ database: ${DB_NAME}/" < ${ZAMMAD_DIR}/contrib/packager.io/database.yml.pkgr > ${ZAMMAD_DIR}/config/database.yml
107 +
108 + echo "# ... Fixing permission database.yml"
109 + chown zammad:zammad ${ZAMMAD_DIR}/config/database.yml
110 + fi
111 +
112 + if [ "${DB_USER}x" == "x" ]; then
113 +
114 + echo "ERROR - Your configuration file does not seem to contain a username."
115 + echo "Aborting the script - double check your installation."
116 +
117 + kind_exit
118 + fi
119 +
120 + if [[ ("${DB_HOST}" == "localhost") || "${DB_HOST}" == "127.0.0.1" || "${DB_HOST}" == "::1" ]]; then
121 + # Looks like a local pgsql installation - let's continue
122 + su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres
123 + state=$?
124 +
125 + else
126 + echo "You don't seem to be using a local PostgreSQL installation."
127 + echo "This script does not support your installation. No changes were done."
128 + echo "Please ensure that your database.yml contains a database user password and that the users password fits."
129 +
130 + kind_exit
131 + fi
132 +
133 + if [ "${state}" != "0" ]; then
134 + echo "ERROR - Our previous command returned an unhandled error code."
135 + kind_exit
136 + fi
137 + }
138 +
139 + function check_for_empty_password() {
140 + if [ "${DB_PASS}x" == "x" ]; then
141 + echo "# ERROR - Found an empty database password ..."
142 + db_helper_alter_user
143 + exit 2
144 + fi
145 + }
146 +
147 + function check_database_config_exists () {
148 + if [ -f ${ZAMMAD_DIR}/config/database.yml ]; then
149 + get_db_credentials
150 + else
151 + echo -e "${ZAMMAD_DIR}/config/database.yml is missing. is Zammad installed and configured yet? \nAborting..."
152 + exit 1
153 + fi
154 + }
155 +
156 + function delete_old_backups () {
157 + echo "# Invoking cleanup as per snapshot rules"
158 + restic -r "$BACKUP_REPOSITORY" forget --group-by '' --keep-hourly $HOLD_HOURLY --keep-daily $HOLD_DAYS --prune
159 + }
160 +
161 + function write_backup () {
162 + stop_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'
163 + echo "# Creating postgresql backup..."
164 +
165 + create_pgpassfile
166 +
167 + pg_dump --dbname "${DB_NAME}" \
168 + --username "${DB_USER}" \
169 + ${DB_HOST:+--host $DB_HOST} \
170 + ${DB_PORT:+--port $DB_PORT} \
171 + --no-privileges --no-owner > /tmp/zammad_db.psql
172 +
173 + state=$?
174 +
175 + if [ "${state}" == "1" ]; then
176 + echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
177 + echo -e " #-> BACKUP WAS NOT SUCCESSFUL"
178 + exit 2
179 + fi
180 +
181 + if [ "${FULL_FS_DUMP}" == 'no' ]; then
182 + BACKUP_DIR="${ZAMMAD_DIR}/storage/"
183 +
184 + if [ ! -d "${ZAMMAD_DIR}/storage/" ]; then
185 + # Admin has requested an attachment backup only, however, there is no storage
186 + # directory. We'll warn Mr.Admin and create the directory as workaround.
187 + echo " ... WARNING: You don't seem to have any attachments in the file system!"
188 + echo " ... This is FINE if you store your attachments either in database or S3!"
189 + echo " ... Creating empty storage directory so the backup can continue ..."
190 +
191 + mkdir -p ${ZAMMAD_DIR}/storage/
192 + chown -R zammad:zammad ${ZAMMAD_DIR}/storage/
193 + fi
194 + else
195 + BACKUP_DIR="${ZAMMAD_DIR}"
196 + fi
197 +
198 + restic -r "$BACKUP_REPOSITORY" migrate
199 + restic --no-scan --read-concurrency=25 --pack-size=128 --compression off -r "$BACKUP_REPOSITORY" backup \
200 + --exclude "${BACKUP_DIR}/tmp/*" --exclude "${BACKUP_DIR}/node_modules/" \
201 + --exclude "${BACKUP_DIR}/.git/" --exclude "${BACKUP_DIR}/.yarn/cache/" \
202 + ${BACKUP_DIR} /tmp/zammad_db.psql
203 +
204 + state=$?
205 +
206 + # clean up temporary database dump
207 + rm -f /tmp/zammad_db.psql
208 +
209 + start_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'
210 +
211 + if [ $state == '1' ]; then
212 + echo "# FATAL - Restic could not create the snapshot."
213 + elif [ $state == '3' ]; then
214 + echo "# WARNING - Files changed during snapshot creation; Snapshot potentially incomplete!"
215 + fi
216 +
217 + if [ $state -gt 0 ]; then
218 + echo "# BACKUP WAS NOT SUCCESSFUL"
219 + exit $state
220 + fi
221 + }
222 +
223 + function list_available_snapshots () {
224 + pre_flight
225 + echo "# Here's your currently available snapshots in your backup repository:"
226 + restic -r "$BACKUP_REPOSITORY" snapshots
227 + }
228 +
229 + function verify_backup_repository_health () {
230 + pre_flight
231 +
232 + VERIFY_PERCENTAGE="${1:-100}"
233 +
234 + echo "# Checking ${VERIFY_PERCENTAGE} of your repository ..."
235 + restic -r "$BACKUP_REPOSITORY" check --read-data-subset="${VERIFY_PERCENTAGE}%"
236 + }
237 +
238 + function get_snapshot_id () {
239 + if [ -n "${1}" ]; then
240 + RESTORE_SNAPSHOT_ID="${1}"
241 + else
242 + # User did not provide snapshot so we'll hard guess 'latest'
243 + RESTORE_SNAPSHOT_ID='latest'
244 +
245 + echo "... No Snapshot provided, guessing you want to restore 'latest'!"
246 + for i in {15..1}; do
247 + echo -ne "... You have $i seconds to abort.\r"; sleep 1
248 + done
249 + fi
250 + }
251 +
252 + function restore_backup () {
253 + echo "# Checking requirements"
254 +
255 + if ! id "zammad" &> /dev/null; then
256 + echo "# ERROR: User 'zammad' is not present, but should be by default! #"
257 + echo "# Restoration was NOT successful, exiting ..."
258 + exit 1
259 + fi
260 +
261 + stop_zammad
262 +
263 + echo "# ... Dropping current database ${DB_NAME}"
264 +
265 + # This step is only needed for the majority of pgsql dumps, as they don't provide a drop table statement which will cause
266 + # relation errors during restoration (as on package installation there's always an initialized database)
267 + if command -v zammad > /dev/null; then
268 + zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=1
269 + zammad run rake db:drop
270 + zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=0
271 + else
272 + DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ${ZAMMAD_DIR}/bin/rake db:drop
273 + fi
274 +
275 + echo "# ... Creating database ${DB_NAME} for owner ${DB_USER}"
276 + if command -v zammad > /dev/null; then
277 + # We'll skip this part for docker installations
278 + su -c "psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres
279 + else
280 + ${ZAMMAD_DIR}/bin/rake db:create
281 + fi
282 +
283 + echo "# Restoring PostgreSQL DB"
284 +
285 + create_pgpassfile
286 +
287 + # We're removing uncritical dump information that caused "ugly" error
288 + # messages on older script versions. These could safely be ignored.
289 + restic -r "$BACKUP_REPOSITORY" dump latest '/tmp/zammad_db.psql' | \
290 + sed '/^CREATE EXTENSION IF NOT EXISTS plpgsql/d'| \
291 + sed '/^COMMENT ON EXTENSION plpgsql/d'| \
292 + psql -q -b -o /dev/null \
293 + ${DB_HOST:+--host $DB_HOST} ${DB_PORT:+--port $DB_PORT} ${DB_USER:+--username $DB_USER} --dbname ${DB_NAME}
294 +
295 + state=$?
296 +
297 + if [[ ("${state}" == "1") || ( "${state}" == "2") || ( "${state}" == "3") ]]; then
298 + # We're checking for critical restoration errors
299 + # It may not cover all possible errors which is out of scope of this script
300 + echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
301 + echo -e " #-> RESTORE WAS NOT SUCCESSFUL"
302 + exit 2
303 + fi
304 +
305 + if [ "${FULL_FS_DUMP}" == 'no' ]; then
306 + BACKUP_DIR="${ZAMMAD_DIR}/storage/"
307 + else
308 + BACKUP_DIR="${ZAMMAD_DIR}"
309 + fi
310 +
311 + echo "# Restoring Files"
312 + restic -r "$BACKUP_REPOSITORY" restore $RESTORE_SNAPSHOT_ID --include "$BACKUP_DIR" --target /
313 +
314 + state=$?
315 +
316 + if [[ ($state == '1') || ($state == '2') ]]; then
317 + echo "# ERROR(${state}) - File restore reported an error."
318 + echo "- Check file permissions, and ensure Zammad IS NOT running, and try again."
319 + echo -e " \n# RESTORE WAS NOT SUCCESSFUL"
320 + exit 1
321 + fi
322 +
323 + echo "# Ensuring correct file permissions ..."
324 + chown -R zammad:zammad ${ZAMMAD_DIR}
325 +
326 + # Ensure all data is loaded from the restored database and not the cache of the previous system state
327 + echo "# Clearing Cache ..."
328 + if command -v zammad > /dev/null; then
329 + zammad run rails r "Rails.cache.clear"
330 + else
331 + ${ZAMMAD_DIR}/bin/rails r "Rails.cache.clear"
332 + fi
333 +
334 + echo "# Attempting to execute database migrations"
335 + if command -v zammad > /dev/null; then
336 + zammad run rake db:migrate
337 + else
338 + ${ZAMMAD_DIR}/bin/rake db:migrate
339 + fi
340 +
341 + start_zammad
342 + }
343 +
344 + function start_backup_message () {
345 + echo -e "\n# Backup script started - $(date)!\n"
346 + }
347 +
348 + function start_restore_message () {
349 + echo -e "\n# Restore script started - $(date)!\n"
350 + }
351 +
352 + function finished_backup_message () {
353 + echo -e "\n# Backup script finished; Check output! - $(date)!\n"
354 + }
355 +
356 + function finished_restore_message () {
357 + echo -e "\n# Restore script finished; Check output! - $(date)!\n"
358 + }
359 +
360 + function execute_backup () {
361 + pre_flight
362 + start_backup_message
363 + check_database_config_exists
364 + check_for_empty_password
365 + write_backup
366 + delete_old_backups
367 + finished_backup_message
368 + }
369 +
370 + function execute_restoration () {
371 + pre_flight
372 + check_database_config_exists
373 + check_for_empty_password
374 + start_restore_message
375 + get_snapshot_id $1
376 + restore_backup
377 + finished_restore_message
378 + }
379 +
380 + function command_reference () {
381 + echo "COMMAND REFERENCE"
382 + echo "- backup: creates a new snapshot and backs up database and storage."
383 + echo "- restore <snapshot>: Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used."
384 + echo "- list_snapshots: List all available snapshot IDs."
385 + echo "- verify_health <percentage>: Verify the health of your backup repository. If you omit percentage, 100% will be assumed."
386 + }
387 +
388 + # ---- Option part and control --------------------------------------------------------------#
389 +
390 + case "$1" in
391 + backup)
392 + execute_backup
393 + ;;
394 +
395 + restore)
396 + execute_restoration $2
397 + ;;
398 +
399 + list_snapshots)
400 + list_available_snapshots
401 + ;;
402 +
403 + verify_health)
404 + verify_backup_repository_health "$2"
405 + ;;
406 +
407 + *)
408 + command_reference
409 + ;;
410 + esac

MrGeneration's Avatar MrGeneration revised this gist 10 months ago. Go to revision

1 file changed, 1 insertion

init(file created)

@@ -0,0 +1 @@
1 + TODO
Newer Older