453 lines
13 KiB
Bash
453 lines
13 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
ENV_FILE="$ROOT_DIR/.env"
|
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
|
SOURCE_DIR="$ROOT_DIR/runtime/source"
|
|
BACKUP_ROOT="$ROOT_DIR/backups"
|
|
BACKUP_NAME="nyone-backup-$(date -u +%Y%m%d-%H%M%S)"
|
|
STOP_WRITERS=false
|
|
FORCE=false
|
|
STAGE_DIR=""
|
|
RESTART_WRITERS=false
|
|
|
|
info() {
|
|
printf '[nyone-backup] %s\n' "$*"
|
|
}
|
|
|
|
warn() {
|
|
printf '[nyone-backup] WARNING: %s\n' "$*" >&2
|
|
}
|
|
|
|
fail() {
|
|
printf '[nyone-backup] ERROR: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
Usage:
|
|
scripts/backup.sh [options]
|
|
|
|
Options:
|
|
--output-dir PATH Directory for the final zip file. Default: dockerized/backups
|
|
--name NAME Backup folder/zip base name. Default: nyone-backup-UTC_TIMESTAMP
|
|
--stop-writers Stop app, MediaMTX, queue, scheduler, and viewer-sync while files are archived
|
|
--force Replace an existing zip with the same name
|
|
-h, --help Show this help
|
|
|
|
The script creates one restore-ready zip containing:
|
|
- dockerized deployment files and .env
|
|
- runtime/source checkout when present, excluding dependencies and Laravel runtime cache/log files
|
|
- PostgreSQL SQL dump
|
|
- app storage volume archive
|
|
- Redis data volume archive
|
|
- restore.sh and README_RESTORE.md
|
|
USAGE
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
|
|
}
|
|
|
|
env_value() {
|
|
local key="$1"
|
|
|
|
if [ ! -f "$ENV_FILE" ]; then
|
|
return 0
|
|
fi
|
|
|
|
awk -v key="$key" '
|
|
index($0, key "=") == 1 {
|
|
value = substr($0, length(key) + 2)
|
|
}
|
|
END {
|
|
print value
|
|
}
|
|
' "$ENV_FILE"
|
|
}
|
|
|
|
run_compose() {
|
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
|
}
|
|
|
|
cleanup() {
|
|
local exit_code=$?
|
|
|
|
if [ "$RESTART_WRITERS" = true ]; then
|
|
info "Restarting writer services."
|
|
run_compose up -d app mediamtx queue scheduler viewer-sync >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
if [ -n "$STAGE_DIR" ] && [[ "$STAGE_DIR" == "$BACKUP_ROOT"/.*.stage ]]; then
|
|
rm -rf "$STAGE_DIR"
|
|
fi
|
|
|
|
exit "$exit_code"
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--output-dir)
|
|
[ "$#" -ge 2 ] || fail "--output-dir requires a value."
|
|
BACKUP_ROOT="$2"
|
|
shift 2
|
|
;;
|
|
--name)
|
|
[ "$#" -ge 2 ] || fail "--name requires a value."
|
|
BACKUP_NAME="$2"
|
|
shift 2
|
|
;;
|
|
--stop-writers)
|
|
STOP_WRITERS=true
|
|
shift
|
|
;;
|
|
--force)
|
|
FORCE=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
fail "Unknown option: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "$BACKUP_NAME" in
|
|
*/*|*\\*)
|
|
fail "--name must be a file name, not a path."
|
|
;;
|
|
esac
|
|
|
|
[ -f "$ENV_FILE" ] || fail "$ENV_FILE was not found. Create it before running a backup."
|
|
[ -f "$COMPOSE_FILE" ] || fail "$COMPOSE_FILE was not found."
|
|
|
|
require_command docker
|
|
require_command tar
|
|
require_command gzip
|
|
require_command zip
|
|
require_command sha256sum
|
|
|
|
mkdir -p "$BACKUP_ROOT"
|
|
BACKUP_ROOT="$(cd "$BACKUP_ROOT" && pwd)"
|
|
STAGE_DIR="$BACKUP_ROOT/.${BACKUP_NAME}.stage"
|
|
ARCHIVE_DIR="$STAGE_DIR/$BACKUP_NAME"
|
|
ZIP_PATH="$BACKUP_ROOT/$BACKUP_NAME.zip"
|
|
|
|
if [ -e "$ZIP_PATH" ] && [ "$FORCE" != true ]; then
|
|
fail "$ZIP_PATH already exists. Use --force or choose another --name."
|
|
fi
|
|
|
|
rm -rf "$STAGE_DIR"
|
|
mkdir -p "$ARCHIVE_DIR"/{database,deployment,metadata,volumes}
|
|
|
|
info "Validating Compose configuration."
|
|
run_compose config > "$ARCHIVE_DIR/metadata/compose.resolved.yml"
|
|
|
|
if [ "$STOP_WRITERS" = true ]; then
|
|
info "Stopping writer services for a consistent file backup."
|
|
run_compose stop app mediamtx queue scheduler viewer-sync >/dev/null || true
|
|
RESTART_WRITERS=true
|
|
fi
|
|
|
|
info "Writing PostgreSQL dump."
|
|
run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges' \
|
|
| gzip -9 > "$ARCHIVE_DIR/database/postgres.sql.gz"
|
|
|
|
info "Archiving app storage volume."
|
|
run_compose run --rm --no-deps -T --user root --entrypoint tar \
|
|
-v "$ARCHIVE_DIR/volumes:/backup" \
|
|
app -czf /backup/app-storage.tar.gz -C /var/www/html/storage .
|
|
|
|
info "Archiving Redis data volume."
|
|
if ! run_compose exec -T redis redis-cli SAVE >/dev/null; then
|
|
warn "Could not force a Redis SAVE before archiving; continuing with the current volume files."
|
|
fi
|
|
|
|
run_compose run --rm --no-deps -T --user root --entrypoint tar \
|
|
-v "$ARCHIVE_DIR/volumes:/backup" \
|
|
redis -czf /backup/redis-data.tar.gz -C /data .
|
|
|
|
info "Archiving dockerized deployment files."
|
|
tar -czf "$ARCHIVE_DIR/deployment/dockerized-files.tar.gz" \
|
|
-C "$(dirname "$ROOT_DIR")" \
|
|
--exclude='dockerized/backups' \
|
|
--exclude='dockerized/runtime' \
|
|
"$(basename "$ROOT_DIR")"
|
|
|
|
if [ -d "$SOURCE_DIR" ]; then
|
|
info "Archiving runtime/source checkout."
|
|
tar -czf "$ARCHIVE_DIR/deployment/runtime-source.tar.gz" \
|
|
-C "$(dirname "$SOURCE_DIR")" \
|
|
--exclude='source/.env' \
|
|
--exclude='source/node_modules' \
|
|
--exclude='source/vendor' \
|
|
--exclude='source/public/build' \
|
|
--exclude='source/storage/app/private/recordings' \
|
|
--exclude='source/storage/framework/cache' \
|
|
--exclude='source/storage/framework/sessions' \
|
|
--exclude='source/storage/framework/testing' \
|
|
--exclude='source/storage/framework/views' \
|
|
--exclude='source/storage/logs' \
|
|
source
|
|
else
|
|
warn "$SOURCE_DIR was not found; the archive will rely on GIT_REPOSITORY_URL for source restoration."
|
|
fi
|
|
|
|
info "Writing metadata."
|
|
{
|
|
printf 'created_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
printf 'compose_project_name=%s\n' "$(env_value COMPOSE_PROJECT_NAME)"
|
|
printf 'app_domain=%s\n' "$(env_value APP_DOMAIN)"
|
|
printf 'hls_domain=%s\n' "$(env_value HLS_DOMAIN)"
|
|
printf 'git_repository_url=%s\n' "$(env_value GIT_REPOSITORY_URL)"
|
|
printf 'git_ref=%s\n' "$(env_value GIT_REF)"
|
|
printf 'backup_name=%s\n' "$BACKUP_NAME"
|
|
printf 'stop_writers=%s\n' "$STOP_WRITERS"
|
|
} > "$ARCHIVE_DIR/metadata/manifest.env"
|
|
|
|
docker version > "$ARCHIVE_DIR/metadata/docker-version.txt" 2>&1 || true
|
|
docker compose version > "$ARCHIVE_DIR/metadata/docker-compose-version.txt" 2>&1 || true
|
|
run_compose ps > "$ARCHIVE_DIR/metadata/container-status.txt" 2>&1 || true
|
|
|
|
if [ -d "$SOURCE_DIR/.git" ]; then
|
|
{
|
|
git -C "$SOURCE_DIR" remote -v
|
|
git -C "$SOURCE_DIR" rev-parse HEAD
|
|
git -C "$SOURCE_DIR" status --short
|
|
} > "$ARCHIVE_DIR/metadata/source-git.txt" 2>&1 || true
|
|
fi
|
|
|
|
cat > "$ARCHIVE_DIR/README_RESTORE.md" <<'RESTORE_README'
|
|
# Nyone Dockerized Backup Restore
|
|
|
|
This archive contains everything managed by the `dockerized/` deployment bundle:
|
|
|
|
- deployment files and `.env`
|
|
- `runtime/source` checkout when it existed at backup time
|
|
- PostgreSQL dump
|
|
- Laravel app storage volume
|
|
- Redis data volume
|
|
|
|
Host-level files outside `dockerized/`, such as Nginx site files and Let's Encrypt certificates, are not included.
|
|
|
|
## Restore on a fresh server
|
|
|
|
Install Docker and the Compose plugin first. Then extract this zip and run:
|
|
|
|
```bash
|
|
chmod +x restore.sh
|
|
./restore.sh /opt/nyone/dockerized
|
|
```
|
|
|
|
If the target already exists and you intentionally want to replace it:
|
|
|
|
```bash
|
|
./restore.sh --replace /opt/nyone/dockerized
|
|
```
|
|
|
|
The restore helper copies the deployment bundle, restores `runtime/source` when present, clones it from `.env` when missing, rebuilds the images, restores PostgreSQL, restores the app storage and Redis volumes, and starts the app services.
|
|
|
|
After restore, re-create host-level Nginx/TLS configuration if this is a new VPS.
|
|
RESTORE_README
|
|
|
|
cat > "$ARCHIVE_DIR/restore.sh" <<'RESTORE_SCRIPT'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
BACKUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
TARGET_DIR="/opt/nyone/dockerized"
|
|
REPLACE=false
|
|
TMP_DIR=""
|
|
|
|
info() {
|
|
printf '[nyone-restore] %s\n' "$*"
|
|
}
|
|
|
|
fail() {
|
|
printf '[nyone-restore] ERROR: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
Usage:
|
|
./restore.sh [--replace] [target-dir]
|
|
|
|
Example:
|
|
./restore.sh /opt/nyone/dockerized
|
|
./restore.sh --replace /opt/nyone/dockerized
|
|
USAGE
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
|
|
}
|
|
|
|
env_value() {
|
|
local key="$1"
|
|
local env_file="$TARGET_DIR/.env"
|
|
|
|
if [ ! -f "$env_file" ]; then
|
|
return 0
|
|
fi
|
|
|
|
awk -v key="$key" '
|
|
index($0, key "=") == 1 {
|
|
value = substr($0, length(key) + 2)
|
|
}
|
|
END {
|
|
print value
|
|
}
|
|
' "$env_file"
|
|
}
|
|
|
|
ensure_source_checkout() {
|
|
local repository_url
|
|
local git_ref
|
|
|
|
if [ -d "$TARGET_DIR/runtime/source" ]; then
|
|
return 0
|
|
fi
|
|
|
|
repository_url="$(env_value GIT_REPOSITORY_URL)"
|
|
git_ref="$(env_value GIT_REF)"
|
|
git_ref="${git_ref:-main}"
|
|
|
|
[ -n "$repository_url" ] || fail "runtime/source is missing and GIT_REPOSITORY_URL is not set in .env."
|
|
|
|
require_command git
|
|
|
|
info "Cloning runtime/source from GIT_REPOSITORY_URL."
|
|
mkdir -p "$TARGET_DIR/runtime"
|
|
git clone "$repository_url" "$TARGET_DIR/runtime/source"
|
|
git -C "$TARGET_DIR/runtime/source" checkout "$git_ref"
|
|
}
|
|
|
|
cleanup() {
|
|
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
|
rm -rf "$TMP_DIR"
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--replace)
|
|
REPLACE=true
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
TARGET_DIR="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[ "$TARGET_DIR" != "/" ] || fail "Refusing to restore into /."
|
|
[ -f "$BACKUP_DIR/deployment/dockerized-files.tar.gz" ] || fail "deployment/dockerized-files.tar.gz is missing."
|
|
[ -f "$BACKUP_DIR/database/postgres.sql.gz" ] || fail "database/postgres.sql.gz is missing."
|
|
[ -f "$BACKUP_DIR/volumes/app-storage.tar.gz" ] || fail "volumes/app-storage.tar.gz is missing."
|
|
|
|
require_command docker
|
|
require_command tar
|
|
require_command gzip
|
|
|
|
TARGET_PARENT="$(dirname "$TARGET_DIR")"
|
|
mkdir -p "$TARGET_PARENT"
|
|
|
|
if [ -e "$TARGET_DIR" ]; then
|
|
if [ "$REPLACE" != true ]; then
|
|
fail "$TARGET_DIR already exists. Use --replace if you want to replace it."
|
|
fi
|
|
|
|
if [ -f "$TARGET_DIR/docker-compose.yml" ] && [ -f "$TARGET_DIR/.env" ]; then
|
|
info "Stopping existing Compose stack."
|
|
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" down || true
|
|
fi
|
|
|
|
rm -rf "$TARGET_DIR"
|
|
fi
|
|
|
|
TMP_DIR="$(mktemp -d)"
|
|
info "Restoring dockerized files."
|
|
tar -xzf "$BACKUP_DIR/deployment/dockerized-files.tar.gz" -C "$TMP_DIR"
|
|
[ -d "$TMP_DIR/dockerized" ] || fail "Backup did not contain a dockerized directory."
|
|
mv "$TMP_DIR/dockerized" "$TARGET_DIR"
|
|
|
|
if [ -f "$BACKUP_DIR/deployment/runtime-source.tar.gz" ]; then
|
|
info "Restoring runtime/source."
|
|
mkdir -p "$TARGET_DIR/runtime"
|
|
tar -xzf "$BACKUP_DIR/deployment/runtime-source.tar.gz" -C "$TARGET_DIR/runtime"
|
|
fi
|
|
|
|
chmod +x "$TARGET_DIR"/scripts/*.sh
|
|
|
|
cd "$TARGET_DIR"
|
|
ensure_source_checkout
|
|
|
|
run_compose() {
|
|
docker compose --env-file "$TARGET_DIR/.env" -f "$TARGET_DIR/docker-compose.yml" "$@"
|
|
}
|
|
|
|
info "Validating Compose configuration."
|
|
run_compose config >/dev/null
|
|
|
|
info "Building Docker images."
|
|
run_compose build
|
|
|
|
info "Starting PostgreSQL."
|
|
run_compose up -d db
|
|
run_compose exec -T db sh -c 'until pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do sleep 2; done'
|
|
|
|
info "Restoring PostgreSQL dump."
|
|
gzip -dc "$BACKUP_DIR/database/postgres.sql.gz" \
|
|
| run_compose exec -T db sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1'
|
|
|
|
info "Restoring app storage volume."
|
|
run_compose run --rm --no-deps -T --user root --entrypoint sh \
|
|
-v "$BACKUP_DIR/volumes:/backup:ro" \
|
|
app -c 'find /var/www/html/storage -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/app-storage.tar.gz -C /var/www/html/storage'
|
|
|
|
if [ -f "$BACKUP_DIR/volumes/redis-data.tar.gz" ]; then
|
|
info "Restoring Redis data volume."
|
|
run_compose up -d redis
|
|
run_compose stop redis >/dev/null || true
|
|
run_compose run --rm --no-deps -T --user root --entrypoint sh \
|
|
-v "$BACKUP_DIR/volumes:/backup:ro" \
|
|
redis -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf /backup/redis-data.tar.gz -C /data'
|
|
fi
|
|
|
|
info "Starting application services."
|
|
run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync redis
|
|
run_compose ps
|
|
RESTORE_SCRIPT
|
|
|
|
chmod +x "$ARCHIVE_DIR/restore.sh"
|
|
|
|
info "Writing checksums."
|
|
(
|
|
cd "$ARCHIVE_DIR"
|
|
find deployment database volumes metadata -type f -print0 | sort -z | xargs -0 sha256sum > checksums.sha256
|
|
)
|
|
|
|
info "Creating zip archive."
|
|
rm -f "$ZIP_PATH"
|
|
(
|
|
cd "$STAGE_DIR"
|
|
zip -rq "$ZIP_PATH" "$BACKUP_NAME"
|
|
)
|
|
|
|
info "Backup ready: $ZIP_PATH"
|