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
@@ -12,6 +12,7 @@ DATA_DIR=/app/data
# ─── رفتار ─── # ─── رفتار ───
ROLLBACK_MINUTES=15 ROLLBACK_MINUTES=15
TEST_ROLLBACK_MINUTES=2
AUTO_TEST_INTERVAL_MINUTES=30 AUTO_TEST_INTERVAL_MINUTES=30
FLASK_ENV=production FLASK_ENV=production
+9 -2
View File
@@ -99,14 +99,21 @@ echo 'export PYTHONPATH=/opt/mirror-manager' | sudo tee /etc/profile.d/mirror-ma
1. به **https://mirror.itistan.ir** بروید 1. به **https://mirror.itistan.ir** بروید
2. با `ADMIN_USERNAME` / `ADMIN_PASSWORD` وارد شوید 2. با `ADMIN_USERNAME` / `ADMIN_PASSWORD` وارد شوید
3. در **اولین ورود** backup اولیه خودکار گرفته می‌شود 3. در **اولین ورود** backup اولیه خودکار گرفته می‌شود
4. یک **پروفایل** انتخاب و **اعمال** کنید 4. یک **پروفایل** انتخاب کنید → **تست موقت (۲ دقیقه)** بزنید
5. در پنجره rollback تست کنید → **تأیید** یا **rollback** 5. Dokploy و deploy را تست کنید → **اعمال نهایی** (نگه‌داشتن تغییرات)
6. اگر مشکل پیش آمد: `mirror-manager emergency-restore` از SSH
--- ---
## CLI (از SSH) ## CLI (از SSH)
```bash ```bash
# بازگردانی فوری — آخرین backup قبل از apply (توصیه در emergency)
mirror-manager emergency-restore
# یا معادل:
mirror-manager restore --last
# بازگردانی تنظیمات اولیه (قبل از نصب Mirror Manager) # بازگردانی تنظیمات اولیه (قبل از نصب Mirror Manager)
mirror-manager restore --initial mirror-manager restore --initial
+39 -4
View File
@@ -13,8 +13,12 @@ from app.routes.main import main_bp
from app.routes.mirrors import mirrors_bp from app.routes.mirrors import mirrors_bp
from app.routes.profiles import profiles_bp from app.routes.profiles import profiles_bp
from app.seed import seed_all from app.seed import seed_all
from app.services.applier import ensure_initial_backup, restore_initial from app.services.applier import (
from app.services.backup import restore_backup ensure_initial_backup,
emergency_restore_last_apply,
restore_initial,
)
from app.services.backup import get_latest_apply_backup, restore_backup
from app.services.scheduler import init_scheduler from app.services.scheduler import init_scheduler
@@ -87,17 +91,29 @@ def cli():
@cli.command() @cli.command()
@click.option("--initial", is_flag=True, help="بازگردانی تنظیمات اولیه") @click.option("--initial", is_flag=True, help="بازگردانی تنظیمات اولیه")
@click.option("--last", is_flag=True, help="آخرین backup قبل از apply")
@click.option("--backup", default=None, help="مسیر backup مشخص") @click.option("--backup", default=None, help="مسیر backup مشخص")
def restore(initial, backup): def restore(initial, backup, last):
os.environ["DISABLE_SCHEDULER"] = "1" os.environ["DISABLE_SCHEDULER"] = "1"
if not os.environ.get("HOST_ROOT"):
os.environ.setdefault("HOST_ROOT", "/")
if not os.environ.get("DATA_DIR"):
os.environ.setdefault("DATA_DIR", "/var/lib/mirror-manager/data")
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
if initial: if initial:
result = restore_initial() result = restore_initial()
elif last:
path = get_latest_apply_backup()
if not path:
click.echo("backup قبل از apply یافت نشد.")
return
click.echo(f"بازگردانی از: {path}")
result = restore_backup(path)
elif backup: elif backup:
result = restore_backup(backup) result = restore_backup(backup)
else: else:
click.echo("یکی از --initial یا --backup را مشخص کنید.") click.echo("یکی از --initial، --last یا --backup را مشخص کنید.")
return return
if result.get("success"): if result.get("success"):
click.echo("بازگردانی موفق:") click.echo("بازگردانی موفق:")
@@ -107,6 +123,25 @@ def restore(initial, backup):
click.echo(f"خطا: {result.get('error')}") click.echo(f"خطا: {result.get('error')}")
@cli.command("emergency-restore")
def emergency_restore_cmd():
"""بازگردانی فوری آخرین backup قبل از apply — یک دستور"""
os.environ["DISABLE_SCHEDULER"] = "1"
if not os.environ.get("HOST_ROOT"):
os.environ.setdefault("HOST_ROOT", "/")
if not os.environ.get("DATA_DIR"):
os.environ.setdefault("DATA_DIR", "/var/lib/mirror-manager/data")
app = create_app()
with app.app_context():
result = emergency_restore_last_apply()
if result.get("success"):
click.echo(f"بازگردانی فوری موفق از: {result.get('backup_path', '')}")
for item in result.get("restored", []):
click.echo(f" - {item}")
else:
click.echo(f"خطا: {result.get('error')}")
@cli.command() @cli.command()
def disable(): def disable():
container = os.environ.get("MIRROR_CONTAINER_NAME", "mirror-manager") container = os.environ.get("MIRROR_CONTAINER_NAME", "mirror-manager")
+1
View File
@@ -18,6 +18,7 @@ class Config:
HOST_ROOT = os.environ.get("HOST_ROOT", "") HOST_ROOT = os.environ.get("HOST_ROOT", "")
ROLLBACK_MINUTES = int(os.environ.get("ROLLBACK_MINUTES", "15")) ROLLBACK_MINUTES = int(os.environ.get("ROLLBACK_MINUTES", "15"))
TEST_ROLLBACK_MINUTES = int(os.environ.get("TEST_ROLLBACK_MINUTES", "2"))
AUTO_TEST_INTERVAL_MINUTES = int(os.environ.get("AUTO_TEST_INTERVAL_MINUTES", "30")) AUTO_TEST_INTERVAL_MINUTES = int(os.environ.get("AUTO_TEST_INTERVAL_MINUTES", "30"))
RESOLVED_DROPIN = "mirror-manager.conf" RESOLVED_DROPIN = "mirror-manager.conf"
+21 -5
View File
@@ -71,7 +71,8 @@ GUIDES = {
</ul> </ul>
<h5>هشدار</h5> <h5>هشدار</h5>
<p>با restart Docker، containerها (از جمله Dokploy) چند ثانیه قطع می‌شوند. بهتر است در ساعات کم‌ترافیک apply کنید.</p> <p>با restart Docker، containerها (از جمله Dokploy) چند ثانیه قطع می‌شوند. همیشه ابتدا «تست موقت» را بزنید.</p>
<p>فقط کلیدهای رسمی Docker در <code>daemon.json</code> نوشته می‌شوند — کلیدهای نامعتبر باعث crash شدن Docker نمی‌شوند.</p>
<h5>میرور پیش‌فرض</h5> <h5>میرور پیش‌فرض</h5>
<ul> <ul>
@@ -131,6 +132,16 @@ GUIDES = {
<h5>پروفایل چیست؟</h5> <h5>پروفایل چیست؟</h5>
<p>مجموعه‌ای از میرورها برای DNS، APT، Docker، GitHub و... که با یک کلیک apply می‌شوند.</p> <p>مجموعه‌ای از میرورها برای DNS، APT، Docker، GitHub و... که با یک کلیک apply می‌شوند.</p>
<h5>اعمال امن (توصیه‌شده)</h5>
<ol>
<li><strong>تست موقت (۲ دقیقه):</strong> پروفایل اعمال می‌شود. اگر تأیید نکنید، خودکار rollback.</li>
<li>سرویس‌ها (Dokploy، mirror، deploy) را تست کنید.</li>
<li><strong>اعمال نهایی:</strong> از صفحه وضعیت تست، دکمه «نگه‌داشتن تغییرات» را بزنید.</li>
</ol>
<h5>بازگردانی فوری</h5>
<p>اگر دسترسی قطع شد: <code>mirror-manager emergency-restore</code> از SSH — یا دکمه «بازگردانی فوری» در بخش Restore.</p>
<h5>پروفایل‌های پیش‌فرض</h5> <h5>پروفایل‌های پیش‌فرض</h5>
<ul> <ul>
<li><strong>آروان کامل:</strong> Shecan + Arvan APT/Docker + GitClone</li> <li><strong>آروان کامل:</strong> Shecan + Arvan APT/Docker + GitClone</li>
@@ -139,7 +150,7 @@ GUIDES = {
</ul> </ul>
<h5>Rollback</h5> <h5>Rollback</h5>
<p>بعد از apply، پنجره زمانی (پیش‌فرض ۱۵ دقیقه) برای تست دارید. اگر تأیید نکنید، تنظیمات قبلی بازگردانده می‌شود.</p> <p>تست موقت پیش‌فرض ۲ دقیقه است. اعمال نهایی بدون پنجره rollback — فقط بعد از تست موفق.</p>
""", """,
}, },
"settings": { "settings": {
@@ -163,15 +174,20 @@ GUIDES = {
<h5>Backup اولیه</h5> <h5>Backup اولیه</h5>
<p>در اولین اجرا، snapshot از تنظیمات فعلی سیستم گرفته می‌شود.</p> <p>در اولین اجرا، snapshot از تنظیمات فعلی سیستم گرفته می‌شود.</p>
<h5>بازگردانی فوری (Emergency)</h5>
<p>یک دستور / یک دکمه — برگشت به آخرین backup قبل از apply (DNS، APT، Docker، ...).</p>
<h5>بازگردانی</h5> <h5>بازگردانی</h5>
<ul> <ul>
<li><strong>emergency-restore:</strong> آخرین backup قبل از apply — سریع‌ترین راه</li>
<li><strong>تنظیمات اولیه:</strong> برگشت به وضعیت قبل از نصب Mirror Manager</li> <li><strong>تنظیمات اولیه:</strong> برگشت به وضعیت قبل از نصب Mirror Manager</li>
<li><strong>backup apply:</strong> برگشت به وضعیت قبل از آخرین apply</li> <li><strong>backup apply:</strong> برگشت به backup مشخص</li>
</ul> </ul>
<h5>از SSH</h5> <h5>از SSH</h5>
<pre>mirror-manager restore --initial <pre>mirror-manager emergency-restore
mirror-manager restore --backup /path/to/backup mirror-manager restore --initial
mirror-manager restore --last
mirror-manager disable mirror-manager disable
mirror-manager enable</pre> mirror-manager enable</pre>
""", """,
+1
View File
@@ -0,0 +1 @@
+53 -12
View File
@@ -1,12 +1,19 @@
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_login import login_required from flask_login import login_required
from app.extensions import db
from app.guides import GUIDES from app.guides import GUIDES
from app.models import ApplyLog, Profile, Setting, SystemState from app.models import ApplyLog, Profile, Setting, SystemState
from app.models import utcnow from app.models import utcnow
from app.services.applier import apply_profile, confirm_apply, get_system_status, restore_initial, rollback_apply from app.services.applier import (
from app.services.backup import list_backups, restore_backup APPLY_MODE_FINAL,
APPLY_MODE_TEST,
apply_profile,
confirm_apply,
emergency_restore_last_apply,
restore_initial,
rollback_apply,
)
from app.services.backup import get_latest_apply_backup, list_backups, restore_backup
apply_bp = Blueprint("apply", __name__, url_prefix="/apply") apply_bp = Blueprint("apply", __name__, url_prefix="/apply")
@@ -20,33 +27,52 @@ def index():
if state and state.pending_apply_log_id: if state and state.pending_apply_log_id:
pending = ApplyLog.query.get(state.pending_apply_log_id) pending = ApplyLog.query.get(state.pending_apply_log_id)
test_minutes = int(Setting.get("test_rollback_minutes", "2") or "2")
recent_logs = ApplyLog.query.order_by(ApplyLog.started_at.desc()).limit(10).all() recent_logs = ApplyLog.query.order_by(ApplyLog.started_at.desc()).limit(10).all()
return render_template( return render_template(
"apply/index.html", "apply/index.html",
profiles=profiles, profiles=profiles,
pending=pending, pending=pending,
recent_logs=recent_logs, recent_logs=recent_logs,
test_minutes=test_minutes,
guide=GUIDES.get("profiles", {}), guide=GUIDES.get("profiles", {}),
) )
@apply_bp.route("/profile/<int:profile_id>", methods=["POST"]) def _handle_apply(profile_id: int, mode: str):
@login_required
def apply(profile_id):
profile = Profile.query.get_or_404(profile_id) profile = Profile.query.get_or_404(profile_id)
rollback_minutes = int(request.form.get("rollback_minutes", Setting.get("rollback_minutes", "15") or "15")) state = SystemState.query.first()
if state and state.pending_apply_log_id:
flash("یک تست در انتظار تأیید است. ابتدا آن را تأیید یا rollback کنید.", "warning")
return redirect(url_for("apply.status", log_id=state.pending_apply_log_id))
log = apply_profile(profile, rollback_minutes=rollback_minutes) log = apply_profile(profile, mode=mode)
if log.status == "failed": if log.status == "failed":
flash("اعمال پروفایل با خطا مواجه شد. جزئیات را بررسی کنید.", "danger") flash("اعمال پروفایل با خطا مواجه شد. تنظیمات قبلی خودکار بازگردانده شد.", "danger")
elif mode == APPLY_MODE_FINAL:
flash("پروفایل به‌صورت نهایی اعمال و ثبت شد.", "success")
return redirect(url_for("main.dashboard"))
else: else:
test_minutes = log.get_details().get("rollback_minutes", 2)
flash( flash(
f"پروفایل اعمال شد. {rollback_minutes} دقیقه برای تست دارید — سپس rollback خودکار انجام می‌شود.", f"تست موقت اعمال شد. {test_minutes} دقیقه برای بررسی دارید — در صورت عدم تأیید، rollback خودکار انجام می‌شود.",
"warning", "warning",
) )
return redirect(url_for("apply.status", log_id=log.id)) return redirect(url_for("apply.status", log_id=log.id))
@apply_bp.route("/profile/<int:profile_id>/test", methods=["POST"])
@login_required
def apply_test(profile_id):
return _handle_apply(profile_id, APPLY_MODE_TEST)
@apply_bp.route("/profile/<int:profile_id>/final", methods=["POST"])
@login_required
def apply_final(profile_id):
return _handle_apply(profile_id, APPLY_MODE_FINAL)
@apply_bp.route("/status/<int:log_id>") @apply_bp.route("/status/<int:log_id>")
@login_required @login_required
def status(log_id): def status(log_id):
@@ -60,6 +86,7 @@ def status(log_id):
log=log, log=log,
details=details, details=details,
remaining_seconds=remaining_seconds, remaining_seconds=remaining_seconds,
apply_mode=details.get("mode", APPLY_MODE_TEST),
) )
@@ -67,7 +94,7 @@ def status(log_id):
@login_required @login_required
def confirm(log_id): def confirm(log_id):
confirm_apply(log_id) confirm_apply(log_id)
flash("تغییرات تأیید و ثبت شد.", "success") flash("تست موفق بود — تغییرات به‌صورت نهایی ثبت شد.", "success")
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@@ -106,14 +133,27 @@ restore_bp = Blueprint("restore", __name__, url_prefix="/restore")
def index(): def index():
backups = list_backups() backups = list_backups()
state = SystemState.query.first() state = SystemState.query.first()
latest_apply = get_latest_apply_backup()
return render_template( return render_template(
"restore/index.html", "restore/index.html",
backups=backups, backups=backups,
state=state, state=state,
latest_apply_backup=str(latest_apply) if latest_apply else None,
guide=GUIDES.get("restore", {}), guide=GUIDES.get("restore", {}),
) )
@restore_bp.route("/emergency", methods=["POST"])
@login_required
def emergency_restore_view():
result = emergency_restore_last_apply()
if result.get("success"):
flash(f"بازگردانی فوری انجام شد از: {result.get('backup_path', '')}", "success")
else:
flash(result.get("error", "خطا در بازگردانی"), "danger")
return redirect(url_for("restore.index"))
@restore_bp.route("/initial", methods=["POST"]) @restore_bp.route("/initial", methods=["POST"])
@login_required @login_required
def restore_initial_view(): def restore_initial_view():
@@ -158,6 +198,7 @@ def index():
Setting.set(key, "true" if request.form.get(key) == "on" else "false") Setting.set(key, "true" if request.form.get(key) == "on" else "false")
Setting.set("rollback_minutes", request.form.get("rollback_minutes", "15")) Setting.set("rollback_minutes", request.form.get("rollback_minutes", "15"))
Setting.set("test_rollback_minutes", request.form.get("test_rollback_minutes", "2"))
Setting.set("auto_test_interval_minutes", request.form.get("auto_test_interval_minutes", "30")) Setting.set("auto_test_interval_minutes", request.form.get("auto_test_interval_minutes", "30"))
Setting.set("max_switches_per_day", request.form.get("max_switches_per_day", "3")) Setting.set("max_switches_per_day", request.form.get("max_switches_per_day", "3"))
Setting.set( Setting.set(
@@ -170,7 +211,7 @@ def index():
settings = {key: Setting.get(key) for key in ( settings = {key: Setting.get(key) for key in (
"auto_switch_dns", "auto_switch_apt", "auto_switch_docker", "auto_switch_dns", "auto_switch_apt", "auto_switch_docker",
"auto_switch_github", "auto_switch_pip", "auto_switch_npm", "auto_switch_github", "auto_switch_pip", "auto_switch_npm",
"rollback_minutes", "auto_test_interval_minutes", "rollback_minutes", "test_rollback_minutes", "auto_test_interval_minutes",
"max_switches_per_day", "rollback_on_all_fail", "max_switches_per_day", "rollback_on_all_fail",
)} )}
return render_template("settings/index.html", settings=settings, guide=GUIDES.get("settings", {})) return render_template("settings/index.html", settings=settings, guide=GUIDES.get("settings", {}))
+1
View File
@@ -203,6 +203,7 @@ def seed_settings() -> None:
"auto_test_interval_minutes": "30", "auto_test_interval_minutes": "30",
"max_switches_per_day": "3", "max_switches_per_day": "3",
"rollback_minutes": "15", "rollback_minutes": "15",
"test_rollback_minutes": "2",
"rollback_on_all_fail": "true", "rollback_on_all_fail": "true",
} }
from app.models import Setting from app.models import Setting
+1
View File
@@ -0,0 +1 @@
+72 -9
View File
@@ -2,11 +2,10 @@ from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
from pathlib import Path
from app.config import Config from app.config import Config
from app.extensions import db 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.models import utcnow
from app.services import apt, backup, dns, docker_svc, github, pip_npm from app.services import apt, backup, dns, docker_svc, github, pip_npm
from app.services.host import run_on_host from app.services.host import run_on_host
@@ -22,6 +21,9 @@ APPLIERS = {
"npm": pip_npm.apply_npm, "npm": pip_npm.apply_npm,
} }
APPLY_MODE_TEST = "test"
APPLY_MODE_FINAL = "final"
def ensure_initial_backup() -> SystemState: def ensure_initial_backup() -> SystemState:
state = SystemState.query.first() state = SystemState.query.first()
@@ -40,9 +42,22 @@ def ensure_initial_backup() -> SystemState:
return state 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() 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}") previous_backup = backup.create_backup(f"before_apply_{profile.id}")
apply_log = ApplyLog( apply_log = ApplyLog(
@@ -50,12 +65,17 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
status="running", status="running",
backup_path=str(previous_backup), backup_path=str(previous_backup),
previous_backup_path=state.initial_backup_path, 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.add(apply_log)
db.session.flush() 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() mirrors = profile.mirrors_by_category()
order = ["dns", "apt", "docker", "github", "pip", "npm"] order = ["dns", "apt", "docker", "github", "pip", "npm"]
@@ -69,11 +89,14 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
try: try:
result = applier(mirror) result = applier(mirror)
details["steps"].append({"category": category, "mirror": mirror.name, **result}) details["steps"].append({"category": category, "mirror": mirror.name, **result})
if not result.get("success", True):
break
except Exception as exc: except Exception as exc:
logger.exception("Apply failed for %s", category) logger.exception("Apply failed for %s", category)
details["steps"].append( details["steps"].append(
{"category": category, "mirror": mirror.name, "success": False, "error": str(exc)} {"category": category, "mirror": mirror.name, "success": False, "error": str(exc)}
) )
break
failed = [s for s in details["steps"] if not s.get("success", True)] failed = [s for s in details["steps"] if not s.get("success", True)]
if failed and apply_log.backup_path: 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 details["auto_rollback"] = rollback_result
apply_log.set_details(details) apply_log.set_details(details)
apply_log.status = "failed" if failed else "pending_confirm"
apply_log.finished_at = utcnow() apply_log.finished_at = utcnow()
state.current_profile_id = profile.id if not failed else state.current_profile_id if failed:
state.pending_apply_log_id = apply_log.id if not failed else None 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() db.session.commit()
return apply_log return apply_log
@@ -97,9 +131,15 @@ def confirm_apply(apply_log_id: int) -> ApplyLog:
apply_log.status = "confirmed" apply_log.status = "confirmed"
apply_log.rollback_at = None 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() state = SystemState.query.first()
if state: if state:
state.pending_apply_log_id = None state.pending_apply_log_id = None
if apply_log.profile_id:
state.current_profile_id = apply_log.profile_id
db.session.commit() db.session.commit()
return apply_log return apply_log
@@ -112,6 +152,7 @@ def rollback_apply(apply_log_id: int) -> dict:
result = backup.restore_backup(apply_log.backup_path) result = backup.restore_backup(apply_log.backup_path)
apply_log.status = "rolled_back" apply_log.status = "rolled_back"
apply_log.finished_at = utcnow() apply_log.finished_at = utcnow()
apply_log.rollback_at = None
state = SystemState.query.first() state = SystemState.query.first()
if state: if state:
@@ -120,6 +161,28 @@ def rollback_apply(apply_log_id: int) -> dict:
return result 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: def restore_initial() -> dict:
state = SystemState.query.first() state = SystemState.query.first()
if not state or not state.initial_backup_path: 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 pathlib import Path
from app.config import Config 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 from app.services.host import host_path, run_on_host
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -147,12 +148,37 @@ def restore_backup(backup_root: Path | str) -> dict:
elif dropin.exists(): elif dropin.exists():
dropin.unlink() 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", "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} 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: def create_initial_backup() -> Path:
return create_backup("initial") return create_backup("initial")
+54 -4
View File
@@ -10,7 +10,46 @@ from app.services.host import host_path, run_on_host
MIRROR_MANAGER_KEY = "mirror-manager" 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 return False
try: try:
@@ -18,7 +57,13 @@ def read_current_docker() -> dict:
config = json.loads(path.read_text(encoding="utf-8")) config = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))}
return False
cleaned = sanitize_daemon_config(dict(config))
if cleaned == config:
return False return False
path.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8")
@@ -37,8 +82,8 @@ def apply_docker(mirror: Mirror) -> dict:
return { return {
"success": False,
config["_mirror_manager"] = MIRROR_MANAGER_KEY
"error": "Docker بعد از restart بالا نیامد — تنظیمات daemon.json را بررسی کنید", "error": "Docker بعد از restart بالا نیامد — تنظیمات daemon.json را بررسی کنید",
} }
@@ -46,12 +91,17 @@ def apply_docker(mirror: Mirror) -> dict:
info = run_on_host(["docker", "info"], timeout=30) info = run_on_host(["docker", "info"], timeout=30)
if info.returncode != 0: if info.returncode != 0:
"error": (result.stderr or "خطا در restart docker")[:500],
return { return {
"success": False,
"error": (info.stderr or info.stdout or "docker info ناموفق")[:500],
} }
return {"success": True} return {"success": True}
+2 -2
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from app.extensions import db from app.extensions import db
from app.models import ApplyLog, Mirror, Profile, ProfileItem, Setting, SystemState from app.models import ApplyLog, Mirror, Profile, ProfileItem, Setting, SystemState
from app.models import utcnow 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 from app.services.tester import get_best_mirror, test_mirror
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ def run_auto_switch(app) -> None:
item.mirror_id = best.id item.mirror_id = best.id
db.session.commit() db.session.commit()
apply_profile(profile) apply_profile(profile, mode=APPLY_MODE_FINAL)
_increment_switch_count() _increment_switch_count()
logger.info("Auto-switched %s to %s", category, best.name) logger.info("Auto-switched %s to %s", category, best.name)
break break
+32 -10
View File
@@ -7,11 +7,17 @@
{% block content %} {% block content %}
<h2>اعمال پروفایل</h2> <h2>اعمال پروفایل</h2>
یک apply در انتظار تأیید است.
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه</a> {{ guide_box(guide, 'profiles') }}
{% if pending and not pending.confirmed %}
<div class="alert alert-warning">
یک تست موقت در انتظار تأیید است.
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه و تأیید / rollback</a> <a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه و تأیید / rollback</a>
</div> </div>
@@ -19,15 +25,23 @@
{% endif %} {% endif %}
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}">
<div class="mb-2"> <div class="alert alert-info small">
<label class="form-label small">پنجره rollback (دقیقه)</label>
<input type="number" name="rollback_minutes" class="form-control form-control-sm" value="15" min="5" max="60"> <strong>توصیه:</strong> همیشه ابتدا «تست موقت» را بزنید.
</div>
<button type="submit" class="btn btn-success" onclick="return confirm('Backup گرفته می‌شود. Docker ممکن است restart شود. ادامه؟')"> اگر {{ test_minutes }} دقیقه تأیید نکنید، تنظیمات قبلی خودکار برمی‌گردد.
اعمال پروفایل
Docker ممکن است چند ثانیه restart شود.
</div> </div>
<div class="row g-3 mb-4">
{% for p in profiles %}
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
@@ -37,11 +51,18 @@
<h5>{{ p.name }}</h5> <h5>{{ p.name }}</h5>
<p class="small text-muted">{{ p.description }}</p> <p class="small text-muted">{{ p.description }}</p>
<thead><tr><th>پروفایل</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead>
<ul class="list-unstyled small mb-3"> <ul class="list-unstyled small mb-3">
{% for item in p.items %} {% for item in p.items %}
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
{% endfor %}
</ul>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('apply.apply_test', profile_id=p.id) }}"> <form method="post" action="{{ url_for('apply.apply_test', profile_id=p.id) }}">
@@ -51,3 +72,4 @@
</button> </button>
</form>
+20 -3
View File
@@ -3,16 +3,31 @@
{% block title %}وضعیت Apply{% endblock %} {% block title %}وضعیت Apply{% endblock %}
{% block content %} {% block content %}
<p>وضعیت: <span class="badge bg-{% if log.status == 'confirmed' %}success{% elif log.status == 'failed' %}danger{% else %}warning{% endif %}">{{ log.status }}</span></p>
<h2>وضعیت اعمال پروفایل</h2>
<p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p>
<p>
نوع:
{% if apply_mode == 'final' %}
<span class="badge bg-success">اعمال نهایی</span>
{% else %}
<span class="badge bg-warning text-dark">تست موقت</span> <span class="badge bg-warning text-dark">تست موقت</span>
{% endif %} {% endif %}
</p> </p>
<p>وضعیت: <span class="badge bg-{% if log.status == 'confirmed' %}success{% elif log.status == 'failed' %}danger{% elif log.status == 'rolled_back' %}info{% else %}warning{% endif %}">{{ log.status }}</span></p> <p>وضعیت: <span class="badge bg-{% if log.status == 'confirmed' %}success{% elif log.status == 'failed' %}danger{% elif log.status == 'rolled_back' %}info{% else %}warning{% endif %}">{{ log.status }}</span></p>
<div class="d-flex gap-2 mb-4">
<button type="submit" class="btn btn-success">تأیید و نگه‌داشتن تغییرات</button>
{% if log.status == 'failed' and details.get('auto_rollback') %} {% if log.status == 'failed' and details.get('auto_rollback') %}
<div class="alert alert-info"> <div class="alert alert-info">
@@ -40,6 +55,7 @@
<div class="d-flex flex-wrap gap-2 mb-4"> <div class="d-flex flex-wrap gap-2 mb-4">
<form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}"> <form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}">
<button type="submit" class="btn btn-success">اعمال نهایی — نگه‌داشتن تغییرات</button> <button type="submit" class="btn btn-success">اعمال نهایی — نگه‌داشتن تغییرات</button>
</form> </form>
@@ -60,3 +76,4 @@
{% for step in details.get('steps', []) %} {% for step in details.get('steps', []) %}
<div class="card mb-2">
+3 -5
View File
@@ -20,12 +20,9 @@
{% for p in profiles %} {% for p in profiles %}
<div class="card-footer bg-transparent"> <div class="col-md-4">
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}" class="d-inline"> <div class="card h-100 {% if p.is_default %}border-primary{% endif %}">
<input type="hidden" name="rollback_minutes" value="15">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('پروفایل اعمال شود؟ Docker restart می‌شود.')">اعمال</button>
</form>
<div class="card-body"> <div class="card-body">
@@ -37,3 +34,4 @@
{% for item in p.items %} {% for item in p.items %}
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
+23 -5
View File
@@ -6,6 +6,22 @@
{% block content %} {% block content %}
<h2>بازگردانی تنظیمات</h2>
{{ guide_box(guide, 'restore') }}
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-body">
<h5>بازگردانی فوری (Emergency)</h5>
<p class="small text-muted">برگشت به آخرین backup قبل از apply — یک کلیک، بدون انتخاب مسیر</p> <p class="small text-muted">برگشت به آخرین backup قبل از apply — یک کلیک، بدون انتخاب مسیر</p>
{% if latest_apply_backup %} {% if latest_apply_backup %}
@@ -49,11 +65,13 @@
<button type="submit" class="btn btn-warning">بازگردانی اولیه</button> <button type="submit" class="btn btn-warning">بازگردانی اولیه</button>
</form> </form>
<h6>دستورات SSH</h6>
<pre class="mb-0">python3 mirror-manager restore --initial {% else %}
python3 mirror-manager restore --backup /path/to/backup
python3 mirror-manager disable <p class="text-danger">Backup اولیه یافت نشد</p>
python3 mirror-manager enable</pre>
{% endif %}
</div> </div>
</div>
+6 -1
View File
@@ -29,7 +29,12 @@
<h5>Rollback و تست</h5> <h5>Rollback و تست</h5>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">پنجره rollback پیش‌فرض (دقیقه)</label> <label class="form-label">پنجره تست موقت (دقیقه)</label>
<input type="number" name="test_rollback_minutes" class="form-control" value="{{ settings.get('test_rollback_minutes', '2') }}" min="1" max="30">
<div class="form-text">پس از «تست موقت» — rollback خودکار اگر تأیید نشود</div>
</div>
<div class="col-md-4">
<label class="form-label">پنجره rollback قدیمی (دقیقه)</label>
<input type="number" name="rollback_minutes" class="form-control" value="{{ settings.get('rollback_minutes', '15') }}" min="5" max="60"> <input type="number" name="rollback_minutes" class="form-control" value="{{ settings.get('rollback_minutes', '15') }}" min="5" max="60">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
+10 -3
View File
@@ -10,9 +10,16 @@ sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
echo "export PYTHONPATH=$INSTALL_DIR" | sudo tee "$PROFILE_D" > /dev/null echo "نصب Mirror Manager CLI..."
echo "export MIRROR_CONTAINER_NAME=mirror-manager" | sudo tee -a "$PROFILE_D" > /dev/null
sudo mkdir -p /var/lib/mirror-manager/data
sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
sudo chmod +x "$BIN" sudo chmod +x "$BIN"
echo " mirror-manager status"
PROFILE_D="/etc/profile.d/mirror-manager.sh" PROFILE_D="/etc/profile.d/mirror-manager.sh"
{
+1
View File
@@ -19,6 +19,7 @@ services:
- HOST_ROOT=/host - HOST_ROOT=/host
- DATA_DIR=/app/data - DATA_DIR=/app/data
- ROLLBACK_MINUTES=${ROLLBACK_MINUTES:-15} - ROLLBACK_MINUTES=${ROLLBACK_MINUTES:-15}
- TEST_ROLLBACK_MINUTES=${TEST_ROLLBACK_MINUTES:-2}
- AUTO_TEST_INTERVAL_MINUTES=${AUTO_TEST_INTERVAL_MINUTES:-30} - AUTO_TEST_INTERVAL_MINUTES=${AUTO_TEST_INTERVAL_MINUTES:-30}
volumes: volumes:
- /etc:/host/etc - /etc:/host/etc