Add SSM deployment and reliable Maven setup

This commit is contained in:
王鹏
2026-10-08 17:58:09 +08:00
parent 2d7b6216ba
commit d1695eba74
13 changed files with 626 additions and 46 deletions

View File

@@ -1,15 +1,16 @@
# AutoDeploy # AutoDeploy
AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署工具。它通过图形界面扫描前后端项目、检查开发环境、生成部署步骤,并依次完成后端构建、前端依赖安装、SQL 导入和服务启动。 AutoDeploy 是一个面向 Windows 的 Spring Boot / SSM + Vue 本地自动化部署工具。它通过图形界面扫描前后端项目、检查开发环境、生成部署步骤,并依次完成后端构建、前端依赖安装、SQL 导入和服务启动。
## 主要功能 ## 主要功能
- 自动识别 Maven/Gradle Spring Boot 后端、一个或多个 Vue/Vite 前端以及 `.sql` 文件。 - 自动识别 Maven/Gradle Spring Boot 后端、Maven/Gradle SSM WAR 后端、一个或多个 Vue/Vite 前端以及 `.sql` 文件。
- 从 Maven、Gradle 和 Spring Boot 配置中推断 Java 版本,推荐 JDK 8、11、17 或 21。 - 从 Maven、Gradle 和 Spring Boot 配置中推断 Java 版本,推荐 JDK 8、11、17 或 21。
- 检测 JDK、Maven、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。 - 检测 JDK、Maven、Tomcat、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven 和 Gradle 会同步设置对应的环境变量。 - 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven、Tomcat 和 Gradle 会同步设置对应的环境变量。
- 通过 `winget` 辅助安装常用开发环境,执行安装前会要求用户确认。 - Maven 和 Tomcat 从 Apache 官方地址下载并校验后安装到当前用户目录;其他常用开发环境通过 `winget` 辅助安装,执行安装前会要求用户确认。
- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR 时自动跳过重复构建。 - 使用 Maven 或 Gradle 构建后端;已有可运行 JAR/WAR 时自动跳过重复构建。
- 将传统 SSM 项目的 WAR 发布到 Tomcat 9,并等待 8080 端口就绪。
- 前端缺少 `node_modules` 时自动执行 `npm install`,随后运行 `serve`、`dev` 或 `start` 脚本。 - 前端缺少 `node_modules` 时自动执行 `npm install`,随后运行 `serve`、`dev` 或 `start` 脚本。
- 可按顺序导入多个 SQL 文件,并启动后端、用户端和管理端。 - 可按顺序导入多个 SQL 文件,并启动后端、用户端和管理端。
- 服务启动前检查端口占用并终止旧进程,等待新服务端口就绪后再继续。 - 服务启动前检查端口占用并终止旧进程,等待新服务端口就绪后再继续。
@@ -19,7 +20,7 @@ AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署
- Windows 10/11 - Windows 10/11
- Python 3.9 或更高版本(需包含 Tkinter,Windows 官方 Python 安装包默认提供) - Python 3.9 或更高版本(需包含 Tkinter,Windows 官方 Python 安装包默认提供)
- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL - 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL;传统 SSM 项目还需要 Tomcat 9
- 可选:`winget`,用于在界面中安装缺失工具 - 可选:`winget`,用于在界面中安装缺失工具
本项目本身只使用 Python 标准库,不需要执行 `pip install`。 本项目本身只使用 Python 标准库,不需要执行 `pip install`。
@@ -45,6 +46,7 @@ python main.py
| 服务 | 端口 | 地址 | | 服务 | 端口 | 地址 |
| --- | ---: | --- | | --- | ---: | --- |
| Spring Boot 后端 | 8080 | `http://localhost:8080` | | Spring Boot 后端 | 8080 | `http://localhost:8080` |
| SSM / Tomcat 后端 | 8080 | `http://localhost:8080/<项目上下文>` |
| 管理端 | 8081 | `http://localhost:8081` | | 管理端 | 8081 | `http://localhost:8081` |
| 用户端 | 8082 | `http://localhost:8082` | | 用户端 | 8082 | `http://localhost:8082` |
@@ -52,7 +54,7 @@ python main.py
## 项目识别规则 ## 项目识别规则
- 后端:查找 `pom.xml`、`build.gradle` 或 `build.gradle.kts`,并结合 Spring Boot 依赖和 `src/main/java` 判断候选项目。 - 后端:查找 `pom.xml`、`build.gradle` 或 `build.gradle.kts`;Spring Boot 根据 Boot 依赖识别,SSM 根据 WAR 打包、`web.xml`、Spring/MyBatis 依赖识别。
- 前端:查找 `package.json`,结合 Vue 依赖、构建脚本及 `vue.config.js`/`vite.config.*` 判断项目。 - 前端:查找 `package.json`,结合 Vue 依赖、构建脚本及 `vue.config.js`/`vite.config.*` 判断项目。
- 前端角色:目录名包含 `admin`、`manage`、`后台` 等关键字时识别为管理端;包含 `user`、`client`、`web`、`用户` 等关键字时识别为用户端。 - 前端角色:目录名包含 `admin`、`manage`、`后台` 等关键字时识别为管理端;包含 `user`、`client`、`web`、`用户` 等关键字时识别为用户端。
- 启动脚本:依次使用 `serve`、`dev`、`start` 中第一个可用的 npm 脚本。 - 启动脚本:依次使用 `serve`、`dev`、`start` 中第一个可用的 npm 脚本。
@@ -62,10 +64,10 @@ python main.py
部署计划会根据扫描结果动态生成,典型顺序为: 部署计划会根据扫描结果动态生成,典型顺序为:
1. 使用 `mvn clean package -DskipTests` 或 Gradle `bootJar` 构建后端。 1. 使用 `mvn clean package -DskipTests` 或 Gradle `bootJar`/`war` 构建后端。
2. 为尚未安装依赖的前端执行 `npm install`。 2. 为尚未安装依赖的前端执行 `npm install`。
3. 使用 MySQL 命令行依次导入 SQL 文件。 3. 使用 MySQL 命令行依次导入 SQL 文件。
4. 使用 `java -jar` 启动后端,并等待 8080 端口就绪。 4. Spring Boot 使用 `java -jar` 启动;SSM 将 WAR 发布到 Tomcat 9;随后等待 8080 端口就绪。
5. 使用 npm 启动各前端开发服务器,并等待对应端口就绪。 5. 使用 npm 启动各前端开发服务器,并等待对应端口就绪。
6. 在运行日志中输出所有服务的访问地址。 6. 在运行日志中输出所有服务的访问地址。
@@ -94,7 +96,7 @@ python -m unittest discover -s tests -p "test_*.py"
│ ├── gui.py # Tkinter 工作台 │ ├── gui.py # Tkinter 工作台
│ ├── scanner.py # 项目扫描与组件识别 │ ├── scanner.py # 项目扫描与组件识别
│ ├── environment.py # 本机环境检测与工具发现 │ ├── environment.py # 本机环境检测与工具发现
│ ├── installer.py # winget 安装命令与 JDK 推荐 │ ├── installer.py # Maven/Tomcat 下载、winget 安装命令与 JDK 推荐
│ ├── path_manager.py # 用户环境变量管理 │ ├── path_manager.py # 用户环境变量管理
│ ├── deployer.py # 部署计划、命令执行与服务管理 │ ├── deployer.py # 部署计划、命令执行与服务管理
│ └── service_console.py # 后端独立控制台与日志转存 │ └── service_console.py # 后端独立控制台与日志转存

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import socket import socket
import shutil import shutil
import subprocess import subprocess
@@ -52,17 +53,21 @@ ProcessRunner = Callable[[list[str], Path, OutputHandler, Path | None, bool], in
def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]: def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]:
steps: list[DeploymentStep] = [] steps: list[DeploymentStep] = []
output_dir = config.output_dir or scan.root / "deploy" output_dir = config.output_dir or scan.root / "deploy"
backend_is_ssm = bool(
scan.backend and (scan.backend.kind == "SSM" or scan.backend.metadata.get("backend_type") == "ssm")
)
if scan.backend: if scan.backend:
backend_dir = scan.backend.path backend_dir = scan.backend.path
existing_jar = _find_existing_backend_jar(backend_dir) existing_artifact = _find_existing_backend_war(backend_dir) if backend_is_ssm else _find_existing_backend_jar(backend_dir)
if existing_jar: if existing_artifact:
artifact_label = "WAR" if backend_is_ssm else "jar"
steps.append( steps.append(
DeploymentStep( DeploymentStep(
"跳过后端构建", "跳过后端构建",
[], [],
backend_dir, backend_dir,
f"检测到已有后端 jar,跳过构建: {existing_jar}", f"检测到已有后端 {artifact_label},跳过构建: {existing_artifact}",
kind="backend_build_skip", kind="backend_build_skip",
) )
) )
@@ -71,14 +76,15 @@ def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[De
if "pom.xml" in build_file_names: if "pom.xml" in build_file_names:
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"] command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
elif "gradlew.bat" in build_file_names: elif "gradlew.bat" in build_file_names:
command = ["gradlew.bat", "bootJar"] command = ["gradlew.bat", "war" if backend_is_ssm else "bootJar"]
elif "gradlew" in build_file_names: elif "gradlew" in build_file_names:
command = ["./gradlew", "bootJar"] command = ["./gradlew", "war" if backend_is_ssm else "bootJar"]
elif backend_dir.exists(): elif backend_dir.exists():
command = [_tool_command(config, "Gradle", "gradle"), "bootJar"] command = [_tool_command(config, "Gradle", "gradle"), "war" if backend_is_ssm else "bootJar"]
else: else:
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"] command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
steps.append(DeploymentStep("构建后端", command, backend_dir, "打包 Spring Boot 应用", kind="backend_build")) description = "构建 SSM WAR 应用" if backend_is_ssm else "打包 Spring Boot 应用"
steps.append(DeploymentStep("构建后端", command, backend_dir, description, kind="backend_build"))
frontends = scan.detected_frontends frontends = scan.detected_frontends
for frontend in frontends: for frontend in frontends:
@@ -116,6 +122,31 @@ def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[De
if scan.backend: if scan.backend:
backend_context = scan.backend.metadata.get("context_path", "") backend_context = scan.backend.metadata.get("context_path", "")
backend_url = f"http://localhost:{config.backend_port}{backend_context}" backend_url = f"http://localhost:{config.backend_port}{backend_context}"
if backend_is_ssm:
if _validate_port(config.backend_port, "后端端口") != 8080:
raise ValueError("SSM 外置 Tomcat 当前使用默认端口 8080,请将后端端口设置为 8080")
tomcat_command = _required_tool_command(config, "Tomcat", "catalina.bat")
tomcat_home = Path(tomcat_command).parent.parent
context_name = backend_context.strip("/") or "ROOT"
if not re.fullmatch(r"[A-Za-z0-9._-]+", context_name):
raise ValueError(f"SSM context path 不能用于 WAR 文件名: {backend_context}")
deployed_war = tomcat_home / "webapps" / f"{context_name}.war"
steps.append(
DeploymentStep(
"发布并启动 SSM 后端",
[tomcat_command, "run"],
output_dir,
f"发布 WAR 到 Tomcat: {deployed_war}",
kind="ssm_start",
source_path=scan.backend.path,
output_path=deployed_war,
background=True,
service_name="SSM 后端",
port=8080,
url=backend_url,
)
)
else:
steps.append( steps.append(
DeploymentStep( DeploymentStep(
"启动后端", "启动后端",
@@ -193,6 +224,17 @@ def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> st
return str(discovered) if discovered else fallback return str(discovered) if discovered else fallback
def _required_tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> str:
command = _tool_command(config, tool_name, fallback)
path = Path(command)
if path.exists():
return str(path.resolve())
resolved = shutil.which(command)
if resolved:
return resolved
raise FileNotFoundError(f"部署 SSM 项目需要 {tool_name},请先安装或选择其可执行文件")
def _default_npm_command() -> str: def _default_npm_command() -> str:
return "npm.cmd" if os.name == "nt" else "npm" return "npm.cmd" if os.name == "nt" else "npm"
@@ -371,10 +413,12 @@ def _restart_backend(
cwd: Path, cwd: Path,
on_output: OutputHandler, on_output: OutputHandler,
*, *,
port: int | None = None,
require_service_pid: bool = True,
replace_listeners=None, replace_listeners=None,
popen=None, popen=None,
) -> int: ) -> int:
port = _extract_server_port(command) port = port if port is not None else _extract_server_port(command)
if port is None: if port is None:
raise ValueError("后端启动命令缺少 -Dserver.port 配置") raise ValueError("后端启动命令缺少 -Dserver.port 配置")
replace_listeners = replace_listeners or _replace_port_listeners replace_listeners = replace_listeners or _replace_port_listeners
@@ -433,7 +477,8 @@ def _restart_backend(
on_output(f"后端进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}") on_output(f"后端进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
return return_code or 1 return return_code or 1
service_pid = _read_service_pid(pid_path) or process.pid service_pid = _read_service_pid(pid_path) or process.pid
if service_pid in _find_listening_pids(port) and _is_port_open(port): listening_pids = _find_listening_pids(port)
if _is_port_open(port) and (not require_service_pid or service_pid in listening_pids):
on_output(f"后端服务启动成功,PID: {service_pid},端口: {port},日志: {log_path}") on_output(f"后端服务启动成功,PID: {service_pid},端口: {port},日志: {log_path}")
return 0 return 0
time.sleep(0.2) time.sleep(0.2)
@@ -443,6 +488,40 @@ def _restart_backend(
return 1 return 1
def _restart_ssm_backend(
command: list[str],
cwd: Path,
on_output: OutputHandler,
*,
port: int,
source_war: Path,
deployed_war: Path,
replace_listeners=None,
) -> int:
replace_listeners = replace_listeners or _replace_port_listeners
if not replace_listeners(port, on_output):
on_output(f"端口 {port} 未能在限定时间内释放,SSM 后端未启动。")
return 1
_deploy_ssm_war(source_war, deployed_war, on_output)
return _restart_backend(
command,
cwd,
on_output,
port=port,
require_service_pid=False,
replace_listeners=lambda _port, _output: True,
)
def _deploy_ssm_war(source_war: Path, deployed_war: Path, on_output: OutputHandler) -> None:
deployed_war.parent.mkdir(parents=True, exist_ok=True)
exploded_dir = deployed_war.with_suffix("")
if exploded_dir.is_dir():
shutil.rmtree(exploded_dir)
shutil.copy2(source_war, deployed_war)
on_output(f"SSM WAR 已发布: {source_war} -> {deployed_war}")
def _restart_frontend( def _restart_frontend(
command: list[str], command: list[str],
cwd: Path, cwd: Path,
@@ -545,6 +624,22 @@ def run_steps(
service_name=step.service_name or "前端", service_name=step.service_name or "前端",
log_path=step.output_path, log_path=step.output_path,
) )
elif step.kind == "ssm_start":
if step.port is None or step.source_path is None or step.output_path is None:
raise ValueError(f"{step.label}缺少端口、项目目录或 Tomcat WAR 路径配置")
source_war = _find_latest_backend_war(step.source_path)
if process_runner is None:
return_code = _restart_ssm_backend(
resolve_process_command(command),
step.cwd,
on_output,
port=step.port,
source_war=source_war,
deployed_war=step.output_path,
)
else:
_deploy_ssm_war(source_war, step.output_path, on_output)
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
else: else:
return_code = runner(command, step.cwd, on_output, step.input_path, step.background) return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
except Exception as exc: except Exception as exc:
@@ -589,6 +684,23 @@ def _find_existing_backend_jar(backend_dir: Path) -> Path | None:
return None return None
def _find_latest_backend_war(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("*.war") if not path.name.lower().endswith("-sources.war"))
if not candidates:
raise FileNotFoundError(f"未找到 SSM WAR 产物: {backend_dir / 'target'} 或 {backend_dir / 'build' / 'libs'}")
return max(candidates, key=lambda path: (path.stat().st_mtime, path.name))
def _find_existing_backend_war(backend_dir: Path) -> Path | None:
try:
return _find_latest_backend_war(backend_dir)
except FileNotFoundError:
return None
def _is_runnable_jar(path: Path) -> bool: def _is_runnable_jar(path: Path) -> bool:
name = path.name.lower() name = path.name.lower()
excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar") excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar")

View File

@@ -14,6 +14,7 @@ CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
DEFAULT_CHECKS: dict[str, list[str]] = { DEFAULT_CHECKS: dict[str, list[str]] = {
"JDK": ["java", "-version"], "JDK": ["java", "-version"],
"Maven": ["mvn", "-version"], "Maven": ["mvn", "-version"],
"Tomcat": ["catalina.bat", "version"],
"Gradle": ["gradle", "-version"], "Gradle": ["gradle", "-version"],
"Node.js": ["node", "--version"], "Node.js": ["node", "--version"],
"npm": ["npm", "--version"], "npm": ["npm", "--version"],
@@ -25,6 +26,7 @@ DEFAULT_CHECKS: dict[str, list[str]] = {
EXECUTABLE_NAMES: dict[str, list[str]] = { EXECUTABLE_NAMES: dict[str, list[str]] = {
"JDK": ["java.exe", "java"], "JDK": ["java.exe", "java"],
"Maven": ["mvn.cmd", "mvn.bat", "mvn"], "Maven": ["mvn.cmd", "mvn.bat", "mvn"],
"Tomcat": ["catalina.bat", "catalina.sh"],
"Gradle": ["gradle.bat", "gradle"], "Gradle": ["gradle.bat", "gradle"],
"Node.js": ["node.exe", "node"], "Node.js": ["node.exe", "node"],
"npm": ["npm.cmd", "npm"], "npm": ["npm.cmd", "npm"],
@@ -151,7 +153,11 @@ def _try_command(name: str, command: list[str], source: str, runner: CommandRunn
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False) environment = os.environ.copy()
executable = Path(command[0])
if executable.name.lower() in {"catalina.bat", "catalina.sh"} and executable.exists():
environment["CATALINA_HOME"] = str(executable.parent.parent)
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False, env=environment)
def _combine_output(completed: subprocess.CompletedProcess[str]) -> str: def _combine_output(completed: subprocess.CompletedProcess[str]) -> str:
@@ -175,6 +181,8 @@ def _direct_candidates(name: str, root: Path) -> list[Path]:
return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"] return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"]
if name == "Maven": if name == "Maven":
return _glob(root, "apache-maven*/bin/mvn.cmd") return _glob(root, "apache-maven*/bin/mvn.cmd")
if name == "Tomcat":
return _glob(root, "apache-tomcat*/bin/catalina.bat")
if name == "Gradle": if name == "Gradle":
return _glob(root, "gradle*/bin/gradle.bat") return _glob(root, "gradle*/bin/gradle.bat")
if name == "Git": if name == "Git":

View File

@@ -8,13 +8,21 @@ from tkinter import filedialog, messagebox, ttk
from .deployer import DeploymentConfig, build_deployment_plan, run_steps from .deployer import DeploymentConfig, build_deployment_plan, run_steps
from .environment import EnvironmentCheck, check_environment, load_tool_paths, save_tool_paths from .environment import EnvironmentCheck, check_environment, load_tool_paths, save_tool_paths
from .installer import build_install_command, recommend_jdk_version, run_install_command from .installer import (
MAVEN_VERSION,
TOMCAT_VERSION,
build_install_command,
install_maven,
install_tomcat,
recommend_jdk_version,
run_install_command,
)
from .path_manager import apply_user_environment, build_environment_update from .path_manager import apply_user_environment, build_environment_update
from .scanner import ScanResult, scan_project from .scanner import ScanResult, scan_project
TEXT = { TEXT = {
"title": "\u0053\u0070\u0072\u0069\u006e\u0067\u0020\u0042\u006f\u006f\u0074\u0020\u002b\u0020\u0056\u0075\u0065\u0020\u81ea\u52a8\u5316\u90e8\u7f72\u5de5\u5177", "title": "Spring Boot / SSM + Vue 自动化部署工具",
"workbench": "\u90e8\u7f72\u5de5\u4f5c\u53f0", "workbench": "\u90e8\u7f72\u5de5\u4f5c\u53f0",
"select_dir": "\u9009\u62e9\u76ee\u5f55", "select_dir": "\u9009\u62e9\u76ee\u5f55",
"scan": "\u626b\u63cf\u9879\u76ee", "scan": "\u626b\u63cf\u9879\u76ee",
@@ -54,7 +62,7 @@ class DeployWorkbench(tk.Tk):
self.tool_paths = load_tool_paths() self.tool_paths = load_tool_paths()
self.tool_path_vars: dict[str, tk.StringVar] = { self.tool_path_vars: dict[str, tk.StringVar] = {
name: tk.StringVar(value=str(self.tool_paths.get(name, ""))) name: tk.StringVar(value=str(self.tool_paths.get(name, "")))
for name in ("MySQL", "JDK", "Maven", "Gradle", "Node.js", "npm", "Git") for name in ("MySQL", "JDK", "Maven", "Tomcat", "Gradle", "Node.js", "npm", "Git")
} }
self.scan_result: ScanResult | None = None self.scan_result: ScanResult | None = None
@@ -124,7 +132,7 @@ class DeployWorkbench(tk.Tk):
self._build_logs(content) self._build_logs(content)
def _build_header(self, parent: ttk.Frame) -> None: def _build_header(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="Spring Boot + Vue 自动化部署", style="Title.TLabel").pack(anchor=tk.W) ttk.Label(parent, text="Spring Boot / SSM + Vue 自动化部署", style="Title.TLabel").pack(anchor=tk.W)
action_bar = ttk.Frame(parent) action_bar = ttk.Frame(parent)
action_bar.pack(fill=tk.X, pady=(14, 16)) action_bar.pack(fill=tk.X, pady=(14, 16))
self.project_dir_entry = ttk.Entry(action_bar, textvariable=self.project_dir) self.project_dir_entry = ttk.Entry(action_bar, textvariable=self.project_dir)
@@ -237,7 +245,7 @@ class DeployWorkbench(tk.Tk):
ttk.Button(jdk_row, text="\u52a0\u5165 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(8, 0)) ttk.Button(jdk_row, text="\u52a0\u5165 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(8, 0))
ttk.Label(jdk_row, textvariable=self.jdk_reason, style="Muted.TLabel").pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 0)) ttk.Label(jdk_row, textvariable=self.jdk_reason, style="Muted.TLabel").pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 0))
for name in ("Maven", "Gradle", "Node.js", "Git", "MySQL"): for name in ("Maven", "Tomcat", "Gradle", "Node.js", "Git", "MySQL"):
row = ttk.Frame(panel, style="Panel.TFrame") row = ttk.Frame(panel, style="Panel.TFrame")
row.pack(fill=tk.X, pady=5) row.pack(fill=tk.X, pady=5)
ttk.Label(row, text=name, width=12, style="CardTitle.TLabel").pack(side=tk.LEFT) ttk.Label(row, text=name, width=12, style="CardTitle.TLabel").pack(side=tk.LEFT)
@@ -347,6 +355,12 @@ class DeployWorkbench(tk.Tk):
threading.Thread(target=lambda: self.ui_queue.put(("install_done", ("JDK", run_install_command(command, self._thread_log)))), daemon=True).start() threading.Thread(target=lambda: self.ui_queue.put(("install_done", ("JDK", run_install_command(command, self._thread_log)))), daemon=True).start()
def install_tool_async(self, tool_name: str) -> None: def install_tool_async(self, tool_name: str) -> None:
if tool_name == "Maven":
self.install_maven_async()
return
if tool_name == "Tomcat":
self.install_tomcat_async()
return
try: try:
command = build_install_command(tool_name) command = build_install_command(tool_name)
except ValueError as exc: except ValueError as exc:
@@ -365,6 +379,42 @@ class DeployWorkbench(tk.Tk):
daemon=True, daemon=True,
).start() ).start()
def install_maven_async(self) -> None:
if not messagebox.askyesno(
"确认安装 Maven",
f"将从 Apache 官方地址下载 Maven {MAVEN_VERSION},校验安装包后解压到当前用户目录。\n\n"
"安装完成后会自动保存 mvn.cmd 路径。是否继续?",
):
return
self._append_log(f"开始安装 Maven {MAVEN_VERSION}...")
threading.Thread(target=self._install_maven_worker, daemon=True).start()
def _install_maven_worker(self) -> None:
try:
executable = install_maven(self._thread_log)
except Exception as exc:
self.ui_queue.put(("maven_install_done", (None, str(exc))))
return
self.ui_queue.put(("maven_install_done", (executable, "")))
def install_tomcat_async(self) -> None:
if not messagebox.askyesno(
"确认安装 Tomcat",
f"将从 Apache 官方地址下载 Tomcat {TOMCAT_VERSION},校验安装包后解压到当前用户目录。\n\n"
"Tomcat 9 适用于常见的 Java 8 / javax.servlet SSM 项目。是否继续?",
):
return
self._append_log(f"开始安装 Tomcat {TOMCAT_VERSION}...")
threading.Thread(target=self._install_tomcat_worker, daemon=True).start()
def _install_tomcat_worker(self) -> None:
try:
executable = install_tomcat(self._thread_log)
except Exception as exc:
self.ui_queue.put(("tomcat_install_done", (None, str(exc))))
return
self.ui_queue.put(("tomcat_install_done", (executable, "")))
def add_tool_to_path(self, tool_name: str) -> None: def add_tool_to_path(self, tool_name: str) -> None:
result = self._find_environment_result(tool_name) result = self._find_environment_result(tool_name)
executable = Path(result.command[0]) if result and Path(result.command[0]).exists() else None executable = Path(result.command[0]) if result and Path(result.command[0]).exists() else None
@@ -539,6 +589,28 @@ class DeployWorkbench(tk.Tk):
self._append_log(f"{tool_name} 安装命令结束,退出码: {exit_code}") self._append_log(f"{tool_name} 安装命令结束,退出码: {exit_code}")
self.check_environment_async() self.check_environment_async()
self.after(1800, lambda name=tool_name: self._prompt_add_tool_after_install(name)) self.after(1800, lambda name=tool_name: self._prompt_add_tool_after_install(name))
elif event == "maven_install_done":
executable, error = payload # type: ignore[misc]
if error:
self._append_log(f"Maven 安装失败: {error}")
messagebox.showerror("Maven 安装失败", str(error))
continue
self.tool_path_vars["Maven"].set(str(executable))
self.save_tool_path_settings(silent=True)
self._append_log(f"Maven 路径已保存: {executable}")
self.check_environment_async()
self.after(1800, lambda: self._prompt_add_tool_after_install("Maven"))
elif event == "tomcat_install_done":
executable, error = payload # type: ignore[misc]
if error:
self._append_log(f"Tomcat 安装失败: {error}")
messagebox.showerror("Tomcat 安装失败", str(error))
continue
self.tool_path_vars["Tomcat"].set(str(executable))
self.save_tool_path_settings(silent=True)
self._append_log(f"Tomcat 路径已保存: {executable}")
self.check_environment_async()
self.after(1800, lambda: self._prompt_add_tool_after_install("Tomcat"))
self.after(120, self._drain_queue) self.after(120, self._drain_queue)
def _prompt_add_tool_after_install(self, tool_name: str) -> None: def _prompt_add_tool_after_install(self, tool_name: str) -> None:

View File

@@ -1,8 +1,16 @@
from __future__ import annotations from __future__ import annotations
import re import re
import hashlib
import os
import shutil
import subprocess import subprocess
import tempfile
import urllib.request
import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, Callable
from .scanner import ScanResult from .scanner import ScanResult
@@ -15,13 +23,19 @@ JDK_PACKAGES = {
} }
WINGET_PACKAGES = { WINGET_PACKAGES = {
"Maven": "Apache.Maven",
"Gradle": "Gradle.Gradle", "Gradle": "Gradle.Gradle",
"Node.js": "OpenJS.NodeJS.LTS", "Node.js": "OpenJS.NodeJS.LTS",
"Git": "Git.Git", "Git": "Git.Git",
"MySQL": "Oracle.MySQL", "MySQL": "Oracle.MySQL",
} }
MAVEN_VERSION = "3.10.0"
MAVEN_ARCHIVE_NAME = f"apache-maven-{MAVEN_VERSION}-bin.zip"
MAVEN_DOWNLOAD_BASE = f"https://dlcdn.apache.org/maven/maven-3/{MAVEN_VERSION}/binaries"
TOMCAT_VERSION = "9.0.122"
TOMCAT_ARCHIVE_NAME = f"apache-tomcat-{TOMCAT_VERSION}-windows-x64.zip"
TOMCAT_DOWNLOAD_BASE = f"https://dlcdn.apache.org/tomcat/tomcat-9/v{TOMCAT_VERSION}/bin"
ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%") PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%")
PROGRESS_CHARS = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\" PROGRESS_CHARS = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\"
@@ -65,6 +79,153 @@ def build_install_command(tool_name: str, version: str | None = None) -> list[st
return ["winget", "install", "-e", "--id", package_id] return ["winget", "install", "-e", "--id", package_id]
def default_maven_install_root() -> Path:
local_app_data = os.environ.get("LOCALAPPDATA", "").strip()
base = Path(local_app_data) if local_app_data else Path.home() / ".autodeploy"
return base / "AutoDeploy" / "tools"
def install_maven(
on_output: Callable[[str], None],
install_root: Path | None = None,
opener: Callable[..., BinaryIO] | None = None,
) -> Path:
root = Path(install_root) if install_root is not None else default_maven_install_root()
archive_url = f"{MAVEN_DOWNLOAD_BASE}/{MAVEN_ARCHIVE_NAME}"
return _install_verified_apache_archive(
product_name="Maven",
version=MAVEN_VERSION,
archive_name=MAVEN_ARCHIVE_NAME,
archive_url=archive_url,
extracted_dir_name=f"apache-maven-{MAVEN_VERSION}",
executable_relative=Path("bin") / "mvn.cmd",
on_output=on_output,
install_root=root,
opener=opener,
)
def install_tomcat(
on_output: Callable[[str], None],
install_root: Path | None = None,
opener: Callable[..., BinaryIO] | None = None,
) -> Path:
root = Path(install_root) if install_root is not None else default_maven_install_root()
archive_url = f"{TOMCAT_DOWNLOAD_BASE}/{TOMCAT_ARCHIVE_NAME}"
return _install_verified_apache_archive(
product_name="Tomcat",
version=TOMCAT_VERSION,
archive_name=TOMCAT_ARCHIVE_NAME,
archive_url=archive_url,
extracted_dir_name=f"apache-tomcat-{TOMCAT_VERSION}",
executable_relative=Path("bin") / "catalina.bat",
on_output=on_output,
install_root=root,
opener=opener,
)
def _install_verified_apache_archive(
*,
product_name: str,
version: str,
archive_name: str,
archive_url: str,
extracted_dir_name: str,
executable_relative: Path,
on_output: Callable[[str], None],
install_root: Path,
opener: Callable[..., BinaryIO] | None,
) -> Path:
product_home = install_root / extracted_dir_name
executable = product_home / executable_relative
if executable.exists():
on_output(f"{product_name} {version} 已安装: {executable}")
return executable
open_url = opener or urllib.request.urlopen
checksum_url = f"{archive_url}.sha512"
install_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=f"autodeploy-{product_name.lower()}-") as temporary:
temp_dir = Path(temporary)
archive_path = temp_dir / archive_name
on_output(f"正在从 Apache 官方地址下载 {product_name} {version}...")
_download_to_file(archive_url, archive_path, open_url, on_output)
expected_checksum = _download_checksum(checksum_url, open_url)
actual_checksum = _sha512(archive_path)
if actual_checksum.lower() != expected_checksum.lower():
raise RuntimeError(f"{product_name} 安装包 SHA-512 校验失败,已停止安装。")
on_output(f"{product_name} 安装包校验通过,正在解压...")
extract_root = temp_dir / "extracted"
_safe_extract_zip(archive_path, extract_root)
extracted_home = extract_root / extracted_dir_name
extracted_executable = extracted_home / executable_relative
if not extracted_executable.exists():
raise RuntimeError(f"{product_name} 安装包结构异常,未找到 {executable_relative}。")
shutil.copytree(extracted_home, product_home, dirs_exist_ok=True)
if not executable.exists():
raise RuntimeError(f"{product_name} 解压完成,但未找到 {executable_relative}。")
on_output(f"{product_name} {version} 安装完成: {executable}")
return executable
def _download_to_file(
url: str,
destination: Path,
opener: Callable[..., BinaryIO],
on_output: Callable[[str], None],
) -> None:
with opener(url, timeout=60) as response, destination.open("wb") as output:
headers = getattr(response, "headers", {})
total_text = headers.get("Content-Length", "") if hasattr(headers, "get") else ""
total = int(total_text) if str(total_text).isdigit() else 0
downloaded = 0
last_percent = -10
while True:
chunk = response.read(1024 * 256)
if not chunk:
break
output.write(chunk)
downloaded += len(chunk)
if total:
percent = min(downloaded * 100 // total, 100)
if percent >= last_percent + 10 or percent == 100:
on_output(f"下载进度: {percent}%")
last_percent = percent
def _download_checksum(url: str, opener: Callable[..., BinaryIO]) -> str:
with opener(url, timeout=30) as response:
text = response.read().decode("ascii", errors="strict").strip()
checksum = text.split()[0] if text else ""
if not re.fullmatch(r"[0-9a-fA-F]{128}", checksum):
raise RuntimeError("Apache Maven 校验文件格式无效。")
return checksum
def _sha512(path: Path) -> str:
digest = hashlib.sha512()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _safe_extract_zip(archive_path: Path, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
destination_resolved = destination.resolve()
with zipfile.ZipFile(archive_path) as archive:
for member in archive.infolist():
target = (destination / member.filename).resolve()
if target != destination_resolved and destination_resolved not in target.parents:
raise RuntimeError("Maven 安装包包含不安全的文件路径。")
archive.extractall(destination)
def run_install_command(command: list[str], on_output) -> int: def run_install_command(command: list[str], on_output) -> int:
output_lines: list[str] = [] output_lines: list[str] = []
process = subprocess.Popen( process = subprocess.Popen(

View File

@@ -79,6 +79,8 @@ def build_environment_update(tool_name: str, executable: Path) -> EnvironmentUpd
env_vars["JAVA_HOME"] = str(bin_dir.parent) env_vars["JAVA_HOME"] = str(bin_dir.parent)
elif tool_name == "Maven": elif tool_name == "Maven":
env_vars["MAVEN_HOME"] = str(bin_dir.parent) env_vars["MAVEN_HOME"] = str(bin_dir.parent)
elif tool_name == "Tomcat":
env_vars["CATALINA_HOME"] = str(bin_dir.parent)
elif tool_name == "Gradle": elif tool_name == "Gradle":
env_vars["GRADLE_HOME"] = str(bin_dir.parent) env_vars["GRADLE_HOME"] = str(bin_dir.parent)

View File

@@ -50,7 +50,7 @@ def scan_project(root: str | Path) -> ScanResult:
def _find_backend(root: Path) -> ProjectComponent | None: def _find_backend(root: Path) -> ProjectComponent | None:
candidates: list[tuple[int, Path, str]] = [] candidates: list[tuple[int, Path, str, str, dict[str, str]]] = []
for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}): for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}):
project_dir = build_file.parent project_dir = build_file.parent
score = 1 score = 1
@@ -58,9 +58,29 @@ def _find_backend(root: Path) -> ProjectComponent | None:
text = _read_text(build_file) text = _read_text(build_file)
metadata = _extract_backend_metadata(text) metadata = _extract_backend_metadata(text)
metadata.update(_extract_backend_runtime_metadata(project_dir)) metadata.update(_extract_backend_runtime_metadata(project_dir))
if "spring-boot" in text or "org.springframework.boot" in text: is_spring_boot = "spring-boot" in text or "org.springframework.boot" in text
web_xml = project_dir / "src" / "main" / "webapp" / "WEB-INF" / "web.xml"
packaging = metadata.get("packaging", "")
is_ssm = not is_spring_boot and (
packaging == "war"
or web_xml.exists()
or ("spring" in text and "mybatis" in text)
)
if not is_spring_boot and not is_ssm:
continue
if is_spring_boot:
score += 3 score += 3
detail_parts.append("Spring Boot dependency") detail_parts.append("Spring Boot dependency")
kind = "Spring Boot"
metadata["backend_type"] = "spring_boot"
else:
score += 3
kind = "SSM"
metadata["backend_type"] = "ssm"
metadata["packaging"] = "war"
context_name = metadata.get("final_name") or metadata.get("artifact_id") or project_dir.name
metadata.setdefault("context_path", f"/{context_name}")
detail_parts.append("SSM WAR application")
if java_version := metadata.get("java_version"): if java_version := metadata.get("java_version"):
detail_parts.append(f"Java {java_version}") detail_parts.append(f"Java {java_version}")
if boot_version := metadata.get("spring_boot_version"): if boot_version := metadata.get("spring_boot_version"):
@@ -68,14 +88,14 @@ def _find_backend(root: Path) -> ProjectComponent | None:
if (project_dir / "src" / "main" / "java").exists(): if (project_dir / "src" / "main" / "java").exists():
score += 2 score += 2
detail_parts.append("src/main/java") detail_parts.append("src/main/java")
candidates.append((score, project_dir, ", ".join(detail_parts), metadata)) candidates.append((score, project_dir, kind, ", ".join(detail_parts), metadata))
if not candidates: if not candidates:
return None return None
score, path, detail, metadata = sorted(candidates, key=lambda item: (-item[0], len(item[1].parts)))[0] score, path, kind, detail, metadata = sorted(candidates, key=lambda item: (-item[0], len(item[1].parts)))[0]
if score < 2: if score < 2:
return None return None
return ProjectComponent("Spring Boot", path, detail, metadata) return ProjectComponent(kind, path, detail, metadata)
def _find_frontends(root: Path) -> list[ProjectComponent]: def _find_frontends(root: Path) -> list[ProjectComponent]:
@@ -188,6 +208,11 @@ def _load_json(path: Path) -> dict:
def _extract_backend_metadata(text: str) -> dict[str, str]: def _extract_backend_metadata(text: str) -> dict[str, str]:
metadata: dict[str, str] = {} metadata: dict[str, str] = {}
project_text = re.sub(r"<parent\b[^>]*>.*?</parent>", "", text, flags=re.IGNORECASE | re.DOTALL)
for key, tag in (("packaging", "packaging"), ("artifact_id", "artifactId"), ("final_name", "finalName")):
match = re.search(rf"<{tag}>\s*([^<\s]+)\s*</{tag}>", project_text, re.IGNORECASE)
if match:
metadata[key] = match.group(1).strip()
java_patterns = [ java_patterns = [
r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>", r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>",
r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>", r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>",

View File

@@ -35,6 +35,10 @@ def main() -> int:
pid_path.parent.mkdir(parents=True, exist_ok=True) pid_path.parent.mkdir(parents=True, exist_ok=True)
encoding = locale.getpreferredencoding(False) or "utf-8" encoding = locale.getpreferredencoding(False) or "utf-8"
environment = os.environ.copy()
executable = Path(command[0])
if executable.name.lower() in {"catalina.bat", "catalina.sh"} and executable.exists():
environment["CATALINA_HOME"] = str(executable.parent.parent)
with log_path.open("a", encoding="utf-8") as log_file: with log_path.open("a", encoding="utf-8") as log_file:
process = subprocess.Popen( process = subprocess.Popen(
command, command,
@@ -46,6 +50,7 @@ def main() -> int:
errors="replace", errors="replace",
bufsize=1, bufsize=1,
shell=False, shell=False,
env=environment,
) )
pid_path.write_text(str(process.pid), encoding="ascii") pid_path.write_text(str(process.pid), encoding="ascii")
assert process.stdout is not None assert process.stdout is not None

View File

@@ -182,6 +182,30 @@ class DeployerTests(unittest.TestCase):
self.assertEqual(exit_code, 1) self.assertEqual(exit_code, 1)
self.assertEqual(terminated, [27544]) self.assertEqual(terminated, [27544])
def test_tomcat_restart_accepts_port_owned_by_batch_child_process(self):
class RunningProcess:
pid = 27544
def poll(self):
return None
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", return_value=True),
patch.object(deployer_module, "_find_listening_pids", return_value={31000}),
):
exit_code = deployer_module._restart_backend(
["catalina.bat", "run"],
Path(temp),
lambda _value: None,
port=8080,
require_service_pid=False,
replace_listeners=lambda _port, _output: True,
popen=lambda *args, **kwargs: RunningProcess(),
)
self.assertEqual(exit_code, 0)
def test_backend_restart_terminates_child_after_startup_timeout(self): def test_backend_restart_terminates_child_after_startup_timeout(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0) restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
terminated = [] terminated = []
@@ -496,6 +520,64 @@ class DeployerTests(unittest.TestCase):
self.assertEqual(plan[0].command, [str(maven), "clean", "package", "-DskipTests"]) self.assertEqual(plan[0].command, [str(maven), "clean", "package", "-DskipTests"])
def test_deploys_ssm_war_to_configured_tomcat(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "ssm-server"
target = backend / "target"
tomcat = root / "apache-tomcat" / "bin" / "catalina.bat"
target.mkdir(parents=True)
tomcat.parent.mkdir(parents=True)
tomcat.write_text("@echo off", encoding="utf-8")
source_war = target / "movies.war"
source_war.write_text("war-content", encoding="utf-8")
scan = ScanResult(
root=root,
backend=ProjectComponent(
"SSM", backend, "Maven SSM WAR", {"backend_type": "ssm", "context_path": "/movies"}
),
frontend=None,
sql_files=[],
)
config = DeploymentConfig(
output_dir=root / "deploy",
tool_paths={"Tomcat": tomcat},
tool_search_roots=[root / "missing"],
)
plan = build_deployment_plan(scan, config)
self.assertEqual([step.kind for step in plan], ["backend_build_skip", "ssm_start", "deployment_summary"])
start_step = plan[1]
self.assertEqual(start_step.command, [str(tomcat.resolve()), "run"])
self.assertEqual(start_step.output_path, tomcat.parent.parent / "webapps" / "movies.war")
self.assertEqual(start_step.url, "http://localhost:8080/movies")
calls = []
def fake_runner(command, cwd, on_output, input_path=None, background=False):
calls.append((command, cwd, background))
return 0
self.assertEqual(run_steps(plan, lambda _line: None, process_runner=fake_runner), 0)
self.assertEqual((tomcat.parent.parent / "webapps" / "movies.war").read_text(), "war-content")
self.assertIn(([str(tomcat.resolve()), "run"], root / "deploy", True), calls)
def test_ssm_plan_requires_tomcat(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "ssm-server"
backend.mkdir()
scan = ScanResult(
root=root,
backend=ProjectComponent("SSM", backend, "SSM", {"backend_type": "ssm", "context_path": "/app"}),
frontend=None,
sql_files=[],
)
with self.assertRaisesRegex(FileNotFoundError, "需要 Tomcat"):
build_deployment_plan(scan, DeploymentConfig(tool_search_roots=[root / "missing"]))
def test_plan_uses_discovered_mysql_path_for_sql_import(self): def test_plan_uses_discovered_mysql_path_for_sql_import(self):
with TemporaryDirectory() as temp: with TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)

View File

@@ -2,7 +2,9 @@ import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import deploy_tool.environment as environment_module
from deploy_tool.environment import check_environment, load_tool_paths, save_tool_paths from deploy_tool.environment import check_environment, load_tool_paths, save_tool_paths
@@ -98,6 +100,18 @@ class EnvironmentTests(unittest.TestCase):
self.assertEqual(loaded, {"MySQL": mysql}) self.assertEqual(loaded, {"MySQL": mysql})
def test_tomcat_command_receives_catalina_home(self):
with tempfile.TemporaryDirectory() as tmp:
catalina = Path(tmp) / "apache-tomcat" / "bin" / "catalina.bat"
catalina.parent.mkdir(parents=True)
catalina.write_text("@echo off", encoding="utf-8")
completed = subprocess.CompletedProcess([str(catalina), "version"], 0, "Tomcat", "")
with patch("deploy_tool.environment.subprocess.run", return_value=completed) as run:
environment_module._run_command([str(catalina), "version"])
self.assertEqual(run.call_args.kwargs["env"]["CATALINA_HOME"], str(catalina.parent.parent))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,8 +1,20 @@
import hashlib
import io
import tempfile import tempfile
import unittest import unittest
import zipfile
from pathlib import Path from pathlib import Path
from deploy_tool.installer import build_install_command, explain_install_failure, format_install_output, recommend_jdk_version from deploy_tool.installer import (
MAVEN_ARCHIVE_NAME,
TOMCAT_VERSION,
install_maven,
install_tomcat,
build_install_command,
explain_install_failure,
format_install_output,
recommend_jdk_version,
)
from deploy_tool.scanner import scan_project from deploy_tool.scanner import scan_project
@@ -73,6 +85,54 @@ class InstallerTests(unittest.TestCase):
self.assertEqual(command, ["winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"]) self.assertEqual(command, ["winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"])
def test_maven_is_not_offered_as_an_invalid_winget_package(self):
with self.assertRaises(ValueError):
build_install_command("Maven")
def test_installs_maven_from_verified_apache_archive(self):
archive_buffer = io.BytesIO()
with zipfile.ZipFile(archive_buffer, "w") as archive:
archive.writestr("apache-maven-3.10.0/bin/mvn.cmd", "@echo off")
archive_bytes = archive_buffer.getvalue()
checksum = hashlib.sha512(archive_bytes).hexdigest().encode("ascii")
def opener(url, timeout):
return io.BytesIO(checksum if url.endswith(".sha512") else archive_bytes)
with tempfile.TemporaryDirectory() as tmp:
messages = []
executable = install_maven(messages.append, Path(tmp), opener)
self.assertTrue(executable.exists())
self.assertEqual(executable.read_text(), "@echo off")
self.assertTrue(any("校验通过" in message for message in messages))
def test_rejects_maven_archive_with_wrong_checksum(self):
def opener(url, timeout):
payload = ("0" * 128).encode("ascii") if url.endswith(".sha512") else b"not-a-zip"
return io.BytesIO(payload)
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(RuntimeError, "SHA-512"):
install_maven(lambda _message: None, Path(tmp), opener)
self.assertFalse((Path(tmp) / MAVEN_ARCHIVE_NAME.removesuffix("-bin.zip")).exists())
def test_installs_tomcat_from_verified_apache_archive(self):
archive_buffer = io.BytesIO()
with zipfile.ZipFile(archive_buffer, "w") as archive:
archive.writestr(f"apache-tomcat-{TOMCAT_VERSION}/bin/catalina.bat", "@echo off")
archive_bytes = archive_buffer.getvalue()
checksum = hashlib.sha512(archive_bytes).hexdigest().encode("ascii")
def opener(url, timeout):
return io.BytesIO(checksum if url.endswith(".sha512") else archive_bytes)
with tempfile.TemporaryDirectory() as tmp:
executable = install_tomcat(lambda _message: None, Path(tmp), opener)
self.assertTrue(executable.exists())
self.assertEqual(executable.name, "catalina.bat")
def test_formats_winget_progress_without_garbled_bar(self): def test_formats_winget_progress_without_garbled_bar(self):
raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8") raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8")

View File

@@ -32,6 +32,12 @@ class PathManagerTests(unittest.TestCase):
self.assertEqual(update.path_entries, [Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin")]) self.assertEqual(update.path_entries, [Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin")])
self.assertEqual(update.env_vars["JAVA_HOME"], r"C:\Program Files\Eclipse Adoptium\jdk-8") self.assertEqual(update.env_vars["JAVA_HOME"], r"C:\Program Files\Eclipse Adoptium\jdk-8")
def test_build_environment_update_sets_catalina_home_from_bin(self):
update = build_environment_update("Tomcat", Path(r"C:\Tools\apache-tomcat-9\bin\catalina.bat"))
self.assertEqual(update.path_entries, [Path(r"C:\Tools\apache-tomcat-9\bin")])
self.assertEqual(update.env_vars["CATALINA_HOME"], r"C:\Tools\apache-tomcat-9")
def test_apply_user_environment_writes_path_and_env_vars(self): def test_apply_user_environment_writes_path_and_env_vars(self):
registry = FakeRegistry() registry = FakeRegistry()
registry.values["Path"] = r"C:\Windows" registry.values["Path"] = r"C:\Windows"

View File

@@ -99,6 +99,37 @@ class ScannerTests(unittest.TestCase):
self.assertEqual(result.frontend.path, frontend) self.assertEqual(result.frontend.path, frontend)
self.assertEqual(result.sql_files, [sql_dir / "schema.sql"]) self.assertEqual(result.sql_files, [sql_dir / "schema.sql"])
def test_detects_maven_ssm_war_project_and_context_path(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
backend = root / "ssm-server"
(backend / "src" / "main" / "java").mkdir(parents=True)
web_inf = backend / "src" / "main" / "webapp" / "WEB-INF"
web_inf.mkdir(parents=True)
(web_inf / "web.xml").write_text("<web-app />", encoding="utf-8")
(backend / "pom.xml").write_text(
"""
<project>
<artifactId>movie-system</artifactId>
<packaging>war</packaging>
<dependencies>
<dependency><artifactId>spring-webmvc</artifactId></dependency>
<dependency><artifactId>mybatis</artifactId></dependency>
</dependencies>
<build><finalName>movies</finalName></build>
</project>
""",
encoding="utf-8",
)
result = scan_project(root)
self.assertIsNotNone(result.backend)
self.assertEqual(result.backend.kind, "SSM")
self.assertEqual(result.backend.metadata["backend_type"], "ssm")
self.assertEqual(result.backend.metadata["packaging"], "war")
self.assertEqual(result.backend.metadata["context_path"], "/movies")
def test_empty_folder_reports_missing_parts(self): def test_empty_folder_reports_missing_parts(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
result = scan_project(Path(tmp)) result = scan_project(Path(tmp))