156 lines
7.2 KiB
Bash
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"
|