From 3c9d9283bd5d7d4b1e0b958e671f7f1f7bf51ddf Mon Sep 17 00:00:00 2001 From: dsyoon Date: Tue, 26 May 2026 17:02:53 +0900 Subject: [PATCH] Add daily backup scripts for PostgreSQL, MariaDB, and Gitea. Enable scheduled backups of ncue.net databases and git.ncue.net repository mirrors via .env-driven shell scripts. Co-authored-by: Cursor --- .env.example | 34 +++++++++ .gitignore | 3 + README.md | 100 +++++++++++++++++++++++++ requirements.txt | 1 + scripts/daily-backup.sh | 50 +++++++++++++ scripts/gitea-backup.sh | 145 ++++++++++++++++++++++++++++++++++++ scripts/lib/common.sh | 66 +++++++++++++++++ scripts/lib/load-env.sh | 24 ++++++ scripts/lib/mr_dump.py | 130 ++++++++++++++++++++++++++++++++ scripts/mr-backup.sh | 160 ++++++++++++++++++++++++++++++++++++++++ scripts/pg-backup.sh | 153 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 866 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100755 scripts/daily-backup.sh create mode 100755 scripts/gitea-backup.sh create mode 100755 scripts/lib/common.sh create mode 100755 scripts/lib/load-env.sh create mode 100644 scripts/lib/mr_dump.py create mode 100755 scripts/mr-backup.sh create mode 100755 scripts/pg-backup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4def5a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +## Database (PostgreSQL) +PG_DB_HOST=ncue.net +PG_DB_PORT=5432 +PG_DB_NAME=ncue +PG_DB_USER=ncue +PG_DB_PASSWORD=your_pg_password + +## Database (MariaDB) +MR_DB_HOST=ncue.net +MR_DB_PORT=3306 +MR_DB_NAME=wordpress +MR_DB_USER=ncue +MR_DB_PASSWORD=your_mr_password + +## Gitea +GIT_BASE_URL=https://git.ncue.net +GIT_USER=your_gitea_user +GIT_TOKEN=your_gitea_token + +## Backup +BACKUP_DIR=/Users/dsyoon/workspace/backup/ncue +BACKUP_RETENTION_DAYS=30 + +## PostgreSQL backup options +PG_BACKUP_SCOPE=all +PG_BACKUP_GLOBALS=1 +PG_BACKUP_SUPERUSER=postgres +PG_BACKUP_SUPERUSER_PASSWORD= + +## MariaDB backup options +MR_BACKUP_SCOPE=all + +## Gitea backup options +GITEA_REPO_LIMIT=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b64f22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +backup/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1a7551 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# ncue_backup + +ncue.net PostgreSQL, MariaDB, Gitea 저장소를 매일 백업하는 스크립트 모음입니다. +백업 방식은 [ai_platform](https://git.xavis.co.kr/AI_Innovation_Team/ai_platform)의 `scripts/pg-backup.sh` 패턴을 따릅니다. + +## 백업 대상 + +| 구분 | 대상 | 스크립트 | +|------|------|----------| +| PostgreSQL | 연결 가능한 모든 DB (`ai_web_platform`, `meeting_ai`, `ncue`, `tts` 등) | `scripts/pg-backup.sh` | +| MariaDB | 사용자 DB 전체 (`giteadb`, `wordpress`, `roundcube` 등) | `scripts/mr-backup.sh` | +| Gitea | 접근 가능한 모든 Git 저장소 mirror | `scripts/gitea-backup.sh` | + +Gitea 메타데이터 DB(`giteadb`)는 MariaDB 백업에 포함됩니다. Git 소스는 mirror 백업으로 별도 보관합니다. + +## 사전 요구사항 + +- `pg_dump`, `psql` (PostgreSQL 클라이언트) +- `mysqldump`, `mysql` (MariaDB 클라이언트) +- `git`, `curl`, `python3`, `gzip` +- `pymysql` (MariaDB 클라이언트 미설치 시 Python 폴백용, `pip install -r requirements.txt`) + +macOS 예시: + +```bash +brew install libpq mariadb +echo 'export PATH="/opt/homebrew/opt/libpq/bin:$PATH"' >> ~/.zshrc +``` + +## 설정 + +```bash +cp .env.example .env +# .env 편집 (DB 접속 정보, Gitea 토큰, BACKUP_DIR) +``` + +주요 환경 변수: + +| 변수 | 설명 | +|------|------| +| `PG_DB_*` | PostgreSQL 접속 정보 | +| `MR_DB_*` | MariaDB 접속 정보 | +| `GIT_BASE_URL` | Gitea URL (기본 `https://git.ncue.net`) | +| `GIT_USER`, `GIT_TOKEN` | Gitea mirror 인증 | +| `BACKUP_DIR` | 백업 루트 (기본 `프로젝트/backup`) | +| `BACKUP_RETENTION_DAYS` | 보관 일수 (기본 30) | + +## 실행 + +통합 백업 (권장): + +```bash +bash scripts/daily-backup.sh +``` + +개별 백업: + +```bash +bash scripts/pg-backup.sh +bash scripts/mr-backup.sh +bash scripts/gitea-backup.sh +``` + +## 백업 결과 구조 + +```text +backup/ +├─ 20260526/ +│ ├─ postgresql/ +│ │ ├─ 00_manifest.txt +│ │ ├─ ncue.dump +│ │ └─ ... +│ ├─ mariadb/ +│ │ ├─ 00_manifest.txt +│ │ ├─ giteadb.sql.gz +│ │ └─ ... +│ └─ gitea/ +│ ├─ 00_manifest.txt +│ ├─ 00_repos.json +│ └─ mirrors -> ../../_gitea_mirrors/ +├─ _gitea_mirrors/ # Gitea mirror 영구 저장 (증분 업데이트) +│ └─ owner__repo.git/ +└─ latest -> 20260526/ +``` + +## 매일 cron 등록 + +```cron +0 2 * * * cd /Users/dsyoon/workspace/ncue_backup && /usr/bin/bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1 +``` + +## 복원 참고 + +- PostgreSQL: `pg_restore -d dbname file.dump` +- MariaDB: `gunzip -c file.sql.gz | mysql -u user -p dbname` +- Gitea mirror: `git clone /path/to/mirrors/owner__repo.git restored-repo` + +## 변경 이력 + +- 2026-05-26: PostgreSQL/MariaDB/Gitea 일일 백업 스크립트 초기 구성 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..76db31a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pymysql>=1.1.0 diff --git a/scripts/daily-backup.sh b/scripts/daily-backup.sh new file mode 100755 index 0000000..5ad390b --- /dev/null +++ b/scripts/daily-backup.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# PostgreSQL + MariaDB + Gitea 저장소 일일 통합 백업 +# 운영 cron 예: 0 2 * * * cd /path/ncue_backup && bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" +# shellcheck source=scripts/lib/common.sh +source "$REPO_ROOT/scripts/lib/common.sh" + +load_project_env "$REPO_ROOT/.env" + +BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}" +BACKUP_STAMP="${BACKUP_STAMP:-$(date +%Y%m%d)}" +BACKUP_RUN_DIR="$BACKUP_DIR/$BACKUP_STAMP" +export BACKUP_DIR BACKUP_STAMP BACKUP_RUN_DIR + +mkdir -p "$BACKUP_RUN_DIR" "$BACKUP_DIR/latest" + +echo "[$(log_ts)] daily-backup start → $BACKUP_RUN_DIR" + +failures=0 + +run_step() { + local name="$1" + local script="$2" + echo "[$(log_ts)] daily-backup step: $name" + if bash "$script"; then + echo "[$(log_ts)] daily-backup step ok: $name" + else + echo "[$(log_ts)] daily-backup step failed: $name" >&2 + failures=$((failures + 1)) + fi +} + +run_step "postgresql" "$REPO_ROOT/scripts/pg-backup.sh" +run_step "mariadb" "$REPO_ROOT/scripts/mr-backup.sh" +run_step "gitea" "$REPO_ROOT/scripts/gitea-backup.sh" + +ln -sfn "$BACKUP_RUN_DIR" "$BACKUP_DIR/latest" + +prune_old_backups "$BACKUP_DIR" "${BACKUP_RETENTION_DAYS:-30}" + +if [[ "$failures" -gt 0 ]]; then + echo "[$(log_ts)] daily-backup finished with ${failures} failure(s)." >&2 + exit 1 +fi + +echo "[$(log_ts)] daily-backup done → $BACKUP_DIR/latest" diff --git a/scripts/gitea-backup.sh b/scripts/gitea-backup.sh new file mode 100755 index 0000000..a056e98 --- /dev/null +++ b/scripts/gitea-backup.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Gitea 저장소 mirror 백업. .env의 GIT_* 및 GITEA_* 사용. +# Gitea 메타 DB(giteadb 등)는 scripts/mr-backup.sh(MariaDB 전체 백업)에 포함됩니다. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" +# shellcheck source=scripts/lib/common.sh +source "$REPO_ROOT/scripts/lib/common.sh" + +load_project_env "$REPO_ROOT/.env" + +: "${GIT_USER:?GIT_USER is required in .env}" +: "${GIT_TOKEN:?GIT_TOKEN is required in .env}" + +GIT_BASE_URL="${GIT_BASE_URL:-https://git.ncue.net}" +GITEA_API_URL="${GITEA_API_URL:-${GIT_BASE_URL%/}/api/v1}" +BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}" +BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" +GITEA_REPO_LIMIT="${GITEA_REPO_LIMIT:-100}" + +if ! command -v git >/dev/null 2>&1; then + echo "gitea-backup: git not found." >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "gitea-backup: python3 not found." >&2 + exit 1 +fi + +resolve_run_dir "gitea" +mkdir -p "$BACKUP_DIR/latest" +PERSISTENT_MIRROR_ROOT="${GITEA_MIRROR_DIR:-$BACKUP_DIR/_gitea_mirrors}" +MIRROR_ROOT="$PERSISTENT_MIRROR_ROOT" +mkdir -p "$MIRROR_ROOT" + +echo "[$(log_ts)] gitea-backup start → $RUN_DIR (mirrors=$MIRROR_ROOT, api=${GITEA_API_URL})" + +REPO_LIST_FILE="$RUN_DIR/00_repos.json" +MANIFEST="$RUN_DIR/00_manifest.txt" + +mirror_result="$(python3 - "$GITEA_API_URL" "$GIT_TOKEN" "$GITEA_REPO_LIMIT" "$REPO_LIST_FILE" "$MIRROR_ROOT" "$GIT_USER" "$GIT_TOKEN" <<'PY' +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + +api_base, token, limit_str, repo_list_file, mirror_root, git_user, git_token = sys.argv[1:8] +limit = int(limit_str) +headers = {"Authorization": f"token {token}"} + +def fetch_json(url: str): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=120) as resp: + return json.load(resp) + +repos = [] +page = 1 +while True: + url = f"{api_base.rstrip('/')}/user/repos?limit={limit}&page={page}" + try: + batch = fetch_json(url) + except urllib.error.HTTPError as exc: + print(f"API error {exc.code} for {url}", file=sys.stderr) + raise SystemExit(1) from exc + if not batch: + break + repos.extend(batch) + if len(batch) < limit: + break + page += 1 + +with open(repo_list_file, "w", encoding="utf-8") as fh: + json.dump(repos, fh, ensure_ascii=False, indent=2) + +ok = 0 +fail = 0 +manifest_lines = [] + +for repo in repos: + full_name = repo["full_name"] + clone_url = repo["clone_url"] + safe_name = full_name.replace("/", "__") + mirror_path = os.path.join(mirror_root, f"{safe_name}.git") + auth_url = clone_url.replace("https://", f"https://{git_user}:{git_token}@", 1) + + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + + if os.path.isdir(mirror_path): + subprocess.run(["git", "-C", mirror_path, "remote", "set-url", "origin", auth_url], check=True) + proc = subprocess.run(["git", "-C", mirror_path, "remote", "update", "--prune"], env=env) + action = "updated" + else: + proc = subprocess.run(["git", "clone", "--mirror", auth_url, mirror_path], env=env) + action = "cloned" + + if proc.returncode == 0: + size_kb = max(1, sum( + os.path.getsize(os.path.join(root, name)) + for root, _, files in os.walk(mirror_path) + for name in files + ) // 1024) + manifest_lines.append(f"{full_name} {action} {size_kb}KB") + ok += 1 + else: + manifest_lines.append(f"{full_name} failed") + fail += 1 + +print(json.dumps({"ok": ok, "fail": fail, "count": len(repos), "manifest": manifest_lines})) +PY +)" + +repo_count="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['count'])" "$mirror_result")" +mirror_ok="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ok'])" "$mirror_result")" +mirror_fail="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['fail'])" "$mirror_result")" + +{ + echo "# gitea-backup manifest $(log_ts)" + echo "api=${GITEA_API_URL}" + echo "user=${GIT_USER}" + echo "repo_count=${repo_count}" + python3 -c "import json,sys; [print(line) for line in json.loads(sys.argv[1])['manifest']]" "$mirror_result" +} > "$MANIFEST" + +if [[ "$mirror_ok" -eq 0 ]]; then + echo "gitea-backup: all repository mirrors failed." >&2 + exit 1 +fi +if [[ "$mirror_fail" -gt 0 ]]; then + echo "[$(log_ts)] warning: ${mirror_fail} mirror(s) failed, ${mirror_ok} succeeded." >&2 +fi + +ln -sfn "$MIRROR_ROOT" "$RUN_DIR/mirrors" + +update_latest_link "gitea" + +if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then + prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS" +fi + +echo "[$(log_ts)] gitea-backup done: ${mirror_ok} repository mirror(s), latest → $BACKUP_DIR/latest/gitea" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100755 index 0000000..ba41e12 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# 백업 스크립트 공통 유틸 + +log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; } + +# 오래된 YYYYMMDD 백업 디렉터리 삭제 +# 사용: prune_old_backups "$BACKUP_ROOT" "$RETENTION_DAYS" +prune_old_backups() { + local backup_root="$1" + local retention_days="$2" + local cutoff removed=0 base day entry + + [[ "$retention_days" =~ ^[0-9]+$ ]] || return 0 + [[ "$retention_days" -le 0 ]] && return 0 + + if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then + cutoff=$(date -d "${retention_days} days ago" +%Y%m%d) + else + cutoff=$(date -v-"${retention_days}"d +%Y%m%d) + fi + + shopt -s nullglob + for entry in "$backup_root"/*; do + [[ -d "$entry" ]] || continue + base=$(basename "$entry") + [[ "$base" == "latest" ]] && continue + if [[ "$base" =~ ^([0-9]{8}) ]]; then + day="${BASH_REMATCH[1]}" + if [[ "$day" < "$cutoff" ]]; then + rm -rf "$entry" + removed=$((removed + 1)) + echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)" + fi + fi + done + shopt -u nullglob + echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${retention_days} days)" +} + +resolve_run_dir() { + local component="$1" + local stamp="${BACKUP_STAMP:-$(date +%Y%m%d)}" + local root="${BACKUP_DIR:-$REPO_ROOT/backup}" + + if [[ -n "${BACKUP_RUN_DIR:-}" ]]; then + RUN_DIR="$BACKUP_RUN_DIR/$component" + else + RUN_DIR="$root/$stamp/$component" + fi + mkdir -p "$RUN_DIR" +} + +update_latest_link() { + local component="$1" + local root="${BACKUP_DIR:-$REPO_ROOT/backup}" + local stamp="${BACKUP_STAMP:-$(date +%Y%m%d)}" + local target + + if [[ -n "${BACKUP_RUN_DIR:-}" ]]; then + target="$BACKUP_RUN_DIR/$component" + else + target="$root/$stamp/$component" + fi + + ln -sfn "$target" "$root/latest/$component" +} diff --git a/scripts/lib/load-env.sh b/scripts/lib/load-env.sh new file mode 100755 index 0000000..bfa50c2 --- /dev/null +++ b/scripts/lib/load-env.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시) +load_project_env() { + local env_file="$1" + if [[ ! -f "$env_file" ]]; then + echo "load_project_env: .env not found: $env_file" >&2 + return 1 + fi + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + [[ "$line" == \[* ]] && continue + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + local key="${BASH_REMATCH[1]}" + local val="${BASH_REMATCH[2]}" + if [[ "$val" =~ ^\"(.*)\"$ ]]; then + val="${BASH_REMATCH[1]}" + elif [[ "$val" =~ ^\'(.*)\'$ ]]; then + val="${BASH_REMATCH[1]}" + fi + export "$key=$val" + fi + done < "$env_file" +} diff --git a/scripts/lib/mr_dump.py b/scripts/lib/mr_dump.py new file mode 100644 index 0000000..326c14f --- /dev/null +++ b/scripts/lib/mr_dump.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""MariaDB logical dump fallback when mysqldump is unavailable.""" + +from __future__ import annotations + +import argparse +import gzip +import sys +from typing import Iterable + +import pymysql + + +SYSTEM_SCHEMAS = {"information_schema", "performance_schema", "sys", "mysql"} + + +def sql_quote(value) -> str: + """Return a SQL literal for a Python value.""" + if value is None: + return "NULL" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, (bytes, bytearray)): + return "0x" + value.hex() + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace("\0", "\\0") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("'", "\\'") + .replace('"', '\\"') + ) + return f"'{escaped}'" + + +def list_databases(conn) -> list[str]: + """List user databases excluding system schemas.""" + with conn.cursor() as cur: + cur.execute( + """ + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN (%s, %s, %s, %s) + ORDER BY schema_name + """, + tuple(SYSTEM_SCHEMAS), + ) + return [row[0] for row in cur.fetchall()] + + +def dump_database(host: str, port: int, user: str, password: str, database: str, output_path: str) -> None: + """Dump one database to a gzip SQL file.""" + conn = pymysql.connect( + host=host, + port=port, + user=user, + password=password, + database=database, + charset="utf8mb4", + connect_timeout=30, + ) + try: + lines: list[str] = [ + "-- MariaDB dump via ncue_backup Python fallback", + "SET NAMES utf8mb4;", + "SET FOREIGN_KEY_CHECKS=0;", + f"CREATE DATABASE IF NOT EXISTS `{database}`;", + f"USE `{database}`;", + ] + + with conn.cursor() as cur: + cur.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") + tables = [row[0] for row in cur.fetchall()] + + for table in tables: + cur.execute(f"SHOW CREATE TABLE `{table}`") + create_sql = cur.fetchone()[1] + lines.append(f"DROP TABLE IF EXISTS `{table}`;") + lines.append(f"{create_sql};") + + cur.execute(f"SELECT * FROM `{table}`") + columns = [desc[0] for desc in cur.description] + col_list = ", ".join(f"`{col}`" for col in columns) + + while True: + rows = cur.fetchmany(500) + if not rows: + break + for row in rows: + values = ", ".join(sql_quote(value) for value in row) + lines.append(f"INSERT INTO `{table}` ({col_list}) VALUES ({values});") + + lines.append("SET FOREIGN_KEY_CHECKS=1;") + payload = "\n".join(lines) + "\n" + with gzip.open(output_path, "wt", encoding="utf-8") as fh: + fh.write(payload) + finally: + conn.close() + + +def main(argv: Iterable[str] | None = None) -> int: + """CLI entrypoint.""" + parser = argparse.ArgumentParser(description="MariaDB dump fallback") + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, default=3306) + parser.add_argument("--user", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--database", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args(list(argv) if argv is not None else None) + + dump_database( + host=args.host, + port=args.port, + user=args.user, + password=args.password, + database=args.database, + output_path=args.output, + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except pymysql.MySQLError as exc: + print(f"mr_dump.py: database error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/scripts/mr-backup.sh b/scripts/mr-backup.sh new file mode 100755 index 0000000..04ad35e --- /dev/null +++ b/scripts/mr-backup.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# MariaDB/MySQL 논리 백업 (mysqldump). .env의 MR_DB_* 및 MR_BACKUP_* 사용. +# MR_BACKUP_SCOPE=all 이면 접근 가능한 사용자 DB 전체를 각각 .sql.gz 로 저장. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" +# shellcheck source=scripts/lib/common.sh +source "$REPO_ROOT/scripts/lib/common.sh" + +load_project_env "$REPO_ROOT/.env" + +: "${MR_DB_HOST:?MR_DB_HOST is required in .env}" +: "${MR_DB_USER:?MR_DB_USER is required in .env}" +: "${MR_DB_PASSWORD:?MR_DB_PASSWORD is required in .env}" + +MR_DB_PORT="${MR_DB_PORT:-3306}" +MR_DB_NAME="${MR_DB_NAME:-mysql}" +BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}" +BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" +MR_BACKUP_SCOPE="${MR_BACKUP_SCOPE:-all}" + +MYSQL_BIN="${MYSQL_BIN:-mysql}" +MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-mysqldump}" +MR_DUMP_PY="$REPO_ROOT/scripts/lib/mr_dump.py" + +use_python_fallback=0 +if ! command -v "$MYSQLDUMP_BIN" >/dev/null 2>&1; then + if [[ -f "$MR_DUMP_PY" ]] && python3 -c "import pymysql" >/dev/null 2>&1; then + echo "[$(log_ts)] mr-backup: mysqldump not found; using Python fallback (pymysql)." >&2 + use_python_fallback=1 + else + echo "mr-backup: mysqldump not found. Install mariadb-client (apt) or mariadb (brew), or install pymysql." >&2 + exit 1 + fi +fi +if [[ "$use_python_fallback" -eq 0 ]] && ! command -v "$MYSQL_BIN" >/dev/null 2>&1; then + echo "mr-backup: mysql client not found. Install mariadb-client." >&2 + exit 1 +fi + +mysql_common_args=( + -h "$MR_DB_HOST" + -P "$MR_DB_PORT" + -u "$MR_DB_USER" + --password="$MR_DB_PASSWORD" + --batch + --skip-column-names +) + +list_all_databases() { + if [[ "$use_python_fallback" -eq 1 ]]; then + python3 - "$MR_DB_HOST" "$MR_DB_PORT" "$MR_DB_USER" "$MR_DB_PASSWORD" <<'PY' +import pymysql, sys +host, port, user, password = sys.argv[1:5] +conn = pymysql.connect(host=host, port=int(port), user=user, password=password, connect_timeout=30) +try: + with conn.cursor() as cur: + cur.execute( + "SELECT schema_name FROM information_schema.schemata " + "WHERE schema_name NOT IN ('information_schema','performance_schema','sys','mysql') " + "ORDER BY schema_name" + ) + for row in cur.fetchall(): + print(row[0]) +finally: + conn.close() +PY + return + fi + "$MYSQL_BIN" "${mysql_common_args[@]}" -e \ + "SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('information_schema','performance_schema','sys','mysql') + ORDER BY schema_name;" +} + +dump_database() { + local db_name="$1" + local dump_file="$2" + if [[ "$use_python_fallback" -eq 1 ]]; then + python3 "$MR_DUMP_PY" \ + --host "$MR_DB_HOST" \ + --port "$MR_DB_PORT" \ + --user "$MR_DB_USER" \ + --password "$MR_DB_PASSWORD" \ + --database "$db_name" \ + --output "$dump_file" + return + fi + "$MYSQLDUMP_BIN" \ + -h "$MR_DB_HOST" \ + -P "$MR_DB_PORT" \ + -u "$MR_DB_USER" \ + --password="$MR_DB_PASSWORD" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --databases "$db_name" \ + | gzip -c > "$dump_file" +} + +resolve_run_dir "mariadb" +mkdir -p "$BACKUP_DIR/latest" + +echo "[$(log_ts)] mr-backup start → $RUN_DIR (scope=${MR_BACKUP_SCOPE}, retention ${BACKUP_RETENTION_DAYS} days)" + +declare -a TARGET_DBS=() +if [[ "$MR_BACKUP_SCOPE" == "single" ]]; then + TARGET_DBS=("$MR_DB_NAME") +else + while IFS= read -r db; do + [[ -n "$db" ]] && TARGET_DBS+=("$db") + done < <(list_all_databases) + if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then + echo "mr-backup: no databases found to backup." >&2 + exit 1 + fi +fi + +MANIFEST="$RUN_DIR/00_manifest.txt" +{ + echo "# mr-backup manifest $(log_ts)" + echo "scope=${MR_BACKUP_SCOPE}" + echo "host=${MR_DB_HOST}:${MR_DB_PORT}" + echo "user=${MR_DB_USER}" +} > "$MANIFEST" + +dump_ok=0 +dump_fail=0 +for db_name in "${TARGET_DBS[@]}"; do + dump_file="$RUN_DIR/${db_name}.sql.gz" + echo "[$(log_ts)] dumping database: $db_name" + if dump_database "$db_name" "$dump_file"; then + bytes="$(wc -c < "$dump_file" | tr -d ' ')" + echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)" + echo "${db_name}.sql.gz ${bytes}" >> "$MANIFEST" + dump_ok=$((dump_ok + 1)) + else + echo "[$(log_ts)] dump failed: $db_name" >&2 + dump_fail=$((dump_fail + 1)) + fi +done + +if [[ "$dump_ok" -eq 0 ]]; then + echo "mr-backup: all database dumps failed." >&2 + exit 1 +fi +if [[ "$dump_fail" -gt 0 ]]; then + echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2 +fi + +update_latest_link "mariadb" + +if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then + prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS" +fi + +echo "[$(log_ts)] mr-backup done: ${dump_ok} database(s), latest → $BACKUP_DIR/latest/mariadb" diff --git a/scripts/pg-backup.sh b/scripts/pg-backup.sh new file mode 100755 index 0000000..008565b --- /dev/null +++ b/scripts/pg-backup.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 PG_DB_* 및 PG_BACKUP_* 사용. +# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장. +# 운영 cron 예: 0 2 * * * cd /path/ncue_backup && bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" +# shellcheck source=scripts/lib/common.sh +source "$REPO_ROOT/scripts/lib/common.sh" + +load_project_env "$REPO_ROOT/.env" + +: "${PG_DB_HOST:?PG_DB_HOST is required in .env}" +: "${PG_DB_USER:?PG_DB_USER is required in .env}" +: "${PG_DB_PASSWORD:?PG_DB_PASSWORD is required in .env}" + +PG_DB_PORT="${PG_DB_PORT:-5432}" +PG_DB_NAME="${PG_DB_NAME:-postgres}" +BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}" +BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" +PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}" +PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}" +PG_BACKUP_SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}" + +export PGHOST="$PG_DB_HOST" +export PGPORT="$PG_DB_PORT" + +if ! command -v pg_dump >/dev/null 2>&1; then + echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2 + exit 1 +fi +if ! command -v psql >/dev/null 2>&1; then + echo "pg-backup: psql not found. Install postgresql-client." >&2 + exit 1 +fi + +set_backup_credentials() { + if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then + export PGUSER="$PG_BACKUP_SUPERUSER" + export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD" + else + export PGUSER="$PG_DB_USER" + export PGPASSWORD="$PG_DB_PASSWORD" + fi +} + +list_all_databases() { + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \ + "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;" +} + +dump_database() { + local db_name="$1" + local dump_file="$2" + pg_dump \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + -d "$db_name" \ + -Fc \ + --no-password \ + -f "$dump_file" +} + +backup_globals() { + if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then + return 0 + fi + if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then + echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2 + return 0 + fi + local prev_user="$PGUSER" prev_pass="$PGPASSWORD" + export PGUSER="$PG_BACKUP_SUPERUSER" + export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD" + pg_dumpall \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + --globals-only \ + --no-password \ + -f "$RUN_DIR/00_globals.sql" + export PGUSER="$prev_user" + export PGPASSWORD="$prev_pass" + echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql" +} + +set_backup_credentials +resolve_run_dir "postgresql" +mkdir -p "$BACKUP_DIR/latest" + +echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${BACKUP_RETENTION_DAYS} days)" + +if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then + echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${PG_DB_USER}." >&2 +fi + +backup_globals + +declare -a TARGET_DBS=() +if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then + TARGET_DBS=("$PG_DB_NAME") +else + while IFS= read -r db; do + [[ -n "$db" ]] && TARGET_DBS+=("$db") + done < <(list_all_databases) + if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then + echo "pg-backup: no databases found to backup." >&2 + exit 1 + fi +fi + +MANIFEST="$RUN_DIR/00_manifest.txt" +{ + echo "# pg-backup manifest $(log_ts)" + echo "scope=${PG_BACKUP_SCOPE}" + echo "host=${PGHOST}:${PGPORT}" + echo "user=${PGUSER}" +} > "$MANIFEST" + +dump_ok=0 +dump_fail=0 +for db_name in "${TARGET_DBS[@]}"; do + dump_file="$RUN_DIR/${db_name}.dump" + echo "[$(log_ts)] dumping database: $db_name" + if dump_database "$db_name" "$dump_file"; then + bytes="$(wc -c < "$dump_file" | tr -d ' ')" + echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)" + echo "${db_name}.dump ${bytes}" >> "$MANIFEST" + dump_ok=$((dump_ok + 1)) + else + echo "[$(log_ts)] dump failed: $db_name" >&2 + dump_fail=$((dump_fail + 1)) + fi +done + +if [[ "$dump_ok" -eq 0 ]]; then + echo "pg-backup: all database dumps failed." >&2 + exit 1 +fi +if [[ "$dump_fail" -gt 0 ]]; then + echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2 +fi + +update_latest_link "postgresql" + +if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then + prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS" +fi + +echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $BACKUP_DIR/latest/postgresql"