Add SSM deployment and reliable Maven setup
This commit is contained in:
24
README.md
24
README.md
@@ -1,15 +1,16 @@
|
||||
# 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。
|
||||
- 检测 JDK、Maven、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
|
||||
- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven 和 Gradle 会同步设置对应的环境变量。
|
||||
- 通过 `winget` 辅助安装常用开发环境,执行安装前会要求用户确认。
|
||||
- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR 时自动跳过重复构建。
|
||||
- 检测 JDK、Maven、Tomcat、Gradle、Node.js、npm、Vue CLI、MySQL 和 Git。
|
||||
- 支持手动指定工具路径,并可将工具目录写入当前用户的 `PATH`;JDK、Maven、Tomcat 和 Gradle 会同步设置对应的环境变量。
|
||||
- Maven 和 Tomcat 从 Apache 官方地址下载并校验后安装到当前用户目录;其他常用开发环境通过 `winget` 辅助安装,执行安装前会要求用户确认。
|
||||
- 使用 Maven 或 Gradle 构建后端;已有可运行 JAR/WAR 时自动跳过重复构建。
|
||||
- 将传统 SSM 项目的 WAR 发布到 Tomcat 9,并等待 8080 端口就绪。
|
||||
- 前端缺少 `node_modules` 时自动执行 `npm install`,随后运行 `serve`、`dev` 或 `start` 脚本。
|
||||
- 可按顺序导入多个 SQL 文件,并启动后端、用户端和管理端。
|
||||
- 服务启动前检查端口占用并终止旧进程,等待新服务端口就绪后再继续。
|
||||
@@ -19,7 +20,7 @@ AutoDeploy 是一个面向 Windows 的 Spring Boot + Vue 本地自动化部署
|
||||
|
||||
- Windows 10/11
|
||||
- Python 3.9 或更高版本(需包含 Tkinter,Windows 官方 Python 安装包默认提供)
|
||||
- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL
|
||||
- 待部署项目所需的 JDK、Maven/Gradle、Node.js/npm 和 MySQL;传统 SSM 项目还需要 Tomcat 9
|
||||
- 可选:`winget`,用于在界面中安装缺失工具
|
||||
|
||||
本项目本身只使用 Python 标准库,不需要执行 `pip install`。
|
||||
@@ -45,6 +46,7 @@ python main.py
|
||||
| 服务 | 端口 | 地址 |
|
||||
| --- | ---: | --- |
|
||||
| Spring Boot 后端 | 8080 | `http://localhost:8080` |
|
||||
| SSM / Tomcat 后端 | 8080 | `http://localhost:8080/<项目上下文>` |
|
||||
| 管理端 | 8081 | `http://localhost:8081` |
|
||||
| 用户端 | 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.*` 判断项目。
|
||||
- 前端角色:目录名包含 `admin`、`manage`、`后台` 等关键字时识别为管理端;包含 `user`、`client`、`web`、`用户` 等关键字时识别为用户端。
|
||||
- 启动脚本:依次使用 `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`。
|
||||
3. 使用 MySQL 命令行依次导入 SQL 文件。
|
||||
4. 使用 `java -jar` 启动后端,并等待 8080 端口就绪。
|
||||
4. Spring Boot 使用 `java -jar` 启动;SSM 将 WAR 发布到 Tomcat 9;随后等待 8080 端口就绪。
|
||||
5. 使用 npm 启动各前端开发服务器,并等待对应端口就绪。
|
||||
6. 在运行日志中输出所有服务的访问地址。
|
||||
|
||||
@@ -94,7 +96,7 @@ python -m unittest discover -s tests -p "test_*.py"
|
||||
│ ├── gui.py # Tkinter 工作台
|
||||
│ ├── scanner.py # 项目扫描与组件识别
|
||||
│ ├── environment.py # 本机环境检测与工具发现
|
||||
│ ├── installer.py # winget 安装命令与 JDK 推荐
|
||||
│ ├── installer.py # Maven/Tomcat 下载、winget 安装命令与 JDK 推荐
|
||||
│ ├── path_manager.py # 用户环境变量管理
|
||||
│ ├── deployer.py # 部署计划、命令执行与服务管理
|
||||
│ └── service_console.py # 后端独立控制台与日志转存
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import shutil
|
||||
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]:
|
||||
steps: list[DeploymentStep] = []
|
||||
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:
|
||||
backend_dir = scan.backend.path
|
||||
existing_jar = _find_existing_backend_jar(backend_dir)
|
||||
if existing_jar:
|
||||
existing_artifact = _find_existing_backend_war(backend_dir) if backend_is_ssm else _find_existing_backend_jar(backend_dir)
|
||||
if existing_artifact:
|
||||
artifact_label = "WAR" if backend_is_ssm else "jar"
|
||||
steps.append(
|
||||
DeploymentStep(
|
||||
"跳过后端构建",
|
||||
[],
|
||||
backend_dir,
|
||||
f"检测到已有后端 jar,跳过构建: {existing_jar}",
|
||||
f"检测到已有后端 {artifact_label},跳过构建: {existing_artifact}",
|
||||
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:
|
||||
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
|
||||
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:
|
||||
command = ["./gradlew", "bootJar"]
|
||||
command = ["./gradlew", "war" if backend_is_ssm else "bootJar"]
|
||||
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:
|
||||
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
|
||||
for frontend in frontends:
|
||||
@@ -116,6 +122,31 @@ def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[De
|
||||
if scan.backend:
|
||||
backend_context = scan.backend.metadata.get("context_path", "")
|
||||
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(
|
||||
DeploymentStep(
|
||||
"启动后端",
|
||||
@@ -193,6 +224,17 @@ def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> st
|
||||
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:
|
||||
return "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
@@ -371,10 +413,12 @@ def _restart_backend(
|
||||
cwd: Path,
|
||||
on_output: OutputHandler,
|
||||
*,
|
||||
port: int | None = None,
|
||||
require_service_pid: bool = True,
|
||||
replace_listeners=None,
|
||||
popen=None,
|
||||
) -> int:
|
||||
port = _extract_server_port(command)
|
||||
port = port if port is not None else _extract_server_port(command)
|
||||
if port is None:
|
||||
raise ValueError("后端启动命令缺少 -Dserver.port 配置")
|
||||
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}")
|
||||
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):
|
||||
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}")
|
||||
return 0
|
||||
time.sleep(0.2)
|
||||
@@ -443,6 +488,40 @@ def _restart_backend(
|
||||
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(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
@@ -545,6 +624,22 @@ def run_steps(
|
||||
service_name=step.service_name or "前端",
|
||||
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:
|
||||
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
|
||||
except Exception as exc:
|
||||
@@ -589,6 +684,23 @@ def _find_existing_backend_jar(backend_dir: Path) -> Path | 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:
|
||||
name = path.name.lower()
|
||||
excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar")
|
||||
|
||||
@@ -14,6 +14,7 @@ CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
||||
DEFAULT_CHECKS: dict[str, list[str]] = {
|
||||
"JDK": ["java", "-version"],
|
||||
"Maven": ["mvn", "-version"],
|
||||
"Tomcat": ["catalina.bat", "version"],
|
||||
"Gradle": ["gradle", "-version"],
|
||||
"Node.js": ["node", "--version"],
|
||||
"npm": ["npm", "--version"],
|
||||
@@ -25,6 +26,7 @@ DEFAULT_CHECKS: dict[str, list[str]] = {
|
||||
EXECUTABLE_NAMES: dict[str, list[str]] = {
|
||||
"JDK": ["java.exe", "java"],
|
||||
"Maven": ["mvn.cmd", "mvn.bat", "mvn"],
|
||||
"Tomcat": ["catalina.bat", "catalina.sh"],
|
||||
"Gradle": ["gradle.bat", "gradle"],
|
||||
"Node.js": ["node.exe", "node"],
|
||||
"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]:
|
||||
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:
|
||||
@@ -175,6 +181,8 @@ def _direct_candidates(name: str, root: Path) -> list[Path]:
|
||||
return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"]
|
||||
if name == "Maven":
|
||||
return _glob(root, "apache-maven*/bin/mvn.cmd")
|
||||
if name == "Tomcat":
|
||||
return _glob(root, "apache-tomcat*/bin/catalina.bat")
|
||||
if name == "Gradle":
|
||||
return _glob(root, "gradle*/bin/gradle.bat")
|
||||
if name == "Git":
|
||||
|
||||
@@ -8,13 +8,21 @@ from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from .deployer import DeploymentConfig, build_deployment_plan, run_steps
|
||||
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 .scanner import ScanResult, scan_project
|
||||
|
||||
|
||||
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",
|
||||
"select_dir": "\u9009\u62e9\u76ee\u5f55",
|
||||
"scan": "\u626b\u63cf\u9879\u76ee",
|
||||
@@ -54,7 +62,7 @@ class DeployWorkbench(tk.Tk):
|
||||
self.tool_paths = load_tool_paths()
|
||||
self.tool_path_vars: dict[str, tk.StringVar] = {
|
||||
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
|
||||
@@ -124,7 +132,7 @@ class DeployWorkbench(tk.Tk):
|
||||
self._build_logs(content)
|
||||
|
||||
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.pack(fill=tk.X, pady=(14, 16))
|
||||
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.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.pack(fill=tk.X, pady=5)
|
||||
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()
|
||||
|
||||
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:
|
||||
command = build_install_command(tool_name)
|
||||
except ValueError as exc:
|
||||
@@ -365,6 +379,42 @@ class DeployWorkbench(tk.Tk):
|
||||
daemon=True,
|
||||
).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:
|
||||
result = self._find_environment_result(tool_name)
|
||||
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.check_environment_async()
|
||||
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)
|
||||
|
||||
def _prompt_add_tool_after_install(self, tool_name: str) -> None:
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Callable
|
||||
|
||||
from .scanner import ScanResult
|
||||
|
||||
@@ -15,13 +23,19 @@ JDK_PACKAGES = {
|
||||
}
|
||||
|
||||
WINGET_PACKAGES = {
|
||||
"Maven": "Apache.Maven",
|
||||
"Gradle": "Gradle.Gradle",
|
||||
"Node.js": "OpenJS.NodeJS.LTS",
|
||||
"Git": "Git.Git",
|
||||
"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-?]*[ -/]*[@-~]")
|
||||
PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%")
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
output_lines: list[str] = []
|
||||
process = subprocess.Popen(
|
||||
|
||||
@@ -79,6 +79,8 @@ def build_environment_update(tool_name: str, executable: Path) -> EnvironmentUpd
|
||||
env_vars["JAVA_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Maven":
|
||||
env_vars["MAVEN_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Tomcat":
|
||||
env_vars["CATALINA_HOME"] = str(bin_dir.parent)
|
||||
elif tool_name == "Gradle":
|
||||
env_vars["GRADLE_HOME"] = str(bin_dir.parent)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def scan_project(root: str | Path) -> ScanResult:
|
||||
|
||||
|
||||
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"}):
|
||||
project_dir = build_file.parent
|
||||
score = 1
|
||||
@@ -58,9 +58,29 @@ def _find_backend(root: Path) -> ProjectComponent | None:
|
||||
text = _read_text(build_file)
|
||||
metadata = _extract_backend_metadata(text)
|
||||
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
|
||||
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"):
|
||||
detail_parts.append(f"Java {java_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():
|
||||
score += 2
|
||||
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:
|
||||
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:
|
||||
return None
|
||||
return ProjectComponent("Spring Boot", path, detail, metadata)
|
||||
return ProjectComponent(kind, path, detail, metadata)
|
||||
|
||||
|
||||
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]:
|
||||
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 = [
|
||||
r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>",
|
||||
r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>",
|
||||
|
||||
@@ -35,6 +35,10 @@ def main() -> int:
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -46,6 +50,7 @@ def main() -> int:
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
shell=False,
|
||||
env=environment,
|
||||
)
|
||||
pid_path.write_text(str(process.pid), encoding="ascii")
|
||||
assert process.stdout is not None
|
||||
|
||||
@@ -182,6 +182,30 @@ class DeployerTests(unittest.TestCase):
|
||||
self.assertEqual(exit_code, 1)
|
||||
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):
|
||||
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
|
||||
terminated = []
|
||||
@@ -496,6 +520,64 @@ class DeployerTests(unittest.TestCase):
|
||||
|
||||
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):
|
||||
with TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
|
||||
@@ -2,7 +2,9 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
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
|
||||
|
||||
|
||||
@@ -98,6 +100,18 @@ class EnvironmentTests(unittest.TestCase):
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import hashlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
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
|
||||
|
||||
|
||||
@@ -73,6 +85,54 @@ class InstallerTests(unittest.TestCase):
|
||||
|
||||
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):
|
||||
raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8")
|
||||
|
||||
|
||||
@@ -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.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):
|
||||
registry = FakeRegistry()
|
||||
registry.values["Path"] = r"C:\Windows"
|
||||
|
||||
@@ -99,6 +99,37 @@ class ScannerTests(unittest.TestCase):
|
||||
self.assertEqual(result.frontend.path, frontend)
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = scan_project(Path(tmp))
|
||||
|
||||
Reference in New Issue
Block a user