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")
+107 -57
View File
@@ -1,57 +1,107 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
from pathlib import Path import json
from app.models import Mirror import logging
from app.services.host import host_path, run_on_host
from pathlib import Path
logger = logging.getLogger(__name__)
DAEMON_JSON = "etc/docker/daemon.json"
MIRROR_MANAGER_KEY = "mirror-manager" from app.models import Mirror
from app.services.host import host_path, run_on_host
def read_current_docker() -> dict:
path = host_path(DAEMON_JSON)
if not path.exists():
return {"exists": False, "config": {}} logger = logging.getLogger(__name__)
try:
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))}
except json.JSONDecodeError:
return {"exists": True, "config": {}, "parse_error": True} DAEMON_JSON = "etc/docker/daemon.json"
INVALID_DAEMON_KEYS = ("_mirror_manager",)
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) def sanitize_daemon_config(config: dict) -> dict:
config: dict = {} """Remove keys that Docker daemon does not accept."""
if path.exists():
try: for key in INVALID_DAEMON_KEYS:
config = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError: config.pop(key, None)
config = {}
return 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) def sanitize_daemon_json_file(path: Path | None = None) -> bool:
if result.returncode != 0:
return { """Strip invalid keys from daemon.json on disk. Returns True if file was modified."""
"success": False,
"error": (result.stderr or "خطا در restart docker")[:500], path = path or host_path(DAEMON_JSON)
}
if not path.exists():
return {
"success": True, return False
"message": f"Docker registry mirror اعمال شد: {mirror.name}",
"url": mirror.url, try:
"warning": "سرویس Docker restart شد — containerها موقتاً قطع می‌شوند",
} 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.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
+75 -53
View File
@@ -1,53 +1,75 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import guide_box %}
{% block title %}اعمال تنظیمات{% endblock %} {% from "macros.html" import guide_box %}
{% block content %}
<h2>اعمال پروفایل</h2> {% block title %}اعمال تنظیمات{% endblock %}
{{ guide_box(guide, 'profiles') }}
{% block content %}
{% if pending and not pending.confirmed %}
<div class="alert alert-warning"> <h2>اعمال پروفایل</h2>
یک apply در انتظار تأیید است.
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه</a> {{ guide_box(guide, 'profiles') }}
</div>
{% endif %}
<div class="row g-3 mb-4"> {% if pending and not pending.confirmed %}
{% for p in profiles %}
<div class="col-md-6"> <div class="alert alert-warning">
<div class="card">
<div class="card-body"> یک تست موقت در انتظار تأیید است.
<h5>{{ p.name }}</h5>
<p class="small text-muted">{{ p.description }}</p> <a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه و تأیید / rollback</a>
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}">
<div class="mb-2"> </div>
<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"> {% endif %}
</div>
<button type="submit" class="btn btn-success" onclick="return confirm('Backup گرفته می‌شود. Docker ممکن است restart شود. ادامه؟')">
اعمال پروفایل
</button> <div class="alert alert-info small">
</form>
</div> <strong>توصیه:</strong> همیشه ابتدا «تست موقت» را بزنید.
</div>
</div> اگر {{ test_minutes }} دقیقه تأیید نکنید، تنظیمات قبلی خودکار برمی‌گردد.
{% endfor %}
</div> Docker ممکن است چند ثانیه restart شود.
<h4>تاریخچه apply</h4> </div>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>پروفایل</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead>
<tbody> <div class="row g-3 mb-4">
{% for log in recent_logs %}
<tr> {% for p in profiles %}
<td>{{ log.profile.name if log.profile else '-' }}</td>
<td><span class="badge bg-secondary">{{ log.status }}</span></td> <div class="col-md-6">
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td><a href="{{ url_for('apply.status', log_id=log.id) }}">جزئیات</a></td> <div class="card">
</tr>
{% endfor %} <div class="card-body">
</tbody>
</table> <h5>{{ p.name }}</h5>
</div>
{% endblock %} <p class="small text-muted">{{ p.description }}</p>
<ul class="list-unstyled small mb-3">
{% 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) }}">
<button type="submit" class="btn btn-warning" onclick="return confirm('تست موقت {{ test_minutes }} دقیقه‌ای اعمال شود؟ در صورت مشکل خودکار rollback می‌شود.')">
تست موقت ({{ test_minutes }} دقیقه)
</button>
</form>
+79 -62
View File
@@ -1,62 +1,79 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}وضعیت Apply{% endblock %}
{% block content %} {% block title %}وضعیت Apply{% endblock %}
<h2>وضعیت اعمال پروفایل</h2>
<p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p> {% 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>
{% if not log.confirmed and log.status == 'pending_confirm' %}
<div class="alert alert-warning" id="rollback-alert"> <p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p>
<strong>پنجره تست:</strong>
<span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار <p>
</div>
<div class="d-flex gap-2 mb-4"> نوع:
<form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}">
<button type="submit" class="btn btn-success">تأیید و نگه‌داشتن تغییرات</button> {% if apply_mode == 'final' %}
</form>
<form method="post" action="{{ url_for('apply.rollback', log_id=log.id) }}"> <span class="badge bg-success">اعمال نهایی</span>
<button type="submit" class="btn btn-warning">Rollback دستی</button>
</form> {% else %}
</div>
{% endif %} <span class="badge bg-warning text-dark">تست موقت</span>
<h4>جزئیات مراحل</h4> {% endif %}
{% for step in details.get('steps', []) %}
<div class="card mb-2"> </p>
<div class="card-body py-2">
<div class="d-flex justify-content-between"> <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>
<span><strong>{{ step.category }}</strong> — {{ step.get('mirror', '') }}</span>
{% if step.get('success', True) %}
<span class="text-success">موفق</span>
{% else %} {% if log.status == 'failed' and details.get('auto_rollback') %}
<span class="text-danger">ناموفق</span>
{% endif %} <div class="alert alert-info">
</div>
{% if step.get('message') %}<small class="text-muted">{{ step.message }}</small>{% endif %} به‌خاطر خطا در یکی از مراحل، backup قبلی <strong>خودکار</strong> بازگردانده شد.
{% if step.get('error') %}<small class="text-danger d-block">{{ step.error }}</small>{% endif %}
{% if step.get('warning') %}<small class="text-warning d-block">{{ step.warning }}</small>{% endif %} </div>
</div>
</div> {% endif %}
{% endfor %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mt-3">داشبورد</a>
{% endblock %} {% if not log.confirmed and log.status == 'pending_confirm' %}
{% block scripts %} <div class="alert alert-warning" id="rollback-alert">
{% if not log.confirmed and log.status == 'pending_confirm' %}
<script> <strong>پنجره تست:</strong>
(function() {
let remaining = {{ remaining_seconds }}; <span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار
const el = document.getElementById('countdown');
const tick = setInterval(function() { <div class="small mt-1">اگر دسترسی به پنل قطع شد، از SSH: <code>mirror-manager emergency-restore</code></div>
remaining--;
if (el) el.textContent = remaining; </div>
if (remaining <= 0) {
clearInterval(tick); <div class="d-flex flex-wrap gap-2 mb-4">
location.reload();
} <form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}">
}, 1000);
})(); <button type="submit" class="btn btn-success">اعمال نهایی — نگه‌داشتن تغییرات</button>
</script>
{% endif %} </form>
{% endblock %}
<form method="post" action="{{ url_for('apply.rollback', log_id=log.id) }}">
<button type="submit" class="btn btn-warning">Rollback دستی</button>
</form>
</div>
{% endif %}
<h4>جزئیات مراحل</h4>
{% for step in details.get('steps', []) %}
<div class="card mb-2">
+37 -39
View File
@@ -1,39 +1,37 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import guide_box %}
{% block title %}پروفایل‌ها{% endblock %} {% from "macros.html" import guide_box %}
{% block content %}
<div class="d-flex justify-content-between mb-4"> {% block title %}پروفایل‌ها{% endblock %}
<h2>پروفایل‌ها</h2>
<a href="{{ url_for('profiles.create') }}" class="btn btn-primary">+ پروفایل جدید</a> {% block content %}
</div>
{{ guide_box(guide, 'profiles') }} <div class="d-flex justify-content-between mb-4">
<div class="row g-3">
{% for p in profiles %} <h2>پروفایل‌ها</h2>
<div class="col-md-4">
<div class="card h-100 {% if p.is_default %}border-primary{% endif %}"> <a href="{{ url_for('profiles.create') }}" class="btn btn-primary">+ پروفایل جدید</a>
<div class="card-body">
<h5>{{ p.name }}{% if p.is_default %} <span class="badge bg-primary">پیش‌فرض</span>{% endif %}</h5> </div>
<p class="text-muted small">{{ p.description or '' }}</p>
<ul class="list-unstyled small"> {{ guide_box(guide, 'profiles') }}
{% for item in p.items %}
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li> <div class="row g-3">
{% endfor %}
</ul> {% for p in profiles %}
</div>
<div class="card-footer bg-transparent"> <div class="col-md-4">
<a href="{{ url_for('profiles.edit', profile_id=p.id) }}" class="btn btn-sm btn-outline-primary">ویرایش</a>
<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> <div class="card-body">
</form>
<form method="post" action="{{ url_for('profiles.delete', profile_id=p.id) }}" class="d-inline" onsubmit="return confirm('حذف شود؟')"> <h5>{{ p.name }}{% if p.is_default %} <span class="badge bg-primary">پیش‌فرض</span>{% endif %}</h5>
<button type="submit" class="btn btn-sm btn-outline-danger">حذف</button>
</form> <p class="text-muted small">{{ p.description or '' }}</p>
</div>
</div> <ul class="list-unstyled small">
</div>
{% else %} {% for item in p.items %}
<div class="col-12"><p class="text-muted">پروفایلی وجود ندارد</p></div>
{% endfor %} <li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
</div>
{% endblock %}
+77 -59
View File
@@ -1,59 +1,77 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import guide_box %}
{% block title %}بازگردانی{% endblock %} {% from "macros.html" import guide_box %}
{% block content %}
<h2>بازگردانی تنظیمات</h2> {% block title %}بازگردانی{% endblock %}
{{ guide_box(guide, 'restore') }}
{% block content %}
<div class="row g-3 mb-4">
<div class="col-md-6"> <h2>بازگردانی تنظیمات</h2>
<div class="card border-warning">
<div class="card-body"> {{ guide_box(guide, 'restore') }}
<h5>بازگردانی به تنظیمات اولیه</h5>
<p class="small text-muted">وضعیت سیستم قبل از اولین اجرای Mirror Manager</p>
{% if state and state.initial_backup_path %}
<p class="small"><code>{{ state.initial_backup_path }}</code></p> <div class="row g-3 mb-4">
<form method="post" action="{{ url_for('restore.restore_initial_view') }}" onsubmit="return confirm('مطمئنید؟')">
<button type="submit" class="btn btn-warning">بازگردانی اولیه</button> <div class="col-md-6">
</form>
{% else %} <div class="card border-danger">
<p class="text-danger">Backup اولیه یافت نشد</p>
{% endif %} <div class="card-body">
</div>
</div> <h5>بازگردانی فوری (Emergency)</h5>
</div>
</div> <p class="small text-muted">برگشت به آخرین backup قبل از apply — یک کلیک، بدون انتخاب مسیر</p>
<h4>Backupهای موجود</h4> {% if latest_apply_backup %}
<div class="table-responsive">
<table class="table table-sm"> <p class="small"><code>{{ latest_apply_backup }}</code></p>
<thead><tr><th>نام</th><th>برچسب</th><th>عملیات</th></tr></thead>
<tbody> <form method="post" action="{{ url_for('restore.emergency_restore_view') }}" onsubmit="return confirm('تنظیمات به آخرین وضعیت قبل از apply برگردد؟ Docker restart می‌شود.')">
{% for b in backups %}
<tr> <button type="submit" class="btn btn-danger">بازگردانی فوری</button>
<td><code class="small">{{ b.name }}</code></td>
<td>{{ b.label }}</td> </form>
<td>
<form method="post" action="{{ url_for('restore.restore_backup_view') }}" class="d-inline" onsubmit="return confirm('این backup restore شود؟')"> {% else %}
<input type="hidden" name="backup_path" value="{{ b.path }}">
<button type="submit" class="btn btn-sm btn-outline-warning">Restore</button> <p class="text-muted">backup قبل از apply یافت نشد</p>
</form>
</td> {% endif %}
</tr>
{% else %} </div>
<tr><td colspan="3" class="text-muted">backupی وجود ندارد</td></tr>
{% endfor %} </div>
</tbody>
</table> </div>
</div>
<div class="col-md-6">
<div class="card mt-4 bg-light">
<div class="card-body"> <div class="card border-warning">
<h6>دستورات SSH</h6>
<pre class="mb-0">python3 mirror-manager restore --initial <div class="card-body">
python3 mirror-manager restore --backup /path/to/backup
python3 mirror-manager disable <h5>بازگردانی به تنظیمات اولیه</h5>
python3 mirror-manager enable</pre>
</div> <p class="small text-muted">وضعیت سیستم قبل از اولین اجرای Mirror Manager</p>
</div>
{% endblock %} {% if state and state.initial_backup_path %}
<p class="small"><code>{{ state.initial_backup_path }}</code></p>
<form method="post" action="{{ url_for('restore.restore_initial_view') }}" onsubmit="return confirm('مطمئنید؟')">
<button type="submit" class="btn btn-warning">بازگردانی اولیه</button>
</form>
{% else %}
<p class="text-danger">Backup اولیه یافت نشد</p>
{% endif %}
</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">
+25 -18
View File
@@ -1,18 +1,25 @@
#!/bin/bash #!/bin/bash
set -euo pipefail
set -euo pipefail
INSTALL_DIR="${1:-/opt/mirror-manager}"
BIN="/usr/local/bin/mirror-manager"
echo "نصب Mirror Manager CLI..." INSTALL_DIR="${1:-/opt/mirror-manager}"
sudo mkdir -p /var/lib/mirror-manager/data
sudo cp "$INSTALL_DIR/mirror-manager" "$BIN" BIN="/usr/local/bin/mirror-manager"
sudo chmod +x "$BIN"
PROFILE_D="/etc/profile.d/mirror-manager.sh"
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
echo "CLI نصب شد: mirror-manager"
echo " mirror-manager status" sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
echo " mirror-manager restore --initial"
sudo chmod +x "$BIN"
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