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 <cursoragent@cursor.com>
This commit is contained in:
50
scripts/daily-backup.sh
Executable file
50
scripts/daily-backup.sh
Executable file
@@ -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"
|
||||
145
scripts/gitea-backup.sh
Executable file
145
scripts/gitea-backup.sh
Executable file
@@ -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"
|
||||
66
scripts/lib/common.sh
Executable file
66
scripts/lib/common.sh
Executable file
@@ -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"
|
||||
}
|
||||
24
scripts/lib/load-env.sh
Executable file
24
scripts/lib/load-env.sh
Executable file
@@ -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"
|
||||
}
|
||||
130
scripts/lib/mr_dump.py
Normal file
130
scripts/lib/mr_dump.py
Normal file
@@ -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
|
||||
160
scripts/mr-backup.sh
Executable file
160
scripts/mr-backup.sh
Executable file
@@ -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"
|
||||
153
scripts/pg-backup.sh
Executable file
153
scripts/pg-backup.sh
Executable file
@@ -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"
|
||||
Reference in New Issue
Block a user