Files
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

156 lines
7.2 KiB
Bash

#!/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"