180 lines
5.9 KiB
Python
Executable File
180 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""SuperSam daily backup — export public tables to CSV, tar.gz, upload to S3."""
|
||
|
||
import csv
|
||
import io
|
||
import os
|
||
import sys
|
||
import subprocess
|
||
import tarfile
|
||
import tempfile
|
||
from datetime import datetime
|
||
|
||
import boto3
|
||
from botocore.config import Config
|
||
|
||
# S3 config
|
||
S3_ENDPOINT = "https://s3.ru1.storage.beget.cloud"
|
||
S3_KEY = "YG4MQNKAPNL65200MBUY"
|
||
S3_SECRET = "8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||
S3_BUCKET = "02f162ff4a18-supersam-s3"
|
||
|
||
# DB config
|
||
DB_HOST = "supabase-db"
|
||
DB_USER = "supabase_admin"
|
||
DB_NAME = "postgres"
|
||
|
||
|
||
def get_db_pass():
|
||
"""Пароль из окружения или /opt/supabase/docker/.env, иначе известное значение."""
|
||
if os.environ.get("POSTGRES_PASSWORD"):
|
||
return os.environ["POSTGRES_PASSWORD"]
|
||
try:
|
||
with open("/opt/supabase/docker/.env") as f:
|
||
for line in f:
|
||
if line.startswith("POSTGRES_PASSWORD="):
|
||
return line.split("=", 1)[1].strip()
|
||
except OSError:
|
||
pass
|
||
return "4fe80bb21c7c3d17a8d8b226adf7a479"
|
||
|
||
|
||
DB_PASS = get_db_pass()
|
||
|
||
|
||
def log_backup(status, size, size_bytes, s3_path, tables):
|
||
"""Запись об операции в public.backup_logs (SQL через stdin, не через -c)."""
|
||
sql = (
|
||
"INSERT INTO public.backup_logs "
|
||
"(backup_type, status, file_size, file_size_bytes, s3_path, tables_included) "
|
||
f"VALUES ('csv', '{status}', '{size}', {int(size_bytes)}, '{s3_path}', '{tables}');\n"
|
||
)
|
||
result = subprocess.run(
|
||
["docker", "exec", "-i", "-e", f"PGPASSWORD={DB_PASS}", DB_HOST,
|
||
"psql", "-U", DB_USER, "-d", DB_NAME, "-q", "-t", "-A"],
|
||
input=sql, capture_output=True, text=True,
|
||
)
|
||
if result.returncode != 0:
|
||
print(f"WARN: не удалось записать backup_logs: {result.stderr.strip()}", file=sys.stderr)
|
||
|
||
|
||
def get_tables():
|
||
"""Get list of public tables with data."""
|
||
result = subprocess.run(
|
||
["docker", "exec", "-i", DB_HOST, "psql", "-U", DB_USER, "-d", DB_NAME,
|
||
"-t", "-A", "-c",
|
||
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"],
|
||
capture_output=True, text=True
|
||
)
|
||
if result.returncode != 0:
|
||
print(f"ERROR getting tables: {result.stderr}", file=sys.stderr)
|
||
sys.exit(1)
|
||
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||
return tables
|
||
|
||
|
||
def export_table_csv(table_name, out_dir):
|
||
"""Export a single table to CSV via psql \\copy."""
|
||
csv_path = os.path.join(out_dir, f"{table_name}.csv")
|
||
# Use psql \copy (client-side) to avoid needing superuser for COPY
|
||
cmd = [
|
||
"docker", "exec", "-i", DB_HOST,
|
||
"psql", "-U", DB_USER, "-d", DB_NAME,
|
||
"-c", f"\\copy (SELECT * FROM public.{table_name}) TO '/tmp/{table_name}.csv' WITH CSV HEADER;"
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f"WARN: {table_name} export failed: {result.stderr}", file=sys.stderr)
|
||
return False
|
||
|
||
# Copy from container to host
|
||
cmd2 = ["docker", "cp", f"{DB_HOST}:/tmp/{table_name}.csv", csv_path]
|
||
result2 = subprocess.run(cmd2, capture_output=True, text=True)
|
||
if result2.returncode != 0:
|
||
print(f"WARN: {table_name} docker cp failed: {result2.stderr}", file=sys.stderr)
|
||
return False
|
||
|
||
# Clean up temp file in container
|
||
subprocess.run(["docker", "exec", "-i", DB_HOST, "rm", "-f", f"/tmp/{table_name}.csv"],
|
||
capture_output=True, text=True)
|
||
|
||
# Check if file has data (more than just header)
|
||
with open(csv_path, "r") as f:
|
||
lines = f.readlines()
|
||
if len(lines) <= 1:
|
||
print(f" {table_name}: empty (skipping)")
|
||
return False
|
||
|
||
print(f" {table_name}: {len(lines)-1} rows")
|
||
return True
|
||
|
||
|
||
def upload_to_s3(file_path, key):
|
||
"""Upload file to S3."""
|
||
s3 = boto3.client(
|
||
"s3",
|
||
endpoint_url=S3_ENDPOINT,
|
||
aws_access_key_id=S3_KEY,
|
||
aws_secret_access_key=S3_SECRET,
|
||
config=Config(signature_version="s3v4"),
|
||
region_name="ru-1"
|
||
)
|
||
s3.upload_file(file_path, S3_BUCKET, key)
|
||
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
|
||
|
||
|
||
def main():
|
||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||
archive_name = f"supersam-backup-{date_str}.tar.gz"
|
||
s3_key = f"backups/{archive_name}"
|
||
s3_path = f"s3://{S3_BUCKET}/{s3_key}"
|
||
|
||
print(f"=== SuperSam Backup {date_str} ===")
|
||
|
||
try:
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
tables = get_tables()
|
||
print(f"Found {len(tables)} tables")
|
||
|
||
exported = []
|
||
for table in tables:
|
||
if export_table_csv(table, tmpdir):
|
||
exported.append(table)
|
||
|
||
if not exported:
|
||
print("No tables with data — nothing to backup")
|
||
sys.exit(0)
|
||
|
||
# Create tar.gz
|
||
archive_path = os.path.join(tmpdir, archive_name)
|
||
with tarfile.open(archive_path, "w:gz") as tar:
|
||
for table in exported:
|
||
csv_path = os.path.join(tmpdir, f"{table}.csv")
|
||
tar.add(csv_path, arcname=f"{table}.csv")
|
||
|
||
# Get size
|
||
size_bytes = os.path.getsize(archive_path)
|
||
size_mb = size_bytes / (1024 * 1024)
|
||
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
|
||
|
||
# Upload
|
||
upload_to_s3(archive_path, s3_key)
|
||
|
||
log_backup(
|
||
"success",
|
||
f"{size_mb:.2f} MB",
|
||
size_bytes,
|
||
s3_path,
|
||
", ".join(exported),
|
||
)
|
||
except Exception as exc: # noqa: BLE001 — лог провала, затем проброс
|
||
print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||
log_backup("failed", "", 0, s3_path, f"{type(exc).__name__}: {exc}"[:200])
|
||
raise
|
||
|
||
print(f"=== Backup complete ===")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|