Fix Docker apply crash and add safe profile test flow
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>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
+72
-9
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Profile, SystemState
|
||||
from app.models import ApplyLog, Profile, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services import apt, backup, dns, docker_svc, github, pip_npm
|
||||
from app.services.host import run_on_host
|
||||
@@ -22,6 +21,9 @@ APPLIERS = {
|
||||
"npm": pip_npm.apply_npm,
|
||||
}
|
||||
|
||||
APPLY_MODE_TEST = "test"
|
||||
APPLY_MODE_FINAL = "final"
|
||||
|
||||
|
||||
def ensure_initial_backup() -> SystemState:
|
||||
state = SystemState.query.first()
|
||||
@@ -40,9 +42,22 @@ def ensure_initial_backup() -> SystemState:
|
||||
return state
|
||||
|
||||
|
||||
def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> ApplyLog:
|
||||
def _rollback_minutes_for_mode(mode: str, rollback_minutes: int | None) -> int | None:
|
||||
if mode == APPLY_MODE_FINAL:
|
||||
return None
|
||||
if rollback_minutes is not None:
|
||||
return rollback_minutes
|
||||
default = Setting.get("test_rollback_minutes", str(Config.TEST_ROLLBACK_MINUTES))
|
||||
return int(default or Config.TEST_ROLLBACK_MINUTES)
|
||||
|
||||
|
||||
def apply_profile(
|
||||
profile: Profile,
|
||||
mode: str = APPLY_MODE_TEST,
|
||||
rollback_minutes: int | None = None,
|
||||
) -> ApplyLog:
|
||||
state = ensure_initial_backup()
|
||||
rollback_minutes = rollback_minutes or int(Config.ROLLBACK_MINUTES)
|
||||
rollback_minutes = _rollback_minutes_for_mode(mode, rollback_minutes)
|
||||
|
||||
previous_backup = backup.create_backup(f"before_apply_{profile.id}")
|
||||
apply_log = ApplyLog(
|
||||
@@ -50,12 +65,17 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
status="running",
|
||||
backup_path=str(previous_backup),
|
||||
previous_backup_path=state.initial_backup_path,
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes),
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes) if rollback_minutes else None,
|
||||
)
|
||||
db.session.add(apply_log)
|
||||
db.session.flush()
|
||||
|
||||
details: dict = {"steps": [], "profile": profile.name}
|
||||
details: dict = {
|
||||
"steps": [],
|
||||
"profile": profile.name,
|
||||
"mode": mode,
|
||||
"rollback_minutes": rollback_minutes,
|
||||
}
|
||||
mirrors = profile.mirrors_by_category()
|
||||
order = ["dns", "apt", "docker", "github", "pip", "npm"]
|
||||
|
||||
@@ -69,11 +89,14 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
try:
|
||||
result = applier(mirror)
|
||||
details["steps"].append({"category": category, "mirror": mirror.name, **result})
|
||||
if not result.get("success", True):
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.exception("Apply failed for %s", category)
|
||||
details["steps"].append(
|
||||
{"category": category, "mirror": mirror.name, "success": False, "error": str(exc)}
|
||||
)
|
||||
break
|
||||
|
||||
failed = [s for s in details["steps"] if not s.get("success", True)]
|
||||
if failed and apply_log.backup_path:
|
||||
@@ -81,11 +104,22 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
details["auto_rollback"] = rollback_result
|
||||
|
||||
apply_log.set_details(details)
|
||||
apply_log.status = "failed" if failed else "pending_confirm"
|
||||
apply_log.finished_at = utcnow()
|
||||
|
||||
state.current_profile_id = profile.id if not failed else state.current_profile_id
|
||||
state.pending_apply_log_id = apply_log.id if not failed else None
|
||||
if failed:
|
||||
apply_log.status = "failed"
|
||||
apply_log.rollback_at = None
|
||||
elif mode == APPLY_MODE_FINAL:
|
||||
apply_log.confirmed = True
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
state.current_profile_id = profile.id
|
||||
state.pending_apply_log_id = None
|
||||
else:
|
||||
apply_log.status = "pending_confirm"
|
||||
state.current_profile_id = profile.id
|
||||
state.pending_apply_log_id = apply_log.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return apply_log
|
||||
@@ -97,9 +131,15 @@ def confirm_apply(apply_log_id: int) -> ApplyLog:
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
|
||||
details = apply_log.get_details()
|
||||
details["confirmed_from"] = APPLY_MODE_TEST
|
||||
apply_log.set_details(details)
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
state.pending_apply_log_id = None
|
||||
if apply_log.profile_id:
|
||||
state.current_profile_id = apply_log.profile_id
|
||||
db.session.commit()
|
||||
return apply_log
|
||||
|
||||
@@ -112,6 +152,7 @@ def rollback_apply(apply_log_id: int) -> dict:
|
||||
result = backup.restore_backup(apply_log.backup_path)
|
||||
apply_log.status = "rolled_back"
|
||||
apply_log.finished_at = utcnow()
|
||||
apply_log.rollback_at = None
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
@@ -120,6 +161,28 @@ def rollback_apply(apply_log_id: int) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def emergency_restore_last_apply() -> dict:
|
||||
"""One-shot restore of the most recent pre-apply backup."""
|
||||
path = backup.get_latest_apply_backup()
|
||||
if not path:
|
||||
return {"success": False, "error": "backup قبل از apply یافت نشد"}
|
||||
result = backup.restore_backup(path)
|
||||
if result.get("success"):
|
||||
result["backup_path"] = str(path)
|
||||
|
||||
state = SystemState.query.first()
|
||||
pending_id = state.pending_apply_log_id if state else None
|
||||
if pending_id:
|
||||
log = ApplyLog.query.get(pending_id)
|
||||
if log and log.status == "pending_confirm":
|
||||
log.status = "rolled_back"
|
||||
log.finished_at = utcnow()
|
||||
log.rollback_at = None
|
||||
state.pending_apply_log_id = None
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def restore_initial() -> dict:
|
||||
state = SystemState.query.first()
|
||||
if not state or not state.initial_backup_path:
|
||||
|
||||
+27
-1
@@ -7,6 +7,7 @@ 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__)
|
||||
@@ -147,12 +148,37 @@ def restore_backup(backup_root: Path | str) -> dict:
|
||||
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"])
|
||||
run_on_host(["systemctl", "restart", "docker"])
|
||||
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")
|
||||
|
||||
|
||||
+107
-57
@@ -1,57 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAEMON_JSON = "etc/docker/daemon.json"
|
||||
MIRROR_MANAGER_KEY = "mirror-manager"
|
||||
|
||||
|
||||
def read_current_docker() -> dict:
|
||||
path = host_path(DAEMON_JSON)
|
||||
if not path.exists():
|
||||
return {"exists": False, "config": {}}
|
||||
try:
|
||||
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))}
|
||||
except json.JSONDecodeError:
|
||||
return {"exists": True, "config": {}, "parse_error": True}
|
||||
|
||||
|
||||
def apply_docker(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL رجیstry داکر تعریف نشده"}
|
||||
|
||||
path = host_path(DAEMON_JSON)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
config["registry-mirrors"] = [mirror.url.rstrip("/")]
|
||||
config["_mirror_manager"] = MIRROR_MANAGER_KEY
|
||||
|
||||
path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = run_on_host(["systemctl", "restart", "docker"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (result.stderr or "خطا در restart docker")[:500],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Docker registry mirror اعمال شد: {mirror.name}",
|
||||
"url": mirror.url,
|
||||
"warning": "سرویس Docker restart شد — containerها موقتاً قطع میشوند",
|
||||
}
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
from app.models import Mirror
|
||||
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
DAEMON_JSON = "etc/docker/daemon.json"
|
||||
|
||||
INVALID_DAEMON_KEYS = ("_mirror_manager",)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def sanitize_daemon_config(config: dict) -> dict:
|
||||
|
||||
"""Remove keys that Docker daemon does not accept."""
|
||||
|
||||
for key in INVALID_DAEMON_KEYS:
|
||||
|
||||
config.pop(key, None)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def sanitize_daemon_json_file(path: Path | None = None) -> bool:
|
||||
|
||||
"""Strip invalid keys from daemon.json on disk. Returns True if file was modified."""
|
||||
|
||||
path = path or host_path(DAEMON_JSON)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
|
||||
return False
|
||||
|
||||
cleaned = sanitize_daemon_config(dict(config))
|
||||
|
||||
if cleaned == config:
|
||||
|
||||
return False
|
||||
|
||||
path.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def verify_docker_running() -> dict:
|
||||
|
||||
active = run_on_host(["systemctl", "is-active", "docker"], timeout=30)
|
||||
|
||||
if active.returncode != 0:
|
||||
|
||||
return {
|
||||
|
||||
"success": False,
|
||||
|
||||
"error": "Docker بعد از restart بالا نیامد — تنظیمات daemon.json را بررسی کنید",
|
||||
|
||||
}
|
||||
|
||||
info = run_on_host(["docker", "info"], timeout=30)
|
||||
|
||||
if info.returncode != 0:
|
||||
|
||||
return {
|
||||
|
||||
"success": False,
|
||||
|
||||
"error": (info.stderr or info.stdout or "docker info ناموفق")[:500],
|
||||
|
||||
}
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Mirror, Profile, ProfileItem, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services.applier import apply_profile, rollback_apply
|
||||
from app.services.applier import APPLY_MODE_FINAL, apply_profile, rollback_apply
|
||||
from app.services.tester import get_best_mirror, test_mirror
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,7 +78,7 @@ def run_auto_switch(app) -> None:
|
||||
|
||||
item.mirror_id = best.id
|
||||
db.session.commit()
|
||||
apply_profile(profile)
|
||||
apply_profile(profile, mode=APPLY_MODE_FINAL)
|
||||
_increment_switch_count()
|
||||
logger.info("Auto-switched %s to %s", category, best.name)
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user