|
| 1 | +#!/usr/bin/env bash |
| 2 | +# CleanApp MySQL full backup -> GCS (prod/dev) |
| 3 | +# - Streams mysqldump -> gzip -> gsutil (no large local temp files) |
| 4 | +# - Writes metadata JSON alongside backup |
| 5 | +# - Weekly pin (Sundays UTC): copies current object to weekly/<ISO_WEEK>/ |
| 6 | +set -euo pipefail |
| 7 | + |
| 8 | +ENV="" |
| 9 | +while [[ $# -gt 0 ]]; do |
| 10 | + case "$1" in |
| 11 | + -e|--env) |
| 12 | + ENV="$2"; shift 2;; |
| 13 | + *) |
| 14 | + echo "Usage: $0 -e <dev|prod>" >&2 |
| 15 | + exit 2;; |
| 16 | + esac |
| 17 | +done |
| 18 | + |
| 19 | +if [[ -z "${ENV}" ]]; then |
| 20 | + echo "Usage: $0 -e <dev|prod>" >&2 |
| 21 | + exit 2 |
| 22 | +fi |
| 23 | + |
| 24 | +case "${ENV}" in |
| 25 | + dev|prod) ;; |
| 26 | + *) echo "Invalid env: ${ENV} (expected dev|prod)" >&2; exit 2;; |
| 27 | +esac |
| 28 | + |
| 29 | +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*"; } |
| 30 | +need_cmd() { command -v "$1" >/dev/null 2>&1 || { log "ERROR missing command: $1" >&2; exit 1; }; } |
| 31 | + |
| 32 | +need_cmd gcloud |
| 33 | +need_cmd gsutil |
| 34 | +need_cmd gzip |
| 35 | + |
| 36 | +if ! sudo -n true 2>/dev/null; then |
| 37 | + log "ERROR sudo requires a password; cannot run docker exec" >&2 |
| 38 | + exit 1 |
| 39 | +fi |
| 40 | + |
| 41 | +SECRET_SUFFIX="$(echo "${ENV}" | tr '[:lower:]' '[:upper:]')" |
| 42 | +BUCKET="gs://cleanapp_mysql_backup_${ENV}" |
| 43 | +CURRENT_KEY="${BUCKET}/current/cleanapp_all.sql.gz" |
| 44 | +CURRENT_META_KEY="${BUCKET}/current/cleanapp_all.metadata.json" |
| 45 | + |
| 46 | +log "INFO backup start env=${ENV} bucket=${BUCKET}" |
| 47 | + |
| 48 | +MYSQL_ROOT_PASSWORD="$(gcloud secrets versions access latest --secret="MYSQL_ROOT_PASSWORD_${SECRET_SUFFIX}" 2>/dev/null)" || { |
| 49 | + log "ERROR failed to read MySQL root password from Secret Manager" >&2 |
| 50 | + exit 1 |
| 51 | +} |
| 52 | + |
| 53 | +if ! sudo docker ps --format '{{.Names}}' | grep -qx cleanapp_db; then |
| 54 | + log "ERROR cleanapp_db container not running" >&2 |
| 55 | + exit 1 |
| 56 | +fi |
| 57 | + |
| 58 | +# Never pass secrets via `docker exec -e ...` (those end up visible in host `ps` output). |
| 59 | +# Instead, write the secret into a short-lived file inside the container and reference it |
| 60 | +# from inside the container process. |
| 61 | +pwfile="/tmp/cleanapp_mysql_backup_pw.$$.$RANDOM" |
| 62 | +cleanup_pwfile() { |
| 63 | + sudo docker exec cleanapp_db sh -lc "rm -f '${pwfile}'" >/dev/null 2>&1 || true |
| 64 | +} |
| 65 | +trap cleanup_pwfile EXIT |
| 66 | + |
| 67 | +printf '%s' "${MYSQL_ROOT_PASSWORD}" | sudo docker exec -i cleanapp_db sh -lc \ |
| 68 | + "cat > '${pwfile}' && chmod 600 '${pwfile}'" >/dev/null |
| 69 | + |
| 70 | +if command -v pigz >/dev/null 2>&1; then |
| 71 | + COMPRESS=(pigz -1) |
| 72 | +else |
| 73 | + COMPRESS=(gzip -1) |
| 74 | +fi |
| 75 | + |
| 76 | +started_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 77 | +started_epoch="$(date +%s)" |
| 78 | + |
| 79 | +log "INFO mysqldump stream start" |
| 80 | +sudo docker exec -i cleanapp_db sh -lc \ |
| 81 | + "MYSQL_PWD=\"\$(cat '${pwfile}')\" exec mysqldump -uroot \ |
| 82 | + --all-databases \ |
| 83 | + --single-transaction \ |
| 84 | + --quick \ |
| 85 | + --lock-tables=false \ |
| 86 | + --routines --events --triggers \ |
| 87 | + --hex-blob \ |
| 88 | + --set-gtid-purged=OFF" \ |
| 89 | + | "${COMPRESS[@]}" \ |
| 90 | + | gsutil -q -o GSUtil:parallel_composite_upload_threshold=150M cp - "${CURRENT_KEY}" |
| 91 | + |
| 92 | +finished_epoch="$(date +%s)" |
| 93 | +finished_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 94 | +duration_s=$((finished_epoch - started_epoch)) |
| 95 | + |
| 96 | +size_bytes="$(gsutil ls -l "${CURRENT_KEY}" | awk 'NR==1{print $1}')" |
| 97 | +size_bytes="${size_bytes:-0}" |
| 98 | + |
| 99 | +log "INFO capturing row counts" |
| 100 | +reports_count="$(sudo docker exec -i cleanapp_db sh -lc "MYSQL_PWD=\"\$(cat '${pwfile}')\" mysql -uroot -N -e \"SELECT COUNT(*) FROM cleanapp.reports\" 2>/dev/null" | tr -d '\r' | tail -n 1 || true)" |
| 101 | +analysis_count="$(sudo docker exec -i cleanapp_db sh -lc "MYSQL_PWD=\"\$(cat '${pwfile}')\" mysql -uroot -N -e \"SELECT COUNT(*) FROM cleanapp.report_analysis\" 2>/dev/null" | tr -d '\r' | tail -n 1 || true)" |
| 102 | +reports_count="${reports_count:-0}" |
| 103 | +analysis_count="${analysis_count:-0}" |
| 104 | +counts_json="{\"reports\":${reports_count},\"report_analysis\":${analysis_count}}" |
| 105 | + |
| 106 | +meta_tmp="/tmp/cleanapp_all.metadata.$$.$RANDOM.json" |
| 107 | +cat >"${meta_tmp}" <<META |
| 108 | +{ |
| 109 | + "env": "${ENV}", |
| 110 | + "object": "${CURRENT_KEY}", |
| 111 | + "started_utc": "${started_ts}", |
| 112 | + "finished_utc": "${finished_ts}", |
| 113 | + "duration_seconds": ${duration_s}, |
| 114 | + "size_bytes": ${size_bytes}, |
| 115 | + "row_counts": ${counts_json} |
| 116 | +} |
| 117 | +META |
| 118 | + |
| 119 | +gsutil -q cp "${meta_tmp}" "${CURRENT_META_KEY}" |
| 120 | +rm -f "${meta_tmp}" || true |
| 121 | + |
| 122 | +log "INFO backup uploaded object=${CURRENT_KEY} size_bytes=${size_bytes} duration_s=${duration_s}" |
| 123 | + |
| 124 | +if [[ "$(date -u +%u)" == "7" ]]; then |
| 125 | + week="$(date -u +%G-W%V)" |
| 126 | + weekly_key="${BUCKET}/weekly/${week}/cleanapp_all.sql.gz" |
| 127 | + weekly_meta_key="${BUCKET}/weekly/${week}/cleanapp_all.metadata.json" |
| 128 | + log "INFO weekly pin start week=${week}" |
| 129 | + gsutil -q cp "${CURRENT_KEY}" "${weekly_key}" |
| 130 | + gsutil -q cp "${CURRENT_META_KEY}" "${weekly_meta_key}" |
| 131 | + log "INFO weekly pin done weekly_object=${weekly_key}" |
| 132 | +fi |
| 133 | + |
| 134 | +log "INFO backup complete env=${ENV}" |
0 commit comments