Files
AutoDeploy/deploy_tool/installer.py
2026-10-08 17:58:09 +08:00

339 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
JDK_PACKAGES = {
"8": "EclipseAdoptium.Temurin.8.JDK",
"11": "EclipseAdoptium.Temurin.11.JDK",
"17": "EclipseAdoptium.Temurin.17.JDK",
"21": "EclipseAdoptium.Temurin.21.JDK",
}
WINGET_PACKAGES = {
"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 = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\"
@dataclass(frozen=True)
class JdkRecommendation:
version: str
reason: str
def recommend_jdk_version(scan: ScanResult | None) -> JdkRecommendation:
if not scan or not scan.backend:
return JdkRecommendation("8", "\u672a\u626b\u63cf\u5230\u540e\u7aef\u7248\u672c\u4fe1\u606f\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
metadata = scan.backend.metadata
java_version = metadata.get("java_version")
if java_version:
mapped = _map_java_version(java_version)
source = metadata.get("java_version_source", "\u9879\u76ee\u914d\u7f6e")
return JdkRecommendation(mapped, f"\u6839\u636e {source}={java_version} \u63a8\u8350 JDK {mapped}\u3002")
boot_version = metadata.get("spring_boot_version", "")
if boot_version.startswith("3."):
return JdkRecommendation("17", f"Spring Boot 3.x \u9700\u8981 Java 17+\uff0c\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u63a8\u8350 JDK 17\u3002")
if boot_version.startswith("2."):
return JdkRecommendation("8", f"\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u9ed8\u8ba4\u63a8\u8350\u517c\u5bb9\u6027\u66f4\u597d\u7684 JDK 8\u3002")
return JdkRecommendation("8", "\u672a\u53d1\u73b0\u660e\u786e Java \u7248\u672c\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
def build_install_command(tool_name: str, version: str | None = None) -> list[str]:
if tool_name == "JDK":
package_id = JDK_PACKAGES.get(version or "8")
if not package_id:
raise ValueError(f"\u4e0d\u652f\u6301\u7684 JDK \u7248\u672c: {version}")
else:
package_id = WINGET_PACKAGES.get(tool_name)
if not package_id:
raise ValueError(f"\u4e0d\u652f\u6301\u81ea\u52a8\u5b89\u88c5: {tool_name}")
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(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False,
bufsize=0,
)
assert process.stdout is not None
last_line = ""
for raw_line in process.stdout:
for line in format_install_output(raw_line):
if line and line != last_line:
on_output(line)
output_lines.append(line)
last_line = line
exit_code = process.wait()
failure_hint = explain_install_failure(_tool_name_from_command(command), exit_code, output_lines)
if failure_hint:
on_output(failure_hint)
return exit_code
def explain_install_failure(tool_name: str | None, exit_code: int, output_lines: list[str]) -> str:
if exit_code == 0:
return ""
output_text = "\n".join(output_lines)
if tool_name == "Maven" and ("找不到与输入条件匹配的程序包" in output_text or "No package found" in output_text):
return (
"winget 当前源找不到 Maven 包。请手动下载 Apache Maven,解压后在工具里选择 "
"apache-maven 的 bin\\mvn.cmd,或把该 bin 目录加入 PATH。"
)
return ""
def format_install_output(raw: bytes) -> list[str]:
text = _decode_process_bytes(raw)
text = ANSI_PATTERN.sub("", text).replace("\b", "")
lines: list[str] = []
for part in re.split(r"[\r\n]+", text):
cleaned = _clean_install_line(part)
if cleaned:
lines.append(cleaned)
return lines
def _decode_process_bytes(raw: bytes) -> str:
for encoding in ("utf-8", "utf-16", "gb18030", "mbcs"):
try:
return raw.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
return raw.decode("utf-8", errors="replace")
def _clean_install_line(line: str) -> str:
text = line.strip()
if not text:
return ""
percent = PERCENT_PATTERN.search(text)
progress_only = _looks_like_progress_line(text)
if percent and progress_only:
value = min(int(percent.group(1)), 100)
return f"\u5b89\u88c5\u8fdb\u5ea6: {value}%"
if progress_only:
return ""
return text
def _looks_like_progress_line(text: str) -> bool:
if not text:
return False
progress_count = sum(1 for char in text if char in PROGRESS_CHARS)
percent = bool(PERCENT_PATTERN.search(text))
if percent and progress_count:
return True
visible = [char for char in text if not char.isspace()]
if not visible:
return False
return progress_count / len(visible) > 0.45
def _tool_name_from_command(command: list[str]) -> str | None:
package_ids = {package_id: name for name, package_id in WINGET_PACKAGES.items()}
package_ids.update({package_id: "JDK" for package_id in JDK_PACKAGES.values()})
for part in command:
name = package_ids.get(part)
if name:
return name
return None
def _map_java_version(version: str) -> str:
try:
major = int(version.split(".", 1)[0])
except ValueError:
return "8"
if major <= 8:
return "8"
if major <= 11:
return "11"
if major <= 17:
return "17"
return "21"