Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/DATABASE-ROLLBACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,30 @@ If the reverse SQL fails, nothing is committed. The manual equivalent is:

## 3. Roll Back to a Specific Migration

To roll back multiple migrations to a target point:
To roll back more than one migration in a single run, pass `--steps` with a
reverse SQL file that undoes each of those migrations, newest first.
[`scripts/db-rollback.sh`](../scripts/db-rollback.sh) applies that file and
deletes the same number of newest rows from `drizzle.__drizzle_migrations`
in one transaction:

```bash
# Rehearse reverting the three newest migrations
DATABASE_URL=... ./scripts/db-rollback.sh \
--file drizzle/rollback_to_<target>.sql \
--steps 3 \
--dry-run

# Apply (prompts for confirmation when --env production, unless --yes)
DATABASE_URL=... ./scripts/db-rollback.sh \
--file drizzle/rollback_to_<target>.sql \
--steps 3 \
--env production
```

`--steps` defaults to `1`. The script refuses to continue when fewer than
`<n>` migrations are recorded.

The manual equivalent is:

1. **List applied migrations in reverse chronological order:**

Expand Down
1 change: 1 addition & 0 deletions k8s/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ resources:
- frontend-deployment.yaml
- pdb.yaml
- ingress.yaml
- networkpolicy.yaml
# Monitoring stack (kube-prometheus-stack CRs). See docs/DEPLOY-KUBERNETES.md.
- monitoring/servicemonitors.yaml
- monitoring/prometheusrule.yaml
Expand Down
84 changes: 84 additions & 0 deletions k8s/networkpolicy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Only ingress-nginx may reach the frontend and backend.
# Backend egress is limited to cluster DNS, Postgres (direct and pooler),
# and HTTPS for Soroban RPC / Horizon.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-ingress
labels:
app: backend
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 3000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-ingress
labels:
app.kubernetes.io/name: frontend
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 3000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-egress
labels:
app: backend
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 5432
- protocol: TCP
port: 6543
- protocol: TCP
port: 443
52 changes: 40 additions & 12 deletions scripts/db-rollback.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@
#
# db-rollback.sh
#
# Reverts the most recent Drizzle migration by applying a hand-written reverse
# SQL file and removing the migration's row from drizzle.__drizzle_migrations,
# Reverts one or more of the newest Drizzle migrations by applying a hand-written
# reverse SQL file and removing those rows from drizzle.__drizzle_migrations,
# both inside a single transaction. Drizzle has no built-in "down" migrations;
# see docs/DATABASE-ROLLBACK.md for the full runbook.
#
# Usage:
# ./scripts/db-rollback.sh --file <reverse.sql> [options]
# ./scripts/db-rollback.sh --file <reverse.sql> [--steps <n>] [options]
#
# Options:
# -f, --file <path> Required. Reverse SQL that undoes the latest migration.
# -f, --file <path> Required. Reverse SQL that undoes the migrations being
# reverted, newest first.
# --steps <n> How many newest migrations to revert. Default: 1.
# -e, --env <name> Environment name (e.g. development, staging, production).
# Defaults to the value of NODE_ENV, or "development".
# -n, --dry-run Show what would be rolled back, then roll the
Expand Down Expand Up @@ -48,6 +50,7 @@ ASSUME_YES="false"
DRY_RUN="false"
SKIP_BACKUP="false"
REVERSE_SQL=""
STEPS="1"
BACKUP_DIR="${ROLLBACK_BACKUP_DIR:-./backups}"

log() {
Expand All @@ -59,7 +62,7 @@ error() {
}

usage() {
sed -n '2,39p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
}

while [ "$#" -gt 0 ]; do
Expand All @@ -80,6 +83,14 @@ while [ "$#" -gt 0 ]; do
REVERSE_SQL="$2"
shift 2
;;
--steps)
if [ -z "${2:-}" ]; then
error "Option $1 requires an argument."
exit 1
fi
STEPS="$2"
shift 2
;;
-n|--dry-run)
DRY_RUN="true"
shift
Expand Down Expand Up @@ -119,26 +130,43 @@ if [ ! -r "${REVERSE_SQL}" ]; then
exit 1
fi

