1748 lines
67 KiB
Python
1748 lines
67 KiB
Python
from __future__ import annotations
|
||
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
# 镜像 tag 需要拼时间戳。
|
||
from datetime import datetime
|
||
# 本地构建和 git worktree 都依赖标准库 subprocess / tempfile。
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from typing import Any, Callable
|
||
|
||
from .compose_templates import (
|
||
load_local_host_compose_model,
|
||
sync_remote_host_compose_template,
|
||
update_local_service_image,
|
||
)
|
||
from core.config import (
|
||
ADMIN_FRONTEND_BUILD_SCRIPT,
|
||
ADMIN_FRONTEND_COMPOSE_FILE,
|
||
ADMIN_FRONTEND_COMPOSE_SERVICE,
|
||
ADMIN_FRONTEND_DIST_DIR,
|
||
ADMIN_FRONTEND_ENABLED,
|
||
ADMIN_FRONTEND_HOST,
|
||
ADMIN_FRONTEND_INSTANCE_ID,
|
||
ADMIN_FRONTEND_IP,
|
||
ADMIN_FRONTEND_PUBLIC_URL,
|
||
ADMIN_FRONTEND_RELEASE_META_PATH,
|
||
ADMIN_FRONTEND_REMOTE_DIR,
|
||
ADMIN_FRONTEND_REPO_ROOT,
|
||
ADMIN_FRONTEND_REPO_URL,
|
||
ADMIN_FRONTEND_SERVICE_NAME,
|
||
ADMIN_FRONTEND_SITE_DIR,
|
||
ADMIN_FRONTEND_SSH_HOST,
|
||
ADMIN_FRONTEND_SSH_PORT,
|
||
ADMIN_FRONTEND_SSH_USE_SUDO,
|
||
ADMIN_FRONTEND_SSH_USER,
|
||
BUILD_DOCKER_PLATFORM,
|
||
GOLANG_REPO_ROOT,
|
||
HARBOR_PASSWORD,
|
||
HARBOR_PROJECT,
|
||
HARBOR_REGISTRY,
|
||
HARBOR_USERNAME,
|
||
JAVA_IMAGE_BUILD_MAX_WORKERS,
|
||
JAVA_MAVEN_THREADS,
|
||
JAVA_REPO_ROOT,
|
||
ROOT,
|
||
ROLLING_RESTART_STABILIZE_SECONDS,
|
||
ROLLING_RESTART_TIMEOUT_SECONDS,
|
||
SERVICE_BUILD_CACHE_ROOT,
|
||
SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
SERVICE_PULL_TIMEOUT_SECONDS,
|
||
UPDATE_DEFAULT_ADMIN_GIT_REF,
|
||
UPDATE_DEFAULT_GO_GIT_REF,
|
||
UPDATE_DEFAULT_JAVA_GIT_REF,
|
||
utc_now,
|
||
)
|
||
from .service_ops import (
|
||
clear_operation_caches,
|
||
restart_service_instance,
|
||
service_records_by_name,
|
||
wait_http_ready,
|
||
wait_java_registration_ready,
|
||
)
|
||
from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display, write_remote_text
|
||
|
||
|
||
# Java 仓里当前支持 Harbor 更新的服务集合。
|
||
JAVA_BUILDABLE_SERVICES = ("auth", "gateway", "external", "console", "other", "live", "wallet", "order")
|
||
# Go 仓目前只有主业务服务。
|
||
GOLANG_BUILDABLE_SERVICES = ("golang",)
|
||
# Admin 前端当前只接入一个发布目标。
|
||
FRONTEND_BUILDABLE_SERVICES = (ADMIN_FRONTEND_SERVICE_NAME,)
|
||
# Java 批量 Maven 构建时,需要映射回对应的启动模块。
|
||
JAVA_SERVICE_MODULES = {
|
||
"auth": "rc-auth",
|
||
"gateway": "rc-gateway",
|
||
"external": "rc-service/rc-service-external/external-start",
|
||
"console": "rc-service/rc-service-console/console-start",
|
||
"other": "rc-service/rc-service-other/other-start",
|
||
"live": "rc-service/rc-service-live/live-start",
|
||
"wallet": "rc-service/rc-service-wallet/wallet-start",
|
||
"order": "rc-service/rc-service-order/order-start",
|
||
}
|
||
# 当前线上镜像仓路径并不完全统一,这里只给缺失模板信息时兜底。
|
||
SERVICE_IMAGE_REPOSITORY_SUFFIXES = {
|
||
"auth": "java/chatapp3-auth",
|
||
"gateway": "java/chatapp3-gateway",
|
||
"external": "java/chatapp3-external",
|
||
"console": "console",
|
||
"other": "java/chatapp3-other",
|
||
"live": "java/chatapp3-live",
|
||
"wallet": "java/chatapp3-wallet",
|
||
"order": "order",
|
||
"golang": "golang",
|
||
}
|
||
|
||
# 后台任务的日志回调统一走这个签名。
|
||
ProgressLogger = Callable[[str, str, str], None]
|
||
# 私有 Maven 安装缓存统一放到 run 目录,避免混进 Git 工作区。
|
||
PRIVATE_MAVEN_CACHE_DIR = ROOT / "run" / "maven_private_cache"
|
||
# Admin 前端站点备份最多保留最近若干份。
|
||
FRONTEND_SITE_BACKUP_KEEP = 3
|
||
|
||
|
||
def emit_progress(progress: ProgressLogger | None, stage: str, message: str, *, level: str = "info") -> None:
|
||
# 所有过程日志都经这里发出,调用方传空时自动静默。
|
||
if progress is None:
|
||
return
|
||
progress(str(stage or "").strip(), str(level or "info").strip() or "info", str(message or "").rstrip())
|
||
|
||
|
||
def short_text(text: str, limit: int = 600) -> str:
|
||
# 远端输出和异常信息只保留一小段,避免页面日志爆掉。
|
||
normalized = " ".join(str(text or "").split())
|
||
if len(normalized) <= limit:
|
||
return normalized
|
||
return normalized[:limit].rstrip() + " ..."
|
||
|
||
|
||
def stream_log_file_lines(
|
||
log_path: Path,
|
||
stop_event: threading.Event,
|
||
*,
|
||
stage: str,
|
||
progress: ProgressLogger | None,
|
||
) -> None:
|
||
# 构建日志落本地文件后,这里持续追尾并把新增行推给前端。
|
||
if progress is None:
|
||
return
|
||
|
||
position = 0
|
||
remainder = ""
|
||
|
||
while not stop_event.is_set():
|
||
if log_path.exists():
|
||
with log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
||
handle.seek(position)
|
||
chunk = handle.read()
|
||
position = handle.tell()
|
||
if chunk:
|
||
remainder += chunk
|
||
lines = remainder.splitlines(keepends=True)
|
||
remainder = ""
|
||
if lines and not lines[-1].endswith(("\n", "\r")):
|
||
remainder = lines.pop()
|
||
for line in lines:
|
||
text = line.rstrip()
|
||
if text:
|
||
emit_progress(progress, stage, text)
|
||
stop_event.wait(0.4)
|
||
|
||
if remainder.strip():
|
||
emit_progress(progress, stage, remainder.rstrip())
|
||
|
||
|
||
def buildable_service_names() -> list[str]:
|
||
# 页面和接口统一按这里判定哪些服务允许“构建并更新”。
|
||
names = [*JAVA_BUILDABLE_SERVICES, *GOLANG_BUILDABLE_SERVICES]
|
||
if frontend_release_enabled():
|
||
names.extend(service_name for service_name in FRONTEND_BUILDABLE_SERVICES if str(service_name or "").strip())
|
||
return names
|
||
|
||
|
||
def normalize_service_names(service_names: list[str]) -> list[str]:
|
||
# 请求里可能重复勾选同一个服务,这里先去重并保序。
|
||
normalized = [str(name or "").strip() for name in service_names if str(name or "").strip()]
|
||
return list(dict.fromkeys(normalized))
|
||
|
||
|
||
def service_kind(service_name: str) -> str:
|
||
# 更新链路目前分 Java / Golang / Frontend 三类仓库。
|
||
if service_name in JAVA_BUILDABLE_SERVICES:
|
||
return "java"
|
||
if service_name in GOLANG_BUILDABLE_SERVICES:
|
||
return "golang"
|
||
if frontend_release_enabled() and service_name in FRONTEND_BUILDABLE_SERVICES:
|
||
return "frontend"
|
||
raise RuntimeError(f"unsupported update service: {service_name}")
|
||
|
||
|
||
def ensure_repo_root(repo_root: Path, label: str) -> Path:
|
||
# 构建前先确认业务仓真的存在,避免跑到一半才发现路径错了。
|
||
resolved = Path(repo_root).expanduser().resolve()
|
||
if not resolved.exists():
|
||
raise RuntimeError(f"{label} repo root missing: {resolved}")
|
||
if not (resolved / ".git").exists():
|
||
raise RuntimeError(f"{label} repo root is not a git repo: {resolved}")
|
||
return resolved
|
||
|
||
|
||
def ensure_frontend_repo_root(*, progress: ProgressLogger | None = None) -> Path:
|
||
# Admin 前端允许第一次使用时按配置的 repo URL 自动 clone 到 deploy 机。
|
||
repo_root = Path(ADMIN_FRONTEND_REPO_ROOT).expanduser().resolve()
|
||
if (repo_root / ".git").exists():
|
||
return repo_root
|
||
|
||
repo_url = str(ADMIN_FRONTEND_REPO_URL or "").strip()
|
||
if not repo_url:
|
||
raise RuntimeError("missing ADMIN_FRONTEND_REPO_URL in .env")
|
||
|
||
repo_root.parent.mkdir(parents=True, exist_ok=True)
|
||
emit_progress(progress, "build.frontend.repo", f"初始化前端仓库:{repo_url} -> {repo_root}")
|
||
local_command_output(
|
||
["git", "clone", repo_url, str(repo_root)],
|
||
cwd=repo_root.parent,
|
||
label="frontend clone repo",
|
||
timeout_seconds=900,
|
||
)
|
||
return ensure_repo_root(repo_root, "frontend")
|
||
|
||
|
||
def local_command_output(
|
||
command: list[str],
|
||
*,
|
||
cwd: Path,
|
||
label: str,
|
||
env: dict[str, str] | None = None,
|
||
input_text: str = "",
|
||
timeout_seconds: int = 120,
|
||
) -> str:
|
||
# 小输出命令直接走 capture_output,适合 git rev-parse / docker inspect 这类场景。
|
||
merged_env = os.environ.copy()
|
||
if env:
|
||
merged_env.update(env)
|
||
|
||
result = subprocess.run(
|
||
command,
|
||
cwd=str(cwd),
|
||
text=True,
|
||
input=input_text if input_text else None,
|
||
capture_output=True,
|
||
env=merged_env,
|
||
timeout=timeout_seconds,
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
detail = (result.stderr or result.stdout or "").strip()
|
||
raise RuntimeError(f"{label} failed: {detail or f'exit {result.returncode}'}")
|
||
return (result.stdout or "").strip()
|
||
|
||
|
||
def tail_text(path: Path, max_bytes: int = 8192) -> str:
|
||
# 大构建日志只回最后一小段,避免把整份 Maven 输出塞进响应体。
|
||
if not path.exists():
|
||
return ""
|
||
|
||
with path.open("rb") as handle:
|
||
handle.seek(0, os.SEEK_END)
|
||
size = handle.tell()
|
||
handle.seek(max(0, size - max_bytes))
|
||
return handle.read().decode("utf-8", errors="replace").strip()
|
||
|
||
|
||
def frontend_release_enabled() -> bool:
|
||
# Admin 前端需要仓库、主机和发布目录都齐备后才开放到页面。
|
||
return bool(
|
||
ADMIN_FRONTEND_ENABLED
|
||
and str(ADMIN_FRONTEND_SERVICE_NAME or "").strip()
|
||
and str(ADMIN_FRONTEND_HOST or "").strip()
|
||
and str(ADMIN_FRONTEND_SITE_DIR or "").strip()
|
||
and (str(ADMIN_FRONTEND_SSH_HOST or "").strip() or str(ADMIN_FRONTEND_IP or "").strip())
|
||
and (str(ADMIN_FRONTEND_REPO_URL or "").strip() or str(ADMIN_FRONTEND_REPO_ROOT or "").strip())
|
||
)
|
||
|
||
|
||
def frontend_host_record(service_name: str) -> dict[str, Any]:
|
||
# 当前只有 admin 前端走这条链路,后续再扩展时继续在这里分流。
|
||
if not frontend_release_enabled() or service_name != ADMIN_FRONTEND_SERVICE_NAME:
|
||
raise RuntimeError(f"unsupported frontend service: {service_name}")
|
||
|
||
return {
|
||
"host": ADMIN_FRONTEND_HOST,
|
||
"ip": ADMIN_FRONTEND_IP,
|
||
"instanceId": ADMIN_FRONTEND_INSTANCE_ID,
|
||
"sshHost": ADMIN_FRONTEND_SSH_HOST or ADMIN_FRONTEND_IP,
|
||
"sshUser": ADMIN_FRONTEND_SSH_USER,
|
||
"sshUseSudo": ADMIN_FRONTEND_SSH_USE_SUDO,
|
||
"sshPort": ADMIN_FRONTEND_SSH_PORT,
|
||
}
|
||
|
||
|
||
def run_logged_command(
|
||
command: list[str],
|
||
*,
|
||
cwd: Path,
|
||
label: str,
|
||
env: dict[str, str] | None = None,
|
||
timeout_seconds: int = SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage: str = "",
|
||
progress: ProgressLogger | None = None,
|
||
) -> str:
|
||
# Maven / docker build 输出很大,单独落临时日志再取尾部更稳。
|
||
merged_env = os.environ.copy()
|
||
if env:
|
||
merged_env.update(env)
|
||
|
||
log_file = tempfile.NamedTemporaryFile(prefix="hy-app-monitor-build-", suffix=".log", delete=False)
|
||
log_path = Path(log_file.name)
|
||
log_file.close()
|
||
|
||
try:
|
||
stop_event = threading.Event()
|
||
follower: threading.Thread | None = None
|
||
|
||
with log_path.open("w", encoding="utf-8") as handle:
|
||
process = subprocess.Popen(
|
||
command,
|
||
cwd=str(cwd),
|
||
stdout=handle,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
env=merged_env,
|
||
)
|
||
if progress is not None:
|
||
follower = threading.Thread(
|
||
target=stream_log_file_lines,
|
||
args=(log_path, stop_event),
|
||
kwargs={"stage": progress_stage or label, "progress": progress},
|
||
daemon=True,
|
||
)
|
||
follower.start()
|
||
try:
|
||
return_code = process.wait(timeout=timeout_seconds)
|
||
except subprocess.TimeoutExpired as exc:
|
||
process.kill()
|
||
process.wait()
|
||
stop_event.set()
|
||
if follower is not None:
|
||
follower.join(timeout=1)
|
||
raise RuntimeError(f"{label} timed out: {tail_text(log_path)}") from exc
|
||
finally:
|
||
stop_event.set()
|
||
if follower is not None:
|
||
follower.join(timeout=1)
|
||
|
||
output_tail = tail_text(log_path)
|
||
if return_code != 0:
|
||
raise RuntimeError(f"{label} failed: {output_tail or f'exit {return_code}'}")
|
||
return output_tail
|
||
finally:
|
||
log_path.unlink(missing_ok=True)
|
||
|
||
|
||
def fetch_repo_refs(repo_root: Path, label: str) -> None:
|
||
# 统一先刷新远端分支和 tag,后面的 ref 解析才可信。
|
||
local_command_output(
|
||
["git", "-C", str(repo_root), "fetch", "--all", "--tags", "--prune"],
|
||
cwd=repo_root,
|
||
label=f"{label} fetch refs",
|
||
timeout_seconds=300,
|
||
)
|
||
|
||
|
||
def local_maven_repo_root() -> Path:
|
||
# 兼容外部显式指定 MAVEN_REPO_LOCAL,默认仍沿用 ~/.m2/repository。
|
||
override = str(os.environ.get("MAVEN_REPO_LOCAL") or "").strip()
|
||
if override:
|
||
return Path(override).expanduser()
|
||
return Path.home() / ".m2" / "repository"
|
||
|
||
|
||
def private_maven_tree_id(repo_root: Path, commit: str) -> str:
|
||
# 用 git tree id 表示 maven_private 内容,只有私有依赖目录变化时才会变化。
|
||
try:
|
||
return local_command_output(
|
||
["git", "-C", str(repo_root), "rev-parse", "--verify", f"{commit}:maven_private"],
|
||
cwd=repo_root,
|
||
label="resolve private maven tree",
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
|
||
|
||
def private_maven_cache_stamp_path(repo_root: Path) -> Path:
|
||
# 每个仓保留一份最近已安装的私有依赖 tree id。
|
||
return PRIVATE_MAVEN_CACHE_DIR / f"{repo_root.name}.tree"
|
||
|
||
|
||
def private_maven_artifacts_present() -> bool:
|
||
# 本地 Maven 仓至少得有一份私有制品,缓存命中才有意义。
|
||
artifacts_root = local_maven_repo_root() / "com" / "red" / "circle"
|
||
if not artifacts_root.exists():
|
||
return False
|
||
for path in artifacts_root.rglob("*.pom"):
|
||
if path.is_file():
|
||
return True
|
||
return False
|
||
|
||
|
||
def ensure_private_maven_installed(
|
||
repo_root: Path,
|
||
worktree_root: Path,
|
||
commit: str,
|
||
*,
|
||
progress: ProgressLogger | None = None,
|
||
) -> None:
|
||
# 私有依赖没变化时直接复用本机 ~/.m2 缓存,避免每次更新都重复 install-file。
|
||
tree_id = private_maven_tree_id(repo_root, commit)
|
||
stamp_path = private_maven_cache_stamp_path(repo_root)
|
||
cached_tree_id = stamp_path.read_text(encoding="utf-8").strip() if stamp_path.exists() else ""
|
||
|
||
if tree_id and cached_tree_id == tree_id and private_maven_artifacts_present():
|
||
emit_progress(progress, "build.java.maven", f"私有 Maven 依赖未变化,跳过安装:{tree_id[:12]}")
|
||
return
|
||
|
||
emit_progress(progress, "build.java.maven", "开始安装私有 Maven 依赖")
|
||
run_logged_command(
|
||
[str(worktree_root / "ci" / "install-private-maven.sh")],
|
||
cwd=worktree_root,
|
||
label="java install private maven",
|
||
timeout_seconds=min(SERVICE_BUILD_TIMEOUT_SECONDS, 1200),
|
||
progress_stage="build.java.maven.install",
|
||
progress=progress,
|
||
)
|
||
|
||
if tree_id:
|
||
PRIVATE_MAVEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
stamp_path.write_text(f"{tree_id}\n", encoding="utf-8")
|
||
emit_progress(progress, "build.java.maven", f"私有 Maven 依赖安装完成:{tree_id[:12]}")
|
||
return
|
||
|
||
emit_progress(progress, "build.java.maven", "私有 Maven 依赖安装完成")
|
||
|
||
|
||
def resolve_repo_commit(repo_root: Path, git_ref: str, label: str) -> str:
|
||
# 优先把分支解析到 origin/<branch>,避免本地旧分支 HEAD 干扰构建。
|
||
candidates = [
|
||
f"refs/remotes/origin/{git_ref}^{{commit}}",
|
||
f"refs/tags/{git_ref}^{{commit}}",
|
||
f"{git_ref}^{{commit}}",
|
||
]
|
||
for candidate in candidates:
|
||
try:
|
||
return local_command_output(
|
||
["git", "-C", str(repo_root), "rev-parse", "--verify", candidate],
|
||
cwd=repo_root,
|
||
label=f"{label} resolve ref",
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
raise RuntimeError(f"{label} git ref not found: {git_ref}")
|
||
|
||
|
||
def build_cache_root(repo_root: Path) -> Path:
|
||
# 显式配置优先;未配置时按本地 / deploy 机目录结构推导默认缓存根目录。
|
||
override = str(SERVICE_BUILD_CACHE_ROOT or "").strip()
|
||
if override:
|
||
return Path(override).expanduser()
|
||
if str(repo_root).startswith("/opt/deploy/"):
|
||
return Path("/opt/deploy/build-cache")
|
||
return ROOT / "run" / "build_cache"
|
||
|
||
|
||
def repo_origin_url(repo_root: Path, label: str) -> str:
|
||
# 固定构建工作区优先直接连 origin,避免本地源码仓 owner 不一致触发 safe.directory。
|
||
try:
|
||
return local_command_output(
|
||
["git", "-C", str(repo_root), "remote", "get-url", "origin"],
|
||
cwd=repo_root,
|
||
label=f"{label} get origin url",
|
||
timeout_seconds=120,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
return str(repo_root)
|
||
|
||
|
||
def persistent_build_workspace_path(repo_root: Path, git_ref: str) -> Path:
|
||
# 同一仓库同一 ref 固定复用一份构建目录,最大化保留 target 缓存。
|
||
return build_cache_root(repo_root) / repo_root.name / sanitized_ref_fragment(git_ref)
|
||
|
||
|
||
def prepare_persistent_build_workspace(
|
||
repo_root: Path,
|
||
git_ref: str,
|
||
commit: str,
|
||
label: str,
|
||
*,
|
||
progress: ProgressLogger | None = None,
|
||
) -> Path:
|
||
# 固定构建工作区是专用缓存目录,允许 reset --hard 到目标提交。
|
||
workspace_root = persistent_build_workspace_path(repo_root, git_ref)
|
||
workspace_root.parent.mkdir(parents=True, exist_ok=True)
|
||
origin_url = repo_origin_url(repo_root, label)
|
||
|
||
if not (workspace_root / ".git").exists():
|
||
if workspace_root.exists():
|
||
shutil.rmtree(workspace_root, ignore_errors=True)
|
||
local_command_output(
|
||
["git", "clone", "--no-checkout", origin_url, str(workspace_root)],
|
||
cwd=workspace_root.parent,
|
||
label=f"{label} clone build workspace",
|
||
timeout_seconds=600,
|
||
)
|
||
|
||
local_command_output(
|
||
["git", "-C", str(workspace_root), "remote", "set-url", "origin", origin_url],
|
||
cwd=workspace_root,
|
||
label=f"{label} set build workspace origin",
|
||
timeout_seconds=120,
|
||
)
|
||
local_command_output(
|
||
["git", "-C", str(workspace_root), "fetch", "origin", "--tags", "--prune"],
|
||
cwd=workspace_root,
|
||
label=f"{label} fetch build workspace",
|
||
timeout_seconds=300,
|
||
)
|
||
local_command_output(
|
||
["git", "-C", str(workspace_root), "fetch", "origin", commit],
|
||
cwd=workspace_root,
|
||
label=f"{label} fetch build workspace commit",
|
||
timeout_seconds=300,
|
||
)
|
||
local_command_output(
|
||
["git", "-C", str(workspace_root), "reset", "--hard", commit],
|
||
cwd=workspace_root,
|
||
label=f"{label} reset build workspace",
|
||
timeout_seconds=300,
|
||
)
|
||
emit_progress(progress, f"build.{label}", f"已准备固定构建工作区:{workspace_root} -> {commit[:12]}")
|
||
return workspace_root
|
||
|
||
|
||
def create_temp_worktree(repo_root: Path, commit: str, label: str) -> Path:
|
||
# 用临时 worktree 构建,避免直接改业务仓当前工作区。
|
||
worktree_root = Path(tempfile.mkdtemp(prefix=f"hy-app-monitor-{repo_root.name}-"))
|
||
try:
|
||
local_command_output(
|
||
["git", "-C", str(repo_root), "worktree", "add", "--detach", str(worktree_root), commit],
|
||
cwd=repo_root,
|
||
label=f"{label} create worktree",
|
||
timeout_seconds=300,
|
||
)
|
||
return worktree_root
|
||
except Exception:
|
||
shutil.rmtree(worktree_root, ignore_errors=True)
|
||
raise
|
||
|
||
|
||
def remove_temp_worktree(repo_root: Path, worktree_root: Path) -> None:
|
||
# 构建结束后清理 worktree,避免 deploy 机累积一堆临时目录。
|
||
try:
|
||
local_command_output(
|
||
["git", "-C", str(repo_root), "worktree", "remove", "--force", str(worktree_root)],
|
||
cwd=repo_root,
|
||
label="remove worktree",
|
||
timeout_seconds=120,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
shutil.rmtree(worktree_root, ignore_errors=True)
|
||
|
||
|
||
def require_harbor_credentials() -> None:
|
||
# 构建推送和目标机预拉都依赖 Harbor 账号。
|
||
if not HARBOR_REGISTRY:
|
||
raise RuntimeError("missing HARBOR_REGISTRY in .env")
|
||
if not HARBOR_USERNAME or not HARBOR_PASSWORD:
|
||
raise RuntimeError("missing HARBOR_USERNAME / HARBOR_PASSWORD in .env")
|
||
|
||
|
||
def docker_login_local(repo_root: Path, *, progress: ProgressLogger | None = None) -> None:
|
||
# 本机构建前先登录 Harbor,避免 push 时才失败。
|
||
require_harbor_credentials()
|
||
emit_progress(progress, "prepare", f"登录 Harbor:{HARBOR_REGISTRY}")
|
||
local_command_output(
|
||
["docker", "login", HARBOR_REGISTRY, "-u", HARBOR_USERNAME, "--password-stdin"],
|
||
cwd=repo_root,
|
||
label="docker login harbor",
|
||
input_text=HARBOR_PASSWORD,
|
||
timeout_seconds=120,
|
||
)
|
||
emit_progress(progress, "prepare", "Harbor 登录成功")
|
||
|
||
|
||
def image_repository_ref(image_ref: str) -> str:
|
||
# 把 tag / digest 去掉,保留完整仓库地址。
|
||
text = str(image_ref or "").strip()
|
||
if "@" in text:
|
||
return text.split("@", 1)[0]
|
||
|
||
last_slash = text.rfind("/")
|
||
last_colon = text.rfind(":")
|
||
if last_colon > last_slash:
|
||
return text[:last_colon]
|
||
return text
|
||
|
||
|
||
def sanitized_ref_fragment(git_ref: str) -> str:
|
||
# Harbor tag 只保留相对安全的字符。
|
||
cleaned = re.sub(r"[^0-9A-Za-z._-]+", "-", str(git_ref or "").strip()) or "manual"
|
||
return cleaned.strip("-") or "manual"
|
||
|
||
|
||
def derive_image_tag(git_ref: str, commit: str, image_tag: str) -> str:
|
||
# 用户没指定 tag 时,默认用 ref + commit12 + 时间戳。
|
||
explicit = str(image_tag or "").strip()
|
||
if explicit:
|
||
return explicit
|
||
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||
return f"{sanitized_ref_fragment(git_ref)}-{commit[:12]}-{timestamp}"
|
||
|
||
|
||
def frontend_version_label(git_ref: str, commit: str) -> str:
|
||
# 前端发布结果统一显示成 ref@commit12,便于和镜像 tag 区分。
|
||
resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_ADMIN_GIT_REF
|
||
return f"{resolved_ref}@{commit[:12]}"
|
||
|
||
|
||
def frontend_release_metadata(service_name: str) -> dict[str, Any]:
|
||
# 当前前端站点的已部署版本由远端元数据文件提供;缺失时允许返回空结构。
|
||
host = frontend_host_record(service_name)
|
||
try:
|
||
raw = read_remote_text(host, ADMIN_FRONTEND_RELEASE_META_PATH, timeout_seconds=30).strip()
|
||
except Exception as exc: # noqa: BLE001
|
||
return {"error": str(exc)}
|
||
|
||
if not raw:
|
||
return {}
|
||
|
||
try:
|
||
payload = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return {"error": f"invalid frontend release metadata on {target_display(host)}"}
|
||
|
||
if not isinstance(payload, dict):
|
||
return {"error": f"invalid frontend release metadata on {target_display(host)}"}
|
||
return payload
|
||
|
||
|
||
def image_release_display(current_images: list[str]) -> dict[str, str]:
|
||
# 镜像型服务在表格里固定展示 tag / digest / 可复制的主镜像。
|
||
primary_image = str((current_images or [""])[0] or "").strip()
|
||
if not primary_image:
|
||
return {
|
||
"currentLabel": "-",
|
||
"currentTitle": "-",
|
||
"detailLabel": "-",
|
||
"detailTitle": "-",
|
||
"copyValue": "",
|
||
}
|
||
|
||
repository = image_repository_ref(primary_image)
|
||
meta = {
|
||
"repository": repository,
|
||
"tag": "",
|
||
"digest": "",
|
||
}
|
||
without_digest, _, digest_text = primary_image.partition("@")
|
||
meta["digest"] = digest_text or ""
|
||
last_slash = without_digest.rfind("/")
|
||
last_colon = without_digest.rfind(":")
|
||
if last_colon > last_slash:
|
||
meta["tag"] = without_digest[last_colon + 1 :]
|
||
|
||
return {
|
||
"currentLabel": meta["tag"] or "-",
|
||
"currentTitle": "\n".join(current_images) if len(current_images) > 1 else primary_image,
|
||
"detailLabel": (meta["digest"][:12] + "...") if len(meta["digest"]) > 15 else (meta["digest"] or "-"),
|
||
"detailTitle": primary_image,
|
||
"copyValue": primary_image,
|
||
}
|
||
|
||
|
||
def frontend_release_display(service_name: str) -> dict[str, str]:
|
||
# 前端目标优先显示最近一次由监控面板写入的 gitRef / commit。
|
||
metadata = frontend_release_metadata(service_name)
|
||
version = str(metadata.get("version") or "").strip()
|
||
commit = str(metadata.get("commit") or "").strip()
|
||
git_ref = str(metadata.get("gitRef") or "").strip()
|
||
deployed_at = str(metadata.get("deployedAt") or "").strip()
|
||
error = str(metadata.get("error") or "").strip()
|
||
|
||
if version or commit or git_ref:
|
||
label = version or git_ref or commit[:12]
|
||
title_parts = [part for part in [version, git_ref, commit, deployed_at, ADMIN_FRONTEND_PUBLIC_URL] if part]
|
||
return {
|
||
"currentLabel": label,
|
||
"currentTitle": " | ".join(title_parts) if title_parts else label,
|
||
"detailLabel": commit[:12] if commit else "-",
|
||
"detailTitle": commit or deployed_at or label,
|
||
"copyValue": commit or git_ref,
|
||
}
|
||
|
||
if error:
|
||
return {
|
||
"currentLabel": "未记录",
|
||
"currentTitle": error,
|
||
"detailLabel": "SSH",
|
||
"detailTitle": error,
|
||
"copyValue": "",
|
||
}
|
||
|
||
return {
|
||
"currentLabel": "未记录",
|
||
"currentTitle": "当前站点还没有由 hy-app-monitor 写入过版本元数据",
|
||
"detailLabel": "-",
|
||
"detailTitle": "-",
|
||
"copyValue": "",
|
||
}
|
||
|
||
|
||
def docker_digest(image_ref: str, *, cwd: Path) -> str:
|
||
# push 完要拿 digest,后续 compose 一律按 digest 部署。
|
||
return local_command_output(
|
||
["docker", "image", "inspect", image_ref, "--format", "{{index .RepoDigests 0}}"],
|
||
cwd=cwd,
|
||
label=f"inspect digest {image_ref}",
|
||
timeout_seconds=120,
|
||
)
|
||
|
||
|
||
def current_template_image(service_name: str) -> str:
|
||
# 优先从本地完整模板里拿当前运行时镜像仓路径。
|
||
records = service_records_by_name().get(service_name) or []
|
||
for record in records:
|
||
model = load_local_host_compose_model(record["host"])
|
||
image_ref = str(((model.get("services") or {}).get(service_name) or {}).get("image") or "").strip()
|
||
if image_ref.startswith(f"{HARBOR_REGISTRY}/{HARBOR_PROJECT}/"):
|
||
return image_ref
|
||
return ""
|
||
|
||
|
||
def service_repository_ref(service_name: str) -> str:
|
||
# 线上已有镜像时沿用现有仓路径;没有时再走保守兜底。
|
||
current_image = current_template_image(service_name)
|
||
if current_image:
|
||
return image_repository_ref(current_image)
|
||
|
||
suffix = SERVICE_IMAGE_REPOSITORY_SUFFIXES.get(service_name, "").strip()
|
||
if not suffix:
|
||
raise RuntimeError(f"missing Harbor repository mapping for service: {service_name}")
|
||
return f"{HARBOR_REGISTRY}/{HARBOR_PROJECT}/{suffix}"
|
||
|
||
|
||
def maven_module_list(service_names: list[str]) -> str:
|
||
# Java 多服务更新时,先一次性 Maven 打包对应模块,避免重复 clean package。
|
||
modules: list[str] = []
|
||
for service_name in service_names:
|
||
module = JAVA_SERVICE_MODULES.get(service_name)
|
||
if not module:
|
||
raise RuntimeError(f"missing java maven module mapping: {service_name}")
|
||
if module not in modules:
|
||
modules.append(module)
|
||
return ",".join(modules)
|
||
|
||
|
||
def java_maven_package_command(service_names: list[str], *, clean: bool = False) -> list[str]:
|
||
# Java 默认走增量 package,并允许通过 .env 配 Maven 并行线程。
|
||
command = ["mvn"]
|
||
maven_threads = str(JAVA_MAVEN_THREADS or "").strip()
|
||
if maven_threads:
|
||
command.extend(["-T", maven_threads])
|
||
command.extend(
|
||
[
|
||
"-pl",
|
||
maven_module_list(service_names),
|
||
"-am",
|
||
"-Dmaven.test.skip=true",
|
||
]
|
||
)
|
||
if clean:
|
||
command.append("clean")
|
||
command.append("package")
|
||
return command
|
||
|
||
|
||
def should_retry_java_maven_with_clean(error_text: str) -> bool:
|
||
# 固定工作区第一次启用时,历史 target 产物偶尔会有损坏;命中后退回一次 clean package 自愈。
|
||
normalized = str(error_text or "")
|
||
markers = (
|
||
"java.util.zip.ZipException",
|
||
"java.io.EOFException",
|
||
"bad class file",
|
||
"invalid loc",
|
||
"invalid block type",
|
||
"Unexpected end of ZLIB input stream",
|
||
)
|
||
return any(marker in normalized for marker in markers)
|
||
|
||
|
||
def build_single_java_image(
|
||
service_name: str,
|
||
*,
|
||
worktree_root: Path,
|
||
short_commit: str,
|
||
resolved_image_tag: str,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# Maven 产物已经在固定工作区准备好,这里只负责单服务镜像构建和推送。
|
||
repository_ref = service_repository_ref(service_name)
|
||
target_image_ref = f"{repository_ref}:{resolved_image_tag}"
|
||
emit_progress(progress, f"build.java.{service_name}", f"开始构建镜像:{target_image_ref}")
|
||
build_tail = run_logged_command(
|
||
[str(worktree_root / "ci" / "build-service-image.sh"), service_name, target_image_ref, short_commit],
|
||
cwd=worktree_root,
|
||
label=f"java build {service_name}",
|
||
env={
|
||
"DOCKER_PLATFORM": BUILD_DOCKER_PLATFORM,
|
||
# 外层已经统一决定过是否 Maven 打包;镜像阶段一律复用现成产物。
|
||
"SKIP_MAVEN_BUILD": "1",
|
||
},
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage=f"build.java.{service_name}.build",
|
||
progress=progress,
|
||
)
|
||
emit_progress(progress, f"build.java.{service_name}", f"开始推送 Harbor:{target_image_ref}")
|
||
push_tail = run_logged_command(
|
||
["docker", "push", target_image_ref],
|
||
cwd=worktree_root,
|
||
label=f"java push {service_name}",
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage=f"build.java.{service_name}.push",
|
||
progress=progress,
|
||
)
|
||
digest_ref = docker_digest(target_image_ref, cwd=worktree_root)
|
||
emit_progress(progress, f"build.java.{service_name}", f"镜像已推送完成:{digest_ref}")
|
||
return {
|
||
"service": service_name,
|
||
"repository": repository_ref,
|
||
"tag": target_image_ref,
|
||
"image": digest_ref,
|
||
"buildTail": build_tail,
|
||
"pushTail": push_tail,
|
||
}
|
||
|
||
|
||
def build_java_images(
|
||
service_names: list[str],
|
||
git_ref: str,
|
||
image_tag: str,
|
||
*,
|
||
skip_maven_build: bool,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# Java 服务共享一个仓库和 Maven 本地缓存,这里按仓统一构建。
|
||
repo_root = ensure_repo_root(JAVA_REPO_ROOT, "java")
|
||
resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_JAVA_GIT_REF
|
||
emit_progress(progress, "build.java", f"刷新 Java 仓库 refs:{resolved_ref}")
|
||
fetch_repo_refs(repo_root, "java")
|
||
commit = resolve_repo_commit(repo_root, resolved_ref, "java")
|
||
emit_progress(progress, "build.java", f"Java ref 已解析:{resolved_ref} -> {commit[:12]}")
|
||
worktree_root = prepare_persistent_build_workspace(
|
||
repo_root,
|
||
resolved_ref,
|
||
commit,
|
||
"java",
|
||
progress=progress,
|
||
)
|
||
short_commit = commit[:12]
|
||
resolved_image_tag = derive_image_tag(resolved_ref, commit, image_tag)
|
||
|
||
outputs: list[dict[str, Any]] = []
|
||
service_images: dict[str, str] = {}
|
||
|
||
try:
|
||
if not skip_maven_build:
|
||
ensure_private_maven_installed(
|
||
repo_root,
|
||
worktree_root,
|
||
commit,
|
||
progress=progress,
|
||
)
|
||
emit_progress(progress, "build.java.maven", f"开始 Maven 打包:{maven_module_list(service_names)}")
|
||
try:
|
||
run_logged_command(
|
||
java_maven_package_command(service_names),
|
||
cwd=worktree_root,
|
||
label="java maven package",
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage="build.java.maven.package",
|
||
progress=progress,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
if not should_retry_java_maven_with_clean(str(exc)):
|
||
raise
|
||
emit_progress(
|
||
progress,
|
||
"build.java.maven",
|
||
"检测到缓存产物损坏,退回一次 clean package 自愈",
|
||
level="warning",
|
||
)
|
||
run_logged_command(
|
||
java_maven_package_command(service_names, clean=True),
|
||
cwd=worktree_root,
|
||
label="java maven clean package",
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage="build.java.maven.clean-package",
|
||
progress=progress,
|
||
)
|
||
else:
|
||
emit_progress(progress, "build.java.maven", "跳过 Maven,直接复用已有 Jar")
|
||
|
||
max_workers = max(1, min(JAVA_IMAGE_BUILD_MAX_WORKERS, len(service_names)))
|
||
emit_progress(progress, "build.java", f"Java 镜像并行度:{max_workers}")
|
||
if max_workers == 1:
|
||
for service_name in service_names:
|
||
item = build_single_java_image(
|
||
service_name,
|
||
worktree_root=worktree_root,
|
||
short_commit=short_commit,
|
||
resolved_image_tag=resolved_image_tag,
|
||
progress=progress,
|
||
)
|
||
service_images[service_name] = item["image"]
|
||
outputs.append(item)
|
||
else:
|
||
items_by_service: dict[str, dict[str, Any]] = {}
|
||
errors: list[str] = []
|
||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="java-image-build") as executor:
|
||
future_map = {
|
||
executor.submit(
|
||
build_single_java_image,
|
||
service_name,
|
||
worktree_root=worktree_root,
|
||
short_commit=short_commit,
|
||
resolved_image_tag=resolved_image_tag,
|
||
progress=progress,
|
||
): service_name
|
||
for service_name in service_names
|
||
}
|
||
for future in as_completed(future_map):
|
||
service_name = future_map[future]
|
||
try:
|
||
item = future.result()
|
||
except Exception as exc: # noqa: BLE001
|
||
errors.append(f"{service_name}: {exc}")
|
||
emit_progress(progress, f"build.java.{service_name}", str(exc), level="error")
|
||
continue
|
||
items_by_service[service_name] = item
|
||
service_images[service_name] = item["image"]
|
||
|
||
if errors:
|
||
raise RuntimeError("java image build failed: " + "; ".join(errors))
|
||
|
||
outputs = [items_by_service[service_name] for service_name in service_names]
|
||
finally:
|
||
emit_progress(progress, "build.java", f"固定构建工作区保留复用:{worktree_root}")
|
||
|
||
return {
|
||
"kind": "java",
|
||
"repoRoot": str(repo_root),
|
||
"gitRef": resolved_ref,
|
||
"commit": commit,
|
||
"imageTag": resolved_image_tag,
|
||
"services": list(service_names),
|
||
"items": outputs,
|
||
"serviceImages": service_images,
|
||
}
|
||
|
||
|
||
def build_golang_images(
|
||
service_names: list[str],
|
||
git_ref: str,
|
||
image_tag: str,
|
||
*,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# Go 服务当前是单仓构建,逻辑更简单。
|
||
repo_root = ensure_repo_root(GOLANG_REPO_ROOT, "golang")
|
||
resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_GO_GIT_REF
|
||
emit_progress(progress, "build.golang", f"刷新 Go 仓库 refs:{resolved_ref}")
|
||
fetch_repo_refs(repo_root, "golang")
|
||
commit = resolve_repo_commit(repo_root, resolved_ref, "golang")
|
||
emit_progress(progress, "build.golang", f"Go ref 已解析:{resolved_ref} -> {commit[:12]}")
|
||
worktree_root = create_temp_worktree(repo_root, commit, "golang")
|
||
emit_progress(progress, "build.golang", f"已创建 Go 临时 worktree:{worktree_root}")
|
||
short_commit = commit[:12]
|
||
resolved_image_tag = derive_image_tag(resolved_ref, commit, image_tag)
|
||
|
||
outputs: list[dict[str, Any]] = []
|
||
service_images: dict[str, str] = {}
|
||
|
||
try:
|
||
for service_name in service_names:
|
||
repository_ref = service_repository_ref(service_name)
|
||
target_image_ref = f"{repository_ref}:{resolved_image_tag}"
|
||
emit_progress(progress, f"build.golang.{service_name}", f"开始构建镜像:{target_image_ref}")
|
||
build_tail = run_logged_command(
|
||
[str(worktree_root / "ci" / "build-image.sh"), target_image_ref, short_commit],
|
||
cwd=worktree_root,
|
||
label=f"golang build {service_name}",
|
||
env={"DOCKER_PLATFORM": BUILD_DOCKER_PLATFORM},
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage=f"build.golang.{service_name}.build",
|
||
progress=progress,
|
||
)
|
||
emit_progress(progress, f"build.golang.{service_name}", f"开始推送 Harbor:{target_image_ref}")
|
||
push_tail = run_logged_command(
|
||
["docker", "push", target_image_ref],
|
||
cwd=worktree_root,
|
||
label=f"golang push {service_name}",
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage=f"build.golang.{service_name}.push",
|
||
progress=progress,
|
||
)
|
||
digest_ref = docker_digest(target_image_ref, cwd=worktree_root)
|
||
emit_progress(progress, f"build.golang.{service_name}", f"镜像已推送完成:{digest_ref}")
|
||
service_images[service_name] = digest_ref
|
||
outputs.append(
|
||
{
|
||
"service": service_name,
|
||
"repository": repository_ref,
|
||
"tag": target_image_ref,
|
||
"image": digest_ref,
|
||
"buildTail": build_tail,
|
||
"pushTail": push_tail,
|
||
}
|
||
)
|
||
finally:
|
||
remove_temp_worktree(repo_root, worktree_root)
|
||
emit_progress(progress, "build.golang", f"已清理 Go worktree:{worktree_root}")
|
||
|
||
return {
|
||
"kind": "golang",
|
||
"repoRoot": str(repo_root),
|
||
"gitRef": resolved_ref,
|
||
"commit": commit,
|
||
"imageTag": resolved_image_tag,
|
||
"services": list(service_names),
|
||
"items": outputs,
|
||
"serviceImages": service_images,
|
||
}
|
||
|
||
|
||
def build_frontend_bundle(
|
||
service_name: str,
|
||
git_ref: str,
|
||
*,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# Admin 前端在 deploy 机做 pnpm install + build,最后产出静态 dist 目录。
|
||
repo_root = ensure_frontend_repo_root(progress=progress)
|
||
resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_ADMIN_GIT_REF
|
||
emit_progress(progress, "build.frontend", f"刷新前端仓库 refs:{resolved_ref}")
|
||
fetch_repo_refs(repo_root, "frontend")
|
||
commit = resolve_repo_commit(repo_root, resolved_ref, "frontend")
|
||
emit_progress(progress, "build.frontend", f"Frontend ref 已解析:{resolved_ref} -> {commit[:12]}")
|
||
worktree_root = prepare_persistent_build_workspace(
|
||
repo_root,
|
||
resolved_ref,
|
||
commit,
|
||
"frontend",
|
||
progress=progress,
|
||
)
|
||
|
||
shim_dir = worktree_root / ".hy-app-monitor-bin"
|
||
shim_dir.mkdir(parents=True, exist_ok=True)
|
||
pnpm_shim = shim_dir / "pnpm"
|
||
pnpm_shim.write_text("#!/bin/sh\nexec corepack pnpm \"$@\"\n", encoding="utf-8")
|
||
pnpm_shim.chmod(0o755)
|
||
|
||
frontend_env = {
|
||
"CI": "true",
|
||
"NODE_OPTIONS": "--max-old-space-size=8192",
|
||
"PATH": f"{shim_dir}:{os.environ.get('PATH', '')}",
|
||
}
|
||
emit_progress(progress, "build.frontend", f"已注入 pnpm shim:{pnpm_shim}")
|
||
emit_progress(progress, "build.frontend.install", "开始安装前端依赖")
|
||
run_logged_command(
|
||
["pnpm", "install", "--frozen-lockfile"],
|
||
cwd=worktree_root,
|
||
label="frontend pnpm install",
|
||
env=frontend_env,
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage="build.frontend.install",
|
||
progress=progress,
|
||
)
|
||
emit_progress(progress, "build.frontend.build", f"开始构建静态站点:{ADMIN_FRONTEND_BUILD_SCRIPT}")
|
||
run_logged_command(
|
||
["pnpm", "run", ADMIN_FRONTEND_BUILD_SCRIPT],
|
||
cwd=worktree_root,
|
||
label="frontend pnpm build",
|
||
env=frontend_env,
|
||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||
progress_stage="build.frontend.build",
|
||
progress=progress,
|
||
)
|
||
|
||
artifact_root = (worktree_root / ADMIN_FRONTEND_DIST_DIR).resolve()
|
||
if not artifact_root.exists():
|
||
raise RuntimeError(f"frontend dist missing after build: {artifact_root}")
|
||
if not artifact_root.is_dir():
|
||
raise RuntimeError(f"frontend dist is not a directory: {artifact_root}")
|
||
|
||
version = frontend_version_label(resolved_ref, commit)
|
||
emit_progress(progress, "build.frontend", f"前端静态产物已生成:{artifact_root}")
|
||
return {
|
||
"kind": "frontend",
|
||
"repoRoot": str(repo_root),
|
||
"repoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(),
|
||
"gitRef": resolved_ref,
|
||
"commit": commit,
|
||
"imageTag": version,
|
||
"services": [service_name],
|
||
"artifactPath": str(artifact_root),
|
||
"serviceArtifacts": {service_name: str(artifact_root)},
|
||
}
|
||
|
||
|
||
def build_service_images(
|
||
service_names: list[str],
|
||
*,
|
||
java_git_ref: str,
|
||
go_git_ref: str,
|
||
image_tag: str,
|
||
skip_maven_build: bool,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# 一个请求里可能同时选了 Java 和 Go,需要分仓构建后再合并 digest。
|
||
normalized = normalize_service_names(service_names)
|
||
java_services = [service_name for service_name in normalized if service_kind(service_name) == "java"]
|
||
golang_services = [service_name for service_name in normalized if service_kind(service_name) == "golang"]
|
||
|
||
if not normalized:
|
||
raise RuntimeError("services are required")
|
||
|
||
docker_login_local(
|
||
ensure_repo_root(JAVA_REPO_ROOT if java_services else GOLANG_REPO_ROOT, "build"),
|
||
progress=progress,
|
||
)
|
||
|
||
builds: list[dict[str, Any]] = []
|
||
service_images: dict[str, str] = {}
|
||
if java_services:
|
||
java_build = build_java_images(
|
||
java_services,
|
||
java_git_ref,
|
||
image_tag,
|
||
skip_maven_build=skip_maven_build,
|
||
progress=progress,
|
||
)
|
||
builds.append(java_build)
|
||
service_images.update(java_build["serviceImages"])
|
||
if golang_services:
|
||
golang_build = build_golang_images(golang_services, go_git_ref, image_tag, progress=progress)
|
||
builds.append(golang_build)
|
||
service_images.update(golang_build["serviceImages"])
|
||
|
||
return {
|
||
"builds": builds,
|
||
"serviceImages": service_images,
|
||
}
|
||
|
||
|
||
def host_image_plan(service_names: list[str], service_images: dict[str, str]) -> list[dict[str, Any]]:
|
||
# 先按宿主机聚合 image,后面可以先预拉再做滚动重建。
|
||
records_map = service_records_by_name()
|
||
items: list[dict[str, Any]] = []
|
||
host_index: dict[str, dict[str, Any]] = {}
|
||
|
||
for service_name in service_names:
|
||
image_ref = str(service_images.get(service_name) or "").strip()
|
||
if not image_ref:
|
||
raise RuntimeError(f"missing built image for service: {service_name}")
|
||
|
||
for record in records_map.get(service_name) or []:
|
||
host_entry = host_index.get(record["host"])
|
||
if host_entry is None:
|
||
host_entry = {
|
||
"host": record["host"],
|
||
"ip": record["ip"],
|
||
"instanceId": record["instanceId"],
|
||
"services": [],
|
||
"images": [],
|
||
}
|
||
host_index[record["host"]] = host_entry
|
||
items.append(host_entry)
|
||
|
||
if service_name not in host_entry["services"]:
|
||
host_entry["services"].append(service_name)
|
||
if image_ref not in host_entry["images"]:
|
||
host_entry["images"].append(image_ref)
|
||
|
||
return items
|
||
|
||
|
||
def render_remote_login_lines() -> list[str]:
|
||
# 目标机拉 Harbor 镜像前同样需要显式登录。
|
||
require_harbor_credentials()
|
||
return [
|
||
f"printf '%s' {shlex.quote(HARBOR_PASSWORD)} | docker login {shlex.quote(HARBOR_REGISTRY)} -u {shlex.quote(HARBOR_USERNAME)} --password-stdin",
|
||
]
|
||
|
||
|
||
def remote_host_result(host_entry: dict[str, Any], script: str, command_name: str, timeout_seconds: int) -> dict[str, Any]:
|
||
# 统一执行单宿主机命令,便于预拉镜像和其他操作复用。
|
||
return run_host_script_checked(
|
||
host_entry,
|
||
script,
|
||
command_name=command_name,
|
||
timeout_seconds=timeout_seconds,
|
||
)
|
||
|
||
|
||
def prepull_host_images(host_entry: dict[str, Any]) -> dict[str, Any]:
|
||
# 先把该宿主机上要更新的镜像拉好,减少单实例重建时的等待。
|
||
lines = ["set -eu", *render_remote_login_lines()]
|
||
for image_ref in host_entry.get("images") or []:
|
||
lines.append(f"docker pull {shlex.quote(str(image_ref))}")
|
||
script = "\n".join(lines) + "\n"
|
||
result = remote_host_result(
|
||
host_entry,
|
||
script,
|
||
f"prepull-{host_entry['host']}",
|
||
timeout_seconds=max(SERVICE_PULL_TIMEOUT_SECONDS, 120),
|
||
)
|
||
return {
|
||
"status": result.get("status"),
|
||
"output": str(result.get("output") or "").strip()[-1200:],
|
||
}
|
||
|
||
|
||
def prepare_frontend_remote_tmp(host: dict[str, Any]) -> str:
|
||
# 先在远端站点目录旁边建一份临时上传目录,避免直接覆盖线上内容。
|
||
site_dir = str(ADMIN_FRONTEND_SITE_DIR or "").rstrip("/")
|
||
parent_dir = str(Path(site_dir).parent)
|
||
script = f"""set -eu
|
||
PARENT_DIR={shlex.quote(parent_dir)}
|
||
mkdir -p "$PARENT_DIR"
|
||
mktemp -d "$PARENT_DIR/.hy-app-monitor-site.XXXXXX"
|
||
"""
|
||
result = run_host_script_checked(
|
||
host,
|
||
script,
|
||
command_name=f"frontend-tmp-{host['host']}",
|
||
timeout_seconds=30,
|
||
)
|
||
tmp_dir = str(result.get("output") or "").strip().splitlines()[-1].strip()
|
||
if not tmp_dir:
|
||
raise RuntimeError(f"failed to create frontend temp dir on {target_display(host)}")
|
||
return tmp_dir
|
||
|
||
|
||
def activate_frontend_site(
|
||
host: dict[str, Any],
|
||
*,
|
||
tmp_dir: str,
|
||
backup_stamp: str,
|
||
) -> dict[str, str]:
|
||
# 站点目录保留原 inode,只在目录内替换文件,避免 bind mount 指向失效。
|
||
site_dir = str(ADMIN_FRONTEND_SITE_DIR or "").rstrip("/")
|
||
remote_dir = str(ADMIN_FRONTEND_REMOTE_DIR or "").rstrip("/")
|
||
compose_file = str(ADMIN_FRONTEND_COMPOSE_FILE or "").rstrip("/")
|
||
compose_service = str(ADMIN_FRONTEND_COMPOSE_SERVICE or "").strip() or "caddy"
|
||
backup_root = str(Path(site_dir).parent / ".hy-app-monitor-site-backups")
|
||
keep_count = max(1, int(FRONTEND_SITE_BACKUP_KEEP))
|
||
script = f"""set -eu
|
||
SITE_DIR={shlex.quote(site_dir)}
|
||
TMP_DIR={shlex.quote(tmp_dir)}
|
||
BACKUP_ROOT={shlex.quote(backup_root)}
|
||
BACKUP_DIR="$BACKUP_ROOT/{shlex.quote(backup_stamp)}"
|
||
REMOTE_DIR={shlex.quote(remote_dir)}
|
||
COMPOSE_FILE={shlex.quote(compose_file)}
|
||
COMPOSE_SERVICE={shlex.quote(compose_service)}
|
||
KEEP_COUNT={keep_count}
|
||
|
||
mkdir -p "$SITE_DIR" "$BACKUP_ROOT"
|
||
if [ -d "$SITE_DIR" ]; then
|
||
mkdir -p "$BACKUP_DIR"
|
||
cp -a "$SITE_DIR"/. "$BACKUP_DIR"/ 2>/dev/null || true
|
||
fi
|
||
find "$SITE_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {{}} +
|
||
cp -a "$TMP_DIR"/. "$SITE_DIR"/
|
||
rm -rf "$TMP_DIR"
|
||
|
||
if [ -d "$BACKUP_ROOT" ]; then
|
||
ls -1dt "$BACKUP_ROOT"/* 2>/dev/null | awk "NR>$KEEP_COUNT" | xargs -r rm -rf
|
||
fi
|
||
|
||
cd "$REMOTE_DIR"
|
||
docker compose -f "$COMPOSE_FILE" up -d "$COMPOSE_SERVICE" >/dev/null
|
||
docker compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE" || true
|
||
"""
|
||
result = run_host_script_checked(
|
||
host,
|
||
script,
|
||
command_name=f"frontend-activate-{host['host']}",
|
||
timeout_seconds=max(SERVICE_BUILD_TIMEOUT_SECONDS, 180),
|
||
)
|
||
return {
|
||
"backupPath": f"{backup_root}/{backup_stamp}",
|
||
"output": str(result.get("output") or "").strip()[-1200:],
|
||
}
|
||
|
||
|
||
def deploy_frontend_site(
|
||
service_name: str,
|
||
*,
|
||
git_ref: str,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# 前端发布链路:构建 dist -> SCP 到目标机 -> 原地切换站点内容 -> 写版本元数据。
|
||
host = frontend_host_record(service_name)
|
||
build_result = build_frontend_bundle(service_name, git_ref, progress=progress)
|
||
artifact_path = Path(str(build_result.get("artifactPath") or "")).expanduser().resolve()
|
||
version = str(build_result.get("imageTag") or "").strip()
|
||
backup_stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||
step_stage = f"frontend.{host['host']}.{service_name}"
|
||
started_at = time.time()
|
||
step: dict[str, Any] = {
|
||
"service": service_name,
|
||
"host": host["host"],
|
||
"ip": host["ip"],
|
||
"kind": "frontend",
|
||
"image": version,
|
||
"status": "running",
|
||
}
|
||
tmp_dir = ""
|
||
|
||
try:
|
||
tmp_dir = prepare_frontend_remote_tmp(host)
|
||
emit_progress(progress, step_stage, f"上传静态产物:{artifact_path} -> {target_display(host)}:{tmp_dir}")
|
||
copy_local_path_to_host(
|
||
host,
|
||
f"{artifact_path}/.",
|
||
f"{tmp_dir}/",
|
||
recursive=True,
|
||
timeout_seconds=max(SERVICE_BUILD_TIMEOUT_SECONDS, 180),
|
||
)
|
||
emit_progress(progress, step_stage, "上传完成,开始切换站点目录")
|
||
activate_result = activate_frontend_site(host, tmp_dir=tmp_dir, backup_stamp=backup_stamp)
|
||
except Exception as exc: # noqa: BLE001
|
||
# 上传或切换失败时尽量收尾远端临时目录,避免后续发布被历史垃圾影响。
|
||
cleanup_script = f"""set -eu
|
||
TMP_DIR={shlex.quote(tmp_dir)}
|
||
if [ -d "$TMP_DIR" ]; then
|
||
rm -rf "$TMP_DIR"
|
||
fi
|
||
"""
|
||
if tmp_dir:
|
||
try:
|
||
run_host_script_checked(
|
||
host,
|
||
cleanup_script,
|
||
command_name=f"frontend-cleanup-{host['host']}",
|
||
timeout_seconds=30,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
step["status"] = "failed"
|
||
step["error"] = str(exc)
|
||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||
emit_progress(progress, step_stage, f"前端发布失败:{step['error']}", level="error")
|
||
return {
|
||
"ok": False,
|
||
"updatedAt": utc_now(),
|
||
"services": [service_name],
|
||
"build": [build_result],
|
||
"templates": [],
|
||
"preparedHosts": [],
|
||
"steps": [step],
|
||
"error": step["error"],
|
||
}
|
||
|
||
deployed_at = utc_now()
|
||
metadata = {
|
||
"service": service_name,
|
||
"kind": "frontend",
|
||
"repoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(),
|
||
"repoRoot": str(ADMIN_FRONTEND_REPO_ROOT),
|
||
"gitRef": str(build_result.get("gitRef") or "").strip(),
|
||
"commit": str(build_result.get("commit") or "").strip(),
|
||
"version": version,
|
||
"publicUrl": ADMIN_FRONTEND_PUBLIC_URL,
|
||
"siteDir": ADMIN_FRONTEND_SITE_DIR,
|
||
"composeFile": ADMIN_FRONTEND_COMPOSE_FILE,
|
||
"deployedAt": deployed_at,
|
||
"backupPath": activate_result["backupPath"],
|
||
"deployedBy": "hy-app-monitor",
|
||
}
|
||
write_remote_text(
|
||
host,
|
||
ADMIN_FRONTEND_RELEASE_META_PATH,
|
||
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
||
timeout_seconds=30,
|
||
)
|
||
emit_progress(progress, step_stage, f"版本元数据已写入:{ADMIN_FRONTEND_RELEASE_META_PATH}")
|
||
step["status"] = "success"
|
||
step["remoteStatus"] = "SUCCESS"
|
||
step["remoteOutput"] = activate_result["output"]
|
||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||
|
||
return {
|
||
"ok": True,
|
||
"updatedAt": deployed_at,
|
||
"services": [service_name],
|
||
"build": [build_result],
|
||
"templates": [],
|
||
"preparedHosts": [],
|
||
"steps": [step],
|
||
"message": "service update complete",
|
||
}
|
||
|
||
|
||
def build_update_targets_payload() -> dict[str, Any]:
|
||
# 页面需要展示当前可更新服务、宿主机,以及本地构建所依赖的仓路径。
|
||
records_map = service_records_by_name()
|
||
items: list[dict[str, Any]] = []
|
||
|
||
for service_name in buildable_service_names():
|
||
records = records_map.get(service_name) or []
|
||
if not records:
|
||
continue
|
||
current_images: list[str] = []
|
||
for record in records:
|
||
model = load_local_host_compose_model(record["host"])
|
||
image_ref = str(((model.get("services") or {}).get(service_name) or {}).get("image") or "").strip()
|
||
if image_ref and image_ref not in current_images:
|
||
current_images.append(image_ref)
|
||
display = image_release_display(current_images)
|
||
|
||
items.append(
|
||
{
|
||
"service": service_name,
|
||
"kind": service_kind(service_name),
|
||
"repository": service_repository_ref(service_name),
|
||
"currentLabel": display["currentLabel"],
|
||
"currentTitle": display["currentTitle"],
|
||
"detailLabel": display["detailLabel"],
|
||
"detailTitle": display["detailTitle"],
|
||
"copyValue": display["copyValue"],
|
||
"currentImages": current_images,
|
||
"hosts": [
|
||
{
|
||
"host": record["host"],
|
||
"ip": record["ip"],
|
||
"instanceId": record["instanceId"],
|
||
"port": record["port"],
|
||
"path": record["path"],
|
||
}
|
||
for record in records
|
||
],
|
||
}
|
||
)
|
||
|
||
if frontend_release_enabled():
|
||
frontend_service = ADMIN_FRONTEND_SERVICE_NAME
|
||
display = frontend_release_display(frontend_service)
|
||
items.append(
|
||
{
|
||
"service": frontend_service,
|
||
"kind": "frontend",
|
||
"repository": str(ADMIN_FRONTEND_REPO_URL or ADMIN_FRONTEND_REPO_ROOT),
|
||
"currentLabel": display["currentLabel"],
|
||
"currentTitle": display["currentTitle"],
|
||
"detailLabel": display["detailLabel"],
|
||
"detailTitle": display["detailTitle"],
|
||
"copyValue": display["copyValue"],
|
||
"currentImages": [],
|
||
"hosts": [
|
||
{
|
||
"host": ADMIN_FRONTEND_HOST,
|
||
"ip": ADMIN_FRONTEND_IP,
|
||
"instanceId": ADMIN_FRONTEND_INSTANCE_ID,
|
||
"port": 0,
|
||
"path": ADMIN_FRONTEND_SITE_DIR,
|
||
}
|
||
],
|
||
}
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"updatedAt": utc_now(),
|
||
"defaults": {
|
||
"javaGitRef": UPDATE_DEFAULT_JAVA_GIT_REF,
|
||
"goGitRef": UPDATE_DEFAULT_GO_GIT_REF,
|
||
"adminGitRef": UPDATE_DEFAULT_ADMIN_GIT_REF,
|
||
"dockerPlatform": BUILD_DOCKER_PLATFORM,
|
||
},
|
||
"builder": {
|
||
"harborRegistry": HARBOR_REGISTRY,
|
||
"harborProject": HARBOR_PROJECT,
|
||
"javaRepoRoot": str(Path(JAVA_REPO_ROOT).expanduser()),
|
||
"golangRepoRoot": str(Path(GOLANG_REPO_ROOT).expanduser()),
|
||
"frontendRepoRoot": str(Path(ADMIN_FRONTEND_REPO_ROOT).expanduser()),
|
||
"frontendRepoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(),
|
||
"frontendPublicUrl": ADMIN_FRONTEND_PUBLIC_URL,
|
||
},
|
||
"items": items,
|
||
}
|
||
|
||
|
||
def deploy_runtime_services_payload(
|
||
service_names: list[str],
|
||
*,
|
||
java_git_ref: str,
|
||
go_git_ref: str,
|
||
image_tag: str,
|
||
skip_maven_build: bool,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# Java / Go 服务沿用镜像构建、完整 compose、预拉、滚动重建这条链路。
|
||
normalized = normalize_service_names(service_names)
|
||
if not normalized:
|
||
raise RuntimeError("services are required")
|
||
|
||
records_map = service_records_by_name()
|
||
unsupported = [service_name for service_name in normalized if service_name not in buildable_service_names()]
|
||
if unsupported:
|
||
raise RuntimeError(f"unsupported update services: {', '.join(unsupported)}")
|
||
for service_name in normalized:
|
||
if service_name not in records_map:
|
||
raise RuntimeError(f"service not found in runtime topology: {service_name}")
|
||
|
||
emit_progress(progress, "prepare", f"准备更新服务:{', '.join(normalized)}")
|
||
build_result = build_service_images(
|
||
normalized,
|
||
java_git_ref=java_git_ref,
|
||
go_git_ref=go_git_ref,
|
||
image_tag=image_tag,
|
||
skip_maven_build=skip_maven_build,
|
||
progress=progress,
|
||
)
|
||
service_images = dict(build_result.get("serviceImages") or {})
|
||
emit_progress(progress, "prepare", "镜像构建完成,开始更新本地完整 compose 模板")
|
||
|
||
template_updates: list[dict[str, Any]] = []
|
||
for service_name in normalized:
|
||
for record in records_map[service_name]:
|
||
image_ref = service_images[service_name]
|
||
emit_progress(progress, "compose.template", f"更新模板:{record['host']} / {service_name} -> {image_ref}")
|
||
template_updates.append(update_local_service_image(record["host"], service_name, image_ref))
|
||
|
||
host_plan = host_image_plan(normalized, service_images)
|
||
prepared_hosts: list[dict[str, Any]] = []
|
||
steps: list[dict[str, Any]] = []
|
||
|
||
try:
|
||
for host_entry in host_plan:
|
||
emit_progress(progress, f"compose.sync.{host_entry['host']}", f"同步完整 compose 到远端:{host_entry['host']}")
|
||
compose_result = sync_remote_host_compose_template(host_entry["host"], refresh_from_remote=False)
|
||
emit_progress(
|
||
progress,
|
||
f"compose.sync.{host_entry['host']}",
|
||
f"compose 同步完成:changed={bool(compose_result.get('changed'))} path={compose_result.get('path')}",
|
||
)
|
||
emit_progress(progress, f"image.prepull.{host_entry['host']}", f"预拉镜像:{', '.join(host_entry.get('images') or [])}")
|
||
prepull_result = prepull_host_images(host_entry)
|
||
emit_progress(
|
||
progress,
|
||
f"image.prepull.{host_entry['host']}",
|
||
f"预拉完成:{prepull_result['status']} {short_text(prepull_result['output'])}",
|
||
)
|
||
prepared_hosts.append(
|
||
{
|
||
"host": host_entry["host"],
|
||
"ip": host_entry["ip"],
|
||
"services": list(host_entry["services"]),
|
||
"images": list(host_entry["images"]),
|
||
"compose": {
|
||
"path": compose_result["path"],
|
||
"changed": bool(compose_result.get("changed")),
|
||
"source": str(compose_result.get("source") or ""),
|
||
"backupPath": str(compose_result.get("backupPath") or ""),
|
||
},
|
||
"prepull": {
|
||
"status": prepull_result["status"],
|
||
"output": prepull_result["output"],
|
||
},
|
||
}
|
||
)
|
||
|
||
for service_name in normalized:
|
||
for service in records_map[service_name]:
|
||
started_at = time.time()
|
||
step_stage = f"restart.{service['host']}.{service_name}"
|
||
step: dict[str, Any] = {
|
||
"service": service_name,
|
||
"host": service["host"],
|
||
"ip": service["ip"],
|
||
"kind": service["kind"],
|
||
"image": service_images[service_name],
|
||
"status": "running",
|
||
}
|
||
try:
|
||
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
|
||
restart_result = restart_service_instance(service)
|
||
step["remoteStatus"] = restart_result.get("status")
|
||
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
||
emit_progress(
|
||
progress,
|
||
step_stage,
|
||
f"远端重建完成:{restart_result.get('status')} {short_text(step['remoteOutput'])}",
|
||
)
|
||
|
||
http_result = wait_http_ready(service, timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS)
|
||
step["http"] = {
|
||
"ok": bool(http_result.get("ok")),
|
||
"statusCode": int(http_result.get("statusCode") or 0),
|
||
"latencyMs": int(http_result.get("latencyMs") or 0),
|
||
"detail": str(http_result.get("detail") or ""),
|
||
}
|
||
emit_progress(
|
||
progress,
|
||
step_stage,
|
||
f"HTTP 恢复:{step['http']['statusCode']} / {step['http']['latencyMs']}ms",
|
||
)
|
||
|
||
if service["kind"] == "java":
|
||
step["nacos"] = wait_java_registration_ready(
|
||
service,
|
||
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
|
||
)
|
||
emit_progress(
|
||
progress,
|
||
step_stage,
|
||
f"Nacos 注册恢复:{step['nacos'].get('source') or 'ok'}",
|
||
)
|
||
|
||
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
||
emit_progress(
|
||
progress,
|
||
step_stage,
|
||
f"稳定观察 {ROLLING_RESTART_STABILIZE_SECONDS}s",
|
||
)
|
||
time.sleep(ROLLING_RESTART_STABILIZE_SECONDS)
|
||
|
||
step["status"] = "success"
|
||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||
steps.append(step)
|
||
emit_progress(
|
||
progress,
|
||
step_stage,
|
||
f"实例更新成功,耗时 {step['durationSeconds']}s",
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
step["status"] = "failed"
|
||
step["error"] = str(exc)
|
||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||
steps.append(step)
|
||
emit_progress(progress, step_stage, f"实例更新失败:{step['error']}", level="error")
|
||
clear_operation_caches()
|
||
return {
|
||
"ok": False,
|
||
"updatedAt": utc_now(),
|
||
"services": normalized,
|
||
"build": build_result.get("builds") or [],
|
||
"templates": template_updates,
|
||
"preparedHosts": prepared_hosts,
|
||
"steps": steps,
|
||
"error": step["error"],
|
||
}
|
||
finally:
|
||
clear_operation_caches()
|
||
emit_progress(progress, "finish", "已清理监控缓存")
|
||
|
||
return {
|
||
"ok": True,
|
||
"updatedAt": utc_now(),
|
||
"services": normalized,
|
||
"build": build_result.get("builds") or [],
|
||
"templates": template_updates,
|
||
"preparedHosts": prepared_hosts,
|
||
"steps": steps,
|
||
"message": "service update complete",
|
||
}
|
||
|
||
|
||
def merge_update_results(service_names: list[str], results: list[dict[str, Any]]) -> dict[str, Any]:
|
||
# 一个任务里允许同时带镜像服务和前端服务,这里统一把结果拼回页面结构。
|
||
merged = {
|
||
"ok": True,
|
||
"updatedAt": utc_now(),
|
||
"services": list(service_names),
|
||
"build": [],
|
||
"templates": [],
|
||
"preparedHosts": [],
|
||
"steps": [],
|
||
"message": "service update complete",
|
||
}
|
||
|
||
for result in results:
|
||
if not result:
|
||
continue
|
||
merged["ok"] = merged["ok"] and bool(result.get("ok"))
|
||
merged["build"].extend(list(result.get("build") or result.get("builds") or []))
|
||
merged["templates"].extend(list(result.get("templates") or []))
|
||
merged["preparedHosts"].extend(list(result.get("preparedHosts") or []))
|
||
merged["steps"].extend(list(result.get("steps") or []))
|
||
if not result.get("ok"):
|
||
merged["error"] = str(result.get("error") or "service update failed").strip()
|
||
merged["message"] = str(result.get("message") or merged["error"] or "service update failed").strip()
|
||
return merged
|
||
if str(result.get("message") or "").strip():
|
||
merged["message"] = str(result.get("message") or "").strip()
|
||
|
||
return merged
|
||
|
||
|
||
def deploy_updated_services_payload(
|
||
service_names: list[str],
|
||
*,
|
||
java_git_ref: str,
|
||
go_git_ref: str,
|
||
admin_git_ref: str,
|
||
image_tag: str,
|
||
skip_maven_build: bool,
|
||
progress: ProgressLogger | None = None,
|
||
) -> dict[str, Any]:
|
||
# 顶层按服务类型拆分:容器服务走 Harbor/compose,Admin 前端走 pnpm build + SSH 发布。
|
||
normalized = normalize_service_names(service_names)
|
||
if not normalized:
|
||
raise RuntimeError("services are required")
|
||
|
||
unsupported = [service_name for service_name in normalized if service_name not in buildable_service_names()]
|
||
if unsupported:
|
||
raise RuntimeError(f"unsupported update services: {', '.join(unsupported)}")
|
||
|
||
runtime_services = [service_name for service_name in normalized if service_kind(service_name) in {"java", "golang"}]
|
||
frontend_services = [service_name for service_name in normalized if service_kind(service_name) == "frontend"]
|
||
records_map = service_records_by_name()
|
||
|
||
for service_name in runtime_services:
|
||
if service_name not in records_map:
|
||
raise RuntimeError(f"service not found in runtime topology: {service_name}")
|
||
|
||
emit_progress(progress, "prepare", f"准备更新服务:{', '.join(normalized)}")
|
||
results: list[dict[str, Any]] = []
|
||
|
||
if runtime_services:
|
||
runtime_result = deploy_runtime_services_payload(
|
||
runtime_services,
|
||
java_git_ref=java_git_ref,
|
||
go_git_ref=go_git_ref,
|
||
image_tag=image_tag,
|
||
skip_maven_build=skip_maven_build,
|
||
progress=progress,
|
||
)
|
||
results.append(runtime_result)
|
||
if not runtime_result.get("ok"):
|
||
return merge_update_results(normalized, results)
|
||
|
||
for service_name in frontend_services:
|
||
frontend_result = deploy_frontend_site(service_name, git_ref=admin_git_ref, progress=progress)
|
||
results.append(frontend_result)
|
||
if not frontend_result.get("ok"):
|
||
return merge_update_results(normalized, results)
|
||
|
||
emit_progress(progress, "finish", "全部服务更新完成")
|
||
return merge_update_results(normalized, results)
|