diff --git a/scripts/deploy.py b/scripts/deploy.py index 41e471a..e51799a 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -14,6 +14,14 @@ import sys import time import urllib.error import urllib.request +from nav_common import ( + get_nav_port, + http_ready, + is_process_running, + pid_file_path, + read_background_pid, + stop_background_process, +) from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -239,6 +247,40 @@ def wait_for_http(port: str, timeout: float = 15.0) -> bool: return False +def start_background_process(port: str) -> str | None: + """无 pm2/systemd 时,用 venv python 后台启动并记录 pid。""" + py = venv_python() + if not Path(py).is_file(): + return None + + old_pid = read_background_pid(ROOT) + if old_pid and is_process_running(old_pid): + if http_ready(port, timeout=3): + return f"后台进程已在运行 pid={old_pid}" + + if old_pid and is_process_running(old_pid): + stop_background_process(ROOT) + + ensure_logs_dir() + log_file = ROOT / "logs" / "nav-site.log" + pid_file = pid_file_path(ROOT) + + with open(log_file, "ab") as log: + proc = subprocess.Popen( + [py, str(ROOT / "app.py")], + cwd=ROOT, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + pid_file.write_text(str(proc.pid), encoding="utf-8") + + if wait_for_http(port): + return f"后台启动 pid={proc.pid}(日志 logs/nav-site.log)" + _warn(f"后台进程已启动 pid={proc.pid},但 :{port} 暂未响应,请查看 logs/nav-site.log") + return f"后台启动 pid={proc.pid}" + + def start_service(port: str) -> str | None: """尝试自动启动服务,返回启动方式描述;失败返回 None。""" ensure_logs_dir() @@ -274,11 +316,11 @@ def start_service(port: str) -> str | None: _warn("systemd 已启动 nav-site,但健康检查未通过,请执行 journalctl -u nav-site 排查") return "systemctl enable --now nav-site" - return None + return start_background_process(port) def print_summary(started_via: str | None = None) -> None: - port = os.environ.get("NAV_PORT", "5070") + port = get_nav_port(ROOT) print() print("=" * 50) print("部署完成") @@ -288,12 +330,15 @@ def print_summary(started_via: str | None = None) -> None: print() if started_via: print(f" 服务状态: 已自动启动({started_via})") - if shutil.which("pm2"): + if shutil.which("pm2") and "pm2" in started_via: print(" 查看状态: pm2 status") print(" 查看日志: pm2 logs nav-site") print(" 开机自启: pm2 save && pm2 startup # 按提示执行一次 sudo 命令") + elif "后台" in started_via: + print(" 查看日志: tail -f logs/nav-site.log") + print(" 建议生产环境安装 pm2 以便守护与开机自启") else: - print(" 服务状态: 未自动启动(未检测到 pm2 / systemd nav-site)") + print(" 服务状态: 未自动启动") print(" 手动启动:") print(f" {venv_python()} app.py") if shutil.which("pm2"): @@ -333,7 +378,7 @@ def main() -> None: started_via = None if not args.no_start: - port = os.environ.get("NAV_PORT", "5070") + port = get_nav_port(ROOT) try: started_via = start_service(port) if started_via: diff --git a/scripts/nav_common.py b/scripts/nav_common.py index 0f36a8c..10570b8 100644 --- a/scripts/nav_common.py +++ b/scripts/nav_common.py @@ -6,7 +6,10 @@ import json import os import platform import shutil +import socket import subprocess +import urllib.error +import urllib.request from pathlib import Path DEFAULT_REPO = "https://git.bz121.com/dekun/LocalNav.git" @@ -74,6 +77,91 @@ def resolve_project_dir(explicit: str | None = None) -> Path | None: return None +def pid_file_path(project_dir: Path) -> Path: + return project_dir / "logs" / "nav-site.pid" + + +def get_nav_port(project_dir: Path | None = None) -> str: + if project_dir: + env_file = project_dir / ".env" + if env_file.is_file(): + for line in env_file.read_text(encoding="utf-8-sig").splitlines(): + s = line.strip() + if s.startswith("NAV_PORT="): + val = s.split("=", 1)[1].strip().strip("\"'") + if val: + return val + return os.environ.get("NAV_PORT", "5070") + + +def http_ready(port: str, timeout: float = 2.0) -> bool: + url = f"http://127.0.0.1:{port}/login" + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return resp.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def port_open(port: str) -> bool: + try: + p = int(port) + except ValueError: + return False + try: + with socket.create_connection(("127.0.0.1", p), timeout=1.5): + return True + except OSError: + return False + + +def read_background_pid(project_dir: Path) -> int | None: + path = pid_file_path(project_dir) + if not path.is_file(): + return None + try: + return int(path.read_text(encoding="utf-8").strip()) + except (ValueError, OSError): + return None + + +def is_process_running(pid: int) -> bool: + if pid <= 0: + return False + if platform.system() == "Windows": + try: + proc = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + check=False, + ) + return str(pid) in (proc.stdout or "") + except OSError: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def stop_background_process(project_dir: Path) -> bool: + pid = read_background_pid(project_dir) + if not pid or not is_process_running(pid): + pid_file_path(project_dir).unlink(missing_ok=True) + return False + try: + if platform.system() == "Windows": + subprocess.run(["taskkill", "/PID", str(pid), "/F"], check=False) + else: + os.kill(pid, 15) + except OSError: + pass + pid_file_path(project_dir).unlink(missing_ok=True) + return True + + def pm2_available() -> str | None: return shutil.which("pm2") @@ -149,17 +237,35 @@ def systemd_active(name: str = APP_NAME) -> bool: def service_status_text(project_dir: Path | None) -> str: if not project_dir: return "未安装" + + port = get_nav_port(project_dir) + if http_ready(port): + pm2_state = pm2_app_status() + if pm2_state: + return f"运行中 · pm2 {pm2_state} · :{port}" + if systemd_unit_ready() and systemd_active(): + return f"运行中 · systemd · :{port}" + pid = read_background_pid(project_dir) + if pid and is_process_running(pid): + return f"运行中 · 后台 pid {pid} · :{port}" + return f"运行中 · 端口 {port}" + pm2_state = pm2_app_status() if pm2_state: - return f"pm2 {pm2_state}" + return f"pm2 {pm2_state}(未响应 :{port})" if systemd_unit_ready() and systemd_active(): - return "systemd active" + return f"systemd active(未响应 :{port})" if systemd_unit_ready(): return "systemd 已配置但未运行" + pid = read_background_pid(project_dir) + if pid and is_process_running(pid): + return f"后台 pid {pid}(未响应 :{port})" + if port_open(port): + return f"端口 {port} 已占用(进程未知)" return "未运行" -def stop_service() -> list[str]: +def stop_service(project_dir: Path | None = None) -> list[str]: """停止并移除进程管理中的 nav-site,返回已执行操作说明。""" actions: list[str] = [] pm2 = pm2_available() @@ -175,4 +281,7 @@ def stop_service() -> list[str]: subprocess.run([systemctl, "disable", f"{APP_NAME}.service"], check=False) actions.append("systemctl stop/disable nav-site") + if project_dir and stop_background_process(project_dir): + actions.append("已停止后台 nav-site 进程") + return actions diff --git a/scripts/navctl.py b/scripts/navctl.py index a2ec49e..2c6bed9 100644 --- a/scripts/navctl.py +++ b/scripts/navctl.py @@ -213,7 +213,7 @@ def action_uninstall(*, dest: str = "") -> None: _warn("已取消卸载") return - actions = stop_service() + actions = stop_service(project_dir) if actions: for item in actions: _ok(item) diff --git a/部署与使用说明.md b/部署与使用说明.md index 46c9bff..ba931a5 100644 --- a/部署与使用说明.md +++ b/部署与使用说明.md @@ -128,7 +128,7 @@ python3 scripts/navctl.py uninstall --dir /opt/LocalNav | 默认账号 | `NAV_ADMIN_USERNAME=admin`、`NAV_ADMIN_PASSWORD=admin123` | | 中控自动登录 | 默认 `NAV_HUB_AUTO_LOGIN=1` | | 内网 Cookie | 默认 `NAV_COOKIES_INSECURE_HTTP=1`(便于 `http://IP:端口` 访问) | -| **自动启动** | 若检测到 **pm2**:`pm2 start` 或 `pm2 restart nav-site`;否则尝试 **systemd** `nav-site` 服务 | +| **自动启动** | 优先 **pm2**;其次 **systemd**;都没有则用 **后台进程**(`logs/nav-site.pid` + `logs/nav-site.log`) | **注意:** 脚本不会覆盖 `.env` 中已有的 `NAV_SECRET_KEY` 等配置。生产环境部署后请尽快登录并在「系统设置」中修改密码。仅安装不启动可在 `deploy.py` 加 `--no-start`。