Last active 3 months ago

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

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

No changes

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

No changes

MrGeneration's Avatar MrGeneration revised this gist 3 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 4 months ago. Go to revision

1 file changed, 1 insertion

init(file created)

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