#!/bin/sh set -eu umask 077 usage() { echo "Usage: backup.sh --project NAME --output /absolute/new-directory --application-version VERSION --application-commit COMMIT [--env-file /absolute/path] [--dry-run]" >&2 } PROJECT='' OUTPUT='' APP_VERSION='' APP_COMMIT='' ENV_FILE='' DRY_RUN=false while [ "$#" -gt 0 ]; do case "$1" in --project) PROJECT=${2-}; shift 2 ;; --output) OUTPUT=${2-}; shift 2 ;; --application-version) APP_VERSION=${2-}; shift 2 ;; --application-commit) APP_COMMIT=${2-}; shift 2 ;; --env-file) ENV_FILE=${2-}; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; *) usage; exit 64 ;; esac done case "$PROJECT" in ''|*[!a-zA-Z0-9_-]*) echo 'Invalid Compose project name.' >&2; exit 64 ;; esac case "$OUTPUT" in /*) ;; *) echo 'Backup output must be an absolute path.' >&2; exit 64 ;; esac [ "$OUTPUT" != '/' ] || { echo 'Backup output cannot be the filesystem root.' >&2; exit 64; } [ -n "$APP_VERSION" ] || { echo 'Application version is required.' >&2; exit 64; } case "$APP_COMMIT" in ???????*) ;; *) echo 'Application commit must contain at least seven characters.' >&2; exit 64 ;; esac if [ -n "$ENV_FILE" ]; then case "$ENV_FILE" in /*) ;; *) echo 'Environment file must be an absolute path.' >&2; exit 64 ;; esac [ -f "$ENV_FILE" ] || { echo 'Environment file does not exist.' >&2; exit 66; } fi [ ! -e "$OUTPUT" ] || { echo 'Backup output already exists; refusing to overwrite it.' >&2; exit 73; } [ -d "$(dirname "$OUTPUT")" ] || { echo 'Backup parent directory does not exist.' >&2; exit 73; } if "$DRY_RUN"; then printf 'Validated backup target for Compose project %s at %s\n' "$PROJECT" "$OUTPUT" exit 0 fi for command in docker python3 sha256sum; do command -v "$command" >/dev/null 2>&1 || { echo "Required command missing: $command" >&2; exit 69; } done compose() { if [ -n "$ENV_FILE" ]; then docker compose -p "$PROJECT" --env-file "$ENV_FILE" "$@" else docker compose -p "$PROJECT" "$@" fi } POSTGRES_CONTAINER=$(compose ps -q postgres) [ -n "$POSTGRES_CONTAINER" ] || { echo 'PostgreSQL service is not created.' >&2; exit 69; } [ "$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$POSTGRES_CONTAINER")" = "$PROJECT" ] || { echo 'Resolved PostgreSQL container does not belong to the requested project.' >&2; exit 69; } POSTGRES_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$POSTGRES_CONTAINER") WEB_CONTAINER=$(compose ps -q web) [ -n "$WEB_CONTAINER" ] || { echo 'Web service is not created.' >&2; exit 69; } ARTIFACT_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/artifacts"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER") OPERATOR_VOLUME=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/operator-content"}}{{.Name}}{{end}}{{end}}' "$WEB_CONTAINER") for volume in "$POSTGRES_VOLUME" "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do [ -n "$volume" ] || { echo 'A required persistent volume could not be resolved.' >&2; exit 69; } [ "$(docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$volume")" = "$PROJECT" ] || { echo "Volume $volume is outside the requested Compose project." >&2; exit 69; } done mkdir -m 700 "$OUTPUT" WEB_WAS_RUNNING=false WORKER_WAS_RUNNING=false [ -n "$(compose ps --status running -q web)" ] && WEB_WAS_RUNNING=true [ -n "$(compose ps --status running -q worker)" ] && WORKER_WAS_RUNNING=true resume_services() { "$WEB_WAS_RUNNING" && compose start web >/dev/null "$WORKER_WAS_RUNNING" && compose start worker >/dev/null } resume_on_exit() { STATUS=$? trap - EXIT HUP INT TERM resume_services exit "$STATUS" } trap resume_on_exit EXIT HUP INT TERM compose stop web worker >/dev/null compose exec -T postgres pg_dump --username devrunbook --dbname devrunbook --format custom --no-owner --no-privileges > "$OUTPUT/database.dump" POSTGRES_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER") WEB_IMAGE=$(docker inspect -f '{{.Config.Image}}' "$WEB_CONTAINER") ARCHIVE_UID_GID=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --entrypoint sh "$WEB_IMAGE" -c 'printf "%s:%s" "$(id -u)" "$(id -g)"') case "$ARCHIVE_UID_GID" in *[!0-9:]*) echo 'Web image returned an invalid archive UID/GID.' >&2; exit 69 ;; esac for volume in "$ARTIFACT_VOLUME" "$OPERATOR_VOLUME"; do if ! SYMLINK=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" \ -v "$volume:/source:ro" --entrypoint sh "$POSTGRES_IMAGE" \ -c 'find /source -type l -print -quit'); then echo "Persistent volume $volume could not be read completely." >&2 exit 74 fi [ -z "$SYMLINK" ] || { echo "Persistent volume $volume contains a symbolic link; refusing to archive it." >&2; exit 65; } done if ! docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" -v "$ARTIFACT_VOLUME:/source:ro" \ --entrypoint tar "$POSTGRES_IMAGE" -C /source -czf - . > "$OUTPUT/artifacts.tar.gz"; then echo 'Artifact volume archive failed.' >&2 exit 74 fi if ! docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ --user "$ARCHIVE_UID_GID" -v "$OPERATOR_VOLUME:/source:ro" \ --entrypoint tar "$POSTGRES_IMAGE" -C /source -czf - . > "$OUTPUT/operator-content.tar.gz"; then echo 'Operator-content volume archive failed.' >&2 exit 74 fi MIGRATION_COUNT=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \ "select count(*) from drizzle.__drizzle_migrations") POSTGRES_VERSION=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \ "show server_version") KEY_VERSIONS=$(compose exec -T postgres psql --username devrunbook --dbname devrunbook --tuples-only --no-align --command \ "select distinct key_version from integration_secrets order by key_version") export APP_VERSION APP_COMMIT PROJECT MIGRATION_COUNT POSTGRES_VERSION KEY_VERSIONS python3 - "$OUTPUT/metadata.json" <<'PY' import datetime, json, os, sys metadata = { "schemaVersion": 1, "createdAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "applicationVersion": os.environ["APP_VERSION"], "applicationCommit": os.environ["APP_COMMIT"], "composeProject": os.environ["PROJECT"], "postgresVersion": os.environ["POSTGRES_VERSION"].strip(), "migrationCount": int(os.environ["MIGRATION_COUNT"].strip()), "integrationEncryptionKeyVersionsRequired": [v for v in os.environ["KEY_VERSIONS"].splitlines() if v], "secretsIncluded": False, "files": { name: os.path.getsize(os.path.join(os.path.dirname(sys.argv[1]), name)) for name in ("database.dump", "artifacts.tar.gz", "operator-content.tar.gz") }, } with open(sys.argv[1], "x", encoding="utf-8", newline="\n") as output: json.dump(metadata, output, indent=2, sort_keys=True) output.write("\n") PY ( cd "$OUTPUT" sha256sum database.dump artifacts.tar.gz operator-content.tar.gz metadata.json > SHA256SUMS chmod 600 database.dump artifacts.tar.gz operator-content.tar.gz metadata.json SHA256SUMS ) trap - EXIT HUP INT TERM resume_services printf 'Backup created at %s. Encryption keys were not included.\n' "$OUTPUT"