From 7944383a9d9d776178b864c50a607a3697c1b99a Mon Sep 17 00:00:00 2001 From: Musa March Date: Wed, 21 Oct 2026 10:08:24 +0100 Subject: [PATCH 1/3] test: cover restore URL rewriting, manifest fields, and script arguments Argument parsing, with_database, and manifest parsing in the shell scripts had no bats coverage. --- scripts/restore.sh | 39 +++++++++++++++++------------- test/db-rollback.bats | 25 +++++++++++++++++++ test/restore.bats | 56 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 test/db-rollback.bats create mode 100644 test/restore.bats diff --git a/scripts/restore.sh b/scripts/restore.sh index 6a744dc..719b474 100644 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -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 # --------------------------------------------------------------------------- @@ -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 # --------------------------------------------------------------------------- diff --git a/test/db-rollback.bats b/test/db-rollback.bats new file mode 100644 index 0000000..dac563e --- /dev/null +++ b/test/db-rollback.bats @@ -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 ] +} diff --git a/test/restore.bats b/test/restore.bats new file mode 100644 index 0000000..1efa745 --- /dev/null +++ b/test/restore.bats @@ -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 ] +} From 91e4e6fe914a0ac42391061f434001fd656add77 Mon Sep 17 00:00:00 2001 From: Musa March Date: Wed, 21 Oct 2026 10:09:17 +0100 Subject: [PATCH 2/3] fix: roll back more than one Drizzle migration per run db-rollback.sh always deleted only the newest journal row, so reverting several migrations took a separate run for each one. --- docs/DATABASE-ROLLBACK.md | 25 ++++++++++++++++++- scripts/db-rollback.sh | 52 ++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/DATABASE-ROLLBACK.md b/docs/DATABASE-ROLLBACK.md index 8f5b016..e3a3f73 100644 --- a/docs/DATABASE-ROLLBACK.md +++ b/docs/DATABASE-ROLLBACK.md @@ -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_.sql \ + --steps 3 \ + --dry-run + +# Apply (prompts for confirmation when --env production, unless --yes) +DATABASE_URL=... ./scripts/db-rollback.sh \ + --file drizzle/rollback_to_.sql \ + --steps 3 \ + --env production +``` + +`--steps` defaults to `1`. The script refuses to continue when fewer than +`` migrations are recorded. + +The manual equivalent is: 1. **List applied migrations in reverse chronological order:** diff --git a/scripts/db-rollback.sh b/scripts/db-rollback.sh index 962cb77..63d25fe 100644 --- a/scripts/db-rollback.sh +++ b/scripts/db-rollback.sh @@ -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 [options] +# ./scripts/db-rollback.sh --file [--steps ] [options] # # Options: -# -f, --file Required. Reverse SQL that undoes the latest migration. +# -f, --file Required. Reverse SQL that undoes the migrations being +# reverted, newest first. +# --steps How many newest migrations to revert. Default: 1. # -e, --env 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 @@ -48,6 +50,7 @@ ASSUME_YES="false" DRY_RUN="false" SKIP_BACKUP="false" REVERSE_SQL="" +STEPS="1" BACKUP_DIR="${ROLLBACK_BACKUP_DIR:-./backups}" log() { @@ -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 @@ -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 @@ -119,6 +130,11 @@ 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 @@ -126,19 +142,31 @@ 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 @@ -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. @@ -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 From 6cd4892afd16f7d5bdfea1e45367430fca2ea6f2 Mon Sep 17 00:00:00 2001 From: Musa March Date: Wed, 21 Oct 2026 10:09:58 +0100 Subject: [PATCH 3/3] fix: restrict frontend and backend traffic with NetworkPolicies Every pod could reach every other pod. Only ingress-nginx should reach the apps, and the backend only needs DNS, the database, and RPC. --- k8s/kustomization.yaml | 1 + k8s/networkpolicy.yaml | 84 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 k8s/networkpolicy.yaml diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index f6d0126..a55db32 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -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 diff --git a/k8s/networkpolicy.yaml b/k8s/networkpolicy.yaml new file mode 100644 index 0000000..be421e1 --- /dev/null +++ b/k8s/networkpolicy.yaml @@ -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