Compare commits

...

6 Commits

Author SHA1 Message Date
mohammadian7 384680cd82 Fix Docker build without VPN proxy dependency
Make build proxy optional and use Arvan mirrors for apt/pip by default. Fixes 503 errors when xray proxy on 172.17.0.1:1081 is unavailable during Dokploy deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:26:09 +03:30
mohammadian7 63d0699cdd 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>
2026-06-10 20:20:10 +03:30
mohammadian7 1c9d647787 Fix pip build: use PyPI via VPN proxy instead of Arvan mirror
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 22:33:09 +03:30
mohammadian7 8ab80c00e9 Fix build hang: use Arvan mirror for base image and pip
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 22:19:41 +03:30
mohammadian7 5519a9d93b Merge remote and fix: proxy Dockerfile + main.py gunicorn entrypoint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 20:12:44 +03:30
mohammadian7 dfae55980a Fix deploy: main.py entrypoint and proxy Dockerfile for VPN build
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 19:58:30 +03:30
24 changed files with 753 additions and 346 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.venv
venv
__pycache__
*.pyc
data/
.env
*.db
.cursor
+5
View File
@@ -12,8 +12,13 @@ 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
# ─── Build (اختیاری — فقط اگر xray روی host فعال است) ───
# BUILD_HTTP_PROXY=http://172.17.0.1:1081
# BUILD_HTTPS_PROXY=http://172.17.0.1:1081
# ─── CLI روی host ─── # ─── CLI روی host ───
MIRROR_CONTAINER_NAME=mirror-manager MIRROR_CONTAINER_NAME=mirror-manager
+46 -20
View File
@@ -1,36 +1,62 @@
FROM python:3.12-slim-bookworm # میرور آروان — بدون نیاز به Docker Hub یا VPN برای build
ARG BASE_IMAGE=docker.arvancloud.ir/library/python:3.12-slim-bookworm
FROM ${BASE_IMAGE}
# ۱. تعریف آدرس پروکسی به صورت مستقیم (Hardcoded) برای اطمینان از صحت در مرحله Build # پروکسی اختیاری — فقط اگر xray/VPN روی host فعال است (مثلاً BUILD_HTTP_PROXY=http://172.17.0.1:1081)
ENV http_proxy=http://172.17.0.1:1081 ARG BUILD_HTTP_PROXY=
ENV https_proxy=http://172.17.0.1:1081 ARG BUILD_HTTPS_PROXY=
ENV HTTP_PROXY=http://172.17.0.1:1081 ARG PYPI_INDEX=https://mirror.arvancloud.ir/pypi/simple
ENV HTTPS_PROXY=http://172.17.0.1:1081
ENV no_proxy=localhost,127.0.0.1,172.17.0.1
ENV NO_PROXY=localhost,127.0.0.1,172.17.0.1
# ۲. تنظیم پروکسی برای APT (استفاده از آدرس مستقیم به جای متغیر برای جلوگیری از خطای خالی بودن) # apt: بدون پروکسی از میرور آروان؛ با پروکسی از VPN
RUN echo 'Acquire::http::Proxy "http://172.17.0.1:1081";' > /etc/apt/apt.conf.d/99proxy && \ RUN if [ -n "$BUILD_HTTP_PROXY" ]; then \
echo 'Acquire::https::Proxy "http://172.17.0.1:1081";' >> /etc/apt/apt.conf.d/99proxy echo "Acquire::http::Proxy \"$BUILD_HTTP_PROXY\";" > /etc/apt/apt.conf.d/99proxy && \
echo "Acquire::https::Proxy \"${BUILD_HTTPS_PROXY:-$BUILD_HTTP_PROXY}\";" >> /etc/apt/apt.conf.d/99proxy; \
else \
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do \
[ -f "$$f" ] || continue; \
sed -i \
-e 's|http://deb.debian.org/debian|https://mirror.arvancloud.ir/debian|g' \
-e 's|http://security.debian.org/debian-security|https://mirror.arvancloud.ir/debian|g' \
"$$f"; \
done; \
fi
# ۳. نصب پکیج‌های مورد نیاز (حالا بدون خطا اجرا می‌شود)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
util-linux \ util-linux \
ca-certificates \ ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ۴. حذف تنظیمات پروکسی APT (اختیاری - اگر می‌خواهید در زمان اجرای برنامه پروکسی فعال نباشد)
# RUN rm -f /etc/apt/apt.conf.d/99proxy
# ۵. تنظیمات محیط کاری
WORKDIR /app WORKDIR /app
# ۶. نصب کتابخانه‌های پایتون (Pip هم از ENV بالا استفاده می‌کند)
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ۷. کپی کردن بقیه کدها # pip از میرور آروان — بدون نیاز به پروکسی
RUN if [ -n "$BUILD_HTTP_PROXY" ]; then \
export http_proxy="$BUILD_HTTP_PROXY" https_proxy="${BUILD_HTTPS_PROXY:-$BUILD_HTTP_PROXY}"; \
fi && \
pip install --no-cache-dir \
--default-timeout=300 \
--retries 10 \
--index-url "${PYPI_INDEX}" \
--trusted-host mirror.arvancloud.ir \
-r requirements.txt
COPY . . COPY . .
# ۸. دستور اجرای برنامه RUN mkdir -p /app/data
# حذف پروکسی apt برای runtime
RUN rm -f /etc/apt/apt.conf.d/99proxy
ENV HOST_ROOT=/host
ENV DATA_DIR=/app/data
ENV PYTHONUNBUFFERED=1
EXPOSE 8765
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/login')" || exit 1
CMD ["python", "main.py"] CMD ["python", "main.py"]
+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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DAEMON_JSON = "etc/docker/daemon.json" DAEMON_JSON = "etc/docker/daemon.json"
MIRROR_MANAGER_KEY = "mirror-manager" INVALID_DAEMON_KEYS = ("_mirror_manager",)
def sanitize_daemon_config(config: dict) -> dict:
"""Remove keys that Docker daemon does not accept."""
for key in INVALID_DAEMON_KEYS:
config.pop(key, None)
return config
def sanitize_daemon_json_file(path: Path | None = None) -> bool:
"""Strip invalid keys from daemon.json on disk. Returns True if file was modified."""
path = path or host_path(DAEMON_JSON)
if not path.exists():
return False
try:
config = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return False
cleaned = sanitize_daemon_config(dict(config))
if cleaned == config:
return False
path.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8")
return True
def verify_docker_running() -> dict:
active = run_on_host(["systemctl", "is-active", "docker"], timeout=30)
if active.returncode != 0:
return {
"success": False,
"error": "Docker بعد از restart بالا نیامد — تنظیمات daemon.json را بررسی کنید",
}
info = run_on_host(["docker", "info"], timeout=30)
if info.returncode != 0:
return {
"success": False,
"error": (info.stderr or info.stdout or "docker info ناموفق")[:500],
}
return {"success": True}
def read_current_docker() -> dict: def read_current_docker() -> dict:
@@ -18,7 +57,13 @@ def read_current_docker() -> dict:
if not path.exists(): if not path.exists():
return {"exists": False, "config": {}} return {"exists": False, "config": {}}
try: try:
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))} config = json.loads(path.read_text(encoding="utf-8"))
has_invalid = any(key in config for key in INVALID_DAEMON_KEYS)
return {
"exists": True,
"config": config,
"has_invalid_keys": has_invalid,
}
except json.JSONDecodeError: except json.JSONDecodeError:
return {"exists": True, "config": {}, "parse_error": True} return {"exists": True, "config": {}, "parse_error": True}
@@ -37,8 +82,8 @@ def apply_docker(mirror: Mirror) -> dict:
except json.JSONDecodeError: except json.JSONDecodeError:
config = {} config = {}
config = sanitize_daemon_config(config)
config["registry-mirrors"] = [mirror.url.rstrip("/")] 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") path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
@@ -46,12 +91,17 @@ def apply_docker(mirror: Mirror) -> dict:
if result.returncode != 0: if result.returncode != 0:
return { return {
"success": False, "success": False,
"error": (result.stderr or "خطا در restart docker")[:500], "error": (result.stderr or result.stdout or "خطا در restart docker")[:500],
} }
health = verify_docker_running()
if not health.get("success"):
return health
return { return {
"success": True, "success": True,
"message": f"Docker registry mirror اعمال شد: {mirror.name}", "message": f"Docker registry mirror اعمال شد: {mirror.name}",
"url": mirror.url, "url": mirror.url,
"warning": "سرویس Docker restart شد — containerها موقتاً قطع می‌شوند", "warning": "سرویس Docker restart شد — containerها موقتاً قطع می‌شوند",
} }
+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 @@
{% if pending and not pending.confirmed %} {% if pending and not pending.confirmed %}
<div class="alert alert-warning"> <div class="alert alert-warning">
یک apply در انتظار تأیید است. یک تست موقت در انتظار تأیید است.
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه</a> <a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه و تأیید / rollback</a>
</div> </div>
{% endif %} {% endif %}
<div class="alert alert-info small">
<strong>توصیه:</strong> همیشه ابتدا «تست موقت» را بزنید.
اگر {{ test_minutes }} دقیقه تأیید نکنید، تنظیمات قبلی خودکار برمی‌گردد.
Docker ممکن است چند ثانیه restart شود.
</div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
{% for p in profiles %} {% for p in profiles %}
<div class="col-md-6"> <div class="col-md-6">
@@ -19,15 +25,23 @@
<div class="card-body"> <div class="card-body">
<h5>{{ p.name }}</h5> <h5>{{ p.name }}</h5>
<p class="small text-muted">{{ p.description }}</p> <p class="small text-muted">{{ p.description }}</p>
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}"> <ul class="list-unstyled small mb-3">
<div class="mb-2"> {% for item in p.items %}
<label class="form-label small">پنجره rollback (دقیقه)</label> <li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
<input type="number" name="rollback_minutes" class="form-control form-control-sm" value="15" min="5" max="60"> {% endfor %}
</div> </ul>
<button type="submit" class="btn btn-success" onclick="return confirm('Backup گرفته می‌شود. Docker ممکن است restart شود. ادامه؟')"> <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> </button>
</form> </form>
<form method="post" action="{{ url_for('apply.apply_final', profile_id=p.id) }}">
<button type="submit" class="btn btn-success" onclick="return confirm('اعمال نهایی بدون پنجره rollback. فقط بعد از تست موفق بزنید. ادامه؟')">
اعمال نهایی
</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -37,11 +51,18 @@
<h4>تاریخچه apply</h4> <h4>تاریخچه apply</h4>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm"> <table class="table table-sm">
<thead><tr><th>پروفایل</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead> <thead><tr><th>پروفایل</th><th>نوع</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead>
<tbody> <tbody>
{% for log in recent_logs %} {% for log in recent_logs %}
<tr> <tr>
<td>{{ log.profile.name if log.profile else '-' }}</td> <td>{{ log.profile.name if log.profile else '-' }}</td>
<td>
{% if log.get_details().get('mode') == 'final' %}
<span class="badge bg-success">نهایی</span>
{% else %}
<span class="badge bg-warning text-dark">تست</span>
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ log.status }}</span></td> <td><span class="badge bg-secondary">{{ log.status }}</span></td>
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}</td> <td>{{ log.started_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td><a href="{{ url_for('apply.status', log_id=log.id) }}">جزئیات</a></td> <td><a href="{{ url_for('apply.status', log_id=log.id) }}">جزئیات</a></td>
@@ -51,3 +72,4 @@
</table> </table>
</div> </div>
{% endblock %} {% endblock %}
+20 -3
View File
@@ -3,16 +3,31 @@
{% block content %} {% block content %}
<h2>وضعیت اعمال پروفایل</h2> <h2>وضعیت اعمال پروفایل</h2>
<p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p> <p>پروفایل: <strong>{{ log.profile.name if log.profile else '-' }}</strong></p>
<p>وضعیت: <span class="badge bg-{% if log.status == 'confirmed' %}success{% elif log.status == 'failed' %}danger{% else %}warning{% endif %}">{{ log.status }}</span></p> <p>
نوع:
{% if apply_mode == 'final' %}
<span class="badge bg-success">اعمال نهایی</span>
{% else %}
<span class="badge bg-warning text-dark">تست موقت</span>
{% endif %}
</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>
{% if log.status == 'failed' and details.get('auto_rollback') %}
<div class="alert alert-info">
به‌خاطر خطا در یکی از مراحل، backup قبلی <strong>خودکار</strong> بازگردانده شد.
</div>
{% endif %}
{% if not log.confirmed and log.status == 'pending_confirm' %} {% if not log.confirmed and log.status == 'pending_confirm' %}
<div class="alert alert-warning" id="rollback-alert"> <div class="alert alert-warning" id="rollback-alert">
<strong>پنجره تست:</strong> <strong>پنجره تست:</strong>
<span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار <span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار
<div class="small mt-1">اگر دسترسی به پنل قطع شد، از SSH: <code>mirror-manager emergency-restore</code></div>
</div> </div>
<div class="d-flex 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>
<form method="post" action="{{ url_for('apply.rollback', log_id=log.id) }}"> <form method="post" action="{{ url_for('apply.rollback', log_id=log.id) }}">
<button type="submit" class="btn btn-warning">Rollback دستی</button> <button type="submit" class="btn btn-warning">Rollback دستی</button>
@@ -40,6 +55,7 @@
{% endfor %} {% endfor %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mt-3">داشبورد</a> <a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mt-3">داشبورد</a>
<a href="{{ url_for('restore.index') }}" class="btn btn-outline-danger mt-3">بازگردانی</a>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -60,3 +76,4 @@
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+3 -5
View File
@@ -20,12 +20,9 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<div class="card-footer bg-transparent"> <div class="card-footer bg-transparent d-flex flex-wrap gap-1">
<a href="{{ url_for('profiles.edit', profile_id=p.id) }}" class="btn btn-sm btn-outline-primary">ویرایش</a> <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"> <a href="{{ url_for('apply.index') }}" class="btn btn-sm btn-warning">تست / اعمال</a>
<input type="hidden" name="rollback_minutes" value="15">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('پروفایل اعمال شود؟ Docker restart می‌شود.')">اعمال</button>
</form>
<form method="post" action="{{ url_for('profiles.delete', profile_id=p.id) }}" class="d-inline" onsubmit="return confirm('حذف شود؟')"> <form method="post" action="{{ url_for('profiles.delete', profile_id=p.id) }}" class="d-inline" onsubmit="return confirm('حذف شود؟')">
<button type="submit" class="btn btn-sm btn-outline-danger">حذف</button> <button type="submit" class="btn btn-sm btn-outline-danger">حذف</button>
</form> </form>
@@ -37,3 +34,4 @@
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}
+23 -5
View File
@@ -6,6 +6,22 @@
{{ guide_box(guide, 'restore') }} {{ guide_box(guide, 'restore') }}
<div class="row g-3 mb-4"> <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>
{% if latest_apply_backup %}
<p class="small"><code>{{ latest_apply_backup }}</code></p>
<form method="post" action="{{ url_for('restore.emergency_restore_view') }}" onsubmit="return confirm('تنظیمات به آخرین وضعیت قبل از apply برگردد؟ Docker restart می‌شود.')">
<button type="submit" class="btn btn-danger">بازگردانی فوری</button>
</form>
{% else %}
<p class="text-muted">backup قبل از apply یافت نشد</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card border-warning"> <div class="card border-warning">
<div class="card-body"> <div class="card-body">
@@ -49,11 +65,13 @@
<div class="card mt-4 bg-light"> <div class="card mt-4 bg-light">
<div class="card-body"> <div class="card-body">
<h6>دستورات SSH</h6> <h6>دستورات SSH (یک خط)</h6>
<pre class="mb-0">python3 mirror-manager restore --initial <pre class="mb-0">mirror-manager emergency-restore
python3 mirror-manager restore --backup /path/to/backup mirror-manager restore --initial
python3 mirror-manager disable mirror-manager restore --last
python3 mirror-manager enable</pre> mirror-manager disable
mirror-manager enable</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+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 -1
View File
@@ -144,7 +144,8 @@ openssl rand -hex 32
1. تب **Domains** 1. تب **Domains**
2. Host: `mirror.itistan.ir` 2. Host: `mirror.itistan.ir`
3. Port: `8765` 3. Port: **`8765`** (نه 3000)
4. Command در Dokploy باید **خالی** باشد — Dockerfile خودش `python main.py` را اجرا می‌کند
4. HTTPS: فعال / Let's Encrypt 4. HTTPS: فعال / Let's Encrypt
5. **Deploy** مجدد 5. **Deploy** مجدد
@@ -209,6 +210,29 @@ git push origin main
## عیب‌یابی ## عیب‌یابی
### ۰. Build گیر می‌کند روی `load metadata for python`
علت: Docker Hub از ایران در دسترس نیست.
**راه‌حل (در Dockerfile اعمال شده):**
- Base image از `docker.arvancloud.ir/library/python:3.12-slim-bookworm`
- pip از `pypi.org` از طریق پروکسی VPN (`172.17.0.1:1081`)
**اگر pip fail شد:** VPN را روشن کنید و دوباره Deploy بزنید.
**قبل از deploy دستی روی سرور (اختیاری):**
```bash
docker pull docker.arvancloud.ir/library/python:3.12-slim-bookworm
```
**اگر باز هم گیر کرد** — VPN را روشن کنید و دوباره Deploy بزنید.
**تست build دستی:**
```bash
cd /etc/dokploy/compose/vpn-mirror-sazg0b/code
docker compose -p vpn-mirror-sazg0b build --progress=plain 2>&1 | tee /tmp/mirror-build.log
```
### ۱. 502 / 504 از Traefik ### ۱. 502 / 504 از Traefik
```bash ```bash
+10 -3
View File
@@ -10,9 +10,16 @@ sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
sudo chmod +x "$BIN" sudo chmod +x "$BIN"
PROFILE_D="/etc/profile.d/mirror-manager.sh" PROFILE_D="/etc/profile.d/mirror-manager.sh"
echo "export PYTHONPATH=$INSTALL_DIR" | sudo tee "$PROFILE_D" > /dev/null {
echo "export MIRROR_CONTAINER_NAME=mirror-manager" | sudo tee -a "$PROFILE_D" > /dev/null echo "export PYTHONPATH=$INSTALL_DIR"
echo "export MIRROR_CONTAINER_NAME=mirror-manager"
echo "export DATA_DIR=/var/lib/mirror-manager/data"
echo "export HOST_ROOT=/"
} | sudo tee "$PROFILE_D" > /dev/null
echo "CLI نصب شد: mirror-manager" echo "CLI نصب شد: mirror-manager"
echo " mirror-manager status" echo " mirror-manager emergency-restore # بازگردانی فوری"
echo " mirror-manager restore --last"
echo " mirror-manager restore --initial" echo " mirror-manager restore --initial"
echo " mirror-manager status"
+10 -1
View File
@@ -1,6 +1,14 @@
services: services:
mirror-manager: mirror-manager:
build: . build:
context: .
dockerfile: Dockerfile
args:
BASE_IMAGE: docker.arvancloud.ir/library/python:3.12-slim-bookworm
PYPI_INDEX: https://mirror.arvancloud.ir/pypi/simple
# اختیاری — فقط اگر xray روی host فعال است:
BUILD_HTTP_PROXY: ${BUILD_HTTP_PROXY:-}
BUILD_HTTPS_PROXY: ${BUILD_HTTPS_PROXY:-}
container_name: mirror-manager container_name: mirror-manager
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
@@ -12,6 +20,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
+25
View File
@@ -0,0 +1,25 @@
"""Entry point for container: python main.py"""
import subprocess
import sys
def main() -> None:
subprocess.run(
[
sys.executable,
"-m",
"gunicorn",
"-b",
"0.0.0.0:8765",
"-w",
"2",
"--timeout",
"180",
"run:app",
],
check=True,
)
if __name__ == "__main__":
main()