Compare commits

...

5 Commits

Author SHA1 Message Date
elpatron 4eb2b4c517 chore: release v0.1.1.20 2026-06-05 19:07:30 +02:00
elpatron be3b23ed8c docs: update marketing sticker image 2026-06-05 19:07:21 +02:00
elpatron 697c5781b7 feat(deploy): Unterstützung für Staging-Backups und -Wiederherstellungen
Erweitert die Backup- und Restore-Skripte um die Möglichkeit, Staging-Umgebungen zu unterstützen. Fügt die Option `-dest stage` hinzu, um spezifische Konfigurationen für Staging zu verwenden, einschließlich separater Docker-Compose-Dateien und Datenbankcontainer. Dokumentation aktualisiert, um manuelle Tests und Umgebungsvariablen für Staging zu reflektieren.
2026-06-05 18:45:52 +02:00
elpatron 4c36c9160a feat(deploy): Server-Backup und Restore für Produktion
Automatisiert pg_dump, .env, Compose und Git-Archiv mit Tag-Zuordnung, Retention (5) und Pre-Deploy-Hook nur für Prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:40:47 +02:00
elpatron d559a762d2 fix(deploy): Vor Deploy sauberen und synchronen Git-Stand erzwingen
update-remotes.sh bricht ab, wenn uncommitted Änderungen bestehen oder
HEAD nicht mit origin übereinstimmt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:24:21 +02:00
9 changed files with 803 additions and 2 deletions
+3
View File
@@ -258,6 +258,8 @@ Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
@@ -277,6 +279,7 @@ Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `R
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
+1 -1
View File
@@ -1 +1 @@
0.1.1.20
0.1.1.21
+111
View File
@@ -0,0 +1,111 @@
# Server-Backup (Produktion)
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
## Was wird gesichert?
| Inhalt | Beschreibung |
|--------|--------------|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
| `docker-compose.yml` | Aktive Compose-Datei |
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
## Einmalige Einrichtung (Prod-Server)
```bash
ssh root@10.0.0.25
mkdir -p /var/backups/kapteins-daagbok
chmod 700 /var/backups/kapteins-daagbok
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/backup.sh scripts/restore-backup.sh
./scripts/backup.sh --reason manual
```
## Manuell sichern
```bash
cd /opt/kapteins-daagbok
./scripts/backup.sh
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
```
### Staging (manueller Test)
```bash
cd /opt/kapteins-daagbok-staging
./scripts/backup.sh -dest stage --reason manual
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
./scripts/backup.sh --reason manual
```
## Crontab (unbeaufsichtigt)
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
```bash
crontab -e
# Zeile einfügen:
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
```
## Pre-Deploy-Backup
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
## Wiederherstellen
Verfügbare Backups anzeigen:
```bash
./scripts/restore-backup.sh --list
```
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
```bash
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
```
Nur Datenbank:
```bash
./scripts/restore-backup.sh --restore PATH --db-only
```
Nur `.env`:
```bash
./scripts/restore-backup.sh --restore PATH --env-only
```
Ohne Rückfragen (Notfall):
```bash
./scripts/restore-backup.sh --restore PATH --full --yes
```
## Vor Passwort-Rotation
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
```bash
./scripts/backup.sh --reason manual
```
## Umgebungsvariablen
| Variable | Prod (default) | Staging (`-dest stage`) |
|----------|----------------|-------------------------|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
| `RETENTION` | `5` | `5` |
+1 -1
View File
@@ -8,7 +8,7 @@
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 359 KiB

+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
#
# Run on the server in repo root (/opt/kapteins-daagbok on production).
#
# Usage:
# ./scripts/backup.sh
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
# ./scripts/backup.sh --reason cron
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
# ./scripts/backup.sh --dry-run
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
RETENTION="${RETENTION:-5}"
DEST="prod"
REASON="manual"
EXPLICIT_TAG=""
DRY_RUN=0
COMPOSE_FILE=""
DB_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
fi
}
usage() {
sed -n '2,14p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
echo " --dry-run Show actions without writing backup"
echo " -h, --help Show this help"
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--reason)
REASON="${2:?--reason requires an argument}"
shift 2
;;
--tag)
EXPLICIT_TAG="${2:?--tag requires an argument}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
case "$REASON" in
cron|pre-deploy|manual) ;;
*)
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
exit 1
;;
esac
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Error: $COMPOSE_FILE not found" >&2
exit 1
fi
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_TAG="$EXPLICIT_TAG"
if [ -z "$GIT_TAG" ]; then
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
fi
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
fi
if [ -z "$GIT_TAG" ]; then
GIT_TAG="unknown"
fi
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
TAG_SLUG="${GIT_TAG}"
if [ "$REASON" = "pre-deploy" ]; then
TAG_SLUG="${GIT_TAG}-predeploy"
fi
TAG_SLUG="${TAG_SLUG//\//-}"
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8}$ARCHIVE_PATH"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] Would dump database from $DB_CONTAINER"
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
echo "[dry-run] Would create git archive"
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
echo "[dry-run] Would apply retention (keep $RETENTION)"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found (run from repo root)" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
export PGPASSWORD="$POSTGRES_PASSWORD"
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
| gzip > "$WORK_DIR/database.sql.gz"; then
echo "Error: pg_dump failed" >&2
exit 1
fi
unset PGPASSWORD
cp "$ENV_FILE" "$WORK_DIR/.env"
chmod 600 "$WORK_DIR/.env"
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
echo "Creating app snapshot (git archive)..."
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
:
else
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
rm -f "$WORK_DIR/app.tar.gz"
fi
python3 - "$WORK_DIR/manifest.json" <<PY
import json
import socket
import sys
from datetime import datetime, timezone
manifest = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"local_timestamp": "${TIMESTAMP}",
"destination": "${DEST}",
"reason": "${REASON}",
"git_tag": "${GIT_TAG}",
"git_sha": "${GIT_SHA}",
"app_version": "${APP_VERSION}",
"compose_file": "${COMPOSE_FILE}",
"db_container": "${DB_CONTAINER}",
"postgres_db": "${POSTGRES_DB}",
"hostname": socket.gethostname(),
"archive_name": "${ARCHIVE_NAME}",
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
PY
echo "Packing backup archive..."
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
manifest.json database.sql.gz .env docker-compose.yml \
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
chmod 600 "$ARCHIVE_PATH"
echo "Applying retention (keep last $RETENTION backups)..."
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
rm -f "${ALL_BACKUPS[$i]}"
done
fi
echo "Backup complete: $ARCHIVE_PATH"
echo "$ARCHIVE_PATH"
+11
View File
@@ -0,0 +1,11 @@
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
#
# Install:
# crontab -e
# (paste the line below)
#
# Ensure log directory exists:
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bash
# Restore server backup created by scripts/backup.sh
#
# Usage:
# ./scripts/restore-backup.sh --list
# ./scripts/restore-backup.sh -dest stage --restore PATH
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
MAX_WAIT=90
DEST="prod"
COMPOSE_FILE=""
DB_CONTAINER=""
BACKEND_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-staging-backend"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-prod-backend"
fi
fi
}
MODE="full"
RESTORE_PATH=""
LIST=0
ASSUME_YES=0
usage() {
sed -n '2,10p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --list List available backups"
echo " --restore PATH Backup archive to restore"
echo " --full Restore DB + .env (default)"
echo " --db-only Restore database only"
echo " --env-only Restore .env only"
echo " --yes Skip confirmation prompts"
echo " -h, --help Show this help"
}
confirm() {
local prompt="$1"
if [ "$ASSUME_YES" -eq 1 ]; then
return 0
fi
read -r -p "$prompt [y/N] " answer
[[ "$answer" =~ ^[yY]$ ]]
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--list)
LIST=1
shift
;;
--restore)
RESTORE_PATH="${2:?--restore requires a path}"
shift 2
;;
--full)
MODE="full"
shift
;;
--db-only)
MODE="db-only"
shift
;;
--env-only)
MODE="env-only"
shift
;;
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
list_backups() {
if [ ! -d "$BACKUP_DIR" ]; then
echo "No backup directory: $BACKUP_DIR"
return 0
fi
local found=0
while IFS= read -r archive; do
found=1
echo "=== $archive ==="
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
echo ""
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "$found" -eq 0 ]; then
echo "No backups found in $BACKUP_DIR"
fi
}
if [ "$LIST" -eq 1 ]; then
list_backups
exit 0
fi
if [ -z "$RESTORE_PATH" ]; then
echo "Error: --restore PATH or --list required" >&2
usage >&2
exit 1
fi
if [ ! -f "$RESTORE_PATH" ]; then
echo "Error: backup archive not found: $RESTORE_PATH" >&2
exit 1
fi
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Extracting $RESTORE_PATH..."
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
if [ ! -f "$WORK_DIR/manifest.json" ]; then
echo "Error: manifest.json missing in backup archive" >&2
exit 1
fi
MANIFEST="$WORK_DIR/manifest.json"
echo "Backup manifest:"
python3 -m json.tool "$MANIFEST"
echo ""
read_manifest_field() {
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
}
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
MANIFEST_DB="$(read_manifest_field db_container)"
MANIFEST_DEST="$(read_manifest_field destination)"
if [ -n "$MANIFEST_COMPOSE" ]; then
COMPOSE_FILE="$MANIFEST_COMPOSE"
fi
if [ -n "$MANIFEST_DB" ]; then
DB_CONTAINER="$MANIFEST_DB"
fi
if [ -n "$MANIFEST_DEST" ]; then
if [[ "$MANIFEST_DEST" == "stage" ]]; then
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
else
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
fi
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
GIT_TAG="$(read_manifest_field git_tag)"
GIT_SHA="$(read_manifest_field git_sha)"
BACKUP_TS="$(read_manifest_field local_timestamp)"
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
echo "Aborted."
exit 1
fi
restore_env() {
if [ ! -f "$WORK_DIR/.env" ]; then
echo "Error: .env missing in backup archive" >&2
exit 1
fi
if [ -f "$ENV_FILE" ]; then
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BAK"
echo "Current $ENV_FILE saved to $BAK"
fi
cp "$WORK_DIR/.env" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "Restored $ENV_FILE"
}
restore_db() {
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
echo "Error: database.sql.gz missing in backup archive" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE required for database restore" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
echo "Stopping backend before database restore..."
docker compose -f "$COMPOSE_FILE" stop backend || true
echo "Resetting public schema..."
export PGPASSWORD="$POSTGRES_PASSWORD"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
GRANT ALL ON SCHEMA public TO public;
SQL
echo "Importing database dump..."
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
unset PGPASSWORD
echo "Database restore complete."
}
wait_for_healthy() {
echo "Starting stack and waiting for health..."
docker compose -f "$COMPOSE_FILE" up -d
local counter=0
while [ "$counter" -lt "$MAX_WAIT" ]; do
local status
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
if [ "$status" = "healthy" ]; then
echo "Backend is healthy."
return 0
fi
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
echo "API health check OK."
return 0
fi
sleep 1
counter=$((counter + 1))
printf "."
done
echo ""
echo "Warning: backend did not become healthy in time." >&2
return 1
}
case "$MODE" in
env-only)
restore_env
;;
db-only)
restore_db
wait_for_healthy || exit 1
;;
full)
restore_env
restore_db
wait_for_healthy || exit 1
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
git fetch --tags origin
git checkout "$GIT_TAG"
echo "Checked out $GIT_TAG"
fi
fi
;;
*)
echo "Error: unknown mode $MODE" >&2
exit 1
;;
esac
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
+76
View File
@@ -16,6 +16,8 @@ Environment overrides (optional):
DEPLOY_BRANCH (stage only, default: master)
SKIP_PREDEPLOY_CHECK=1
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
Examples:
$(basename "$0") -dest prod
$(basename "$0") -dest stage
@@ -158,6 +160,66 @@ ensure_clean_git_tree() {
git commit -m "$commit_message"
}
ensure_local_sync_with_origin() {
local branch="$1"
local local_sha origin_sha current_branch
if [ -z "$branch" ]; then
echo "Error: deploy branch is not set." >&2
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
git status --short
exit 1
fi
current_branch="$(git branch --show-current)"
if [ -z "$current_branch" ]; then
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
exit 1
fi
if [ "$current_branch" != "$branch" ]; then
echo "Error: On branch '$current_branch', expected '$branch'." >&2
exit 1
fi
echo "Syncing with origin..."
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
exit 1
fi
local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
return 0
fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi
exit 1
}
prepare_release() {
local current_version release_version next_version tag_name
@@ -199,7 +261,9 @@ prepare_release() {
if [[ "$DEST" == "prod" ]]; then
prepare_release
ensure_local_sync_with_origin "$(git branch --show-current)"
else
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
APP_VERSION="$(read_current_version)"
fi
@@ -235,6 +299,18 @@ if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
if [[ "$DEST" == "prod" ]]; then
echo "Creating pre-deploy backup..."
if [ -x "./scripts/backup.sh" ]; then
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
echo "Error: Pre-deploy backup failed. Aborting update."
exit 1
fi
else
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
fi
fi
if [[ "$DEST" == "stage" ]]; then
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
git fetch origin