if ! [ "${STEPS}" -ge 1 ] 2>/dev/null; then
error "--steps must be an integer greater than or equal to 1."
exit 1
fi

if ! command -v psql >/dev/null 2>&1; then
error "psql is not available. Install the PostgreSQL client before running ${SCRIPT_NAME}."
exit 1
fi

log "Environment: ${ENVIRONMENT}"

LAST_MIGRATION="$(psql "${DATABASE_URL}" -X -At -v ON_ERROR_STOP=1 -c \
MIGRATIONS="$(psql "${DATABASE_URL}" -X -At -v ON_ERROR_STOP=1 -c \
"SELECT id || ' (hash ' || hash || ', created_at ' || created_at || ')'
FROM drizzle.__drizzle_migrations ORDER BY created_at DESC, id DESC LIMIT 1;")" || {
FROM drizzle.__drizzle_migrations
ORDER BY created_at DESC, id DESC
LIMIT ${STEPS};")" || {
error "Could not read drizzle.__drizzle_migrations. Is DATABASE_URL correct?"
exit 1
}

if [ -z "${LAST_MIGRATION}" ]; then
if [ -z "${MIGRATIONS}" ]; then
error "No applied migrations recorded in drizzle.__drizzle_migrations; nothing to roll back."
exit 1
fi

log "Latest applied migration: ${LAST_MIGRATION}"
MIGRATION_COUNT="$(printf '%s\n' "${MIGRATIONS}" | grep -c .)"
if [ "${MIGRATION_COUNT}" -lt "${STEPS}" ]; then
error "Only ${MIGRATION_COUNT} applied migration(s); --steps ${STEPS} needs that many."
exit 1
fi

log "Reverting ${STEPS} newest migration(s):"
printf '%s\n' "${MIGRATIONS}" | while IFS= read -r line; do
[ -n "${line}" ] || continue
log " ${line}"
done
log "Reverse SQL: ${REVERSE_SQL}"

if [ "${ENVIRONMENT}" = "production" ] && [ "${ASSUME_YES}" != "true" ]; then
Expand Down Expand Up @@ -180,7 +208,7 @@ else
FINISH="COMMIT"
fi

log "Reverting the most recent migration..."
log "Reverting ${STEPS} migration(s)..."

# Run the reverse SQL and the journal update in one transaction so a failure
# leaves both the schema and drizzle.__drizzle_migrations untouched.
Expand All @@ -189,8 +217,8 @@ psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 \
-c "BEGIN;" \
-f "${REVERSE_SQL}" \
-c "DELETE FROM drizzle.__drizzle_migrations
WHERE id = (SELECT id FROM drizzle.__drizzle_migrations
ORDER BY created_at DESC, id DESC LIMIT 1);" \
WHERE id IN (SELECT id FROM drizzle.__drizzle_migrations
ORDER BY created_at DESC, id DESC LIMIT ${STEPS});" \
-c "${FINISH};"
STATUS=$?
set -e
Expand Down
39 changes: 22 additions & 17 deletions scripts/restore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ usage() {
sed -n '2,43p' "$0" | sed 's/^# \{0,1\}//'
}

# Read a string field from the manifest written by backup.sh. The manifest has
# one "key": "value" pair per line, so a sed match is enough (no jq needed).
manifest_field() {
local field="$1" file="$2"
sed -n "s/^ *\"$field\": *\"\\([^\"]*\\)\".*/\\1/p" "$file" | head -n 1
}

# Replace the database name in a postgres:// URL, keeping any query string.
with_database() {
local url="$1" db="$2"
if [[ "$url" =~ ^(postgres(ql)?://[^/?]+)(/[^?]*)?(\?.*)?$ ]]; then
printf '%s/%s%s' "${BASH_REMATCH[1]}" "$db" "${BASH_REMATCH[4]}"
else
die 1 "cannot parse database URL for --verify-only (expected postgres://...)"
fi
}

# Sourced by bats: expose with_database and manifest_field, then stop.
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -257,23 +279,6 @@ latest_backup_name() {
esac
}

# Read a string field from the manifest written by backup.sh. The manifest has
# one "key": "value" pair per line, so a sed match is enough (no jq needed).
manifest_field() {
local field="$1" file="$2"
sed -n "s/^ *\"$field\": *\"\\([^\"]*\\)\".*/\\1/p" "$file" | head -n 1
}

# Replace the database name in a postgres:// URL, keeping any query string.
with_database() {
local url="$1" db="$2"
if [[ "$url" =~ ^(postgres(ql)?://[^/?]+)(/[^?]*)?(\?.*)?$ ]]; then
printf '%s/%s%s' "${BASH_REMATCH[1]}" "$db" "${BASH_REMATCH[4]}"
else
die 1 "cannot parse database URL for --verify-only (expected postgres://...)"
fi
}

# ---------------------------------------------------------------------------
# Download, verify, decrypt, extract
# ---------------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions test/db-rollback.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bats

setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
}

@test "db-rollback.sh --help exits 0" {
run "$REPO_ROOT/scripts/db-rollback.sh" --help
[ "$status" -eq 0 ]
}

@test "db-rollback.sh rejects an unknown option" {
run "$REPO_ROOT/scripts/db-rollback.sh" --not-an-option
[ "$status" -eq 1 ]
}

@test "db-rollback.sh requires --file" {
run env -u DATABASE_URL DATABASE_URL="postgres://localhost/app" "$REPO_ROOT/scripts/db-rollback.sh"
[ "$status" -eq 1 ]
}

@test "db-rollback.sh requires DATABASE_URL" {
run env -u DATABASE_URL "$REPO_ROOT/scripts/db-rollback.sh" --file "$BATS_TEST_TMPDIR/reverse.sql"
[ "$status" -eq 1 ]
}
56 changes: 56 additions & 0 deletions test/restore.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bats

setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
# shellcheck disable=SC1091
source "$REPO_ROOT/scripts/restore.sh"
}

@test "with_database rewrites the database name and keeps the query string" {
run with_database "postgres://user:pass@localhost:5432/olddb?sslmode=require" "restore_verify"
[ "$status" -eq 0 ]
[ "$output" = "postgres://user:pass@localhost:5432/restore_verify?sslmode=require" ]
}

@test "with_database accepts a postgresql scheme" {
run with_database "postgresql://localhost/app" "other"
[ "$status" -eq 0 ]
[ "$output" = "postgresql://localhost/other" ]
}

@test "with_database rejects a non-postgres URL" {
run with_database "http://localhost/olddb" "other"
[ "$status" -eq 1 ]
}

@test "manifest_field reads key, value, and method" {
local manifest="$BATS_TEST_TMPDIR/sample.manifest.json"
cat >"$manifest" <<'EOF'
{
"key": "backups/soroban-backup-20260101T020000Z.tar.gz.enc",
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"method": "gpg"
}
EOF
run manifest_field key "$manifest"
[ "$status" -eq 0 ]
[ "$output" = "backups/soroban-backup-20260101T020000Z.tar.gz.enc" ]

run manifest_field value "$manifest"
[ "$status" -eq 0 ]
[ "$output" = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ]

run manifest_field method "$manifest"
[ "$status" -eq 0 ]
[ "$output" = "gpg" ]
}

@test "restore.sh --help exits 0" {
run "$REPO_ROOT/scripts/restore.sh" --help
[ "$status" -eq 0 ]
}

@test "restore.sh rejects an unknown flag" {
run "$REPO_ROOT/scripts/restore.sh" --not-a-flag
[ "$status" -eq 1 ]
}