Initial commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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 utcnow
|
||||
from app.services import apt, backup, dns, docker_svc, github, pip_npm
|
||||
from app.services.host import run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APPLIERS = {
|
||||
"dns": dns.apply_dns,
|
||||
"apt": apt.apply_apt,
|
||||
"docker": docker_svc.apply_docker,
|
||||
"github": github.apply_github,
|
||||
"pip": pip_npm.apply_pip,
|
||||
"npm": pip_npm.apply_npm,
|
||||
}
|
||||
|
||||
|
||||
def ensure_initial_backup() -> SystemState:
|
||||
state = SystemState.query.first()
|
||||
if not state:
|
||||
state = SystemState()
|
||||
db.session.add(state)
|
||||
db.session.commit()
|
||||
|
||||
if not state.initial_backup_done:
|
||||
path = backup.create_initial_backup()
|
||||
state.initial_backup_path = str(path)
|
||||
state.initial_backup_done = True
|
||||
db.session.commit()
|
||||
logger.info("Initial backup created at %s", path)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def apply_profile(profile: Profile, rollback_minutes: int | None = None) -> ApplyLog:
|
||||
state = ensure_initial_backup()
|
||||
rollback_minutes = rollback_minutes or int(Config.ROLLBACK_MINUTES)
|
||||
|
||||
previous_backup = backup.create_backup(f"before_apply_{profile.id}")
|
||||
apply_log = ApplyLog(
|
||||
profile_id=profile.id,
|
||||
status="running",
|
||||
backup_path=str(previous_backup),
|
||||
previous_backup_path=state.initial_backup_path,
|
||||
rollback_at=utcnow() + timedelta(minutes=rollback_minutes),
|
||||
)
|
||||
db.session.add(apply_log)
|
||||
db.session.flush()
|
||||
|
||||
details: dict = {"steps": [], "profile": profile.name}
|
||||
mirrors = profile.mirrors_by_category()
|
||||
order = ["dns", "apt", "docker", "github", "pip", "npm"]
|
||||
|
||||
for category in order:
|
||||
mirror = mirrors.get(category)
|
||||
if not mirror:
|
||||
continue
|
||||
applier = APPLIERS.get(category)
|
||||
if not applier:
|
||||
continue
|
||||
try:
|
||||
result = applier(mirror)
|
||||
details["steps"].append({"category": category, "mirror": mirror.name, **result})
|
||||
except Exception as exc:
|
||||
logger.exception("Apply failed for %s", category)
|
||||
details["steps"].append(
|
||||
{"category": category, "mirror": mirror.name, "success": False, "error": str(exc)}
|
||||
)
|
||||
|
||||
failed = [s for s in details["steps"] if not s.get("success", True)]
|
||||
if failed and apply_log.backup_path:
|
||||
rollback_result = backup.restore_backup(apply_log.backup_path)
|
||||
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
|
||||
db.session.commit()
|
||||
|
||||
return apply_log
|
||||
|
||||
|
||||
def confirm_apply(apply_log_id: int) -> ApplyLog:
|
||||
apply_log = ApplyLog.query.get_or_404(apply_log_id)
|
||||
apply_log.confirmed = True
|
||||
apply_log.status = "confirmed"
|
||||
apply_log.rollback_at = None
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
state.pending_apply_log_id = None
|
||||
db.session.commit()
|
||||
return apply_log
|
||||
|
||||
|
||||
def rollback_apply(apply_log_id: int) -> dict:
|
||||
apply_log = ApplyLog.query.get_or_404(apply_log_id)
|
||||
if not apply_log.backup_path:
|
||||
return {"success": False, "error": "مسیر backup یافت نشد"}
|
||||
|
||||
result = backup.restore_backup(apply_log.backup_path)
|
||||
apply_log.status = "rolled_back"
|
||||
apply_log.finished_at = utcnow()
|
||||
|
||||
state = SystemState.query.first()
|
||||
if state:
|
||||
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:
|
||||
return {"success": False, "error": "backup اولیه یافت نشد"}
|
||||
return backup.restore_backup(state.initial_backup_path)
|
||||
|
||||
|
||||
def get_system_status() -> dict:
|
||||
codename_result = run_on_host(["lsb_release", "-cs"])
|
||||
version_result = run_on_host(["lsb_release", "-d"])
|
||||
|
||||
return {
|
||||
"ubuntu_codename": codename_result.stdout.strip() if codename_result.returncode == 0 else "unknown",
|
||||
"ubuntu_description": version_result.stdout.strip() if version_result.returncode == 0 else "unknown",
|
||||
"dns": dns.read_current_dns(),
|
||||
"apt": apt.read_current_apt(),
|
||||
"docker": docker_svc.read_current_docker(),
|
||||
"github": github.read_current_github(),
|
||||
"host_root": Config.HOST_ROOT or "(local mode)",
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import get_ubuntu_codename, host_path, run_on_host, validate_codename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOURCES_PATH = "etc/apt/sources.list"
|
||||
SOURCES_D_PATH = "etc/apt/sources.list.d"
|
||||
MIRROR_MARKER = "mirror-manager"
|
||||
|
||||
|
||||
def read_current_apt() -> dict:
|
||||
sources = host_path(SOURCES_PATH)
|
||||
return {
|
||||
"sources_list": sources.read_text(encoding="utf-8") if sources.exists() else None,
|
||||
"path": str(sources),
|
||||
}
|
||||
|
||||
|
||||
def _build_sources_content(base_url: str, codename: str) -> str:
|
||||
url = base_url.rstrip("/")
|
||||
return f"""# Managed by Mirror Manager - {MIRROR_MARKER}
|
||||
deb {url} {codename} main restricted universe multiverse
|
||||
deb {url} {codename}-updates main restricted universe multiverse
|
||||
deb {url} {codename}-backports main restricted universe multiverse
|
||||
deb {url} {codename}-security main restricted universe multiverse
|
||||
"""
|
||||
|
||||
|
||||
def apply_apt(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL میرور APT تعریف نشده"}
|
||||
|
||||
codename = validate_codename(get_ubuntu_codename())
|
||||
sources_path = host_path(SOURCES_PATH)
|
||||
sources_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = _build_sources_content(mirror.url, codename)
|
||||
sources_path.write_text(content, encoding="utf-8")
|
||||
|
||||
sources_d = host_path(SOURCES_D_PATH)
|
||||
if sources_d.exists():
|
||||
for f in sources_d.glob("*.list"):
|
||||
if MIRROR_MARKER not in f.read_text(encoding="utf-8", errors="ignore"):
|
||||
backup_name = f.with_suffix(".list.disabled")
|
||||
f.rename(backup_name)
|
||||
|
||||
result = run_on_host(["apt-get", "update", "-qq"], timeout=180)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (result.stderr or result.stdout or "apt-get update ناموفق")[:500],
|
||||
"codename": codename,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"APT mirror اعمال شد: {mirror.name}",
|
||||
"codename": codename,
|
||||
"url": mirror.url,
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_TARGETS = {
|
||||
"resolved.conf": "etc/systemd/resolved.conf",
|
||||
"resolved.conf.d": "etc/systemd/resolved.conf.d",
|
||||
"sources.list": "etc/apt/sources.list",
|
||||
"sources.list.d": "etc/apt/sources.list.d",
|
||||
"daemon.json": "etc/docker/daemon.json",
|
||||
"gitconfig_system": "etc/gitconfig",
|
||||
}
|
||||
|
||||
|
||||
def _backup_dir(name: str) -> Path:
|
||||
path = Config.BACKUP_DIR / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
if not src.exists():
|
||||
return
|
||||
if src.is_dir():
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _restore_file(relative: str, backup_root: Path) -> None:
|
||||
key = relative.replace("/", "_").replace(".", "_")
|
||||
for name, rel in BACKUP_TARGETS.items():
|
||||
if rel == relative or rel.endswith(relative):
|
||||
key = name
|
||||
break
|
||||
|
||||
src = backup_root / key
|
||||
dst = host_path(relative)
|
||||
if not src.exists():
|
||||
if dst.exists() and key in ("daemon.json", "gitconfig_system"):
|
||||
dst.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
if src.is_dir():
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def create_backup(label: str = "manual") -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_root = _backup_dir(f"{timestamp}_{label}")
|
||||
manifest: dict = {"label": label, "timestamp": timestamp, "files": {}}
|
||||
|
||||
for key, relative in BACKUP_TARGETS.items():
|
||||
src = host_path(relative)
|
||||
dst = backup_root / key
|
||||
_copy_if_exists(src, dst)
|
||||
manifest["files"][key] = {
|
||||
"relative": relative,
|
||||
"existed": src.exists(),
|
||||
}
|
||||
|
||||
gitconfig_global = host_path("root/.gitconfig")
|
||||
if not gitconfig_global.exists():
|
||||
gitconfig_global = Path.home() / ".gitconfig"
|
||||
if gitconfig_global.exists():
|
||||
shutil.copy2(gitconfig_global, backup_root / "gitconfig_global")
|
||||
manifest["files"]["gitconfig_global"] = {"path": str(gitconfig_global), "existed": True}
|
||||
|
||||
pip_conf = host_path("etc/pip.conf")
|
||||
if pip_conf.exists():
|
||||
shutil.copy2(pip_conf, backup_root / "pip.conf")
|
||||
manifest["files"]["pip.conf"] = {"relative": "etc/pip.conf", "existed": True}
|
||||
|
||||
npmrc = host_path("root/.npmrc")
|
||||
if npmrc.exists():
|
||||
shutil.copy2(npmrc, backup_root / "npmrc")
|
||||
manifest["files"]["npmrc"] = {"relative": "root/.npmrc", "existed": True}
|
||||
|
||||
mirror_dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
||||
if mirror_dropin.exists():
|
||||
shutil.copy2(mirror_dropin, backup_root / "mirror_manager_resolved.conf")
|
||||
|
||||
with open(backup_root / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info("Backup created at %s", backup_root)
|
||||
return backup_root
|
||||
|
||||
|
||||
def restore_backup(backup_root: Path | str) -> dict:
|
||||
backup_root = Path(backup_root)
|
||||
if not backup_root.exists():
|
||||
return {"success": False, "error": "مسیر backup یافت نشد"}
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
for key, relative in BACKUP_TARGETS.items():
|
||||
src = backup_root / key
|
||||
if src.exists():
|
||||
dst = host_path(relative)
|
||||
_copy_if_exists(src, dst)
|
||||
results.append(f"بازگردانی {relative}")
|
||||
|
||||
gitconfig_backup = backup_root / "gitconfig_global"
|
||||
if gitconfig_backup.exists():
|
||||
for target in [host_path("root/.gitconfig"), host_path("home/.gitconfig")]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(gitconfig_backup, target)
|
||||
results.append("بازگردانی gitconfig global")
|
||||
|
||||
pip_backup = backup_root / "pip.conf"
|
||||
if pip_backup.exists():
|
||||
dst = host_path("etc/pip.conf")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(pip_backup, dst)
|
||||
results.append("بازگردانی pip.conf")
|
||||
|
||||
npmrc_backup = backup_root / "npmrc"
|
||||
if npmrc_backup.exists():
|
||||
dst = host_path("root/.npmrc")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(npmrc_backup, dst)
|
||||
results.append("بازگردانی .npmrc")
|
||||
|
||||
dropin = host_path("etc/systemd/resolved.conf.d/mirror-manager.conf")
|
||||
mirror_backup = backup_root / "mirror_manager_resolved.conf"
|
||||
if mirror_backup.exists():
|
||||
dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(mirror_backup, dropin)
|
||||
elif dropin.exists():
|
||||
dropin.unlink()
|
||||
|
||||
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
run_on_host(["systemctl", "restart", "docker"])
|
||||
|
||||
return {"success": True, "restored": results}
|
||||
|
||||
|
||||
def create_initial_backup() -> Path:
|
||||
return create_backup("initial")
|
||||
|
||||
|
||||
def list_backups() -> list[dict]:
|
||||
backups = []
|
||||
if not Config.BACKUP_DIR.exists():
|
||||
return backups
|
||||
for path in sorted(Config.BACKUP_DIR.iterdir(), reverse=True):
|
||||
if path.is_dir():
|
||||
manifest_path = path / "manifest.json"
|
||||
label = path.name
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
label = manifest.get("label", path.name)
|
||||
backups.append({"path": str(path), "name": path.name, "label": label})
|
||||
return backups
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DROPIN_FILENAME = "mirror-manager.conf"
|
||||
|
||||
|
||||
def read_current_dns() -> dict:
|
||||
dropin = host_path("etc/systemd/resolved.conf.d", DROPIN_FILENAME)
|
||||
main_conf = host_path("etc/systemd/resolved.conf")
|
||||
return {
|
||||
"dropin_exists": dropin.exists(),
|
||||
"dropin_path": str(dropin),
|
||||
"main_conf_exists": main_conf.exists(),
|
||||
"content": dropin.read_text(encoding="utf-8") if dropin.exists() else None,
|
||||
}
|
||||
|
||||
|
||||
def apply_dns(mirror: Mirror) -> dict:
|
||||
ips = mirror.get_ips()
|
||||
if not ips:
|
||||
return {"success": False, "error": "آدرس DNS تعریف نشده"}
|
||||
|
||||
dropin_dir = host_path("etc/systemd/resolved.conf.d")
|
||||
dropin_dir.mkdir(parents=True, exist_ok=True)
|
||||
dropin_path = dropin_dir / DROPIN_FILENAME
|
||||
|
||||
dns_line = " ".join(ips)
|
||||
content = f"""# Managed by Mirror Manager - do not edit manually
|
||||
[Resolve]
|
||||
DNS={dns_line}
|
||||
FallbackDNS=1.1.1.1 8.8.8.8
|
||||
DNSStubListener=yes
|
||||
"""
|
||||
|
||||
dropin_path.write_text(content, encoding="utf-8")
|
||||
|
||||
result = run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.stderr or "خطا در restart systemd-resolved",
|
||||
"applied_content": content,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"DNS اعمال شد: {mirror.name}",
|
||||
"ips": ips,
|
||||
"path": str(dropin_path),
|
||||
}
|
||||
|
||||
|
||||
def remove_dns_dropin() -> None:
|
||||
dropin = host_path("etc/systemd/resolved.conf.d", DROPIN_FILENAME)
|
||||
if dropin.exists():
|
||||
dropin.unlink()
|
||||
run_on_host(["systemctl", "restart", "systemd-resolved"])
|
||||
@@ -0,0 +1,57 @@
|
||||
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ها موقتاً قطع میشوند",
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path, run_on_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MARKER = "mirror-manager"
|
||||
INSTEAD_SECTION = f'url "https://github.com/" managed by {MARKER}'
|
||||
|
||||
|
||||
def _gitconfig_paths() -> list[Path]:
|
||||
paths = []
|
||||
for rel in ("root/.gitconfig", "home/.gitconfig"):
|
||||
p = host_path(rel)
|
||||
if p not in paths:
|
||||
paths.append(p)
|
||||
local = Path.home() / ".gitconfig"
|
||||
if local not in paths:
|
||||
paths.append(local)
|
||||
system = host_path("etc/gitconfig")
|
||||
if system not in paths:
|
||||
paths.append(system)
|
||||
return paths
|
||||
|
||||
|
||||
def read_current_github() -> dict:
|
||||
configs = {}
|
||||
for path in _gitconfig_paths():
|
||||
if path.exists():
|
||||
configs[str(path)] = path.read_text(encoding="utf-8")
|
||||
return configs
|
||||
|
||||
|
||||
def _parse_gitconfig(content: str) -> configparser.ConfigParser:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read_string(content if content.strip() else "[core]\n")
|
||||
return parser
|
||||
|
||||
|
||||
def _serialize_gitconfig(parser: configparser.ConfigParser) -> str:
|
||||
buf = StringIO()
|
||||
parser.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _remove_mirror_sections(parser: configparser.ConfigParser) -> None:
|
||||
to_remove = []
|
||||
for section in parser.sections():
|
||||
if not section.startswith('url "'):
|
||||
continue
|
||||
instead_of = parser.get(section, "insteadOf", fallback="")
|
||||
if instead_of in ("https://github.com/", "git@github.com:"):
|
||||
to_remove.append(section)
|
||||
for section in to_remove:
|
||||
parser.remove_section(section)
|
||||
|
||||
|
||||
def apply_github(mirror: Mirror) -> dict:
|
||||
meta = mirror.get_meta()
|
||||
instead_prefix = meta.get("instead_prefix") or mirror.url
|
||||
if not instead_prefix:
|
||||
return {"success": False, "error": "prefix میرور GitHub تعریف نشده"}
|
||||
|
||||
instead_prefix = instead_prefix.rstrip("/") + "/"
|
||||
section_name = f'url "{instead_prefix}"'
|
||||
|
||||
applied_paths = []
|
||||
for path in _gitconfig_paths():
|
||||
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
parser = _parse_gitconfig(content)
|
||||
_remove_mirror_sections(parser)
|
||||
|
||||
if not parser.has_section(section_name):
|
||||
parser.add_section(section_name)
|
||||
parser.set(section_name, "insteadOf", "https://github.com/")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(_serialize_gitconfig(parser), encoding="utf-8")
|
||||
applied_paths.append(str(path))
|
||||
|
||||
test = run_on_host(
|
||||
["git", "ls-remote", "https://github.com/octocat/Hello-World.git", "HEAD"],
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": test.returncode == 0,
|
||||
"message": f"GitHub mirror اعمال شد: {mirror.name}",
|
||||
"paths": applied_paths,
|
||||
"test_output": (test.stdout or test.stderr)[:300] if test.returncode != 0 else "git ls-remote موفق",
|
||||
"error": test.stderr[:300] if test.returncode != 0 else None,
|
||||
}
|
||||
|
||||
|
||||
def remove_github_config() -> None:
|
||||
for path in _gitconfig_paths():
|
||||
if not path.exists():
|
||||
continue
|
||||
parser = _parse_gitconfig(path.read_text(encoding="utf-8"))
|
||||
_remove_mirror_sections(parser)
|
||||
path.write_text(_serialize_gitconfig(parser), encoding="utf-8")
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def host_path(*parts: str) -> Path:
|
||||
return Config.host_path(*parts)
|
||||
|
||||
|
||||
def is_container_mode() -> bool:
|
||||
return bool(Config.HOST_ROOT) and Path(Config.HOST_ROOT).exists()
|
||||
|
||||
|
||||
def run_on_host(command: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
"""Run a command on the host when inside a privileged container."""
|
||||
if is_container_mode():
|
||||
full_cmd = ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--"] + command
|
||||
else:
|
||||
full_cmd = command
|
||||
return subprocess.run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def get_ubuntu_codename() -> str:
|
||||
result = run_on_host(["lsb_release", "-cs"])
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "noble"
|
||||
|
||||
|
||||
def get_ubuntu_version() -> str:
|
||||
result = run_on_host(["lsb_release", "-rs"])
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "24.04"
|
||||
|
||||
|
||||
SUPPORTED_CODENAMES = {"noble", "jammy", "bionic"}
|
||||
|
||||
|
||||
def validate_codename(codename: str) -> str:
|
||||
if codename in SUPPORTED_CODENAMES:
|
||||
return codename
|
||||
mapping = {"24.04": "noble", "22.04": "jammy", "18.04": "bionic"}
|
||||
version = get_ubuntu_version()
|
||||
return mapping.get(version, "noble")
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import Mirror
|
||||
from app.services.host import host_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_pip(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL میرور pip تعریف نشده"}
|
||||
|
||||
conf_path = host_path("etc/pip.conf")
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"""# Managed by Mirror Manager
|
||||
[global]
|
||||
index-url = {mirror.url.rstrip("/")}
|
||||
trusted-host = {mirror.url.split("//")[-1].split("/")[0]}
|
||||
"""
|
||||
conf_path.write_text(content, encoding="utf-8")
|
||||
return {"success": True, "message": f"pip mirror اعمال شد: {mirror.name}", "path": str(conf_path)}
|
||||
|
||||
|
||||
def apply_npm(mirror: Mirror) -> dict:
|
||||
if not mirror.url:
|
||||
return {"success": False, "error": "URL رجیstry npm تعریف نشده"}
|
||||
|
||||
npmrc = host_path("root/.npmrc")
|
||||
npmrc.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# Managed by Mirror Manager\nregistry={mirror.url.rstrip('/')}\n"
|
||||
npmrc.write_text(content, encoding="utf-8")
|
||||
return {"success": True, "message": f"npm registry اعمال شد: {mirror.name}", "path": str(npmrc)}
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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.tester import get_best_mirror, test_mirror
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def _auto_switch_enabled(category: str) -> bool:
|
||||
return Setting.get(f"auto_switch_{category}", "false").lower() == "true"
|
||||
|
||||
|
||||
def _get_switch_count_today() -> int:
|
||||
key = f"switch_count_{utcnow().strftime('%Y%m%d')}"
|
||||
return int(Setting.get(key, "0") or "0")
|
||||
|
||||
|
||||
def _increment_switch_count() -> None:
|
||||
key = f"switch_count_{utcnow().strftime('%Y%m%d')}"
|
||||
Setting.set(key, str(_get_switch_count_today() + 1))
|
||||
|
||||
|
||||
def check_pending_rollbacks(app) -> None:
|
||||
with app.app_context():
|
||||
now = utcnow()
|
||||
pending = ApplyLog.query.filter(
|
||||
ApplyLog.status == "pending_confirm",
|
||||
ApplyLog.confirmed.is_(False),
|
||||
ApplyLog.rollback_at.isnot(None),
|
||||
ApplyLog.rollback_at <= now,
|
||||
).all()
|
||||
|
||||
for log in pending:
|
||||
logger.info("Auto rollback for apply log %s", log.id)
|
||||
rollback_apply(log.id)
|
||||
|
||||
|
||||
def run_auto_switch(app) -> None:
|
||||
with app.app_context():
|
||||
max_switches = int(Setting.get("max_switches_per_day", "3") or "3")
|
||||
if _get_switch_count_today() >= max_switches:
|
||||
logger.info("Max switches per day reached")
|
||||
return
|
||||
|
||||
state = SystemState.query.first()
|
||||
if not state or not state.current_profile_id:
|
||||
return
|
||||
|
||||
profile = Profile.query.get(state.current_profile_id)
|
||||
if not profile:
|
||||
return
|
||||
|
||||
for category in ("dns", "apt", "docker", "github", "pip", "npm"):
|
||||
if not _auto_switch_enabled(category):
|
||||
continue
|
||||
|
||||
item = ProfileItem.query.filter_by(profile_id=profile.id, category=category).first()
|
||||
if not item or not item.mirror:
|
||||
continue
|
||||
|
||||
results = test_mirror(item.mirror)
|
||||
if results and all(r.success for r in results):
|
||||
continue
|
||||
|
||||
best = get_best_mirror(category)
|
||||
if not best or best.id == item.mirror_id:
|
||||
if Setting.get("rollback_on_all_fail", "true").lower() == "true":
|
||||
logger.warning("All mirrors failed for %s", category)
|
||||
continue
|
||||
|
||||
item.mirror_id = best.id
|
||||
db.session.commit()
|
||||
apply_profile(profile)
|
||||
_increment_switch_count()
|
||||
logger.info("Auto-switched %s to %s", category, best.name)
|
||||
break
|
||||
|
||||
|
||||
def init_scheduler(app) -> None:
|
||||
global _scheduler
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
interval = int(app.config.get("AUTO_TEST_INTERVAL_MINUTES", 30))
|
||||
_scheduler = BackgroundScheduler(daemon=True)
|
||||
_scheduler.add_job(
|
||||
check_pending_rollbacks,
|
||||
"interval",
|
||||
minutes=1,
|
||||
args=[app],
|
||||
id="rollback_checker",
|
||||
)
|
||||
_scheduler.add_job(
|
||||
run_auto_switch,
|
||||
"interval",
|
||||
minutes=interval,
|
||||
args=[app],
|
||||
id="auto_switch",
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler started (interval=%s min)", interval)
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Mirror, TestResult
|
||||
from app.services.host import get_ubuntu_codename, run_on_host, validate_codename
|
||||
|
||||
|
||||
def _record(mirror: Mirror, test_type: str, success: bool, latency_ms: float | None, error: str | None) -> TestResult:
|
||||
result = TestResult(
|
||||
mirror_id=mirror.id,
|
||||
test_type=test_type,
|
||||
success=success,
|
||||
latency_ms=latency_ms,
|
||||
error=error,
|
||||
)
|
||||
db.session.add(result)
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def test_dns(mirror: Mirror) -> TestResult:
|
||||
ips = mirror.get_ips()
|
||||
if not ips:
|
||||
return _record(mirror, "dns_resolve", False, None, "IP تعریف نشده")
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
for domain in ("github.com", "docker.io"):
|
||||
socket.getaddrinfo(domain, 443, type=socket.SOCK_STREAM)
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
return _record(mirror, "dns_resolve", True, latency, None)
|
||||
except socket.gaierror as exc:
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
return _record(mirror, "dns_resolve", False, latency, str(exc))
|
||||
|
||||
|
||||
def test_tcp(url: str, timeout: int = 10) -> tuple[bool, float | None, str | None]:
|
||||
parsed = urlparse(url if "://" in url else f"https://{url}")
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
if not host:
|
||||
return False, None, "host نامعتبر"
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
sock = socket.create_connection((host, port), timeout=timeout)
|
||||
sock.close()
|
||||
return True, (time.perf_counter() - start) * 1000, None
|
||||
except OSError as exc:
|
||||
return False, (time.perf_counter() - start) * 1000, str(exc)
|
||||
|
||||
|
||||
def test_http(url: str, timeout: int = 15) -> tuple[bool, float | None, str | None]:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = requests.head(url.rstrip("/"), timeout=timeout, allow_redirects=True)
|
||||
if response.status_code >= 500:
|
||||
return False, (time.perf_counter() - start) * 1000, f"HTTP {response.status_code}"
|
||||
return True, (time.perf_counter() - start) * 1000, None
|
||||
except requests.RequestException as exc:
|
||||
return False, (time.perf_counter() - start) * 1000, str(exc)
|
||||
|
||||
|
||||
def test_apt(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "apt_release", False, None, "URL تعریف نشده")
|
||||
|
||||
codename = validate_codename(get_ubuntu_codename())
|
||||
release_url = f"{mirror.url.rstrip('/')}/dists/{codename}/Release"
|
||||
ok, latency, error = test_http(release_url)
|
||||
return _record(mirror, "apt_release", ok, latency, error)
|
||||
|
||||
|
||||
def test_docker(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "docker_registry", False, None, "URL تعریف نشده")
|
||||
|
||||
api_url = f"{mirror.url.rstrip('/')}/v2/"
|
||||
ok, latency, error = test_http(api_url)
|
||||
return _record(mirror, "docker_registry", ok, latency, error)
|
||||
|
||||
|
||||
def test_github(mirror: Mirror) -> TestResult:
|
||||
meta = mirror.get_meta()
|
||||
prefix = meta.get("instead_prefix") or mirror.url
|
||||
if not prefix:
|
||||
return _record(mirror, "github_ls_remote", False, None, "prefix تعریف نشده")
|
||||
|
||||
test_url = f"{prefix.rstrip('/')}/octocat/Hello-World.git"
|
||||
start = time.perf_counter()
|
||||
result = run_on_host(["git", "ls-remote", test_url, "HEAD"], timeout=45)
|
||||
latency = (time.perf_counter() - start) * 1000
|
||||
if result.returncode == 0:
|
||||
return _record(mirror, "github_ls_remote", True, latency, None)
|
||||
return _record(mirror, "github_ls_remote", False, latency, (result.stderr or result.stdout)[:300])
|
||||
|
||||
|
||||
def test_pip(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "pip_index", False, None, "URL تعریف نشده")
|
||||
ok, latency, error = test_http(mirror.url)
|
||||
return _record(mirror, "pip_index", ok, latency, error)
|
||||
|
||||
|
||||
def test_npm(mirror: Mirror) -> TestResult:
|
||||
if not mirror.url:
|
||||
return _record(mirror, "npm_registry", False, None, "URL تعریف نشده")
|
||||
ok, latency, error = test_http(mirror.url)
|
||||
return _record(mirror, "npm_registry", ok, latency, error)
|
||||
|
||||
|
||||
def test_mirror(mirror: Mirror) -> list[TestResult]:
|
||||
testers = {
|
||||
"dns": [test_dns],
|
||||
"apt": [test_apt],
|
||||
"docker": [test_docker],
|
||||
"github": [test_github],
|
||||
"pip": [test_pip],
|
||||
"npm": [test_npm],
|
||||
}
|
||||
results = []
|
||||
for fn in testers.get(mirror.category, []):
|
||||
results.append(fn(mirror))
|
||||
return results
|
||||
|
||||
|
||||
def test_all_enabled(category: str | None = None) -> list[TestResult]:
|
||||
query = Mirror.query.filter_by(enabled=True)
|
||||
if category:
|
||||
query = query.filter_by(category=category)
|
||||
all_results: list[TestResult] = []
|
||||
for mirror in query.order_by(Mirror.priority).all():
|
||||
all_results.extend(test_mirror(mirror))
|
||||
return all_results
|
||||
|
||||
|
||||
def get_best_mirror(category: str) -> Mirror | None:
|
||||
mirrors = Mirror.query.filter_by(category=category, enabled=True).order_by(Mirror.priority).all()
|
||||
best: Mirror | None = None
|
||||
best_latency = float("inf")
|
||||
|
||||
for mirror in mirrors:
|
||||
results = test_mirror(mirror)
|
||||
if not results:
|
||||
continue
|
||||
if all(r.success for r in results):
|
||||
avg_latency = sum(r.latency_ms or 9999 for r in results) / len(results)
|
||||
if avg_latency < best_latency:
|
||||
best_latency = avg_latency
|
||||
best = mirror
|
||||
return best
|
||||
Reference in New Issue
Block a user