2b348854fe
Backups land under /root/eth_hedge_backups; restore accepts zip body for new-server migration and restarts the process. Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""数据库 + .env 备份 / 恢复(自动落盘到 /root/eth_hedge_backups)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import sqlite3
|
||
import tempfile
|
||
import time
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from .config import get_settings
|
||
from .credentials import resolve_env_file_path
|
||
from .models.db import Database, get_db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MANIFEST_NAME = "manifest.json"
|
||
DB_ARCNAME = "hedge.db"
|
||
ENV_ARCNAME = ".env"
|
||
BACKUP_PREFIX = "eth_hedge_backup_"
|
||
DEFAULT_KEEP = 14
|
||
DEFAULT_INTERVAL_HOURS = 24
|
||
|
||
|
||
def resolve_backup_dir() -> Path:
|
||
"""优先 BACKUP_DIR;Linux 默认 /root/eth_hedge_backups;否则用户目录。"""
|
||
override = (os.environ.get("BACKUP_DIR") or "").strip()
|
||
if override:
|
||
p = Path(override)
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
if os.name != "nt":
|
||
root = Path("/root/eth_hedge_backups")
|
||
try:
|
||
if Path("/root").is_dir():
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
return root
|
||
except OSError as e:
|
||
logger.warning("cannot use /root/eth_hedge_backups: %s", e)
|
||
p = Path.home() / "eth_hedge_backups"
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
|
||
|
||
def resolve_env_path() -> Path | None:
|
||
p = resolve_env_file_path()
|
||
return p
|
||
|
||
|
||
def _settings_int(db: Database, key: str, default: int) -> int:
|
||
raw = db.get_setting(key, str(default))
|
||
try:
|
||
return int(float(raw or default))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _settings_bool(db: Database, key: str, default: bool) -> bool:
|
||
raw = db.get_setting(key, "1" if default else "0")
|
||
if raw is None or raw == "":
|
||
return default
|
||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||
|
||
|
||
def backup_settings(db: Database | None = None) -> dict[str, Any]:
|
||
db = db or get_db()
|
||
return {
|
||
"auto_enabled": _settings_bool(db, "backup_auto_enabled", True),
|
||
"interval_hours": max(1, _settings_int(db, "backup_interval_hours", DEFAULT_INTERVAL_HOURS)),
|
||
"keep_count": max(1, min(90, _settings_int(db, "backup_keep_count", DEFAULT_KEEP))),
|
||
"last_at_ms": _settings_int(db, "backup_last_at_ms", 0) or None,
|
||
"backup_dir": str(resolve_backup_dir()),
|
||
}
|
||
|
||
|
||
def list_backups() -> list[dict[str, Any]]:
|
||
d = resolve_backup_dir()
|
||
items: list[dict[str, Any]] = []
|
||
if not d.is_dir():
|
||
return items
|
||
for p in sorted(d.glob(f"{BACKUP_PREFIX}*.zip"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||
st = p.stat()
|
||
items.append(
|
||
{
|
||
"name": p.name,
|
||
"path": str(p),
|
||
"size_bytes": st.st_size,
|
||
"mtime_ms": int(st.st_mtime * 1000),
|
||
}
|
||
)
|
||
return items
|
||
|
||
|
||
def prune_backups(keep: int | None = None) -> int:
|
||
keep_n = keep if keep is not None else backup_settings()["keep_count"]
|
||
items = list_backups()
|
||
removed = 0
|
||
for old in items[int(keep_n) :]:
|
||
try:
|
||
Path(old["path"]).unlink(missing_ok=True)
|
||
removed += 1
|
||
except OSError as e:
|
||
logger.warning("prune backup failed %s: %s", old.get("name"), e)
|
||
return removed
|
||
|
||
|
||
def _sqlite_snapshot(db: Database, dest: Path) -> None:
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
if dest.exists():
|
||
dest.unlink()
|
||
with db._lock:
|
||
dst = sqlite3.connect(str(dest))
|
||
try:
|
||
db._conn.backup(dst)
|
||
dst.commit()
|
||
finally:
|
||
dst.close()
|
||
|
||
|
||
def create_backup(
|
||
*,
|
||
db: Database | None = None,
|
||
reason: str = "manual",
|
||
) -> dict[str, Any]:
|
||
db = db or get_db()
|
||
backup_dir = resolve_backup_dir()
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip"
|
||
env_path = resolve_env_path()
|
||
db_path = Path(db.path)
|
||
|
||
with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td:
|
||
tmp_db = Path(td) / "hedge.db"
|
||
_sqlite_snapshot(db, tmp_db)
|
||
manifest = {
|
||
"product": "比特骆驼自动化对冲系统",
|
||
"version": 1,
|
||
"created_at_ms": int(time.time() * 1000),
|
||
"reason": reason,
|
||
"db_source": str(db_path),
|
||
"env_source": str(env_path) if env_path else None,
|
||
"has_env": bool(env_path and env_path.is_file()),
|
||
"mode": get_settings().mode,
|
||
"env_name": get_settings().env_name,
|
||
}
|
||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||
zf.write(tmp_db, DB_ARCNAME)
|
||
if env_path and env_path.is_file():
|
||
zf.write(env_path, ENV_ARCNAME)
|
||
zf.writestr(
|
||
MANIFEST_NAME,
|
||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||
)
|
||
|
||
db.set_setting("backup_last_at_ms", str(int(time.time() * 1000)))
|
||
prune_backups()
|
||
st = out.stat()
|
||
logger.info("backup created path=%s reason=%s size=%s", out, reason, st.st_size)
|
||
return {
|
||
"ok": True,
|
||
"name": out.name,
|
||
"path": str(out),
|
||
"size_bytes": st.st_size,
|
||
"mtime_ms": int(st.st_mtime * 1000),
|
||
"backup_dir": str(backup_dir),
|
||
"manifest": manifest,
|
||
}
|
||
|
||
|
||
def read_backup_file(name: str) -> Path:
|
||
safe = Path(name).name
|
||
if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"):
|
||
raise ValueError("非法备份文件名")
|
||
path = resolve_backup_dir() / safe
|
||
if not path.is_file():
|
||
raise FileNotFoundError(f"备份不存在: {safe}")
|
||
return path
|
||
|
||
|
||
def _extract_backup_zip(zip_path: Path, dest_dir: Path) -> dict[str, Path]:
|
||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||
names = set(zf.namelist())
|
||
if DB_ARCNAME not in names:
|
||
# 兼容偶发相对路径
|
||
db_candidates = [n for n in names if n.endswith("hedge.db") or n.endswith(".db")]
|
||
if not db_candidates:
|
||
raise ValueError("备份包缺少 hedge.db")
|
||
db_name = db_candidates[0]
|
||
else:
|
||
db_name = DB_ARCNAME
|
||
env_name = ENV_ARCNAME if ENV_ARCNAME in names else (
|
||
next((n for n in names if n.endswith(".env") or n == "env"), None)
|
||
)
|
||
zf.extract(db_name, dest_dir)
|
||
db_out = dest_dir / db_name
|
||
if not db_out.is_file():
|
||
# zip 内可能带目录
|
||
found = list(dest_dir.rglob("*.db"))
|
||
if not found:
|
||
raise ValueError("解压后未找到数据库文件")
|
||
db_out = found[0]
|
||
env_out: Path | None = None
|
||
if env_name:
|
||
zf.extract(env_name, dest_dir)
|
||
env_out = dest_dir / env_name
|
||
if not env_out.is_file():
|
||
found_env = list(dest_dir.rglob(".env")) + list(dest_dir.rglob("*.env"))
|
||
env_out = found_env[0] if found_env else None
|
||
return {"db": db_out, "env": env_out} # type: ignore[dict-item]
|
||
|
||
|
||
def validate_backup_zip(zip_path: Path) -> dict[str, Any]:
|
||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||
names = zf.namelist()
|
||
has_db = any(n.endswith("hedge.db") or n == DB_ARCNAME or n.endswith(".db") for n in names)
|
||
has_env = any(n.endswith(".env") or n == ENV_ARCNAME for n in names)
|
||
manifest = None
|
||
if MANIFEST_NAME in names:
|
||
try:
|
||
manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
|
||
except Exception:
|
||
manifest = None
|
||
if not has_db:
|
||
raise ValueError("备份包无效:缺少数据库")
|
||
return {"has_db": has_db, "has_env": has_env, "manifest": manifest, "files": names}
|
||
|
||
|
||
def restore_from_zip(
|
||
zip_path: Path,
|
||
*,
|
||
db: Database | None = None,
|
||
make_safety_backup: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""用备份包覆盖当前 hedge.db 与 .env。调用方应在此前后暂停策略并准备重启进程。"""
|
||
db = db or get_db()
|
||
info = validate_backup_zip(zip_path)
|
||
safety = None
|
||
if make_safety_backup:
|
||
try:
|
||
safety = create_backup(db=db, reason="pre_restore")
|
||
except Exception as e:
|
||
logger.warning("pre_restore backup failed: %s", e)
|
||
|
||
with tempfile.TemporaryDirectory(prefix="eth_hedge_restore_") as td:
|
||
extracted = _extract_backup_zip(zip_path, Path(td))
|
||
src_db = extracted["db"]
|
||
src_env = extracted.get("env")
|
||
|
||
db_path = Path(db.path)
|
||
bak_live: Path | None = None
|
||
db.close()
|
||
|
||
if db_path.is_file():
|
||
bak_live = db_path.with_suffix(db_path.suffix + f".pre_restore_{int(time.time())}")
|
||
shutil.copy2(db_path, bak_live)
|
||
shutil.copy2(src_db, db_path)
|
||
|
||
env_written = None
|
||
if src_env and Path(src_env).is_file():
|
||
env_target = resolve_env_file_path()
|
||
env_target.parent.mkdir(parents=True, exist_ok=True)
|
||
if env_target.is_file():
|
||
shutil.copy2(env_target, env_target.with_suffix(".env.pre_restore"))
|
||
shutil.copy2(src_env, env_target)
|
||
try:
|
||
env_target.chmod(0o600)
|
||
except Exception:
|
||
pass
|
||
env_written = str(env_target)
|
||
|
||
return {
|
||
"ok": True,
|
||
"db_path": str(db_path),
|
||
"env_path": env_written,
|
||
"had_env_in_backup": bool(info.get("has_env")),
|
||
"safety_backup": safety.get("name") if safety else None,
|
||
"local_db_copy": str(bak_live) if bak_live and Path(bak_live).exists() else None,
|
||
"restart_required": True,
|
||
"manifest": info.get("manifest"),
|
||
}
|
||
|
||
|
||
_auto_task: asyncio.Task[None] | None = None
|
||
|
||
|
||
async def auto_backup_loop() -> None:
|
||
"""后台定时备份;默认开启。"""
|
||
logger.info("auto backup loop started dir=%s", resolve_backup_dir())
|
||
while True:
|
||
try:
|
||
await asyncio.sleep(60)
|
||
db = get_db()
|
||
cfg = backup_settings(db)
|
||
if not cfg["auto_enabled"]:
|
||
continue
|
||
interval_ms = int(cfg["interval_hours"]) * 3600 * 1000
|
||
last = int(cfg["last_at_ms"] or 0)
|
||
now = int(time.time() * 1000)
|
||
if last and now - last < interval_ms:
|
||
continue
|
||
await asyncio.to_thread(create_backup, db=db, reason="auto")
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("auto backup tick failed")
|
||
await asyncio.sleep(30)
|
||
|
||
|
||
def start_auto_backup_task() -> asyncio.Task[None]:
|
||
global _auto_task
|
||
if _auto_task is None or _auto_task.done():
|
||
_auto_task = asyncio.create_task(auto_backup_loop(), name="auto-backup")
|
||
return _auto_task
|
||
|
||
|
||
async def stop_auto_backup_task() -> None:
|
||
global _auto_task
|
||
if _auto_task and not _auto_task.done():
|
||
_auto_task.cancel()
|
||
try:
|
||
await _auto_task
|
||
except Exception:
|
||
pass
|
||
_auto_task = None
|