339 lines
12 KiB
Python
339 lines
12 KiB
Python
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"
|