# coding: utf-8 # ------------------------------------------------------------------- # YakPanel # ------------------------------------------------------------------- # Copyright (c) 2014-2099 YakPanel(www.yakpanel.com) All rights reserved. # ------------------------------------------------------------------- # Author: yakpanel # ------------------------------------------------------------------- # ------------------------------ # py service model app # ------------------------------ import json import os.path import re import sys import time import psutil from uuid import uuid4 from typing import Optional, Union, List, Dict, Tuple, Any, Set SERVICE_PATH = "/www/server/python_project/service" if not os.path.isdir(SERVICE_PATH): try: os.makedirs(SERVICE_PATH, 0o755) except: pass if "/www/server/panel/class" not in sys.path: sys.path.insert(0, "/www/server/panel/class") if "/www/server/panel" not in sys.path: sys.path.insert(0, "/www/server/panel") import public from mod.base import json_response from mod.project.python.pyenv_tool import EnvironmentManager from public.exceptions import HintException class Environment(object): def __init__(self, project_name: str, python_path: str, python_bin: str, project_path: str, user: str, env_list: List[Dict[str, str]], env_file: str): self.python_path = python_path self.project_path = project_path self.env_list = env_list self.env_file = env_file self.project_name = project_name self.user = user self._env_cache: Optional[str] = None self.pyenv = EnvironmentManager().get_env_py_path(python_bin) @classmethod def form_project_conf(cls, project_config: dict) -> Union["Environment", str]: if not isinstance(project_config, dict): return 'Invalid project configuration file format' python_path: str = project_config.get("vpath") python_bin: str = project_config.get("python_bin", project_config.get("vpath")) project_path: str = project_config.get("path") env_list = project_config.get("env_list", []) env_file = project_config.get("env_file", "") project_name = project_config.get("pjname") user = project_config.get("user", "root") if not python_path or not project_path or not project_name: return 'Invalid project configuration file format' if not os.path.isdir(python_path) or not os.path.isdir(project_path): return 'The project directory or virtual environment directory specified in the config does not exist' python_path = python_path.rstrip("/") project_path = project_path.rstrip("/") if not python_path.endswith("/bin"): python_path = python_path + "/bin" if not os.path.isdir(python_path): return 'The virtual environment directory specified in the config does not exist' return cls(project_name, python_path, python_bin, project_path, user, env_list, env_file) # 组合环境变量,用于启动服务 def shell_env(self) -> str: if self._env_cache is not None: return self._env_cache # cd 到指定路径, 加载环境变量, 加载环境变量文件, 设置Python环境到首位 res_env_list = ["cd {}".format(self.project_path)] if isinstance(self.env_list, list): for i in self.env_list: if not isinstance(i, dict): continue if 'k' in i and 'v' in i: res_env_list.append("export {}={}".format(i['k'], i['v'])) if self.env_file and os.path.isfile(self.env_file): res_env_list.append("source {}".format(self.env_file)) res_env_list.append(self.pyenv.activate_shell()) self._env_cache = "\n".join(res_env_list) return self._env_cache class PythonService(object): def __init__(self, sid: str, name: str, command: str, level: Optional[int], log_type: Optional[str]): self.sid = sid self.name = name self.command = command self.level = level self.log_type = log_type self.env: Optional[Environment] = None def set_env(self, env: Environment): self.env = env def write_pid(self, pid: int): if not self.env: raise RuntimeError('Env not set') pid_file = os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.env.project_name, self.name)) if not os.path.isdir(os.path.dirname(pid_file)): os.makedirs(os.path.dirname(pid_file), 0o755) public.writeFile(pid_file, str(pid)) def read_pid(self) -> Optional[int]: if not self.env: raise RuntimeError('Env not set') pid_file = os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.env.project_name, self.name)) if not os.path.isfile(pid_file): return None res = None try: res = int(public.readFile(pid_file)) except: pass if isinstance(res, int) and res > 0: return res return None @classmethod def from_config(cls, config: dict, env: Optional[Environment]) -> Union['PythonService', "MainPythonService", 'CeleryService', str]: sid = config.get('sid', None) if sid == 'main': return MainPythonService(env) name: str = config.get('name', "") command: str = config.get('command', "") if not sid or not name or not command: return 'Missing required parameters' if not isinstance(command, str) or not isinstance(name, str) or not isinstance(sid, str): return 'Invalid parameter type' level = config.get('level', 11) log_type = config.get('log_type', "append") if command.split()[0].endswith("celery"): res = CeleryService(sid, name, command, level, log_type) else: res = cls(sid, name, command, level, log_type) if env: res.set_env(env) return res # 执行启动服务并返回PID信息或错误信息 def start(self) -> Optional[int]: if not self.env: raise RuntimeError('Env not set') log_file = os.path.join(SERVICE_PATH, '{}/{}.log'.format(self.env.project_name, self.name)) pid_file = os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.env.project_name, self.name)) if not os.path.exists(os.path.dirname(pid_file)): os.makedirs(os.path.dirname(pid_file), 0o755) if os.path.exists(pid_file): os.remove(pid_file) prep_sh = self.env.shell_env() prep_sh += "\nexport BT_PYTHON_SERVICE_SID={}".format(self.sid) if not os.path.isfile(log_file): public.writeFile(log_file, '') public.set_own(log_file, self.env.user) public.set_mode(log_file, "755") if self.log_type == "append": prep_sh += "\nnohup {} &>> {} &".format(self.command, log_file) else: prep_sh += "\nnohup {} &> {} &".format(self.command, log_file) public.ExecShell(prep_sh, user=self.env.user) time.sleep(0.5) return self.get_service_pid() def get_service_pid(self, only_service: bool = False) -> Optional[int]: pid = self.read_pid() if pid and psutil.pid_exists(pid): return pid if not pid: pid = self.get_pid_by_env_key() if not pid and not only_service: pid = self.get_pid_by_command() if pid: self.write_pid(pid) return pid return None def get_pid_by_env_key(self) -> Optional[int]: env_key = "BT_PYTHON_SERVICE_SID={}".format(self.sid) target = [] for p in psutil.pids(): try: data: str = public.readFile("/proc/{}/environ".format(p)) if data.rfind(env_key) != -1: target.append(p) except: continue for i in target: try: p = psutil.Process(i) if p.ppid() not in target: return i except: continue return None def get_pid_by_command(self) -> Optional[int]: cmd_list = self.split_command() target = [] for p in psutil.process_iter(["cmdline", "pid", "exe"]): try: real_cmd = p.cmdline() if cmd_list == real_cmd: target.append(p) if real_cmd[2:] == cmd_list[1:] and real_cmd[0].startswith(self.env.python_path): target.append(p) except: continue for p in target: try: if p.ppid() not in target: return p.pid except: continue return None def split_command(self) -> List[str]: res = [] tmp = "" in_quot = False for i in self.command: if i in (' ', '\t', '\r'): if tmp and not in_quot: res.append(tmp) tmp = "" if in_quot: tmp += ' ' elif i in ("'", '"'): if in_quot: in_quot = False else: in_quot = True else: tmp += i if tmp: res.append(tmp) return res def stop(self) -> None: pid = self.get_service_pid() if not pid: return try: p = psutil.Process(pid) p.kill() except: pass def get_log(self) -> str: if not self.env: raise RuntimeError('env not set') log_file = os.path.join(SERVICE_PATH, '{}/{}.log'.format(self.env.project_name, self.name)) if not os.path.isfile(log_file): return 'No logs available' data = public.GetNumLines(log_file, 1000) if not data: return 'No logs available' return data @staticmethod def _get_ports_by_pid(pid: int) -> List[int]: try: res = set() for con in psutil.Process(pid).connections(): # NOQA if con.status == 'LISTEN': res.add(con.laddr.port) return list(res) except: return [] def get_info(self) -> Dict[str, Any]: if not self.env: raise RuntimeError('env not set') pid = self.get_service_pid() if isinstance(pid, int) and psutil.pid_exists(pid): ports = self._get_ports_by_pid(pid) return { 'pid': pid, 'ports': ports } return {"pid": None, "ports": []} class MainPythonService(PythonService): from projectModelV2.pythonModel import main as py_project_main _py_main_class = py_project_main def __init__(self, env: Environment): super().__init__('main', 'main', 'main', 10, 'append') self.set_env(env) @property def py_main(self): return self._py_main_class() def start(self) -> Optional[int]: if not self.env: raise RuntimeError('Env not set') self.py_main.only_start_main_project(self.env.project_name) return self.get_service_pid() def get_service_pid(self, only_service: bool = False) -> Optional[int]: if not self.env: raise RuntimeError('env not set') pids: List[int] = self.py_main.get_project_run_state(self.env.project_name) if not pids: return None pids.sort() return pids[0] def stop(self) -> None: if not self.env: raise RuntimeError('env not set') self.py_main.only_stop_main_project(self.env.project_name) def get_log(self) -> str: if not self.env: raise RuntimeError('env not set') get_obj = public.dict_obj() get_obj.name = self.env.project_name res = self.py_main.GetProjectLog(get_obj) data = None if res.get("status"): data = res.get("message", {}).get("data", "") if not data: return 'no log found' return data def get_info(self): res = super().get_info() res['name'] = "Main project service" return res class CeleryService(PythonService): def get_celery_env(self) -> Tuple[str, str]: celery = "{}/celery".format(self.env.python_path) if not os.path.isfile(celery): return '', '' celery_data = public.readFile(celery) if not isinstance(celery_data, str): return '', '' celery_python = celery_data.split("\n", 1)[0] if celery_python.startswith("#!"): celery_python = celery_python[2:].strip() return celery_python, celery def get_pid_by_command(self) -> Optional[int]: celery_env = self.get_celery_env() if not celery_env[0] or not celery_env[1]: return super().get_pid_by_command() target = [] cmd_list = list(celery_env) + self.split_command()[1:] for p in psutil.process_iter(["cmdline", "pid"]): try: if cmd_list == p.cmdline(): target.append(p) except: continue for p in target: try: if p.ppid() not in target: return p.pid except: continue return None # 协同服务管理类, 包括主服务和其他服务 class ServiceManager: MAIN_SERVICE_CONF = { "sid": "main", "name": "main", "command": "main", "level": 10, "log_type": "append", } def __init__(self, project_name: str, project_config: dict): self.project_name = project_name self.project_config = project_config self._other_services: Optional[List[Dict]] = None self._env: Optional[Environment] = None @classmethod def new_mgr(cls, project_name: str) -> Union["ServiceManager", str]: data = public.M("sites").where( 'project_type=? AND name=? ', ('Python', project_name) ).field('id,project_config').find() if not data: raise HintException("Project [{}] Not Found!".format(project_name)) try: project_config = json.loads(data['project_config']) except json.JSONDecodeError: raise HintException("Project [{}] db's Project Config Error!".format(project_name)) return cls(project_name, project_config) @property def service_list(self) -> List[Dict]: res = [self.MAIN_SERVICE_CONF] res.extend(self.other_services) res.sort(key=lambda x: x['level']) return res @property def other_services(self) -> List[Dict]: if self._other_services is None: services = [] for service in self.project_config.get('services', []): if service.get('sid') == 'main': continue services.append(service) self._other_services = services return self._other_services @staticmethod def new_id() -> str: return uuid4().hex[::3] def save_service_conf(self) -> Optional[str]: data = public.M("sites").where( 'project_type=? AND name=? ', ('Python', self.project_name) ).field('id,project_config').find() if not data: return "Website information not found" data['project_config'] = json.loads(data['project_config']) data['project_config']['services'] = self.other_services public.M("sites").where('id=?', (data['id'],)).update({'project_config': json.dumps(data['project_config'])}) return None def add_service(self, service_conf: dict) -> Optional[str]: try: conf = { "name": service_conf.get("name", "").strip(), "command": service_conf.get("command", "").strip(), "level": int(service_conf.get("level", 11)), "log_type": service_conf.get("log_type", "append"), } except: return "Parameter error" if re.search(r"[\s$^`]+", conf['name']): return "Service name cannot contain spaces or special characters" for i in self.other_services: if i['name'] == conf['name']: return "Service name must be unique" if i['command'] == conf['command']: return "This start command already exists; service name: {}".format(i['name']) if not (conf['name'] and conf['command']): return "Service name and start command cannot be empty" conf["sid"] = self.new_id() self.other_services.append(conf) self.save_service_conf() return None def modify_service(self, sid: str, service_conf: dict) -> Optional[str]: target_data = None for i in self.other_services: if i["sid"] == sid: target_data = i break if target_data is None: return "Service not found" name = target_data["name"] if "name" in service_conf and service_conf["name"] != target_data["name"]: name = service_conf["name"].strip() if re.search(r"[\s$^`]+", name): return "Service name cannot contain spaces or special characters" command = target_data["command"] if "command" in service_conf and service_conf["command"] != target_data["command"]: command = service_conf["command"].strip() for i in self.other_services: if i["sid"] == sid: continue if i["name"] == name: return "Service name must be unique" if i["command"] == command: return "This start command already exists; service name: {}".format(i["name"]) if name != target_data["name"]: log_file = os.path.join(SERVICE_PATH, '{}/{}.log'.format(self.project_name, target_data["name"])) pid_file = os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.project_name, target_data["name"])) if os.path.exists(log_file): os.rename(log_file, os.path.join(SERVICE_PATH, '{}/{}.log'.format(self.project_name, name))) if os.path.exists(pid_file): os.rename(pid_file, os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.project_name, name))) target_data["name"] = name target_data["command"] = command target_data["level"] = int(service_conf.get("level", int(target_data.get("level", 11)))) target_data["log_type"] = service_conf.get("log_type", target_data["log_type"]) self.save_service_conf() return None def remove_service(self, sid: str) -> Optional[str]: del_idx = None for idx, i in enumerate(self.other_services): if i["sid"] == sid: del_idx = idx break if del_idx is None: return "Service not found" del_conf = self.other_services.pop(del_idx) self.save_service_conf() log_file = os.path.join(SERVICE_PATH, '{}/{}.log'.format(self.project_name, del_conf["name"])) pid_file = os.path.join(SERVICE_PATH, '{}/{}.pid'.format(self.project_name, del_conf["name"])) if os.path.exists(log_file): os.remove(log_file) if os.path.exists(pid_file): os.remove(pid_file) return None def _get_service_conf_by_sid(self, sid: str) -> Optional[Dict]: for i in self.service_list: if i["sid"] == sid: return i return None def _build_service_by_conf(self, conf: dict) -> Union[PythonService, str]: if not self._env: self._env = Environment.form_project_conf(self.project_config) if isinstance(self._env, str): return self._env return PythonService.from_config(conf, env=self._env) def handle_service(self, sid: str, action: str = "start") -> Optional[str]: conf = self._get_service_conf_by_sid(sid) if conf is None: return "Service not found" service = self._build_service_by_conf(conf) if isinstance(service, str): return service pid = service.get_service_pid() if not pid: pid = -1 if action == "start": if not psutil.pid_exists(pid): service.start() elif action == "stop": service.stop() elif action == "restart": if psutil.pid_exists(pid): service.stop() for i in range(50): if not psutil.pid_exists(pid): break time.sleep(0.1) else: service.stop() time.sleep(1) service.start() else: return "Unknown action" return None def get_service_log(self, sid: str) -> Tuple[bool, str]: conf = self._get_service_conf_by_sid(sid) if conf is None: return False, "Service Not Found" service = self._build_service_by_conf(conf) if isinstance(service, str): return False, service return True, service.get_log() def get_services_info(self) -> List[dict]: res = [] for i in self.service_list: service = self._build_service_by_conf(i) if isinstance(service, str): i["error"] = service res.append(i) else: i.update(service.get_info()) res.append(i) return res def start_project(self): services = [ self._build_service_by_conf(i) for i in self.service_list ] for i in services: try: if isinstance(i, str): continue pid = i.get_service_pid() if isinstance(pid, int) and pid > 0 and psutil.pid_exists(pid): continue i.start() time.sleep(0.5) except Exception: import traceback public.print_log("start project service error: {}".format(traceback.format_exc())) continue return True def stop_project(self): services = [self._build_service_by_conf(i) for i in self.service_list] for i in services[::-1]: if isinstance(i, str): continue i.stop() return "Stop command executed" def other_service_pids(self) -> Set[int]: res_pid = [] for i in self.other_services: service = self._build_service_by_conf(i) if isinstance(service, str): continue pid = service.get_service_pid() if isinstance(pid, int) and pid > 0 and psutil.pid_exists(pid): res_pid.append(pid) sub_pid = [] def get_sub_pid(pro: psutil.Process) -> List[int]: tmp_res = [] if pro.status() != psutil.STATUS_ZOMBIE and pro.children(): for sub_pro in pro.children(): tmp_res.append(sub_pro.pid) tmp_res.extend(get_sub_pid(sub_pro)) return tmp_res for i in res_pid: try: p = psutil.Process(i) sub_pid.extend(get_sub_pid(p)) except: pass return set(res_pid + sub_pid) # 协同服务api class main: def __init__(self): pass @staticmethod def get_services_info(get): try: project_name = get.project_name.strip() except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) return json_response(True, data=s_mgr.get_services_info()) @staticmethod def add_service(get): try: project_name = get.project_name.strip() service_conf = get.service_conf if isinstance(service_conf, str): service_conf = json.loads(service_conf) if not isinstance(service_conf, dict): return json_response(False, 'Invalid service configuration parameters') except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) res = s_mgr.add_service(service_conf) if isinstance(res, str): return json_response(False, res) public.set_module_logs('python_project', 'add_service', 1) return json_response(True, msg="Added successfully") @staticmethod def modify_service(get): try: project_name = get.project_name.strip() sid = get.sid.strip() service_conf = get.service_conf if isinstance(service_conf, str): service_conf = json.loads(service_conf) if not isinstance(service_conf, dict): return json_response(False, 'Invalid service configuration parameters') except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) res = s_mgr.modify_service(sid, service_conf) if isinstance(res, str): return json_response(False, res) return json_response(True, msg="Updated successfully") @staticmethod def remove_service(get): try: project_name = get.project_name.strip() sid = get.sid.strip() except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) res = s_mgr.remove_service(sid) if isinstance(res, str): return json_response(False, res) return json_response(True, msg="Deleted successfully") @staticmethod def handle_service(get): try: project_name = get.project_name.strip() sid = get.sid.strip() action = get.option.strip() except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) res = s_mgr.handle_service(sid, action) if isinstance(res, str): return json_response(False, res) return json_response(True, msg="Operation successful") @staticmethod def get_service_log(get): try: project_name = get.project_name.strip() sid = get.sid.strip() except: return json_response(False, 'Parameter error') s_mgr = ServiceManager.new_mgr(project_name) if isinstance(s_mgr, str): return json_response(False, s_mgr) res, log = s_mgr.get_service_log(sid) if not res: return json_response(False, log) return json_response(True, data=log)