This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user