diff --git a/README.md b/README.md index 7f401d1..aed0463 100644 --- a/README.md +++ b/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 # 后端独立控制台与日志转存 diff --git a/deploy_tool/deployer.py b/deploy_tool/deployer.py index b80ae47..5bedb20 100644 --- a/deploy_tool/deployer.py +++ b/deploy_tool/deployer.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,20 +122,45 @@ 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}" - 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, + 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( + "启动后端", + [_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] = {} @@ -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") diff --git a/deploy_tool/environment.py b/deploy_tool/environment.py index 195dd66..bb35483 100644 --- a/deploy_tool/environment.py +++ b/deploy_tool/environment.py @@ -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": diff --git a/deploy_tool/gui.py b/deploy_tool/gui.py index aa5594c..843aef9 100644 --- a/deploy_tool/gui.py +++ b/deploy_tool/gui.py @@ -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: diff --git a/deploy_tool/installer.py b/deploy_tool/installer.py index cdbc021..e56ae4f 100644 --- a/deploy_tool/installer.py +++ b/deploy_tool/installer.py @@ -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( diff --git a/deploy_tool/path_manager.py b/deploy_tool/path_manager.py index 5325e47..397b712 100644 --- a/deploy_tool/path_manager.py +++ b/deploy_tool/path_manager.py @@ -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) diff --git a/deploy_tool/scanner.py b/deploy_tool/scanner.py index b8dc5bf..b12fb1f 100644 --- a/deploy_tool/scanner.py +++ b/deploy_tool/scanner.py @@ -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"]*>.*?", "", 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*", project_text, re.IGNORECASE) + if match: + metadata[key] = match.group(1).strip() java_patterns = [ r"\s*([^<\s]+)\s*", r"\s*([^<\s]+)\s*", diff --git a/deploy_tool/service_console.py b/deploy_tool/service_console.py index 2c54151..fd552c1 100644 --- a/deploy_tool/service_console.py +++ b/deploy_tool/service_console.py @@ -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 diff --git a/tests/test_deployer.py b/tests/test_deployer.py index ba2b814..88e0d71 100644 --- a/tests/test_deployer.py +++ b/tests/test_deployer.py @@ -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) diff --git a/tests/test_environment.py b/tests/test_environment.py index 8a3af25..c6db92a 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -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() diff --git a/tests/test_installer.py b/tests/test_installer.py index aa4a10b..8614179 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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") diff --git a/tests/test_path_manager.py b/tests/test_path_manager.py index 7915dd1..b7d9fa1 100644 --- a/tests/test_path_manager.py +++ b/tests/test_path_manager.py @@ -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" diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 3c25970..8e5adb1 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -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("", encoding="utf-8") + (backend / "pom.xml").write_text( + """ + + movie-system + war + + spring-webmvc + mybatis + + movies + + """, + 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))