63d0699cdd
Remove invalid _mirror_manager from daemon.json that prevented Docker from starting. Add 2-minute test apply with auto-rollback, final apply confirmation, emergency-restore CLI/UI, and post-restart Docker health checks. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.6 KiB
Python
200 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from app.config import Config
|
|
from app.services.docker_svc import sanitize_daemon_json_file
|
|
from app.services.host import host_path, run_on_host
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BACKUP_TARGETS = {
|
|
"resolved.conf": "etc/systemd/resolved.conf",
|
|
"resolved.conf.d": "etc/systemd/resolved.conf.d",
|
|
"sources.list": "etc/apt/sources.list",
|
|
"sources.list.d": "etc/apt/sources.list.d",
|
|
"daemon.json": "etc/docker/daemon.json",
|
|
"gitconfig_system": "etc/gitconfig",
|
|
}
|
|
|
|
|
|
def _backup_dir(name: str) -> Path:
|
|
path = Config.BACKUP_DIR / name
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _copy_if_exists(src: Path, dst: Path) -> None:
|
|
if not src.exists():
|
|
return
|
|
if src.is_dir():
|
|
if dst.exists():
|
|
shutil.rmtree(dst)
|
|
shutil.copytree(src, dst)
|
|
else:
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def _restore_file(relative: str, backup_root: Path) -> None:
|
|
key = relative.replace("/", "_").replace(".", "_")
|
|
for name, rel in BACKUP_TARGETS.items():
|
|
if rel == relative or rel.endswith(relative):
|
|
key = name
|
|
break
|
|
|
|
src = backup_root / key
|
|
dst = host_path(relative)
|
|
if not src.exists():
|
|
if dst.exists() and key in ("daemon.json", "gitconfig_system"):
|
|
dst.unlink(missing_ok=True)
|
|
return
|
|
|
|
if src.is_dir():
|
|
if dst.exists():
|
|
shutil.rmtree(dst)
|
|
shutil.copytree(src, dst)
|
|
else:
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def create_backup(label: str = "manual") -> Path:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_root = _backup_dir(f"{timestamp}_{label}")
|
|
manifest: dict = {"label": label, "timestamp": timestamp, "files": {}}
|
|
|
|
for key, relative in BACKUP_TARGETS.items():
|
|
src = host_path(relative)
|
|
dst = backup_root / key
|
|
_copy_if_exists(src, dst)
|
|
manifest["files"][key] = {
|
|
"relative": relative,
|
|
"existed": src.exists(),
|
|
}
|
|
|
|
gitconfig_global = host_path("root/.gitconfig")
|
|
if not gitconfig_global.exists():
|
|
gitconfig_global = Path.home() / ".gitconfig"
|
|
if gitconfig_global.exists():
|
|
shutil.copy2(gitconfig_global, backup_root / "gitconfig_global")
|
|
manifest["files"]["gitconfig_global"] = {"path": str(gitconfig_global), "existed": True}
|
|
|
|
pip_conf = host_path("etc/pip.conf")
|
|
if pip_conf.exists():
|
|
shutil.copy2(pip_conf, backup_root / "pip.conf")
|
|
manifest["files"]["pip.conf"] = {"relative": "etc/pip.conf", "existed": True}
|
|
|
|
npmrc = host_path("root/.npmrc")
|
|
if npmrc.exists():
|
|
shutil.copy2(npmrc, backup_root / "npmrc")
|
|
manifest["files"]["npmrc"] = {"relative": "root/.npmrc", "existed": True}
|
|
|
|
mirror_dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
|
if mirror_dropin.exists():
|
|
shutil.copy2(mirror_dropin, backup_root / "mirror_manager_resolved.conf")
|
|
|
|
with open(backup_root / "manifest.json", "w", encoding="utf-8") as f:
|
|
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
|
|
|
logger.info("Backup created at %s", backup_root)
|
|
return backup_root
|
|
|
|
|
|
def restore_backup(backup_root: Path | str) -> dict:
|
|
backup_root = Path(backup_root)
|
|
if not backup_root.exists():
|
|
return {"success": False, "error": "مسیر backup یافت نشد"}
|
|
|
|
results: list[str] = []
|
|
|
|
for key, relative in BACKUP_TARGETS.items():
|
|
src = backup_root / key
|
|
if src.exists():
|
|
dst = host_path(relative)
|
|
_copy_if_exists(src, dst)
|
|
results.append(f"بازگردانی {relative}")
|
|
|
|
gitconfig_backup = backup_root / "gitconfig_global"
|
|
if gitconfig_backup.exists():
|
|
for target in [host_path("root/.gitconfig"), host_path("home/.gitconfig")]:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(gitconfig_backup, target)
|
|
results.append("بازگردانی gitconfig global")
|
|
|
|
pip_backup = backup_root / "pip.conf"
|
|
if pip_backup.exists():
|
|
dst = host_path("etc/pip.conf")
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(pip_backup, dst)
|
|
results.append("بازگردانی pip.conf")
|
|
|
|
npmrc_backup = backup_root / "npmrc"
|
|
if npmrc_backup.exists():
|
|
dst = host_path("root/.npmrc")
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(npmrc_backup, dst)
|
|
results.append("بازگردانی .npmrc")
|
|
|
|
dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
|
mirror_backup = backup_root / "mirror_manager_resolved.conf"
|
|
if mirror_backup.exists():
|
|
dropin.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(mirror_backup, dropin)
|
|
elif dropin.exists():
|
|
dropin.unlink()
|
|
|
|
daemon_path = host_path("etc/docker/daemon.json")
|
|
if sanitize_daemon_json_file(daemon_path):
|
|
results.append("پاکسازی کلیدهای نامعتبر daemon.json")
|
|
|
|
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
|
docker_result = run_on_host(["systemctl", "restart", "docker"], timeout=120)
|
|
if docker_result.returncode != 0:
|
|
return {
|
|
"success": False,
|
|
"error": (docker_result.stderr or "خطا در restart docker")[:500],
|
|
"restored": results,
|
|
}
|
|
|
|
return {"success": True, "restored": results}
|
|
|
|
|
|
def get_latest_apply_backup() -> Path | None:
|
|
"""Return the most recent before_apply backup directory, if any."""
|
|
if not Config.BACKUP_DIR.exists():
|
|
return None
|
|
candidates = sorted(
|
|
(
|
|
p
|
|
for p in Config.BACKUP_DIR.iterdir()
|
|
if p.is_dir() and "before_apply" in p.name
|
|
),
|
|
reverse=True,
|
|
)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def create_initial_backup() -> Path:
|
|
return create_backup("initial")
|
|
|
|
|
|
def list_backups() -> list[dict]:
|
|
backups = []
|
|
if not Config.BACKUP_DIR.exists():
|
|
return backups
|
|
for path in sorted(Config.BACKUP_DIR.iterdir(), reverse=True):
|
|
if path.is_dir():
|
|
manifest_path = path / "manifest.json"
|
|
label = path.name
|
|
if manifest_path.exists():
|
|
with open(manifest_path, encoding="utf-8") as f:
|
|
manifest = json.load(f)
|
|
label = manifest.get("label", path.name)
|
|
backups.append({"path": str(path), "name": path.name, "label": label})
|
|
return backups
|