627 lines
22 KiB
Python
627 lines
22 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import socket
|
|||
|
|
import shutil
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Callable, Iterable, Mapping
|
|||
|
|
|
|||
|
|
from .environment import discover_executable
|
|||
|
|
from .scanner import ScanResult
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class DeploymentConfig:
|
|||
|
|
mysql_user: str = "root"
|
|||
|
|
mysql_password: str = ""
|
|||
|
|
mysql_database: str = ""
|
|||
|
|
backend_port: str = "8080"
|
|||
|
|
user_frontend_port: str = "8082"
|
|||
|
|
admin_frontend_port: str = "8081"
|
|||
|
|
output_dir: Path | None = None
|
|||
|
|
tool_paths: Mapping[str, str | Path] | None = None
|
|||
|
|
tool_search_roots: Iterable[Path] | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class DeploymentStep:
|
|||
|
|
label: str
|
|||
|
|
command: list[str]
|
|||
|
|
cwd: Path
|
|||
|
|
description: str
|
|||
|
|
kind: str = "command"
|
|||
|
|
input_path: Path | None = None
|
|||
|
|
output_path: Path | None = None
|
|||
|
|
source_path: Path | None = None
|
|||
|
|
background: bool = False
|
|||
|
|
service_name: str | None = None
|
|||
|
|
port: int | None = None
|
|||
|
|
url: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
OutputHandler = Callable[[str], None]
|
|||
|
|
ProcessRunner = Callable[[list[str], Path, OutputHandler, Path | None, bool], int]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]:
|
|||
|
|
steps: list[DeploymentStep] = []
|
|||
|
|
output_dir = config.output_dir or scan.root / "deploy"
|
|||
|
|
|
|||
|
|
if scan.backend:
|
|||
|
|
backend_dir = scan.backend.path
|
|||
|
|
existing_jar = _find_existing_backend_jar(backend_dir)
|
|||
|
|
if existing_jar:
|
|||
|
|
steps.append(
|
|||
|
|
DeploymentStep(
|
|||
|
|
"跳过后端构建",
|
|||
|
|
[],
|
|||
|
|
backend_dir,
|
|||
|
|
f"检测到已有后端 jar,跳过构建: {existing_jar}",
|
|||
|
|
kind="backend_build_skip",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
build_file_names = {path.name for path in backend_dir.iterdir() if path.is_file()} if backend_dir.exists() else set()
|
|||
|
|
if "pom.xml" in build_file_names:
|
|||
|
|
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
|||
|
|
elif "gradlew.bat" in build_file_names:
|
|||
|
|
command = ["gradlew.bat", "bootJar"]
|
|||
|
|
elif "gradlew" in build_file_names:
|
|||
|
|
command = ["./gradlew", "bootJar"]
|
|||
|
|
elif backend_dir.exists():
|
|||
|
|
command = [_tool_command(config, "Gradle", "gradle"), "bootJar"]
|
|||
|
|
else:
|
|||
|
|
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
|||
|
|
steps.append(DeploymentStep("构建后端", command, backend_dir, "打包 Spring Boot 应用", kind="backend_build"))
|
|||
|
|
|
|||
|
|
frontends = scan.detected_frontends
|
|||
|
|
for frontend in frontends:
|
|||
|
|
display_name = frontend.metadata.get("display_name", frontend.path.name)
|
|||
|
|
npm_command = _tool_command(config, "npm", _default_npm_command())
|
|||
|
|
if not _frontend_dependencies_installed(frontend.path):
|
|||
|
|
steps.append(
|
|||
|
|
DeploymentStep(
|
|||
|
|
f"安装{display_name}依赖",
|
|||
|
|
[npm_command, "install"],
|
|||
|
|
frontend.path,
|
|||
|
|
f"安装{display_name} package.json 中的依赖",
|
|||
|
|
kind="frontend_install",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if scan.sql_files and config.mysql_database:
|
|||
|
|
for sql_file in scan.sql_files:
|
|||
|
|
command = [_tool_command(config, "MySQL", "mysql"), "-u", config.mysql_user]
|
|||
|
|
if config.mysql_password:
|
|||
|
|
command.append(f"-p{config.mysql_password}")
|
|||
|
|
if not _sql_file_selects_database(sql_file):
|
|||
|
|
command.append(config.mysql_database)
|
|||
|
|
steps.append(
|
|||
|
|
DeploymentStep(
|
|||
|
|
"导入 SQL",
|
|||
|
|
command,
|
|||
|
|
scan.root,
|
|||
|
|
f"导入 {sql_file.name}",
|
|||
|
|
kind="sql_import",
|
|||
|
|
input_path=sql_file,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if scan.backend:
|
|||
|
|
backend_context = scan.backend.metadata.get("context_path", "")
|
|||
|
|
backend_url = f"http://localhost:{config.backend_port}{backend_context}"
|
|||
|
|
steps.append(
|
|||
|
|
DeploymentStep(
|
|||
|
|
"启动后端",
|
|||
|
|
[_tool_command(config, "JDK", "java"), f"-Dserver.port={config.backend_port}", "-jar", "latest-built-jar"],
|
|||
|
|
output_dir,
|
|||
|
|
"启动 Spring Boot 服务",
|
|||
|
|
kind="backend_start",
|
|||
|
|
source_path=scan.backend.path,
|
|||
|
|
background=True,
|
|||
|
|
service_name="后端",
|
|||
|
|
port=_validate_port(config.backend_port, "后端端口"),
|
|||
|
|
url=backend_url,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
frontend_urls: list[tuple[str, str]] = []
|
|||
|
|
allocated_ports: dict[int, str] = {}
|
|||
|
|
if scan.backend:
|
|||
|
|
allocated_ports[_validate_port(config.backend_port, "后端端口")] = "后端"
|
|||
|
|
for index, frontend in enumerate(frontends):
|
|||
|
|
display_name = frontend.metadata.get("display_name", frontend.path.name)
|
|||
|
|
port = _frontend_port(frontend, config, index)
|
|||
|
|
if port in allocated_ports:
|
|||
|
|
raise ValueError(f"{display_name}端口 {port} 与{allocated_ports[port]}端口冲突")
|
|||
|
|
allocated_ports[port] = display_name
|
|||
|
|
start_script = frontend.metadata.get("start_script") or _frontend_start_script(frontend.path)
|
|||
|
|
if not start_script:
|
|||
|
|
raise ValueError(f"{display_name}缺少 serve、dev 或 start 启动脚本: {frontend.path / 'package.json'}")
|
|||
|
|
url = f"http://localhost:{port}/"
|
|||
|
|
role = frontend.metadata.get("role", f"frontend-{index + 1}")
|
|||
|
|
steps.append(
|
|||
|
|
DeploymentStep(
|
|||
|
|
f"启动{display_name}",
|
|||
|
|
[
|
|||
|
|
_tool_command(config, "npm", _default_npm_command()),
|
|||
|
|
"run",
|
|||
|
|
start_script,
|
|||
|
|
"--",
|
|||
|
|
"--host",
|
|||
|
|
"0.0.0.0",
|
|||
|
|
"--port",
|
|||
|
|
str(port),
|
|||
|
|
],
|
|||
|
|
frontend.path,
|
|||
|
|
f"启动{display_name}开发服务",
|
|||
|
|
kind="frontend_start",
|
|||
|
|
output_path=output_dir / f"frontend-{role}.log",
|
|||
|
|
background=True,
|
|||
|
|
service_name=display_name,
|
|||
|
|
port=port,
|
|||
|
|
url=url,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
frontend_urls.append((display_name, url))
|
|||
|
|
|
|||
|
|
addresses: list[tuple[str, str]] = []
|
|||
|
|
if scan.backend:
|
|||
|
|
addresses.append(("后端", backend_url))
|
|||
|
|
addresses.extend(frontend_urls)
|
|||
|
|
if addresses:
|
|||
|
|
summary = "部署完成,访问地址:\n" + "\n".join(f" {name}: {url}" for name, url in addresses)
|
|||
|
|
steps.append(DeploymentStep("访问地址", [], output_dir, summary, kind="deployment_summary"))
|
|||
|
|
|
|||
|
|
return steps
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> str:
|
|||
|
|
configured = config.tool_paths.get(tool_name) if config.tool_paths else None
|
|||
|
|
if configured:
|
|||
|
|
path = Path(configured)
|
|||
|
|
if path.exists():
|
|||
|
|
return str(path)
|
|||
|
|
|
|||
|
|
discovered = discover_executable(tool_name, config.tool_search_roots)
|
|||
|
|
return str(discovered) if discovered else fallback
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_npm_command() -> str:
|
|||
|
|
return "npm.cmd" if os.name == "nt" else "npm"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _validate_port(value: str | int, label: str) -> int:
|
|||
|
|
try:
|
|||
|
|
port = int(value)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}") from exc
|
|||
|
|
if not 1 <= port <= 65535:
|
|||
|
|
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}")
|
|||
|
|
return port
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _frontend_port(frontend, config: DeploymentConfig, index: int) -> int:
|
|||
|
|
role = frontend.metadata.get("role")
|
|||
|
|
if role == "user":
|
|||
|
|
return _validate_port(config.user_frontend_port, "用户端端口")
|
|||
|
|
if role == "admin":
|
|||
|
|
return _validate_port(config.admin_frontend_port, "管理端端口")
|
|||
|
|
configured = frontend.metadata.get("port")
|
|||
|
|
if configured:
|
|||
|
|
return _validate_port(configured, f"{frontend.path.name} 端口")
|
|||
|
|
return 8081 + index
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _frontend_start_script(frontend_dir: Path) -> str | None:
|
|||
|
|
package_file = frontend_dir / "package.json"
|
|||
|
|
try:
|
|||
|
|
package = json.loads(package_file.read_text(encoding="utf-8"))
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return None
|
|||
|
|
scripts = package.get("scripts")
|
|||
|
|
if not isinstance(scripts, dict):
|
|||
|
|
return None
|
|||
|
|
for name in ("serve", "dev", "start"):
|
|||
|
|
if isinstance(scripts.get(name), str):
|
|||
|
|
return name
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _sql_file_selects_database(sql_file: Path) -> bool:
|
|||
|
|
try:
|
|||
|
|
text = sql_file.read_text(encoding="utf-8", errors="ignore")
|
|||
|
|
except OSError:
|
|||
|
|
return False
|
|||
|
|
normalized = text.lower()
|
|||
|
|
return "create database" in normalized and "use `" in normalized or "create database" in normalized and "use " in normalized
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_process_command(command: list[str], path: str | None = None) -> list[str]:
|
|||
|
|
if not command:
|
|||
|
|
return command
|
|||
|
|
executable = command[0]
|
|||
|
|
if Path(executable).is_absolute() or any(separator in executable for separator in ("/", "\\")):
|
|||
|
|
return command
|
|||
|
|
|
|||
|
|
resolved = shutil.which(executable, path=path)
|
|||
|
|
if resolved is None and os.name == "nt":
|
|||
|
|
for suffix in (".cmd", ".bat", ".exe"):
|
|||
|
|
resolved = shutil.which(executable + suffix, path=path)
|
|||
|
|
if resolved:
|
|||
|
|
break
|
|||
|
|
if resolved is None:
|
|||
|
|
return command
|
|||
|
|
return [resolved, *command[1:]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _extract_server_port(command: list[str]) -> int | None:
|
|||
|
|
prefix = "-Dserver.port="
|
|||
|
|
for part in command:
|
|||
|
|
if part.startswith(prefix):
|
|||
|
|
raw_port = part[len(prefix) :]
|
|||
|
|
try:
|
|||
|
|
port = int(raw_port)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}") from exc
|
|||
|
|
if not 1 <= port <= 65535:
|
|||
|
|
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}")
|
|||
|
|
return port
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_windows_listening_pids(output: str, port: int) -> set[int]:
|
|||
|
|
pids: set[int] = set()
|
|||
|
|
for line in output.splitlines():
|
|||
|
|
parts = line.split()
|
|||
|
|
if len(parts) < 5 or parts[0].upper() != "TCP" or parts[-2].upper() != "LISTENING":
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
local_port = int(parts[1].rsplit(":", 1)[1])
|
|||
|
|
pid = int(parts[-1])
|
|||
|
|
except (IndexError, ValueError):
|
|||
|
|
continue
|
|||
|
|
if local_port == port:
|
|||
|
|
pids.add(pid)
|
|||
|
|
return pids
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_listening_pids(port: int) -> set[int]:
|
|||
|
|
if os.name != "nt":
|
|||
|
|
raise RuntimeError("自动终止端口占用进程当前仅支持 Windows")
|
|||
|
|
result = subprocess.run(
|
|||
|
|
["netstat", "-ano", "-p", "tcp"],
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.PIPE,
|
|||
|
|
text=True,
|
|||
|
|
errors="replace",
|
|||
|
|
shell=False,
|
|||
|
|
)
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
message = result.stderr.strip() or f"netstat 退出码 {result.returncode}"
|
|||
|
|
raise RuntimeError(f"无法检查端口占用: {message}")
|
|||
|
|
return _parse_windows_listening_pids(result.stdout, port)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _terminate_process_tree(pid: int) -> None:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.STDOUT,
|
|||
|
|
text=True,
|
|||
|
|
errors="replace",
|
|||
|
|
shell=False,
|
|||
|
|
)
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
message = result.stdout.strip() or f"taskkill 退出码 {result.returncode}"
|
|||
|
|
raise RuntimeError(f"无法终止进程 PID {pid}: {message}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_port_open(port: int) -> bool:
|
|||
|
|
try:
|
|||
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
|||
|
|
return True
|
|||
|
|
except OSError:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _wait_until_port_closed(port: int, timeout: float = 10.0) -> bool:
|
|||
|
|
deadline = time.monotonic() + timeout
|
|||
|
|
while time.monotonic() < deadline:
|
|||
|
|
if not _find_listening_pids(port):
|
|||
|
|
return True
|
|||
|
|
time.sleep(0.1)
|
|||
|
|
return not _find_listening_pids(port)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _replace_port_listeners(
|
|||
|
|
port: int,
|
|||
|
|
on_output: OutputHandler,
|
|||
|
|
*,
|
|||
|
|
find_listeners=None,
|
|||
|
|
terminate=None,
|
|||
|
|
wait_until_closed=None,
|
|||
|
|
) -> bool:
|
|||
|
|
find_listeners = find_listeners or _find_listening_pids
|
|||
|
|
terminate = terminate or _terminate_process_tree
|
|||
|
|
wait_until_closed = wait_until_closed or _wait_until_port_closed
|
|||
|
|
|
|||
|
|
for pid in sorted(find_listeners(port)):
|
|||
|
|
if pid not in find_listeners(port):
|
|||
|
|
on_output(f"端口 {port} 的 PID {pid} 已退出,无需再次终止。")
|
|||
|
|
continue
|
|||
|
|
on_output(f"端口 {port} 已被 PID {pid} 占用,正在终止旧进程树...")
|
|||
|
|
try:
|
|||
|
|
terminate(pid)
|
|||
|
|
except RuntimeError:
|
|||
|
|
if pid in find_listeners(port):
|
|||
|
|
raise
|
|||
|
|
on_output(f"PID {pid} 在终止前已退出,继续检查端口 {port}。")
|
|||
|
|
return wait_until_closed(port)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _restart_backend(
|
|||
|
|
command: list[str],
|
|||
|
|
cwd: Path,
|
|||
|
|
on_output: OutputHandler,
|
|||
|
|
*,
|
|||
|
|
replace_listeners=None,
|
|||
|
|
popen=None,
|
|||
|
|
) -> int:
|
|||
|
|
port = _extract_server_port(command)
|
|||
|
|
if port is None:
|
|||
|
|
raise ValueError("后端启动命令缺少 -Dserver.port 配置")
|
|||
|
|
replace_listeners = replace_listeners or _replace_port_listeners
|
|||
|
|
if not replace_listeners(port, on_output):
|
|||
|
|
on_output(f"端口 {port} 未能在限定时间内释放,后端未启动。")
|
|||
|
|
return 1
|
|||
|
|
popen = popen or subprocess.Popen
|
|||
|
|
log_path = cwd / "backend.log"
|
|||
|
|
pid_path = cwd / "backend.pid"
|
|||
|
|
try:
|
|||
|
|
pid_path.unlink()
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
pass
|
|||
|
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|||
|
|
with log_path.open("a", encoding="utf-8") as log_file:
|
|||
|
|
log_file.write(f"\n\n===== 后端部署 {started_at},端口 {port} =====\n")
|
|||
|
|
log_file.flush()
|
|||
|
|
if os.name == "nt":
|
|||
|
|
console_runner = Path(__file__).with_name("service_console.py")
|
|||
|
|
process = popen(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(console_runner),
|
|||
|
|
"--title",
|
|||
|
|
f"后端服务 - 端口 {port}",
|
|||
|
|
"--log",
|
|||
|
|
str(log_path),
|
|||
|
|
"--pid-file",
|
|||
|
|
str(pid_path),
|
|||
|
|
"--",
|
|||
|
|
*command,
|
|||
|
|
],
|
|||
|
|
cwd=cwd,
|
|||
|
|
stdin=subprocess.DEVNULL,
|
|||
|
|
shell=False,
|
|||
|
|
creationflags=_backend_console_creation_flags(),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
log_file = log_path.open("a", encoding="utf-8")
|
|||
|
|
try:
|
|||
|
|
process = popen(
|
|||
|
|
command,
|
|||
|
|
cwd=cwd,
|
|||
|
|
stdin=subprocess.DEVNULL,
|
|||
|
|
stdout=log_file,
|
|||
|
|
stderr=subprocess.STDOUT,
|
|||
|
|
shell=False,
|
|||
|
|
creationflags=0,
|
|||
|
|
)
|
|||
|
|
finally:
|
|||
|
|
log_file.close()
|
|||
|
|
deadline = time.monotonic() + 60.0
|
|||
|
|
while time.monotonic() < deadline:
|
|||
|
|
return_code = process.poll()
|
|||
|
|
if return_code is not None:
|
|||
|
|
on_output(f"后端进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
|
|||
|
|
return return_code or 1
|
|||
|
|
service_pid = _read_service_pid(pid_path) or process.pid
|
|||
|
|
if service_pid in _find_listening_pids(port) and _is_port_open(port):
|
|||
|
|
on_output(f"后端服务启动成功,PID: {service_pid},端口: {port},日志: {log_path}")
|
|||
|
|
return 0
|
|||
|
|
time.sleep(0.2)
|
|||
|
|
|
|||
|
|
on_output(f"后端进程 PID {process.pid} 启动超时,端口 {port} 在 60 秒内未就绪,正在终止进程树。")
|
|||
|
|
_terminate_process_tree(process.pid)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _restart_frontend(
|
|||
|
|
command: list[str],
|
|||
|
|
cwd: Path,
|
|||
|
|
on_output: OutputHandler,
|
|||
|
|
*,
|
|||
|
|
port: int,
|
|||
|
|
service_name: str,
|
|||
|
|
log_path: Path,
|
|||
|
|
replace_listeners=None,
|
|||
|
|
popen=None,
|
|||
|
|
) -> int:
|
|||
|
|
port = _validate_port(port, f"{service_name}端口")
|
|||
|
|
replace_listeners = replace_listeners or _replace_port_listeners
|
|||
|
|
if not replace_listeners(port, on_output):
|
|||
|
|
on_output(f"端口 {port} 未能在限定时间内释放,{service_name}未启动。")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
popen = popen or subprocess.Popen
|
|||
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
with log_path.open("a", encoding="utf-8") as log_file:
|
|||
|
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|||
|
|
log_file.write(f"\n\n===== {service_name}部署 {started_at},端口 {port} =====\n")
|
|||
|
|
log_file.flush()
|
|||
|
|
process = popen(
|
|||
|
|
command,
|
|||
|
|
cwd=cwd,
|
|||
|
|
stdin=subprocess.DEVNULL,
|
|||
|
|
stdout=log_file,
|
|||
|
|
stderr=subprocess.STDOUT,
|
|||
|
|
shell=False,
|
|||
|
|
creationflags=_background_creation_flags(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
deadline = time.monotonic() + 120.0
|
|||
|
|
while time.monotonic() < deadline:
|
|||
|
|
if _is_port_open(port) and _find_listening_pids(port):
|
|||
|
|
on_output(
|
|||
|
|
f"{service_name}启动成功,PID: {process.pid},端口: {port},"
|
|||
|
|
f"访问地址: http://localhost:{port}/,日志: {log_path}"
|
|||
|
|
)
|
|||
|
|
return 0
|
|||
|
|
return_code = process.poll()
|
|||
|
|
if return_code is not None:
|
|||
|
|
on_output(f"{service_name}进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
|
|||
|
|
return return_code or 1
|
|||
|
|
time.sleep(0.2)
|
|||
|
|
|
|||
|
|
on_output(f"{service_name}进程 PID {process.pid} 启动超时,端口 {port} 在 120 秒内未就绪,正在终止进程树。")
|
|||
|
|
_terminate_process_tree(process.pid)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _background_creation_flags() -> int:
|
|||
|
|
if os.name != "nt":
|
|||
|
|
return 0
|
|||
|
|
return subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _backend_console_creation_flags() -> int:
|
|||
|
|
if os.name != "nt":
|
|||
|
|
return 0
|
|||
|
|
return subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_service_pid(pid_path: Path) -> int | None:
|
|||
|
|
try:
|
|||
|
|
return int(pid_path.read_text(encoding="ascii").strip())
|
|||
|
|
except (OSError, ValueError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_steps(
|
|||
|
|
steps: Iterable[DeploymentStep],
|
|||
|
|
on_output: OutputHandler,
|
|||
|
|
process_runner: ProcessRunner = None,
|
|||
|
|
) -> int:
|
|||
|
|
runner = process_runner or _run_process
|
|||
|
|
for step in steps:
|
|||
|
|
try:
|
|||
|
|
command = _resolve_command(step)
|
|||
|
|
command_text = " ".join(command)
|
|||
|
|
on_output(f"\n[{step.label}]{' ' + command_text if command_text else ''}")
|
|||
|
|
if step.input_path:
|
|||
|
|
on_output(f"stdin: {step.input_path}")
|
|||
|
|
if _is_skip_step(step):
|
|||
|
|
on_output(step.description)
|
|||
|
|
continue
|
|||
|
|
if step.kind == "deployment_summary":
|
|||
|
|
on_output(step.description)
|
|||
|
|
continue
|
|||
|
|
step.cwd.mkdir(parents=True, exist_ok=True)
|
|||
|
|
if step.kind == "frontend_start" and process_runner is None:
|
|||
|
|
if step.port is None or step.output_path is None:
|
|||
|
|
raise ValueError(f"{step.label}缺少端口或日志路径配置")
|
|||
|
|
return_code = _restart_frontend(
|
|||
|
|
resolve_process_command(command),
|
|||
|
|
step.cwd,
|
|||
|
|
on_output,
|
|||
|
|
port=step.port,
|
|||
|
|
service_name=step.service_name or "前端",
|
|||
|
|
log_path=step.output_path,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
|
|||
|
|
except Exception as exc:
|
|||
|
|
on_output(f"步骤失败: {exc}")
|
|||
|
|
return 1
|
|||
|
|
if return_code != 0:
|
|||
|
|
on_output(f"步骤失败,退出码: {return_code}")
|
|||
|
|
return return_code
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_command(step: DeploymentStep) -> list[str]:
|
|||
|
|
if step.kind != "backend_start":
|
|||
|
|
return step.command
|
|||
|
|
backend_dir = step.source_path or step.cwd
|
|||
|
|
jar_file = _find_latest_backend_jar(backend_dir)
|
|||
|
|
return [part if part != "latest-built-jar" else str(jar_file) for part in step.command]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_skip_step(step: DeploymentStep) -> bool:
|
|||
|
|
return step.kind == "skip" or step.kind.endswith("_skip")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _frontend_dependencies_installed(frontend_dir: Path) -> bool:
|
|||
|
|
return (frontend_dir / "node_modules").is_dir()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_latest_backend_jar(backend_dir: Path) -> Path:
|
|||
|
|
candidates: list[Path] = []
|
|||
|
|
for folder in (backend_dir / "target", backend_dir / "build" / "libs"):
|
|||
|
|
if folder.is_dir():
|
|||
|
|
candidates.extend(path for path in folder.glob("*.jar") if _is_runnable_jar(path))
|
|||
|
|
if not candidates:
|
|||
|
|
raise FileNotFoundError(f"未找到后端 jar 产物: {backend_dir / 'target'} 或 {backend_dir / 'build' / 'libs'}")
|
|||
|
|
return max(candidates, key=lambda path: (path.stat().st_mtime, path.name))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_existing_backend_jar(backend_dir: Path) -> Path | None:
|
|||
|
|
try:
|
|||
|
|
return _find_latest_backend_jar(backend_dir)
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_runnable_jar(path: Path) -> bool:
|
|||
|
|
name = path.name.lower()
|
|||
|
|
excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar")
|
|||
|
|
return not name.startswith("original-") and not name.endswith(excluded_suffixes)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_process(
|
|||
|
|
command: list[str],
|
|||
|
|
cwd: Path,
|
|||
|
|
on_output: OutputHandler,
|
|||
|
|
input_path: Path | None = None,
|
|||
|
|
background: bool = False,
|
|||
|
|
) -> int:
|
|||
|
|
command = resolve_process_command(command)
|
|||
|
|
if background:
|
|||
|
|
return _restart_backend(command, cwd, on_output)
|
|||
|
|
|
|||
|
|
input_file = input_path.open("r", encoding="utf-8", errors="ignore") if input_path else None
|
|||
|
|
try:
|
|||
|
|
process = subprocess.Popen(
|
|||
|
|
command,
|
|||
|
|
cwd=cwd,
|
|||
|
|
stdin=input_file,
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.STDOUT,
|
|||
|
|
text=True,
|
|||
|
|
shell=False,
|
|||
|
|
)
|
|||
|
|
assert process.stdout is not None
|
|||
|
|
for line in process.stdout:
|
|||
|
|
on_output(line.rstrip())
|
|||
|
|
return process.wait()
|
|||
|
|
finally:
|
|||
|
|
if input_file:
|
|||
|
|
input_file.close()
|