Files
AutoDeploy/deploy_tool/deployer.py

627 lines
22 KiB
Python
Raw Normal View History

2026-07-14 16:30:36 +08:00
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()