139 lines
4.3 KiB
Bash
139 lines
4.3 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
cd "$ROOT_DIR"
|
|
|
|
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
|
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
|
|
APP_VERSION="${APP_VERSION:-0.9.1}"
|
|
DATABASE_URL="${DATABASE_URL:-}"
|
|
|
|
if [[ -z "$DATABASE_URL" ]]; then
|
|
echo "DATABASE_URL ist erforderlich." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v pg_dump >/dev/null 2>&1; then
|
|
echo "pg_dump ist nicht verfuegbar." >&2
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$BACKUP_DIR" "$STORAGE_HOST_PATH"
|
|
|
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|
FILENAME="olympus-backup-${TIMESTAMP}.zip"
|
|
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")"
|
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
|
|
|
export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME
|
|
|
|
python3 - <<'PY'
|
|
from pathlib import Path
|
|
import os
|
|
import shutil
|
|
|
|
source = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
|
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
|
target = Path(os.environ["WORK_DIR"]).resolve() / "storage"
|
|
source.mkdir(parents=True, exist_ok=True)
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
|
|
for source_path in sorted(source.rglob("*")):
|
|
if source_path == backup_dir or backup_dir in source_path.parents:
|
|
continue
|
|
relative = source_path.relative_to(source)
|
|
destination = target / relative
|
|
if source_path.is_dir():
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
if source_path.is_file():
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, destination)
|
|
PY
|
|
|
|
PG_DUMP_URL="$(python3 - <<'PY'
|
|
import os
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
database_url = os.environ["DATABASE_URL"]
|
|
parts = urlsplit(database_url)
|
|
scheme = parts.scheme.split("+", 1)[0]
|
|
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
|
|
PY
|
|
)"
|
|
|
|
pg_dump \
|
|
--format=custom \
|
|
--no-owner \
|
|
--no-privileges \
|
|
--file="$WORK_DIR/database.dump" \
|
|
--dbname="$PG_DUMP_URL"
|
|
|
|
python3 - <<'PY'
|
|
from datetime import datetime, UTC
|
|
from hashlib import sha256
|
|
from pathlib import Path
|
|
import json
|
|
import os
|
|
from urllib.parse import urlsplit
|
|
import zipfile
|
|
|
|
work_dir = Path(os.environ["WORK_DIR"]).resolve()
|
|
storage_dir = work_dir / "storage"
|
|
dump_path = work_dir / "database.dump"
|
|
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
|
filename = os.environ["FILENAME"]
|
|
archive_path = backup_dir / filename
|
|
database_url = urlsplit(os.environ["DATABASE_URL"])
|
|
|
|
def update_digest_from_file(digest, file_path: Path) -> None:
|
|
with file_path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
|
|
checksum = sha256()
|
|
checksum.update(b"database.dump")
|
|
update_digest_from_file(checksum, dump_path)
|
|
|
|
file_count = 1
|
|
total_size = dump_path.stat().st_size
|
|
for file_path in sorted(storage_dir.rglob("*")):
|
|
if file_path.is_dir():
|
|
continue
|
|
checksum.update(file_path.relative_to(work_dir).as_posix().encode("utf-8"))
|
|
update_digest_from_file(checksum, file_path)
|
|
file_count += 1
|
|
total_size += file_path.stat().st_size
|
|
|
|
host = database_url.hostname or "unknown"
|
|
manifest = {
|
|
"backup_id": sha256(f"{filename}:{datetime.now(UTC).isoformat()}".encode("utf-8")).hexdigest()[:24],
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"app_version": os.environ["APP_VERSION"],
|
|
"backup_type": "full",
|
|
"database_url_host_anonymized": f"sha256:{sha256(host.encode('utf-8')).hexdigest()[:12]}",
|
|
"database_name": database_url.path.rsplit("/", 1)[-1] or "unknown",
|
|
"storage_base_path": os.environ["STORAGE_HOST_PATH"],
|
|
"included_sections": ["database", "storage"],
|
|
"file_count": file_count,
|
|
"total_size_bytes": total_size,
|
|
"checksum_sha256": checksum.hexdigest(),
|
|
"created_by_user_id": None,
|
|
"created_by_username": "cli",
|
|
}
|
|
|
|
manifest_path = work_dir / "manifest.json"
|
|
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=True), encoding="utf-8")
|
|
|
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
archive.writestr("storage/", "")
|
|
archive.write(manifest_path, "manifest.json")
|
|
archive.write(dump_path, "database.dump")
|
|
for file_path in sorted(storage_dir.rglob("*")):
|
|
if file_path.is_dir():
|
|
continue
|
|
archive.write(file_path, file_path.relative_to(work_dir).as_posix())
|
|
PY
|
|
|
|
echo "Backup erstellt: ${BACKUP_DIR%/}/${FILENAME}"
|