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:
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
|
||||
Reference in New Issue
Block a user