# coding: utf-8 # ------------------------------------------------------------------- # aaPanel # ------------------------------------------------------------------- # Copyright (c) 2014-2099 aaPanel(www.aapanel.com) All rights reserved. # ------------------------------------------------------------------- # Author: aapanel # ------------------------------------------------------------------- # ------------------------------ # migrate api # ------------------------------ import glob import json import os import shutil import sys import time import uuid if "/www/server/panel" not in sys.path: sys.path.insert(0, "/www/server/panel") if "/www/server/panel/class" not in sys.path: sys.path.insert(0, "/www/server/panel/class") if "/www/server/panel/class_v2" not in sys.path: sys.path.insert(0, "/www/server/panel/class_v2") import public from public.validate import Param from mod.project.migrate.helper import * MIGRATE_LOG = "/tmp/cp_to_aa_migrate.log" ABS_PATH = os.path.dirname(os.path.abspath(__file__)) class main: def __init__(self): pass def connect_cp_verify(self, get): """测试连接""" try: get.validate([ Param("host").String().Require(), Param("password").String().Require(), Param("port").Integer().Require(), ], [ public.validate.trim_filter(), ]) get.port = int(get.port) except Exception as ex: public.print_log("error info: {}".format(ex)) return public.return_message(-1, 0, str(ex)) args = { "host": get.host, "auth": {"password": get.password}, "port": int(get.port), } try: with CpanelSSHManager(**args) as _: pass return public.success_v2("success") except Exception as e: return public.fail_v2(f"Failed to connect to server: {e}") def cp_users_info(self, get): """获取用户""" try: get.validate([ Param("host").String().Require(), Param("password").String().Require(), Param("port").Integer().Require(), ], [ public.validate.trim_filter(), ]) get.port = int(get.port) except Exception as ex: public.print_log("error info: {}".format(ex)) return public.return_message(-1, 0, str(ex)) args = { "host": get.host, "auth": {"password": get.password}, "port": int(get.port), } try: res = [] with CpanelSSHManager(**args) as ssh: res = ssh.get_cp_user_info() return public.success_v2(res) except Exception as e: return public.fail_v2(f"Failed to get cPanel: {str(e)}") def cp_migrate_info(self, get): """迁移详情""" try: get.validate([ Param("host").String().Require(), Param("password").String().Require(), Param("port").Integer().Require(), Param("users_info").String().Require(), ], [ public.validate.trim_filter(), ]) get.port = int(get.port) get.users_info = json.loads(get.users_info) if not isinstance(get.users_info, list): raise Exception("users_info must be a json string list") # init data for u in get.users_info: u['data'] = {} except Exception as ex: public.print_log("error info: {}".format(ex)) return public.return_message(-1, 0, str(ex)) # 删除上次所有临时文件 for file in glob.glob(os.path.join(ABS_PATH, "migrate_*.json")): try: os.remove(file) except: pass for file in glob.glob(os.path.join(ABS_PATH, "progress_*.json")): try: os.remove(file) except: pass args = { "host": get.host, "auth": {"password": get.password}, "port": get.port, } users_info = get.users_info try: usage = shutil.disk_usage("/www").free except Exception as e: try: stat = os.statvfs("/www") usage = stat.f_bavail * stat.f_frsize except: raise Exception(f"Failed to get local disk free space: {e}") res = { "local_disk_free": usage, "remote_disk_free": 0, "detail": [], "host": args["host"], "auth": args["auth"], "port": args["port"], } try: with CpanelSSHManager(**args) as ssh: res["remote_disk_free"] = ssh.get_remote_disk_free() or 0 wp_data = ssh.get_cp_user_wp(users_info) ssl_data = ssh.get_cp_user_ssl(users_info) temp = [] for u in users_info: item: dict = dict(u) item['data'][WpMigrate.task_name] = wp_data.get(u["user"], []) item['data'][SslMigrate.task_name] = ssl_data.get(u["user"], []) temp.append(item) # 注入唯一 _id temp = inject_item_ids(temp) task_id = str(uuid.uuid4())[:8] res["detail"] = temp res["task_id"] = task_id res["timestamp"] = int(time.time()) migrate_info_path = os.path.join(ABS_PATH, f"migrate_{task_id}.json") public.writeFile(migrate_info_path, json.dumps(res, indent=2)) # 只保留 user + 业务数据 res["detail"] = [ {"user": d.get("user", ""), "data": d.get("data", {})} for d in res["detail"] ] try: res.pop("auth", None) res.pop("host", None) res.pop("port", None) except: pass return public.success_v2(res) except Exception as e: import traceback public.print_log(traceback.format_exc()) return public.fail_v2(f"Failed to get cPanel: {str(e)}") def cp_migrate_start(self, get): try: get.validate([ Param("task_id").String().Require(), Param("id_list").String().Require(), ], [ public.validate.trim_filter(), ]) get.id_list = json.loads(get.id_list) if not isinstance(get.id_list, list): raise Exception("id_list must be a json string list") except Exception as ex: public.print_log("error info: {}".format(ex)) return public.return_message(-1, 0, str(ex)) # ========== 简单检查环境 ========== from panelModelV2.publicModel import main as public_model mysql_check = public_model().get_soft_status(public.to_dict_obj({"name": "mysql"})) if not mysql_check.get("message", {}).get("setup"): return public.fail_v2("MySQL is not properly set up, please complete MySQL setup and try again") from panel_site_v2 import panelSite php_check = panelSite().GetPHPVersion(public.to_dict_obj({}), is_http=False) if len(php_check) <= 1: return public.fail_v2("No PHP versions found, please set up PHP and try again") script = os.path.join(ABS_PATH, "service.py") # ========== 进程检测 ========== # 检测 service.py 进程是否在运行 def is_process_running(): cmd = f"pgrep -f '{script}'" result, _ = public.ExecShell(cmd, timeout=5) return bool(result.strip()) process_running = is_process_running() flag_exists = os.path.exists(WORK_FLAG) # 标志存在,检查进程状态 if flag_exists: if process_running: # 正常 return public.fail_v2("Another migration task is currently running, please try again later") else: # 僵尸标志, 标志残留 public.ExecShell(f"rm -f '{WORK_FLAG}'") # 进程在运行但标志不存在 if process_running and not flag_exists: public.writeFile(WORK_FLAG, "1") return public.fail_v2("Migration task is already running, please try again later") # ========== 正常启动流程 ========== err_msg = public.lang("please refresh the page and try again") task_id = get.task_id migrate_info_path = os.path.join(ABS_PATH, f"migrate_{task_id}.json") if not os.path.exists(migrate_info_path): return public.fail_v2(err_msg) migrate_info_str = public.readFile(migrate_info_path) if not migrate_info_str: public.ExecShell(f"rm -f '{migrate_info_path}'") return public.fail_v2(err_msg) try: migrate_info = json.loads(migrate_info_str) info_timestamp = int(migrate_info.get('timestamp', 0)) if not info_timestamp or (int(time.time()) - info_timestamp > 3600): raise Exception("migrate_info expired") except: public.ExecShell(f"rm -f '{migrate_info_path}'") return public.fail_v2(err_msg) if migrate_info.get("task_id") != get.task_id: return public.fail_v2(f"Migration task ID mismatch, {err_msg}") # 过滤需要迁移的数据 id_set = set(get.id_list) for user_detail in migrate_info.get("detail", []): data = user_detail.get("data", {}) for key, val in data.items(): if isinstance(val, list): # 选择的 data[key] = [ item for item in val if item.get("_id") in id_set ] # 检查是否有需要迁移的数据 if not any( item for user_detail in migrate_info.get("detail", []) for item in user_detail.get("data", {}).values() if isinstance(item, list) and item ): return public.fail_v2("No migration data selected") # ========== 计算硬盘占用 ========== # 迁移过程: # WP: # 1. 每个 WP 站点 -> {domain}.tar.gz # 2. 所有站点 tar.gz -> 单 tar.gz # 3. 下载大 tar.gz # 4. 解压单 tar.gz -> 各站点 tar.gz -> 恢复 # SSL:... total_disk_usage = 0 for user_detail in migrate_info.get("detail", []): for items in user_detail.get("data", {}).values(): if not isinstance(items, list): continue for item in items: if "disk_usage" in item: total_disk_usage += item.get("disk_usage", 0) # tar -czf 压缩率约 40% site_tars_total = int(total_disk_usage * 0.5) # 所有站点 tar.gz 总和 big_tar = int(site_tars_total * 0.5) # 大 tar.gz # 远端需要空间: 所有站点 tar.gz (大 tar.gz 打包是流式的, 不需要额外副本) remote_required = site_tars_total # 本地需要空间: 大 tar.gz 下载 + 解压后所有站点 tar.gz local_required = big_tar + site_tars_total # 使用 migrate_info 中已有的空间信息 local_disk_free = migrate_info.get("local_disk_free", 0) remote_disk_free = migrate_info.get("remote_disk_free", 0) # 检查远端空间 if remote_disk_free < remote_required: from mod.project.migrate.helper.tools import format_disk_size return public.fail_v2( "not enough disk space on Remote Server. required: {}, available: {}".format( format_disk_size(remote_required / 1024), format_disk_size(remote_disk_free / 1024) ) ) # 检查本地空间 if local_disk_free < local_required: from mod.project.migrate.helper.tools import format_disk_size return public.fail_v2( "not enough disk space on Local Server. required: {}, available: {}".format( format_disk_size(local_required / 1024), format_disk_size(local_disk_free / 1024) ) ) migrate_info['timestamp'] = int(time.time()) # 覆盖任务信息 public.writeFile(migrate_info_path, json.dumps(migrate_info, indent=2)) # 创建标志,写入 task_id public.writeFile(WORK_FLAG, task_id) # 启动迁移 logger = MigrateLogger() logger.info("Migrate Task has been added to queue, waiting for execution...", prefix="") public.ExecShell( f"nohup {public.get_python_bin()} -u {script} '{task_id}' > /dev/null 2>&1 &" ) public.set_module_logs("migrate_cpanel", "start", 1) return public.success_v2(public.lang("Migrate Task has been started!")) def get_migrate_status(self, get): """获取当前迁移工作状态.""" body = { "running": False, "logs": "", "progress": {}, } if not os.path.exists(WORK_FLAG): return public.success_v2(body) task_id = public.readFile(WORK_FLAG).strip() if not task_id: return public.success_v2(body) if not hasattr(get, "task_id") or not get.task_id: get.task_id = task_id body["running"] = True # 日志 logs = "" if os.path.exists(MIGRATE_LOG): LineCount = 50 try: with open(MIGRATE_LOG, "rb") as f: f.seek(0, 2) file_size = f.tell() pos = file_size lines = [] while len(lines) < LineCount + 1 and pos > 0: read_size = min(4096, pos) pos -= read_size f.seek(pos) chunk = f.read(read_size) lines = chunk.split(b'\n') + lines # 最后N行 last_lines = [ line.decode("utf-8", errors="ignore").strip() for line in lines[-(LineCount + 1):-1] if line.strip() ] logs = "\n".join(last_lines) except: pass body["logs"] = logs # 读取进度 progress_path = os.path.join(ABS_PATH, f"progress_{get.task_id}.json") progress_obj = MigrateProgress.from_file(progress_path) if not progress_obj: return public.success_v2(body) progress_data = progress_obj.get() # 最后一行作为 message title if logs and progress_data: last_log = logs.split("\n")[-1].strip() if last_log: # 格式: [时间戳] [用户进度] 符号 消息 msg = last_log while msg.startswith("[") and "]" in msg: msg_part: list = msg.split("]", 1) if len(msg_part) > 1: msg = msg_part[1].lstrip() # 移除前缀符号 msg = msg.replace(TOP, "").replace(MIDDLE, "").replace(END, "").strip() progress_data["message"] = msg body["progress"] = progress_data return public.success_v2(body) def cancel_migrate(self, get): """取消当前迁移任务""" if not os.path.exists(WORK_FLAG): return public.fail_v2("No migration task is running") task_id = public.readFile(WORK_FLAG).strip() if not task_id: public.ExecShell(f"rm -f '{WORK_FLAG}'") return public.fail_v2("Invalid task state") migrate_path = os.path.join(ABS_PATH, f"migrate_{task_id}.json") if not os.path.exists(migrate_path): # 任务文件不存在,清理 WORK_FLAG public.ExecShell(f"rm -f '{WORK_FLAG}'") return public.fail_v2("Task info file not found") # 先删除 WORK_FLAG # get_migrate_status 立即显示未运行 public.ExecShell(f"rm -f '{WORK_FLAG}'") # kill进程 script = os.path.join(ABS_PATH, "service.py") cmd = f"pkill -9 -f '{script}'" public.ExecShell(cmd) # clean clean_script = os.path.join(ABS_PATH, "helper", "clean.py") python_bin = public.get_python_bin() cmd = f"nohup {python_bin} -u {clean_script} {task_id} > /dev/null 2>&1 &" public.ExecShell(cmd) return public.success_v2("Migration task has been cancelled")