Compare commits
6 Commits
4ddd51a975
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 384680cd82 | |||
| 63d0699cdd | |||
| 1c9d647787 | |||
| 8ab80c00e9 | |||
| 5519a9d93b | |||
| dfae55980a |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
.cursor
|
||||
@@ -12,8 +12,13 @@ DATA_DIR=/app/data
|
||||
|
||||
# ─── رفتار ───
|
||||
ROLLBACK_MINUTES=15
|
||||
TEST_ROLLBACK_MINUTES=2
|
||||
AUTO_TEST_INTERVAL_MINUTES=30
|
||||
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 ───
|
||||
MIRROR_CONTAINER_NAME=mirror-manager
|
||||
|
||||
+46
-20
@@ -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
|
||||
ENV http_proxy=http://172.17.0.1:1081
|
||||
ENV https_proxy=http://172.17.0.1:1081
|
||||
ENV HTTP_PROXY=http://172.17.0.1:1081
|
||||
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
|
||||
# پروکسی اختیاری — فقط اگر xray/VPN روی host فعال است (مثلاً BUILD_HTTP_PROXY=http://172.17.0.1:1081)
|
||||
ARG BUILD_HTTP_PROXY=
|
||||
ARG BUILD_HTTPS_PROXY=
|
||||
ARG PYPI_INDEX=https://mirror.arvancloud.ir/pypi/simple
|
||||
|
||||
# ۲. تنظیم پروکسی برای APT (استفاده از آدرس مستقیم به جای متغیر برای جلوگیری از خطای خالی بودن)
|
||||
RUN echo 'Acquire::http::Proxy "http://172.17.0.1:1081";' > /etc/apt/apt.conf.d/99proxy && \
|
||||
echo 'Acquire::https::Proxy "http://172.17.0.1:1081";' >> /etc/apt/apt.conf.d/99proxy
|
||||
# apt: بدون پروکسی از میرور آروان؛ با پروکسی از VPN
|
||||
RUN if [ -n "$BUILD_HTTP_PROXY" ]; then \
|
||||
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 \
|
||||
git \
|
||||
util-linux \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ۴. حذف تنظیمات پروکسی APT (اختیاری - اگر میخواهید در زمان اجرای برنامه پروکسی فعال نباشد)
|
||||
# RUN rm -f /etc/apt/apt.conf.d/99proxy
|
||||
|
||||
# ۵. تنظیمات محیط کاری
|
||||
WORKDIR /app
|
||||
|
||||
# ۶. نصب کتابخانههای پایتون (Pip هم از ENV بالا استفاده میکند)
|
||||
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 . .
|
||||
|
||||
# ۸. دستور اجرای برنامه
|
||||
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"]
|
||||
|
||||
@@ -99,14 +99,21 @@ echo 'export PYTHONPATH=/opt/mirror-manager' | sudo tee /etc/profile.d/mirror-ma
|
||||
1. به **https://mirror.itistan.ir** بروید
|
||||
2. با `ADMIN_USERNAME` / `ADMIN_PASSWORD` وارد شوید
|
||||
3. در **اولین ورود** backup اولیه خودکار گرفته میشود
|
||||
4. یک **پروفایل** انتخاب و **اعمال** کنید
|
||||
5. در پنجره rollback تست کنید → **تأیید** یا **rollback**
|
||||
4. یک **پروفایل** انتخاب کنید → **تست موقت (۲ دقیقه)** بزنید
|
||||
5. Dokploy و deploy را تست کنید → **اعمال نهایی** (نگهداشتن تغییرات)
|
||||
6. اگر مشکل پیش آمد: `mirror-manager emergency-restore` از SSH
|
||||
|
||||
---
|
||||
|
||||
## CLI (از SSH)
|
||||
|
||||
```bash
|
||||
# بازگردانی فوری — آخرین backup قبل از apply (توصیه در emergency)
|
||||
mirror-manager emergency-restore
|
||||
|
||||
# یا معادل:
|
||||
mirror-manager restore --last
|
||||
|
||||
# بازگردانی تنظیمات اولیه (قبل از نصب Mirror Manager)
|
||||
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.profiles import profiles_bp
|
||||
from app.seed import seed_all
|
||||
from app.services.applier import ensure_initial_backup, restore_initial
|
||||
from app.services.backup import restore_backup
|
||||
from app.services.applier import (
|
||||
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
|
||||
|
||||
|
||||
@@ -87,17 +91,29 @@ def cli():
|
||||
|
||||
@cli.command()
|
||||
@click.option("--initial", is_flag=True, help="بازگردانی تنظیمات اولیه")
|
||||
@click.option("--last", is_flag=True, help="آخرین backup قبل از apply")
|
||||
@click.option("--backup", default=None, help="مسیر backup مشخص")
|
||||
def restore(initial, backup):
|
||||
def restore(initial, backup, last):
|
||||
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():
|
||||
if 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:
|
||||
result = restore_backup(backup)
|
||||
else:
|
||||
click.echo("یکی از --initial یا --backup را مشخص کنید.")
|
||||
click.echo("یکی از --initial، --last یا --backup را مشخص کنید.")
|
||||
return
|
||||
if result.get("success"):
|
||||
click.echo("بازگردانی موفق:")
|
||||
@@ -107,6 +123,25 @@ def restore(initial, backup):
|
||||
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()
|
||||
def disable():
|
||||
container = os.environ.get("MIRROR_CONTAINER_NAME", "mirror-manager")
|
||||
|
||||
@@ -18,6 +18,7 @@ class Config:
|
||||
HOST_ROOT = os.environ.get("HOST_ROOT", "")
|
||||
|
||||
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"))
|
||||
|
||||
RESOLVED_DROPIN = "mirror-manager.conf"
|
||||
|
||||
+21
-5
@@ -71,7 +71,8 @@ GUIDES = {
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
@@ -131,6 +132,16 @@ GUIDES = {
|
||||
<h5>پروفایل چیست؟</h5>
|
||||
<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>
|
||||
<ul>
|
||||
<li><strong>آروان کامل:</strong> Shecan + Arvan APT/Docker + GitClone</li>
|
||||
@@ -139,7 +150,7 @@ GUIDES = {
|
||||
</ul>
|
||||
|
||||
<h5>Rollback</h5>
|
||||
<p>بعد از apply، پنجره زمانی (پیشفرض ۱۵ دقیقه) برای تست دارید. اگر تأیید نکنید، تنظیمات قبلی بازگردانده میشود.</p>
|
||||
<p>تست موقت پیشفرض ۲ دقیقه است. اعمال نهایی بدون پنجره rollback — فقط بعد از تست موفق.</p>
|
||||
""",
|
||||
},
|
||||
"settings": {
|
||||
@@ -163,15 +174,20 @@ GUIDES = {
|
||||
<h5>Backup اولیه</h5>
|
||||
<p>در اولین اجرا، snapshot از تنظیمات فعلی سیستم گرفته میشود.</p>
|
||||
|
||||
<h5>بازگردانی فوری (Emergency)</h5>
|
||||
<p>یک دستور / یک دکمه — برگشت به آخرین backup قبل از apply (DNS، APT، Docker، ...).</p>
|
||||
|
||||
<h5>بازگردانی</h5>
|
||||
<ul>
|
||||
<li><strong>emergency-restore:</strong> آخرین backup قبل از apply — سریعترین راه</li>
|
||||
<li><strong>تنظیمات اولیه:</strong> برگشت به وضعیت قبل از نصب Mirror Manager</li>
|
||||
<li><strong>backup apply:</strong> برگشت به وضعیت قبل از آخرین apply</li>
|
||||
<li><strong>backup apply:</strong> برگشت به backup مشخص</li>
|
||||
</ul>
|
||||
|
||||
<h5>از SSH</h5>
|
||||
<pre>mirror-manager restore --initial
|
||||
mirror-manager restore --backup /path/to/backup
|
||||
<pre>mirror-manager emergency-restore
|
||||
mirror-manager restore --initial
|
||||
mirror-manager restore --last
|
||||
mirror-manager disable
|
||||
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_login import login_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.guides import GUIDES
|
||||
from app.models import ApplyLog, Profile, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services.applier import apply_profile, confirm_apply, get_system_status, restore_initial, rollback_apply
|
||||
from app.services.backup import list_backups, restore_backup
|
||||
from app.services.applier import (
|
||||
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")
|
||||
|
||||
@@ -20,33 +27,52 @@ def index():
|
||||
if state and 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()
|
||||
return render_template(
|
||||
"apply/index.html",
|
||||
profiles=profiles,
|
||||
pending=pending,
|
||||
recent_logs=recent_logs,
|
||||
test_minutes=test_minutes,
|
||||
guide=GUIDES.get("profiles", {}),
|
||||
)
|
||||
|
||||
|
||||
@apply_bp.route("/profile/<int:profile_id>", methods=["POST"])
|
||||
@login_required
|
||||
def apply(profile_id):
|
||||
def _handle_apply(profile_id: int, mode: str):
|
||||
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":
|
||||
flash("اعمال پروفایل با خطا مواجه شد. جزئیات را بررسی کنید.", "danger")
|
||||
flash("اعمال پروفایل با خطا مواجه شد. تنظیمات قبلی خودکار بازگردانده شد.", "danger")
|
||||
elif mode == APPLY_MODE_FINAL:
|
||||
flash("پروفایل بهصورت نهایی اعمال و ثبت شد.", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
else:
|
||||
test_minutes = log.get_details().get("rollback_minutes", 2)
|
||||
flash(
|
||||
f"پروفایل اعمال شد. {rollback_minutes} دقیقه برای تست دارید — سپس rollback خودکار انجام میشود.",
|
||||
f"تست موقت اعمال شد. {test_minutes} دقیقه برای بررسی دارید — در صورت عدم تأیید، rollback خودکار انجام میشود.",
|
||||
"warning",
|
||||
)
|
||||
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>")
|
||||
@login_required
|
||||
def status(log_id):
|
||||
@@ -60,6 +86,7 @@ def status(log_id):
|
||||
log=log,
|
||||
details=details,
|
||||
remaining_seconds=remaining_seconds,
|
||||
apply_mode=details.get("mode", APPLY_MODE_TEST),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +94,7 @@ def status(log_id):
|
||||
@login_required
|
||||
def confirm(log_id):
|
||||
confirm_apply(log_id)
|
||||
flash("تغییرات تأیید و ثبت شد.", "success")
|
||||
flash("تست موفق بود — تغییرات بهصورت نهایی ثبت شد.", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
|
||||
@@ -106,14 +133,27 @@ restore_bp = Blueprint("restore", __name__, url_prefix="/restore")
|
||||
def index():
|
||||
backups = list_backups()
|
||||
state = SystemState.query.first()
|
||||
latest_apply = get_latest_apply_backup()
|
||||
return render_template(
|
||||
"restore/index.html",
|
||||
backups=backups,
|
||||
state=state,
|
||||
latest_apply_backup=str(latest_apply) if latest_apply else None,
|
||||
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"])
|
||||
@login_required
|
||||
def restore_initial_view():
|
||||
@@ -158,6 +198,7 @@ def index():
|
||||
Setting.set(key, "true" if request.form.get(key) == "on" else "false")
|
||||
|
||||
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("max_switches_per_day", request.form.get("max_switches_per_day", "3"))
|
||||
Setting.set(
|
||||
@@ -170,7 +211,7 @@ def index():
|
||||
settings = {key: Setting.get(key) for key in (
|
||||
"auto_switch_dns", "auto_switch_apt", "auto_switch_docker",
|
||||
"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",
|
||||
)}
|
||||
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",
|
||||
"max_switches_per_day": "3",
|
||||
"rollback_minutes": "15",
|
||||
"test_rollback_minutes": "2",
|
||||
"rollback_on_all_fail": "true",
|
||||
}
|
||||
from app.models import Setting
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
+72
-9
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Profile, SystemState
|
||||
from app.models import ApplyLog, Profile, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services import apt, backup, dns, docker_svc, github, pip_npm
|
||||
from app.services.host import run_on_host
|
||||
@@ -22,6 +21,9 @@ APPLIERS = {
|
||||
"npm": pip_npm.apply_npm,
|
||||
}
|
||||
|
||||
APPLY_MODE_TEST = "test"
|
||||
APPLY_MODE_FINAL = "final"
|
||||
|
||||
|
||||
def ensure_initial_backup() -> SystemState:
|
||||
state = SystemState.query.first()
|
||||
@@ -40,9 +42,22 @@ def ensure_initial_backup() -> SystemState:
|
||||
return state
|
||||
|
||||
|
||||
def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> ApplyLog:
|
||||
def _rollback_minutes_for_mode(mode: str, rollback_minutes: int | None) -> int | None:
|
||||
if mode == APPLY_MODE_FINAL:
|
||||
return None
|
||||
if rollback_minutes is not None:
|
||||
return rollback_minutes
|
||||
default = Setting.get("test_rollback_minutes", str(Config.TEST_ROLLBACK_MINUTES))
|
||||
return int(default or Config.TEST_ROLLBACK_MINUTES)
|
||||
|
||||
|
||||
def apply_profile(
|
||||
profile: Profile,
|
||||
mode: str = APPLY_MODE_TEST,
|
||||
rollback_minutes: int | None = None,
|
||||
) -> ApplyLog:
|
||||
state = ensure_initial_backup()
|
||||
rollback_minutes = rollback_minutes or int(Config.ROLLBACK_MINUTES)
|
||||
rollback_minutes = _rollback_minutes_for_mode(mode, rollback_minutes)
|
||||
|
||||
previous_backup = backup.create_backup(f"before_apply_{profile.id}")
|
||||
apply_log = ApplyLog(
|
||||
@@ -50,12 +65,17 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
status="running",
|
||||
backup_path=str(previous_backup),
|
||||
previous_backup_path=state.initial_backup_path,
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes),
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes) if rollback_minutes else None,
|
||||
)
|
||||
db.session.add(apply_log)
|
||||
db.session.flush()
|
||||
|
||||
details: dict = {"steps": [], "profile": profile.name}
|
||||
details: dict = {
|
||||
"steps": [],
|
||||
"profile": profile.name,
|
||||
"mode": mode,
|
||||
"rollback_minutes": rollback_minutes,
|
||||
}
|
||||
mirrors = profile.mirrors_by_category()
|
||||
order = ["dns", "apt", "docker", "github", "pip", "npm"]
|
||||
|
||||
@@ -69,11 +89,14 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
try:
|
||||
result = applier(mirror)
|
||||
details["steps"].append({"category": category, "mirror": mirror.name, **result})
|
||||
if not result.get("success", True):
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.exception("Apply failed for %s", category)
|
||||
details["steps"].append(
|
||||
{"category": category, "mirror": mirror.name, "success": False, "error": str(exc)}
|
||||
)
|
||||
break
|
||||
|
||||
failed = [s for s in details["steps"] if not s.get("success", True)]
|
||||
if failed and apply_log.backup_path:
|
||||
@@ -81,11 +104,22 @@ def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> Appl
|
||||
details["auto_rollback"] = rollback_result
|
||||
|
||||
apply_log.set_details(details)
|
||||
apply_log.status = "failed" if failed else "pending_confirm"
|
||||
apply_log.finished_at = utcnow()
|
||||
|
||||
state.current_profile_id = profile.id if not failed else state.current_profile_id
|
||||
state.pending_apply_log_id = apply_log.id if not failed else None
|
||||
if failed:
|
||||
apply_log.status = "failed"
|
||||
apply_log.rollback_at = None
|
||||
elif mode == APPLY_MODE_FINAL:
|
||||
apply_log.confirmed = True
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
state.current_profile_id = profile.id
|
||||
state.pending_apply_log_id = None
|
||||
else:
|
||||
apply_log.status = "pending_confirm"
|
||||
state.current_profile_id = profile.id
|
||||
state.pending_apply_log_id = apply_log.id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return apply_log
|
||||
@@ -97,9 +131,15 @@ def confirm_apply(apply_log_id: int) -> ApplyLog:
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
|
||||
details = apply_log.get_details()
|
||||
details["confirmed_from"] = APPLY_MODE_TEST
|
||||
apply_log.set_details(details)
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
state.pending_apply_log_id = None
|
||||
if apply_log.profile_id:
|
||||
state.current_profile_id = apply_log.profile_id
|
||||
db.session.commit()
|
||||
return apply_log
|
||||
|
||||
@@ -112,6 +152,7 @@ def rollback_apply(apply_log_id: int) -> dict:
|
||||
result = backup.restore_backup(apply_log.backup_path)
|
||||
apply_log.status = "rolled_back"
|
||||
apply_log.finished_at = utcnow()
|
||||
apply_log.rollback_at = None
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
@@ -120,6 +161,28 @@ def rollback_apply(apply_log_id: int) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def emergency_restore_last_apply() -> dict:
|
||||
"""One-shot restore of the most recent pre-apply backup."""
|
||||
path = backup.get_latest_apply_backup()
|
||||
if not path:
|
||||
return {"success": False, "error": "backup قبل از apply یافت نشد"}
|
||||
result = backup.restore_backup(path)
|
||||
if result.get("success"):
|
||||
result["backup_path"] = str(path)
|
||||
|
||||
state = SystemState.query.first()
|
||||
pending_id = state.pending_apply_log_id if state else None
|
||||
if pending_id:
|
||||
log = ApplyLog.query.get(pending_id)
|
||||
if log and log.status == "pending_confirm":
|
||||
log.status = "rolled_back"
|
||||
log.finished_at = utcnow()
|
||||
log.rollback_at = None
|
||||
state.pending_apply_log_id = None
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def restore_initial() -> dict:
|
||||
state = SystemState.query.first()
|
||||
if not state or not state.initial_backup_path:
|
||||
|
||||
+27
-1
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.services.docker_svc import sanitize_daemon_json_file
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -147,12 +148,37 @@ def restore_backup(backup_root: Path | str) -> dict:
|
||||
elif dropin.exists():
|
||||
dropin.unlink()
|
||||
|
||||
daemon_path = host_path("etc/docker/daemon.json")
|
||||
if sanitize_daemon_json_file(daemon_path):
|
||||
results.append("پاکسازی کلیدهای نامعتبر daemon.json")
|
||||
|
||||
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
run_on_host(["systemctl", "restart", "docker"])
|
||||
docker_result = run_on_host(["systemctl", "restart", "docker"], timeout=120)
|
||||
if docker_result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (docker_result.stderr or "خطا در restart docker")[:500],
|
||||
"restored": results,
|
||||
}
|
||||
|
||||
return {"success": True, "restored": results}
|
||||
|
||||
|
||||
def get_latest_apply_backup() -> Path | None:
|
||||
"""Return the most recent before_apply backup directory, if any."""
|
||||
if not Config.BACKUP_DIR.exists():
|
||||
return None
|
||||
candidates = sorted(
|
||||
(
|
||||
p
|
||||
for p in Config.BACKUP_DIR.iterdir()
|
||||
if p.is_dir() and "before_apply" in p.name
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def create_initial_backup() -> Path:
|
||||
return create_backup("initial")
|
||||
|
||||
|
||||
+107
-57
@@ -1,57 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAEMON_JSON = "etc/docker/daemon.json"
|
||||
MIRROR_MANAGER_KEY = "mirror-manager"
|
||||
|
||||
|
||||
def read_current_docker() -> dict:
|
||||
path = host_path(DAEMON_JSON)
|
||||
if not path.exists():
|
||||
return {"exists": False, "config": {}}
|
||||
try:
|
||||
return {"exists": True, "config": json.loads(path.read_text(encoding="utf-8"))}
|
||||
except json.JSONDecodeError:
|
||||
return {"exists": True, "config": {}, "parse_error": True}
|
||||
|
||||
|
||||
def apply_docker(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL رجیstry داکر تعریف نشده"}
|
||||
|
||||
path = host_path(DAEMON_JSON)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
config["registry-mirrors"] = [mirror.url.rstrip("/")]
|
||||
config["_mirror_manager"] = MIRROR_MANAGER_KEY
|
||||
|
||||
path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = run_on_host(["systemctl", "restart", "docker"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (result.stderr or "خطا در restart docker")[:500],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Docker registry mirror اعمال شد: {mirror.name}",
|
||||
"url": mirror.url,
|
||||
"warning": "سرویس Docker restart شد — containerها موقتاً قطع میشوند",
|
||||
}
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
from app.models import Mirror
|
||||
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
DAEMON_JSON = "etc/docker/daemon.json"
|
||||
|
||||
INVALID_DAEMON_KEYS = ("_mirror_manager",)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def sanitize_daemon_config(config: dict) -> dict:
|
||||
|
||||
"""Remove keys that Docker daemon does not accept."""
|
||||
|
||||
for key in INVALID_DAEMON_KEYS:
|
||||
|
||||
config.pop(key, None)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def sanitize_daemon_json_file(path: Path | None = None) -> bool:
|
||||
|
||||
"""Strip invalid keys from daemon.json on disk. Returns True if file was modified."""
|
||||
|
||||
path = path or host_path(DAEMON_JSON)
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return False
|
||||
|
||||
try:
|
||||
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
|
||||
return False
|
||||
|
||||
cleaned = sanitize_daemon_config(dict(config))
|
||||
|
||||
if cleaned == config:
|
||||
|
||||
return False
|
||||
|
||||
path.write_text(json.dumps(cleaned, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def verify_docker_running() -> dict:
|
||||
|
||||
active = run_on_host(["systemctl", "is-active", "docker"], timeout=30)
|
||||
|
||||
if active.returncode != 0:
|
||||
|
||||
return {
|
||||
|
||||
"success": False,
|
||||
|
||||
"error": "Docker بعد از restart بالا نیامد — تنظیمات daemon.json را بررسی کنید",
|
||||
|
||||
}
|
||||
|
||||
info = run_on_host(["docker", "info"], timeout=30)
|
||||
|
||||
if info.returncode != 0:
|
||||
|
||||
return {
|
||||
|
||||
"success": False,
|
||||
|
||||
"error": (info.stderr or info.stdout or "docker info ناموفق")[:500],
|
||||
|
||||
}
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
||||
from app.extensions import db
|
||||
from app.models import ApplyLog, Mirror, Profile, ProfileItem, Setting, SystemState
|
||||
from app.models import utcnow
|
||||
from app.services.applier import apply_profile, rollback_apply
|
||||
from app.services.applier import APPLY_MODE_FINAL, apply_profile, rollback_apply
|
||||
from app.services.tester import get_best_mirror, test_mirror
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,7 +78,7 @@ def run_auto_switch(app) -> None:
|
||||
|
||||
item.mirror_id = best.id
|
||||
db.session.commit()
|
||||
apply_profile(profile)
|
||||
apply_profile(profile, mode=APPLY_MODE_FINAL)
|
||||
_increment_switch_count()
|
||||
logger.info("Auto-switched %s to %s", category, best.name)
|
||||
break
|
||||
|
||||
@@ -1,53 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}اعمال تنظیمات{% endblock %}
|
||||
{% block content %}
|
||||
<h2>اعمال پروفایل</h2>
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
|
||||
{% if pending and not pending.confirmed %}
|
||||
<div class="alert alert-warning">
|
||||
یک apply در انتظار تأیید است.
|
||||
<a href="{{ url_for('apply.status', log_id=pending.id) }}">ادامه</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% for p in profiles %}
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>{{ p.name }}</h5>
|
||||
<p class="small text-muted">{{ p.description }}</p>
|
||||
<form method="post" action="{{ url_for('apply.apply', profile_id=p.id) }}">
|
||||
<div class="mb-2">
|
||||
<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">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" onclick="return confirm('Backup گرفته میشود. Docker ممکن است restart شود. ادامه؟')">
|
||||
اعمال پروفایل
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h4>تاریخچه apply</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>پروفایل</th><th>وضعیت</th><th>شروع</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in recent_logs %}
|
||||
<tr>
|
||||
<td>{{ log.profile.name if log.profile else '-' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ log.status }}</span></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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "macros.html" import guide_box %}
|
||||
|
||||
{% block title %}اعمال تنظیمات{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>اعمال پروفایل</h2>
|
||||
|
||||
{{ 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>
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div class="alert alert-info small">
|
||||
|
||||
<strong>توصیه:</strong> همیشه ابتدا «تست موقت» را بزنید.
|
||||
|
||||
اگر {{ test_minutes }} دقیقه تأیید نکنید، تنظیمات قبلی خودکار برمیگردد.
|
||||
|
||||
Docker ممکن است چند ثانیه restart شود.
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
{% for p in profiles %}
|
||||
|
||||
<div class="col-md-6">
|
||||
|
||||
<div class="card">
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<h5>{{ p.name }}</h5>
|
||||
|
||||
<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>
|
||||
@@ -1,62 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}وضعیت Apply{% endblock %}
|
||||
{% block content %}
|
||||
<h2>وضعیت اعمال پروفایل</h2>
|
||||
<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>
|
||||
|
||||
{% if not log.confirmed and log.status == 'pending_confirm' %}
|
||||
<div class="alert alert-warning" id="rollback-alert">
|
||||
<strong>پنجره تست:</strong>
|
||||
<span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار
|
||||
</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>
|
||||
</form>
|
||||
<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">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span><strong>{{ step.category }}</strong> — {{ step.get('mirror', '') }}</span>
|
||||
{% if step.get('success', True) %}
|
||||
<span class="text-success">موفق</span>
|
||||
{% else %}
|
||||
<span class="text-danger">ناموفق</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if step.get('message') %}<small class="text-muted">{{ step.message }}</small>{% endif %}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary mt-3">داشبورد</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if not log.confirmed and log.status == 'pending_confirm' %}
|
||||
<script>
|
||||
(function() {
|
||||
let remaining = {{ remaining_seconds }};
|
||||
const el = document.getElementById('countdown');
|
||||
const tick = setInterval(function() {
|
||||
remaining--;
|
||||
if (el) el.textContent = remaining;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(tick);
|
||||
location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}وضعیت Apply{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
|
||||
{% 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' %}
|
||||
|
||||
<div class="alert alert-warning" id="rollback-alert">
|
||||
|
||||
<strong>پنجره تست:</strong>
|
||||
|
||||
<span id="countdown">{{ remaining_seconds }}</span> ثانیه تا rollback خودکار
|
||||
|
||||
<div class="small mt-1">اگر دسترسی به پنل قطع شد، از SSH: <code>mirror-manager emergency-restore</code></div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
|
||||
<form method="post" action="{{ url_for('apply.confirm', log_id=log.id) }}">
|
||||
|
||||
<button type="submit" class="btn btn-success">اعمال نهایی — نگهداشتن تغییرات</button>
|
||||
|
||||
</form>
|
||||
|
||||
<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">
|
||||
@@ -1,39 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}پروفایلها{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
<h2>پروفایلها</h2>
|
||||
<a href="{{ url_for('profiles.create') }}" class="btn btn-primary">+ پروفایل جدید</a>
|
||||
</div>
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
<div class="row g-3">
|
||||
{% for p in profiles %}
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 {% if p.is_default %}border-primary{% endif %}">
|
||||
<div class="card-body">
|
||||
<h5>{{ p.name }}{% if p.is_default %} <span class="badge bg-primary">پیشفرض</span>{% endif %}</h5>
|
||||
<p class="text-muted small">{{ p.description or '' }}</p>
|
||||
<ul class="list-unstyled small">
|
||||
{% for item in p.items %}
|
||||
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<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">
|
||||
<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('حذف شود؟')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">حذف</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12"><p class="text-muted">پروفایلی وجود ندارد</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "macros.html" import guide_box %}
|
||||
|
||||
{% block title %}پروفایلها{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
|
||||
<h2>پروفایلها</h2>
|
||||
|
||||
<a href="{{ url_for('profiles.create') }}" class="btn btn-primary">+ پروفایل جدید</a>
|
||||
|
||||
</div>
|
||||
|
||||
{{ guide_box(guide, 'profiles') }}
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
{% for p in profiles %}
|
||||
|
||||
<div class="col-md-4">
|
||||
|
||||
<div class="card h-100 {% if p.is_default %}border-primary{% endif %}">
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<h5>{{ p.name }}{% if p.is_default %} <span class="badge bg-primary">پیشفرض</span>{% endif %}</h5>
|
||||
|
||||
<p class="text-muted small">{{ p.description or '' }}</p>
|
||||
|
||||
<ul class="list-unstyled small">
|
||||
|
||||
{% for item in p.items %}
|
||||
|
||||
<li>{{ item.category }}: <strong>{{ item.mirror.name if item.mirror else '-' }}</strong></li>
|
||||
@@ -1,59 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import guide_box %}
|
||||
{% block title %}بازگردانی{% endblock %}
|
||||
{% block content %}
|
||||
<h2>بازگردانی تنظیمات</h2>
|
||||
{{ guide_box(guide, 'restore') }}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Backupهای موجود</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>نام</th><th>برچسب</th><th>عملیات</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><code class="small">{{ b.name }}</code></td>
|
||||
<td>{{ b.label }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('restore.restore_backup_view') }}" class="d-inline" onsubmit="return confirm('این backup restore شود؟')">
|
||||
<input type="hidden" name="backup_path" value="{{ b.path }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">Restore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-muted">backupی وجود ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 bg-light">
|
||||
<div class="card-body">
|
||||
<h6>دستورات SSH</h6>
|
||||
<pre class="mb-0">python3 mirror-manager restore --initial
|
||||
python3 mirror-manager restore --backup /path/to/backup
|
||||
python3 mirror-manager disable
|
||||
python3 mirror-manager enable</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "macros.html" import guide_box %}
|
||||
|
||||
{% block title %}بازگردانی{% endblock %}
|
||||
|
||||
{% 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>
|
||||
|
||||
{% 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="card border-warning">
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -29,7 +29,12 @@
|
||||
<h5>Rollback و تست</h5>
|
||||
<div class="row g-3 mb-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">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
|
||||
+25
-1
@@ -144,7 +144,8 @@ openssl rand -hex 32
|
||||
|
||||
1. تب **Domains**
|
||||
2. Host: `mirror.itistan.ir`
|
||||
3. Port: `8765`
|
||||
3. Port: **`8765`** (نه 3000)
|
||||
4. Command در Dokploy باید **خالی** باشد — Dockerfile خودش `python main.py` را اجرا میکند
|
||||
4. HTTPS: فعال / Let's Encrypt
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
+25
-18
@@ -1,18 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${1:-/opt/mirror-manager}"
|
||||
BIN="/usr/local/bin/mirror-manager"
|
||||
|
||||
echo "نصب Mirror Manager CLI..."
|
||||
sudo mkdir -p /var/lib/mirror-manager/data
|
||||
sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
|
||||
sudo chmod +x "$BIN"
|
||||
|
||||
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 "CLI نصب شد: mirror-manager"
|
||||
echo " mirror-manager status"
|
||||
echo " mirror-manager restore --initial"
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
|
||||
INSTALL_DIR="${1:-/opt/mirror-manager}"
|
||||
|
||||
BIN="/usr/local/bin/mirror-manager"
|
||||
|
||||
|
||||
|
||||
echo "نصب Mirror Manager CLI..."
|
||||
|
||||
sudo mkdir -p /var/lib/mirror-manager/data
|
||||
|
||||
sudo cp "$INSTALL_DIR/mirror-manager" "$BIN"
|
||||
|
||||
sudo chmod +x "$BIN"
|
||||
|
||||
|
||||
|
||||
PROFILE_D="/etc/profile.d/mirror-manager.sh"
|
||||
|
||||
{
|
||||
+10
-1
@@ -1,6 +1,14 @@
|
||||
services:
|
||||
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
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
@@ -12,6 +20,7 @@ services:
|
||||
- HOST_ROOT=/host
|
||||
- DATA_DIR=/app/data
|
||||
- ROLLBACK_MINUTES=${ROLLBACK_MINUTES:-15}
|
||||
- TEST_ROLLBACK_MINUTES=${TEST_ROLLBACK_MINUTES:-2}
|
||||
- AUTO_TEST_INTERVAL_MINUTES=${AUTO_TEST_INTERVAL_MINUTES:-30}
|
||||
volumes:
|
||||
- /etc:/host/etc
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user