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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
+53
-12
@@ -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", {}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
+72
-9
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
{
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user