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
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
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
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"]
+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** بروید
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
View File
@@ -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")
+1
View File
@@ -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
View File
@@ -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>
""",
+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_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", {}))
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+72 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -2
View File
@@ -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
+75 -53
View File
@@ -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>
+79 -62
View File
@@ -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">
+37 -39
View File
@@ -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>
+77 -59
View File
@@ -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>
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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()