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:
2026-06-10 20:20:10 +03:30
parent 1c9d647787
commit 63d0699cdd
20 changed files with 635 additions and 324 deletions
+1
View File
@@ -0,0 +1 @@
+72 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -2
View File
@@ -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