diff --git a/.env.example b/.env.example index 5e4ceba..d1df4a3 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ SERVICE_LOG_EXPORT_MAX_LINES=10000 ROLLING_RESTART_TIMEOUT_SECONDS=180 ROLLING_RESTART_POLL_SECONDS=2 ROLLING_RESTART_STABILIZE_SECONDS=2 +NACOS_RELEASE_DRAIN_SERVICES=other +NACOS_RELEASE_DRAIN_SECONDS=8 +NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS=30 SERVICE_BUILD_TIMEOUT_SECONDS=3600 SERVICE_PULL_TIMEOUT_SECONDS=600 SERVICE_BUILD_CACHE_ROOT=/opt/deploy/build-cache @@ -71,6 +74,23 @@ ADMIN_FRONTEND_RELEASE_META_PATH=/opt/yumi-admin/frontend-admin/.hy-app-monitor- ADMIN_FRONTEND_BUILD_SCRIPT=build:antdv-next ADMIN_FRONTEND_DIST_DIR=apps/dist ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com +CHATAPP_CRON_ENABLED=1 +CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git +CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron +CHATAPP_CRON_DEFAULT_GIT_REF=main +CHATAPP_CRON_SERVICE_NAME=chatapp-cron +CHATAPP_CRON_HOST=cron +CHATAPP_CRON_IP=10.3.1.14 +CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0 +CHATAPP_CRON_SSH_HOST=10.3.1.14 +CHATAPP_CRON_SSH_USER=root +CHATAPP_CRON_SSH_PORT=22 +CHATAPP_CRON_SSH_USE_SUDO=0 +CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current +CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml +CHATAPP_CRON_COMPOSE_SERVICE=week-star-job +CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service +CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json MONGO_URI= MONGO_DB_NAME=tarab_all MONGO_INSTANCE_ID=ins-o9mmob66 diff --git a/README.md b/README.md index 19c4b22..4cd6c39 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,28 @@ ADMIN_FRONTEND_DIST_DIR=apps/dist ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com ``` +通用模块里的 `Cron 部署` 用于发布 `chatapp-cron`,需要目标机已有 `docker-compose.yml` 和 `service.env`。部署流程会构建 `week-star-job` 二进制,上传到目标机,再执行 compose 重建: + +```bash +CHATAPP_CRON_ENABLED=1 +CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git +CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron +CHATAPP_CRON_DEFAULT_GIT_REF=main +CHATAPP_CRON_SERVICE_NAME=chatapp-cron +CHATAPP_CRON_HOST=cron +CHATAPP_CRON_IP=10.3.1.14 +CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0 +CHATAPP_CRON_SSH_HOST=10.3.1.14 +CHATAPP_CRON_SSH_USER=root +CHATAPP_CRON_SSH_PORT=22 +CHATAPP_CRON_SSH_USE_SUDO=0 +CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current +CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml +CHATAPP_CRON_COMPOSE_SERVICE=week-star-job +CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service +CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json +``` + 可以直接复制模板: ```bash @@ -205,7 +227,7 @@ python3 ops/bootstrap_ssh_via_tat.py 3. 只允许 `deploy -> target:22` 4. 把目标机本机防火墙和安全组都限制到 `deploy` 私网 IP -如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1`,脚本也会把 admin 前端机器一起纳入这轮 SSH 引导,即使它不在 `config/targets.json` 里。 +如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1` 或 `CHATAPP_CRON_ENABLED=1`,脚本也会把 admin 前端机器和定时任务服务器纳入这轮 SSH 引导,即使它们不在 `config/targets.json` 里。 如果你只需要放开监控面板端口,仍然可以使用: diff --git a/core/config.py b/core/config.py index ea8f329..0cc73be 100644 --- a/core/config.py +++ b/core/config.py @@ -93,6 +93,12 @@ ROLLING_RESTART_TIMEOUT_SECONDS = env_int("ROLLING_RESTART_TIMEOUT_SECONDS", 180 ROLLING_RESTART_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0) # 单实例恢复后额外稳定等待秒数。 ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_SECONDS", 2.0) +# 依赖方较多的服务发布前先在 Nacos 摘掉当前实例,避免调用方继续打到正在重启的节点。 +NACOS_RELEASE_DRAIN_SERVICES = tuple(env_str_list("NACOS_RELEASE_DRAIN_SERVICES", ["other"])) +# Nacos 摘实例后额外等待调用方本地缓存刷新。 +NACOS_RELEASE_DRAIN_SECONDS = env_float("NACOS_RELEASE_DRAIN_SECONDS", 8.0) +# 等待 Nacos enabled 状态变化的最长秒数。 +NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS = env_int("NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS", 30) # 日志查看固定读取最近多少条。 SERVICE_LOG_PREVIEW_LINES = env_int("SERVICE_LOG_PREVIEW_LINES", 1000) # 日志导出最大允许条数,避免单次下载打爆页面进程。 @@ -281,6 +287,70 @@ ADMIN_FRONTEND_ENABLED = truthy( ), ) +# chatapp-cron 仓库地址;仓库缺失时允许按这里自动 clone。 +CHATAPP_CRON_REPO_URL = env_str("CHATAPP_CRON_REPO_URL", "git@gitea.haiyihy.com:hy/chatapp-cron.git") +# chatapp-cron 仓库路径,默认兼容本地工作区和远端 deploy 机。 +CHATAPP_CRON_REPO_ROOT = env_or_candidate_path( + "CHATAPP_CRON_REPO_ROOT", + [ + WORKSPACE_ROOT / "chatapp3" / "chatapp-cron", + WORKSPACE_ROOT / "chatapp-cron", + Path("/opt/deploy/sources/chatapp-cron"), + ], +) +# chatapp-cron 默认拉取的 Git ref。 +CHATAPP_CRON_DEFAULT_GIT_REF = env_str("CHATAPP_CRON_DEFAULT_GIT_REF", "main") +# chatapp-cron 在通用部署页里的服务名。 +CHATAPP_CRON_SERVICE_NAME = env_str("CHATAPP_CRON_SERVICE_NAME", "chatapp-cron").strip() or "chatapp-cron" +# chatapp-cron 远端主机名称。 +CHATAPP_CRON_HOST = env_str("CHATAPP_CRON_HOST", "cron").strip() or "cron" +# chatapp-cron 远端主机私网 IP。 +# 定时任务服务器:ins-nt6bsay0 / cron / 10.3.1.14。 +CHATAPP_CRON_IP = env_str("CHATAPP_CRON_IP", "10.3.1.14") +# chatapp-cron 云服务器实例 ID,主要供 TAT SSH 引导使用。 +CHATAPP_CRON_INSTANCE_ID = env_str("CHATAPP_CRON_INSTANCE_ID", "ins-nt6bsay0") +# chatapp-cron SSH 主机,默认沿用私网 IP。 +CHATAPP_CRON_SSH_HOST = env_str("CHATAPP_CRON_SSH_HOST", CHATAPP_CRON_IP).strip() or CHATAPP_CRON_IP +# chatapp-cron SSH 用户。 +CHATAPP_CRON_SSH_USER = env_str("CHATAPP_CRON_SSH_USER", SSH_USER).strip() or SSH_USER +# chatapp-cron SSH 端口。 +CHATAPP_CRON_SSH_PORT = env_int("CHATAPP_CRON_SSH_PORT", SSH_PORT) +# chatapp-cron 是否需要 sudo。 +CHATAPP_CRON_SSH_USE_SUDO = env_str("CHATAPP_CRON_SSH_USE_SUDO", "0").lower() in {"1", "true", "yes", "on"} +# chatapp-cron 远端运行目录。 +CHATAPP_CRON_REMOTE_DIR = env_str("CHATAPP_CRON_REMOTE_DIR", "/opt/deploy/runtime/current").strip() or "/opt/deploy/runtime/current" +# chatapp-cron compose 文件路径。 +CHATAPP_CRON_COMPOSE_FILE = ( + env_str("CHATAPP_CRON_COMPOSE_FILE", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml").strip() + or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml" +) +# chatapp-cron compose 服务名。 +CHATAPP_CRON_COMPOSE_SERVICE = env_str("CHATAPP_CRON_COMPOSE_SERVICE", "week-star-job").strip() or "week-star-job" +# chatapp-cron 远端二进制路径。 +CHATAPP_CRON_BINARY_PATH = ( + env_str("CHATAPP_CRON_BINARY_PATH", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service").strip() + or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service" +) +# chatapp-cron 当前发布元数据文件路径。 +CHATAPP_CRON_RELEASE_META_PATH = ( + env_str( + "CHATAPP_CRON_RELEASE_META_PATH", + f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json", + ).strip() + or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json" +) +# chatapp-cron 是否启用;未显式指定时,只要目标机和仓库信息齐备就自动开启。 +CHATAPP_CRON_ENABLED = truthy( + os.environ.get("CHATAPP_CRON_ENABLED"), + bool( + CHATAPP_CRON_SERVICE_NAME + and CHATAPP_CRON_HOST + and (CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP) + and CHATAPP_CRON_REMOTE_DIR + and (CHATAPP_CRON_REPO_URL or str(CHATAPP_CRON_REPO_ROOT).strip()) + ), +) + # Mongo 连接串。 MONGO_URI = env_str("MONGO_URI", "") # Mongo 默认关注的业务库名。 diff --git a/monitors/yumi/chatapp_cron_deploy.py b/monitors/yumi/chatapp_cron_deploy.py new file mode 100644 index 0000000..17f3b13 --- /dev/null +++ b/monitors/yumi/chatapp_cron_deploy.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +import base64 +import copy +from datetime import datetime +import json +from pathlib import Path +import re +import shlex +import shutil +import tempfile +import threading +import time +from typing import Any, Callable +import uuid + +from core.config import ( + BUILD_DOCKER_PLATFORM, + CHATAPP_CRON_BINARY_PATH, + CHATAPP_CRON_COMPOSE_FILE, + CHATAPP_CRON_COMPOSE_SERVICE, + CHATAPP_CRON_DEFAULT_GIT_REF, + CHATAPP_CRON_ENABLED, + CHATAPP_CRON_HOST, + CHATAPP_CRON_IP, + CHATAPP_CRON_INSTANCE_ID, + CHATAPP_CRON_RELEASE_META_PATH, + CHATAPP_CRON_REMOTE_DIR, + CHATAPP_CRON_REPO_ROOT, + CHATAPP_CRON_REPO_URL, + CHATAPP_CRON_SERVICE_NAME, + CHATAPP_CRON_SSH_HOST, + CHATAPP_CRON_SSH_PORT, + CHATAPP_CRON_SSH_USE_SUDO, + CHATAPP_CRON_SSH_USER, + ROOT, + SERVICE_BUILD_TIMEOUT_SECONDS, + SERVICE_PULL_TIMEOUT_SECONDS, + utc_now, +) +from .service_release import ( + create_temp_worktree, + emit_progress, + ensure_repo_root, + fetch_repo_refs, + local_command_output, + remove_temp_worktree, + resolve_repo_commit, + run_logged_command, + sanitized_ref_fragment, + short_text, +) +from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display + + +# 后台任务的日志回调统一走这个签名。 +ProgressLogger = Callable[[str, str, str], None] +# 内存里最多保留最近若干次 cron 部署记录。 +MAX_CRON_DEPLOY_OPERATIONS = 10 +# 任务状态和完整日志统一落到 run 目录。 +CRON_DEPLOY_JOB_DIR = ROOT / "run" / "chatapp_cron_deploy_jobs" +# 统一保护任务状态的锁。 +CRON_DEPLOY_JOB_LOCK = threading.Lock() +# 当前进程内的 cron 部署任务表。 +CRON_DEPLOY_JOBS: dict[str, dict[str, Any]] = {} +# 记录任务插入顺序,便于淘汰最旧项。 +CRON_DEPLOY_JOB_ORDER: list[str] = [] + + +def chatapp_cron_release_enabled() -> bool: + # 只有目标机、运行目录和仓库信息齐备时才开放部署按钮。 + return bool( + CHATAPP_CRON_ENABLED + and str(CHATAPP_CRON_SERVICE_NAME or "").strip() + and str(CHATAPP_CRON_HOST or "").strip() + and str(CHATAPP_CRON_REMOTE_DIR or "").strip() + and str(CHATAPP_CRON_BINARY_PATH or "").strip() + and str(CHATAPP_CRON_COMPOSE_FILE or "").strip() + and str(CHATAPP_CRON_COMPOSE_SERVICE or "").strip() + and (str(CHATAPP_CRON_SSH_HOST or "").strip() or str(CHATAPP_CRON_IP or "").strip()) + and (str(CHATAPP_CRON_REPO_URL or "").strip() or str(CHATAPP_CRON_REPO_ROOT or "").strip()) + ) + + +def chatapp_cron_host_record() -> dict[str, Any]: + # chatapp-cron 不在 Yumi 业务服务 targets 里,部署主机单独从 .env 读取。 + if not chatapp_cron_release_enabled(): + raise RuntimeError("chatapp-cron deploy is not configured") + + return { + "host": CHATAPP_CRON_HOST, + "ip": CHATAPP_CRON_IP, + "instanceId": CHATAPP_CRON_INSTANCE_ID, + "sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP, + "sshUser": CHATAPP_CRON_SSH_USER, + "sshUseSudo": CHATAPP_CRON_SSH_USE_SUDO, + "sshPort": CHATAPP_CRON_SSH_PORT, + } + + +def ensure_chatapp_cron_repo_root(*, progress: ProgressLogger | None = None) -> Path: + # 第一次使用时允许按配置仓库地址自动 clone 到 deploy 机。 + repo_root = Path(CHATAPP_CRON_REPO_ROOT).expanduser().resolve() + if (repo_root / ".git").exists(): + return repo_root + + repo_url = str(CHATAPP_CRON_REPO_URL or "").strip() + if not repo_url: + raise RuntimeError("missing CHATAPP_CRON_REPO_URL in .env") + + repo_root.parent.mkdir(parents=True, exist_ok=True) + emit_progress(progress, "cron.repo", f"初始化 chatapp-cron 仓库:{repo_url} -> {repo_root}") + local_command_output( + ["git", "clone", repo_url, str(repo_root)], + cwd=repo_root.parent, + label="chatapp-cron clone repo", + timeout_seconds=900, + ) + return ensure_repo_root(repo_root, "chatapp-cron") + + +def read_chatapp_cron_metadata() -> dict[str, Any]: + # 元数据只用于展示最近部署版本,读取失败不影响页面打开。 + if not chatapp_cron_release_enabled(): + return {} + + try: + text = read_remote_text(chatapp_cron_host_record(), CHATAPP_CRON_RELEASE_META_PATH, timeout_seconds=30) + return json.loads(text or "{}") + except Exception as exc: # noqa: BLE001 + return {"error": str(exc)} + + +def build_chatapp_cron_overview_payload() -> dict[str, Any]: + # 通用页加载时返回配置摘要和最近部署版本。 + enabled = chatapp_cron_release_enabled() + settings = { + "enabled": enabled, + "serviceName": CHATAPP_CRON_SERVICE_NAME, + "host": CHATAPP_CRON_HOST, + "sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP, + "instanceId": CHATAPP_CRON_INSTANCE_ID, + "repoRoot": str(Path(CHATAPP_CRON_REPO_ROOT).expanduser()), + "repoUrl": CHATAPP_CRON_REPO_URL, + "defaultGitRef": CHATAPP_CRON_DEFAULT_GIT_REF, + "remoteDir": CHATAPP_CRON_REMOTE_DIR, + "composeFile": CHATAPP_CRON_COMPOSE_FILE, + "composeService": CHATAPP_CRON_COMPOSE_SERVICE, + "binaryPath": CHATAPP_CRON_BINARY_PATH, + "dockerPlatform": BUILD_DOCKER_PLATFORM, + } + return { + "ok": True, + "enabled": enabled, + "configured": enabled, + "updatedAt": utc_now(), + "settings": settings, + "metadata": read_chatapp_cron_metadata() if enabled else {}, + } + + +def docker_builder_image_name(commit: str) -> str: + # 本地临时 builder 镜像只用于 docker cp 导出二进制。 + stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S") + tag = sanitized_ref_fragment(f"{commit[:12]}-{stamp}").lower() + return f"hy-app-monitor/chatapp-cron-builder:{tag}" + + +def build_chatapp_cron_binary(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]: + # 使用仓库 Dockerfile 的 builder stage 产出 linux/amd64 二进制,避免依赖宿主机 Go 版本。 + repo_root = ensure_chatapp_cron_repo_root(progress=progress) + resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF + emit_progress(progress, "cron.build", f"刷新 chatapp-cron refs:{resolved_ref}") + fetch_repo_refs(repo_root, "chatapp-cron") + commit = resolve_repo_commit(repo_root, resolved_ref, "chatapp-cron") + emit_progress(progress, "cron.build", f"chatapp-cron ref 已解析:{resolved_ref} -> {commit[:12]}") + + worktree_root = create_temp_worktree(repo_root, commit, "chatapp-cron") + artifact_root = Path(tempfile.mkdtemp(prefix="hy-app-monitor-chatapp-cron-artifact-")) + builder_image = docker_builder_image_name(commit) + container_id = "" + binary_path = artifact_root / "week-star-job" + + try: + emit_progress(progress, "cron.build", f"开始 Docker builder 构建:{BUILD_DOCKER_PLATFORM}") + run_logged_command( + ["docker", "build", "--platform", BUILD_DOCKER_PLATFORM, "--target", "builder", "-t", builder_image, "."], + cwd=worktree_root, + label="chatapp-cron docker builder", + timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS, + progress_stage="cron.build.docker", + progress=progress, + ) + + container_id = local_command_output( + ["docker", "create", builder_image], + cwd=worktree_root, + label="chatapp-cron create builder container", + timeout_seconds=120, + ) + local_command_output( + ["docker", "cp", f"{container_id}:/out/week-star-job", str(binary_path)], + cwd=worktree_root, + label="chatapp-cron export binary", + timeout_seconds=180, + ) + binary_path.chmod(0o755) + emit_progress(progress, "cron.build", f"二进制已生成:{binary_path}") + return { + "kind": "chatapp-cron", + "gitRef": resolved_ref, + "commit": commit, + "binaryPath": str(binary_path), + "builderImage": builder_image, + } + finally: + if container_id: + try: + local_command_output( + ["docker", "rm", "-f", container_id], + cwd=worktree_root, + label="chatapp-cron remove builder container", + timeout_seconds=60, + ) + except Exception: # noqa: BLE001 + pass + try: + local_command_output( + ["docker", "rmi", "-f", builder_image], + cwd=worktree_root, + label="chatapp-cron remove builder image", + timeout_seconds=120, + ) + except Exception: # noqa: BLE001 + pass + remove_temp_worktree(repo_root, worktree_root) + + +def activate_chatapp_cron_binary(build_result: dict[str, Any], *, progress: ProgressLogger | None = None) -> dict[str, Any]: + # 把新二进制传到 cron 机器,再用 docker compose 重建 week-star-job。 + host = chatapp_cron_host_record() + binary_path = Path(str(build_result["binaryPath"])) + if not binary_path.exists(): + raise RuntimeError(f"chatapp-cron binary missing: {binary_path}") + + remote_tmp = f"/tmp/hy-app-monitor-chatapp-cron-{build_result['commit'][:12]}-{int(time.time())}" + emit_progress(progress, "cron.deploy.copy", f"上传二进制到 {target_display(host)}:{remote_tmp}") + copy_local_path_to_host( + host, + binary_path, + remote_tmp, + timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS, + ) + + metadata = { + "service": CHATAPP_CRON_SERVICE_NAME, + "component": CHATAPP_CRON_COMPOSE_SERVICE, + "host": CHATAPP_CRON_HOST, + "gitRef": build_result["gitRef"], + "commit": build_result["commit"], + "deployedAt": utc_now(), + "binaryPath": CHATAPP_CRON_BINARY_PATH, + "composeFile": CHATAPP_CRON_COMPOSE_FILE, + "remoteDir": CHATAPP_CRON_REMOTE_DIR, + } + metadata_blob = base64.b64encode(json.dumps(metadata, ensure_ascii=False, indent=2).encode("utf-8")).decode("ascii") + + script = f"""set -euo pipefail +REMOTE_DIR={shlex.quote(CHATAPP_CRON_REMOTE_DIR)} +COMPOSE_FILE={shlex.quote(CHATAPP_CRON_COMPOSE_FILE)} +COMPOSE_SERVICE={shlex.quote(CHATAPP_CRON_COMPOSE_SERVICE)} +BINARY_PATH={shlex.quote(CHATAPP_CRON_BINARY_PATH)} +REMOTE_TMP={shlex.quote(remote_tmp)} +META_PATH={shlex.quote(CHATAPP_CRON_RELEASE_META_PATH)} +META_BLOB={shlex.quote(metadata_blob)} + +compose() {{ + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + else + docker-compose "$@" + fi +}} + +mkdir -p "$(dirname "$BINARY_PATH")" "$REMOTE_DIR/.backups" +if [ -f "$BINARY_PATH" ]; then + cp "$BINARY_PATH" "$REMOTE_DIR/.backups/week-star-job.$(date +%Y%m%d-%H%M%S)" +fi +install -m 0755 "$REMOTE_TMP" "$BINARY_PATH" +rm -f "$REMOTE_TMP" + +cd "$REMOTE_DIR" +compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate "$COMPOSE_SERVICE" + +for _ in $(seq 1 30); do + if compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE" | grep -Eiq 'running|up'; then + break + fi + sleep 2 +done + +STATUS_OUTPUT="$(compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE")" +printf '%s\\n' "$STATUS_OUTPUT" +if ! printf '%s\\n' "$STATUS_OUTPUT" | grep -Eiq 'running|up'; then + exit 1 +fi + +mkdir -p "$(dirname "$META_PATH")" +printf '%s' "$META_BLOB" | base64 -d > "$META_PATH" +""" + + emit_progress(progress, "cron.deploy.activate", f"重建远端服务:{CHATAPP_CRON_COMPOSE_SERVICE}") + result = run_host_script_checked( + host, + script, + command_name="chatapp-cron-activate", + timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS, + ) + emit_progress(progress, "cron.deploy.activate", "chatapp-cron 重启完成") + return { + "host": CHATAPP_CRON_HOST, + "target": result.get("target"), + "status": result.get("status"), + "output": short_text(str(result.get("output") or ""), 1000), + "metadata": metadata, + } + + +def deploy_chatapp_cron_payload(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]: + # 对外的部署主流程:构建二进制 -> 上传目标机 -> compose 重建。 + if not chatapp_cron_release_enabled(): + raise RuntimeError("chatapp-cron deploy is not configured") + + started_at = time.time() + build_result = build_chatapp_cron_binary(git_ref, progress=progress) + try: + deploy_result = activate_chatapp_cron_binary(build_result, progress=progress) + return { + "ok": True, + "message": "chatapp-cron deploy complete", + "updatedAt": utc_now(), + "durationSeconds": round(time.time() - started_at, 2), + "build": { + "kind": build_result["kind"], + "gitRef": build_result["gitRef"], + "commit": build_result["commit"], + "services": [CHATAPP_CRON_SERVICE_NAME], + }, + "deploy": deploy_result, + } + finally: + artifact_root = Path(str(build_result.get("binaryPath") or "")).parent + if str(artifact_root).startswith(tempfile.gettempdir()): + shutil.rmtree(artifact_root, ignore_errors=True) + + +def ensure_cron_deploy_job_dir() -> None: + # 持久化目录不存在时先创建。 + CRON_DEPLOY_JOB_DIR.mkdir(parents=True, exist_ok=True) + + +def cron_deploy_job_state_path(operation_id: str) -> Path: + # 每个任务一份状态 JSON。 + return CRON_DEPLOY_JOB_DIR / f"{operation_id}.json" + + +def cron_deploy_job_log_path(operation_id: str) -> Path: + # 每个任务一份完整文本日志。 + return CRON_DEPLOY_JOB_DIR / f"{operation_id}.log" + + +def valid_operation_id(operation_id: str) -> bool: + # 只接受简单安全字符,避免路径拼接被滥用。 + return re.fullmatch(r"[0-9A-Za-z_-]{6,64}", str(operation_id or "").strip()) is not None + + +def cron_operation_payload(payload: dict[str, Any]) -> dict[str, Any]: + # 对外返回和落盘都统一走这个结构。 + return { + "operationId": payload["operationId"], + "kind": payload["kind"], + "status": payload["status"], + "stage": payload["stage"], + "message": payload["message"], + "error": payload["error"], + "startedAt": payload["startedAt"], + "updatedAt": payload["updatedAt"], + "finishedAt": payload["finishedAt"], + "logs": copy.deepcopy(payload.get("logs") or []), + "result": copy.deepcopy(payload.get("result")), + "request": copy.deepcopy(payload.get("request") or {}), + "logUrl": f"/api/chatapp-cron/deploy-log?id={payload['operationId']}", + } + + +def persist_cron_job_unlocked(payload: dict[str, Any]) -> None: + # 每次状态变化都落盘,服务重启后还能恢复最近任务。 + ensure_cron_deploy_job_dir() + cron_deploy_job_state_path(payload["operationId"]).write_text( + json.dumps({"ok": True, "operation": cron_operation_payload(payload)}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def append_cron_log_file(operation_id: str, line: str) -> None: + # 完整日志单独写入文本文件,供页面查看。 + ensure_cron_deploy_job_dir() + with cron_deploy_job_log_path(operation_id).open("a", encoding="utf-8") as handle: + handle.write(line) + + +def trim_cron_jobs_unlocked() -> None: + # 只保留最近若干次任务,老数据直接淘汰内存引用。 + while len(CRON_DEPLOY_JOB_ORDER) > MAX_CRON_DEPLOY_OPERATIONS: + operation_id = CRON_DEPLOY_JOB_ORDER.pop(0) + CRON_DEPLOY_JOBS.pop(operation_id, None) + + +def create_cron_deploy_job_payload(git_ref: str) -> dict[str, Any]: + # 后台任务一创建就落一份初始状态,前端可以马上开始轮询。 + resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF + operation_id = uuid.uuid4().hex[:12] + now = utc_now() + payload = { + "operationId": operation_id, + "kind": "chatapp-cron-deploy", + "status": "queued", + "stage": "queued", + "message": "chatapp-cron deploy queued", + "error": "", + "startedAt": now, + "updatedAt": now, + "finishedAt": "", + "logs": [], + "result": None, + "request": { + "gitRef": resolved_ref, + "service": CHATAPP_CRON_SERVICE_NAME, + }, + } + with CRON_DEPLOY_JOB_LOCK: + ensure_cron_deploy_job_dir() + CRON_DEPLOY_JOBS[operation_id] = payload + CRON_DEPLOY_JOB_ORDER.append(operation_id) + cron_deploy_job_log_path(operation_id).write_text("", encoding="utf-8") + persist_cron_job_unlocked(payload) + trim_cron_jobs_unlocked() + return snapshot_cron_deploy_job(operation_id) + + +def append_cron_job_log(operation_id: str, stage: str, message: str, *, level: str = "info") -> None: + # 每条日志都带时间和阶段,页面和状态文件都保留日志。 + text = str(message or "").rstrip() + if not text: + return + + with CRON_DEPLOY_JOB_LOCK: + payload = CRON_DEPLOY_JOBS.get(operation_id) + if payload is None: + return + + payload["updatedAt"] = utc_now() + payload["stage"] = str(stage or payload.get("stage") or "running") + payload["logs"].append( + { + "time": payload["updatedAt"], + "stage": str(stage or "").strip(), + "level": str(level or "info").strip() or "info", + "message": text, + } + ) + append_cron_log_file( + operation_id, + f"[{payload['updatedAt']}] [{str(stage or '').strip() or payload['stage']}] [{str(level or 'info').strip() or 'info'}] {text}\n", + ) + persist_cron_job_unlocked(payload) + + +def patch_cron_job(operation_id: str, **fields: Any) -> None: + # 任务元信息更新统一走这里,避免漏掉 updatedAt。 + with CRON_DEPLOY_JOB_LOCK: + payload = CRON_DEPLOY_JOBS.get(operation_id) + if payload is None: + return + payload.update(fields) + payload["updatedAt"] = utc_now() + persist_cron_job_unlocked(payload) + + +def finish_cron_job(operation_id: str, *, result: dict[str, Any], status: str, error: str = "") -> None: + # 结束态统一收口,前端轮询时不用自己猜。 + now = utc_now() + with CRON_DEPLOY_JOB_LOCK: + payload = CRON_DEPLOY_JOBS.get(operation_id) + if payload is None: + return + payload["status"] = status + payload["stage"] = status + payload["message"] = ( + str(result.get("message") or "").strip() + or ("chatapp-cron deploy complete" if status == "success" else "chatapp-cron deploy failed") + ) + payload["error"] = str(error or "").strip() + payload["finishedAt"] = now + payload["updatedAt"] = now + payload["result"] = result + persist_cron_job_unlocked(payload) + + +def snapshot_cron_deploy_job(operation_id: str) -> dict[str, Any]: + # HTTP 返回前复制一份,避免把锁外的可变对象直接暴露出去。 + with CRON_DEPLOY_JOB_LOCK: + payload = CRON_DEPLOY_JOBS.get(operation_id) + if payload is None: + raise KeyError(operation_id) + return {"ok": True, "operation": cron_operation_payload(payload)} + + +def cron_job_progress_logger(operation_id: str) -> ProgressLogger: + # 部署主流程只关心 stage / level / message,落盘细节都交给这里。 + def _logger(stage: str, level: str, message: str) -> None: + append_cron_job_log(operation_id, stage, message, level=level) + + return _logger + + +def run_chatapp_cron_deploy_job(operation_id: str, git_ref: str) -> None: + # 真正的部署逻辑放后台线程里执行。 + patch_cron_job(operation_id, status="running", stage="prepare", message="chatapp-cron deploy running") + append_cron_job_log(operation_id, "prepare", f"开始部署 chatapp-cron:{git_ref}") + + try: + result = deploy_chatapp_cron_payload(git_ref, progress=cron_job_progress_logger(operation_id)) + append_cron_job_log(operation_id, "success", "chatapp-cron 部署完成") + finish_cron_job(operation_id, result=result, status="success") + except Exception as exc: # noqa: BLE001 + error = str(exc) + append_cron_job_log(operation_id, "failed", error, level="error") + finish_cron_job( + operation_id, + result={"ok": False, "updatedAt": utc_now(), "error": error}, + status="failed", + error=error, + ) + + +def start_chatapp_cron_deploy_job(git_ref: str) -> dict[str, Any]: + # 创建任务后立刻拉起后台线程,并把 operationId 返回给前端。 + if not chatapp_cron_release_enabled(): + raise RuntimeError("chatapp-cron deploy is not configured") + + payload = create_cron_deploy_job_payload(git_ref) + operation_id = payload["operation"]["operationId"] + resolved_ref = payload["operation"]["request"]["gitRef"] + + worker = threading.Thread( + target=run_chatapp_cron_deploy_job, + kwargs={"operation_id": operation_id, "git_ref": resolved_ref}, + name=f"chatapp-cron-deploy-{operation_id}", + daemon=True, + ) + worker.start() + return snapshot_cron_deploy_job(operation_id) + + +def recover_lost_cron_job_unlocked(payload: dict[str, Any]) -> None: + # 进程重启后,原后台线程已经不存在;把悬挂中的任务收口成失败态。 + if payload.get("status") not in {"queued", "running"}: + return + + now = utc_now() + message = "monitor process restarted before chatapp-cron deploy status could be finalized" + payload["status"] = "failed" + payload["stage"] = "failed" + payload["message"] = "chatapp-cron deploy interrupted" + payload["error"] = message + payload["updatedAt"] = now + payload["finishedAt"] = now + payload["logs"].append( + { + "time": now, + "stage": "failed", + "level": "error", + "message": message, + } + ) + append_cron_log_file(payload["operationId"], f"[{now}] [failed] [error] {message}\n") + persist_cron_job_unlocked(payload) + + +def load_cron_job_from_disk(operation_id: str) -> dict[str, Any] | None: + # 任务不在内存时,从持久化状态恢复。 + if not valid_operation_id(operation_id): + return None + + state_path = cron_deploy_job_state_path(operation_id) + if not state_path.exists(): + return None + + payload = json.loads(state_path.read_text(encoding="utf-8")) + operation = dict(payload.get("operation") or {}) + if not operation: + return None + + restored = { + "operationId": str(operation.get("operationId") or operation_id).strip(), + "kind": str(operation.get("kind") or "chatapp-cron-deploy").strip() or "chatapp-cron-deploy", + "status": str(operation.get("status") or "").strip(), + "stage": str(operation.get("stage") or "").strip(), + "message": str(operation.get("message") or "").strip(), + "error": str(operation.get("error") or "").strip(), + "startedAt": str(operation.get("startedAt") or "").strip(), + "updatedAt": str(operation.get("updatedAt") or "").strip(), + "finishedAt": str(operation.get("finishedAt") or "").strip(), + "logs": list(operation.get("logs") or []), + "result": operation.get("result"), + "request": dict(operation.get("request") or {}), + } + recover_lost_cron_job_unlocked(restored) + return restored + + +def get_chatapp_cron_deploy_job_payload(operation_id: str) -> dict[str, Any]: + # 供 HTTP 轮询接口直接读取任务状态。 + try: + return snapshot_cron_deploy_job(operation_id) + except KeyError: + restored = load_cron_job_from_disk(operation_id) + if restored is None: + raise + + with CRON_DEPLOY_JOB_LOCK: + CRON_DEPLOY_JOBS[operation_id] = restored + if operation_id not in CRON_DEPLOY_JOB_ORDER: + CRON_DEPLOY_JOB_ORDER.append(operation_id) + trim_cron_jobs_unlocked() + return snapshot_cron_deploy_job(operation_id) + + +def get_chatapp_cron_deploy_log_text(operation_id: str) -> str: + # 下载或弹层展示完整任务日志。 + if not valid_operation_id(operation_id): + raise KeyError(operation_id) + log_path = cron_deploy_job_log_path(operation_id) + if not log_path.exists(): + if load_cron_job_from_disk(operation_id) is None: + raise KeyError(operation_id) + return log_path.read_text(encoding="utf-8") if log_path.exists() else "" diff --git a/monitors/yumi/compose_templates.py b/monitors/yumi/compose_templates.py index e5c1921..5d8e36f 100644 --- a/monitors/yumi/compose_templates.py +++ b/monitors/yumi/compose_templates.py @@ -265,6 +265,19 @@ def build_service_definition( if restart_policy: service_definition["restart"] = restart_policy + log_config = host_config.get("LogConfig") or {} + log_driver = str(log_config.get("Type") or "").strip() + log_options = { + str(key): str(value) + for key, value in (log_config.get("Config") or {}).items() + if str(key).strip() and value is not None + } + if log_driver and (log_driver != "json-file" or log_options): + logging_definition: dict[str, Any] = {"driver": log_driver} + if log_options: + logging_definition["options"] = log_options + service_definition["logging"] = logging_definition + stop_signal = str(config.get("StopSignal") or "").strip() if stop_signal: service_definition["stop_signal"] = stop_signal diff --git a/monitors/yumi/http_app.py b/monitors/yumi/http_app.py index b7c1312..8eb4a62 100644 --- a/monitors/yumi/http_app.py +++ b/monitors/yumi/http_app.py @@ -55,6 +55,12 @@ from .service_release import build_update_targets_payload from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job from .service_ops import build_restart_targets_payload, rolling_restart_payload from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload +from .chatapp_cron_deploy import ( + build_chatapp_cron_overview_payload, + get_chatapp_cron_deploy_job_payload, + get_chatapp_cron_deploy_log_text, + start_chatapp_cron_deploy_job, +) def response_json(payload: dict[str, Any] | list[Any]) -> bytes: @@ -254,6 +260,20 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=send_body, ) + # 返回 chatapp-cron 部署配置和最近版本。 + if local_path == "/api/chatapp-cron/overview": + try: + payload = build_chatapp_cron_overview_payload() + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + # 返回某个库下的表列表。 if local_path == "/api/mysql/tables": database_name = self.query_value(query, "database") @@ -416,6 +436,58 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=send_body, ) + # 返回 chatapp-cron 部署任务状态。 + if local_path == "/api/chatapp-cron/deploy-status": + operation_id = self.query_value(query, "id") + if not operation_id: + return self.send_error_payload( + HTTPStatus.BAD_REQUEST, + "id is required", + send_body=send_body, + ) + + try: + payload = get_chatapp_cron_deploy_job_payload(operation_id) + except KeyError: + return self.send_error_payload( + HTTPStatus.NOT_FOUND, + "chatapp-cron deploy operation not found", + send_body=send_body, + ) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回 chatapp-cron 部署任务完整日志。 + if local_path == "/api/chatapp-cron/deploy-log": + operation_id = self.query_value(query, "id") + if not operation_id: + return self.send_error_payload( + HTTPStatus.BAD_REQUEST, + "id is required", + send_body=send_body, + ) + + try: + content = get_chatapp_cron_deploy_log_text(operation_id) + except KeyError: + return self.send_error_payload(HTTPStatus.NOT_FOUND, "chatapp-cron deploy log not found", send_body=send_body) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + content.encode("utf-8"), + "text/plain; charset=utf-8", + send_body=send_body, + ) + # 返回单个服务实例最近 1000 条容器日志。 if local_path == "/api/ops/service-log": service_name = self.query_value(query, "service") @@ -533,6 +605,9 @@ class MonitorHandler(BaseHTTPRequestHandler): if local_path == "/api/cdn/purge": return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED) + if local_path == "/api/chatapp-cron/deploy": + return self.handle_chatapp_cron_deploy(payload) + if local_path == "/api/ops/rolling-restart": return self.handle_rolling_restart(payload) @@ -746,6 +821,23 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=True, ) + def handle_chatapp_cron_deploy(self, payload: dict[str, Any]) -> None: + git_ref = str(payload.get("gitRef") or "").strip() + + try: + result = start_chatapp_cron_deploy_job(git_ref) + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True) + + return self.send_payload( + HTTPStatus.ACCEPTED, + response_json(result), + "application/json; charset=utf-8", + send_body=True, + ) + def handle_mongo_admin_action( self, builder: Any, diff --git a/monitors/yumi/nacos.py b/monitors/yumi/nacos.py index c9b049c..da79779 100644 --- a/monitors/yumi/nacos.py +++ b/monitors/yumi/nacos.py @@ -602,6 +602,32 @@ def catalog_instances(namespace_id: str, service_name: str, group_name: str) -> return list(payload.get("list") or []) +def set_instance_enabled( + namespace_id: str, + service_name: str, + group_name: str, + *, + ip: str, + port: int, + enabled: bool, + ephemeral: bool | None = None, +) -> str: + # 发布链路用 Nacos enabled 做摘流;这里只改当前 ip:port 这个实例。 + params: dict[str, Any] = { + "serviceName": service_name, + "groupName": group_name, + "namespaceId": namespace_id or None, + "clusterName": NACOS_DISCOVERY_CLUSTER_NAME or None, + "ip": ip, + "port": str(int(port)), + "enabled": "true" if enabled else "false", + } + if ephemeral is not None: + params["ephemeral"] = "true" if bool(ephemeral) else "false" + + return nacos_request_text("PUT", "/nacos/v1/ns/instance", params=params).strip() + + def build_service_level(instance_total: int, healthy_total: int, enabled_total: int) -> str: # 没有实例时直接标 bad。 if instance_total == 0: diff --git a/monitors/yumi/service_ops.py b/monitors/yumi/service_ops.py index 507fec5..297277d 100644 --- a/monitors/yumi/service_ops.py +++ b/monitors/yumi/service_ops.py @@ -8,6 +8,9 @@ from typing import Any from .compose_templates import sync_remote_host_compose_template from core.config import ( + NACOS_RELEASE_DRAIN_SECONDS, + NACOS_RELEASE_DRAIN_SERVICES, + NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS, ROLLING_RESTART_POLL_SECONDS, ROLLING_RESTART_STABILIZE_SECONDS, ROLLING_RESTART_TIMEOUT_SECONDS, @@ -26,6 +29,7 @@ from .nacos import ( current_namespace, list_instances, nacos_truthy, + set_instance_enabled, ) from .ssh_remote import run_host_script @@ -179,6 +183,100 @@ def wait_java_registration_ready(service: dict[str, Any], timeout_seconds: int) raise RuntimeError(f"nacos registration not ready on {service['host']} {service['service']}") +def should_drain_java_registration(service: dict[str, Any]) -> bool: + # 默认只对 other 做发布前摘流;其他服务需要时可通过 .env 扩展。 + return service.get("kind") == "java" and str(service.get("service") or "").strip() in NACOS_RELEASE_DRAIN_SERVICES + + +def current_java_registration_record(service: dict[str, Any]) -> dict[str, Any] | None: + # 返回当前 ip:port 对应的 Nacos 实例,供摘流和恢复复用。 + registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"]) + if not registry_name: + return None + + namespace_id, _ = current_namespace() + return matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service) + + +def update_java_registration_enabled(service: dict[str, Any], enabled: bool) -> dict[str, Any]: + # 只更新当前服务实例的 enabled 状态,不影响同服务其他宿主机。 + registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"]) + if not registry_name: + return {"checked": False, "reason": "service not managed by nacos registry map"} + + namespace_id, _ = current_namespace() + current_record = current_java_registration_record(service) + ephemeral = current_record.get("ephemeral") if current_record and "ephemeral" in current_record else True + response_text = set_instance_enabled( + namespace_id, + registry_name, + NACOS_DISCOVERY_GROUP, + ip=str(service["ip"]), + port=int(service["port"]), + enabled=enabled, + ephemeral=bool(ephemeral), + ) + NACOS_CACHE.clear() + return { + "checked": True, + "serviceName": registry_name, + "enabled": enabled, + "response": response_text, + "ephemeral": bool(ephemeral), + } + + +def wait_java_registration_enabled_state(service: dict[str, Any], enabled: bool, timeout_seconds: int) -> dict[str, Any]: + # 等到 Nacos 读回当前实例 enabled 状态,避免刚写完就立刻重启。 + registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"]) + if not registry_name: + return {"checked": False, "reason": "service not managed by nacos registry map"} + + namespace_id, _ = current_namespace() + deadline = time.time() + timeout_seconds + last_record: dict[str, Any] | None = None + + while time.time() < deadline: + last_record = matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service) + if last_record is not None and nacos_truthy(last_record.get("enabled", True)) == enabled: + return { + "checked": True, + "enabled": enabled, + "healthy": nacos_truthy(last_record.get("healthy", False)), + "source": "instance/list", + } + time.sleep(ROLLING_RESTART_POLL_SECONDS) + + raise RuntimeError(f"nacos enabled={enabled} not ready on {service['host']} {service['service']}") + + +def drain_java_registration(service: dict[str, Any]) -> dict[str, Any]: + # 摘掉当前实例后给调用方一点缓存刷新时间,避免 live 继续打到正在重启的 other。 + if not should_drain_java_registration(service): + return {"checked": False, "reason": "service not configured for nacos release drain"} + + update_result = update_java_registration_enabled(service, False) + state_result = wait_java_registration_enabled_state(service, False, NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS) + if NACOS_RELEASE_DRAIN_SECONDS > 0: + time.sleep(NACOS_RELEASE_DRAIN_SECONDS) + return { + **state_result, + "drainSeconds": NACOS_RELEASE_DRAIN_SECONDS, + "update": update_result, + } + + +def restore_java_registration(service: dict[str, Any]) -> dict[str, Any]: + # 容器 HTTP 恢复后再打开 Nacos enabled,并读回健康状态。 + update_result = update_java_registration_enabled(service, True) + ready_result = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS) + return { + **ready_result, + "restored": True, + "update": update_result, + } + + def clear_operation_caches() -> None: # 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。 SERVICE_CACHE.clear() @@ -209,6 +307,7 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]: "kind": service["kind"], "status": "running", } + nacos_drained = False try: # 单机第一次进入重启前,先把完整 compose 模板同步到远端。 @@ -223,6 +322,12 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]: "source": str(prepared_hosts[service["host"]].get("source") or ""), } + if service["kind"] == "java": + drain_result = drain_java_registration(service) + if drain_result.get("checked"): + nacos_drained = True + step["nacosDrain"] = drain_result + restart_result = restart_service_instance(service) step["remoteStatus"] = restart_result.get("status") step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500] @@ -236,7 +341,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]: } if service["kind"] == "java": - step["nacos"] = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS) + step["nacos"] = ( + restore_java_registration(service) + if nacos_drained + else wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS) + ) if ROLLING_RESTART_STABILIZE_SECONDS > 0: time.sleep(ROLLING_RESTART_STABILIZE_SECONDS) @@ -245,6 +354,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]: step["durationSeconds"] = round(time.time() - started_at, 2) steps.append(step) except Exception as exc: # noqa: BLE001 + if nacos_drained: + try: + step["nacosRestoreRecovered"] = update_java_registration_enabled(service, True) + except Exception as restore_exc: # noqa: BLE001 + step["nacosRestoreError"] = str(restore_exc) step["status"] = "failed" step["error"] = str(exc) step["durationSeconds"] = round(time.time() - started_at, 2) diff --git a/monitors/yumi/service_release.py b/monitors/yumi/service_release.py index 837a750..c5ddd4c 100644 --- a/monitors/yumi/service_release.py +++ b/monitors/yumi/service_release.py @@ -64,8 +64,11 @@ from core.config import ( ) from .service_ops import ( clear_operation_caches, + drain_java_registration, restart_service_instance, + restore_java_registration, service_records_by_name, + update_java_registration_enabled, wait_http_ready, wait_java_registration_ready, ) @@ -1626,6 +1629,7 @@ def deploy_runtime_services_payload( gateway_clb_snapshot: dict[str, Any] | None = None gateway_clb_health_ready = False gateway_clb_restored = False + nacos_drained = False try: emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}") @@ -1655,6 +1659,16 @@ def deploy_runtime_services_payload( if GATEWAY_CLB_DRAIN_SECONDS > 0: emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s") time.sleep(GATEWAY_CLB_DRAIN_SECONDS) + elif service["kind"] == "java": + drain_result = drain_java_registration(service) + if drain_result.get("checked"): + nacos_drained = True + step["nacosDrain"] = drain_result + emit_progress( + progress, + step_stage, + f"Nacos 摘流完成:enabled=false drain={drain_result.get('drainSeconds')}s", + ) restart_result = restart_service_instance(service) step["remoteStatus"] = restart_result.get("status") @@ -1679,9 +1693,13 @@ def deploy_runtime_services_payload( ) if service["kind"] == "java": - step["nacos"] = wait_java_registration_ready( - service, - timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS, + step["nacos"] = ( + restore_java_registration(service) + if nacos_drained + else wait_java_registration_ready( + service, + timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS, + ) ) emit_progress( progress, @@ -1730,6 +1748,13 @@ def deploy_runtime_services_payload( emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}") except Exception as restore_exc: # noqa: BLE001 step.setdefault("clb", {})["restoreError"] = str(restore_exc) + if nacos_drained: + try: + restore_result = update_java_registration_enabled(service, True) + step["nacosRestoreRecovered"] = restore_result + emit_progress(progress, step_stage, "失败收尾时已恢复 Nacos enabled=true") + except Exception as restore_exc: # noqa: BLE001 + step["nacosRestoreError"] = str(restore_exc) step["status"] = "failed" step["error"] = str(exc) step["durationSeconds"] = round(time.time() - started_at, 2) diff --git a/ops/bootstrap_ssh_via_tat.py b/ops/bootstrap_ssh_via_tat.py index fdabe2a..d10f021 100644 --- a/ops/bootstrap_ssh_via_tat.py +++ b/ops/bootstrap_ssh_via_tat.py @@ -100,6 +100,11 @@ ADMIN_FRONTEND_ENABLED = env_truthy("ADMIN_FRONTEND_ENABLED", False) ADMIN_FRONTEND_HOST = os.environ.get("ADMIN_FRONTEND_HOST", "").strip() ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip() ADMIN_FRONTEND_INSTANCE_ID = os.environ.get("ADMIN_FRONTEND_INSTANCE_ID", "").strip() +# 独立 chatapp-cron 目标机配置;配置齐备时也会加入 SSH 引导。 +CHATAPP_CRON_ENABLED = env_truthy("CHATAPP_CRON_ENABLED", False) +CHATAPP_CRON_HOST = os.environ.get("CHATAPP_CRON_HOST", "").strip() +CHATAPP_CRON_IP = os.environ.get("CHATAPP_CRON_IP", "").strip() +CHATAPP_CRON_INSTANCE_ID = os.environ.get("CHATAPP_CRON_INSTANCE_ID", "").strip() def parse_args() -> argparse.Namespace: @@ -234,6 +239,18 @@ def load_target_hosts(host_names: list[str]) -> list[dict[str, str]]: "instanceId": ADMIN_FRONTEND_INSTANCE_ID, } ) + # 定时任务机器也不在 targets.json 时,通过 .env 并入同一轮 SSH 引导。 + if CHATAPP_CRON_ENABLED and CHATAPP_CRON_HOST and CHATAPP_CRON_INSTANCE_ID: + if (not selected) or CHATAPP_CRON_HOST in selected: + if CHATAPP_CRON_INSTANCE_ID not in seen_instance_ids: + seen_instance_ids.add(CHATAPP_CRON_INSTANCE_ID) + items.append( + { + "host": CHATAPP_CRON_HOST, + "ip": CHATAPP_CRON_IP, + "instanceId": CHATAPP_CRON_INSTANCE_ID, + } + ) return items diff --git a/static/app.css b/static/app.css index 8c6b4ad..8a8080f 100644 --- a/static/app.css +++ b/static/app.css @@ -4146,6 +4146,35 @@ body.auth-page { margin-bottom: 0; } +.common-cron-grid { + align-items: start; + grid-template-columns: minmax(0, 420px) minmax(0, 1fr); +} + +.common-cron-summary { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.common-cron-target-strip, +.common-cron-status-strip { + margin-bottom: 0; +} + +.common-cron-log-preview { + margin-top: 0; +} + +.common-cron-log-panel .release-log-list { + max-height: 360px; + overflow: auto; +} + +.compact-button { + min-height: 30px; + padding: 0 10px; + font-size: 12px; +} + .mongo-panel-grid.narrow { grid-template-columns: minmax(0, 220px) minmax(0, 1fr); } @@ -5108,6 +5137,7 @@ body.auth-page { .mongo-panel-grid, .common-cdn-grid, + .common-cron-grid, .mongo-query-grid, .mysql-filter-grid { grid-template-columns: 1fr; @@ -5274,6 +5304,7 @@ body.auth-page { .update-form-grid, .mongo-panel-grid, .common-cdn-grid, + .common-cron-grid, .mongo-query-grid, .mysql-filter-grid, .mysql-checkbox-grid, diff --git a/static/app.js b/static/app.js index a56786e..71e2bbd 100644 --- a/static/app.js +++ b/static/app.js @@ -46,6 +46,7 @@ const COMMON_MODULE_VIEW_TABS = [ const COMMON_TOOL_TABS = [ { id: "mysql", label: "MySQL" }, { id: "cdn", label: "CDN 刷新" }, + { id: "cron", label: "Cron 部署" }, ]; const PRIMARY_FILTERS = [ { id: "all", label: "全部" }, @@ -395,6 +396,51 @@ function createCdnAdminState() { }; } +function createChatappCronDeployState() { + return { + loaded: false, + loading: false, + deploying: false, + error: "", + message: "", + messageTone: "neutral", + form: { + gitRef: "main", + }, + overview: { + ok: false, + enabled: false, + configured: false, + updatedAt: "", + settings: { + enabled: false, + serviceName: "chatapp-cron", + host: "", + instanceId: "", + sshHost: "", + repoRoot: "", + repoUrl: "", + defaultGitRef: "main", + remoteDir: "", + composeFile: "", + composeService: "week-star-job", + binaryPath: "", + dockerPlatform: "linux/amd64", + }, + metadata: {}, + }, + operationId: "", + status: "", + stage: "", + startedAt: "", + finishedAt: "", + logs: [], + result: null, + pollTimer: null, + statusRequestInFlight: false, + }; +} + createApp({ template: APP_TEMPLATE, data() { @@ -552,6 +598,7 @@ createApp({ mongoAdmin: createMongoAdminState(), mySqlAdmin: createMySqlAdminState(), cdnAdmin: createCdnAdminState(), + chatappCron: createChatappCronDeployState(), updateOps: { loading: false, loaded: false, @@ -634,7 +681,7 @@ createApp({ formattedUpdatedAt() { if (CURRENT_APP_CONTEXT.mode === "common") { return this.formatDateTime( - this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "", + this.chatappCron.overview.updatedAt || this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "", ); } return this.formatDateTime(this.payload.updatedAt); @@ -653,6 +700,9 @@ createApp({ if (this.commonTool === "cdn" && this.cdnAdmin.error) { return "bad"; } + if (this.commonTool === "cron" && this.chatappCron.error) { + return "bad"; + } return "neutral"; } @@ -706,7 +756,13 @@ createApp({ return this.releaseKeyword; } if (this.activeView === "common") { - return this.commonTool === "mysql" ? this.mySqlAdmin.keyword : this.cdnAdmin.keyword; + if (this.commonTool === "mysql") { + return this.mySqlAdmin.keyword; + } + if (this.commonTool === "cdn") { + return this.cdnAdmin.keyword; + } + return ""; } return ""; }, @@ -737,7 +793,7 @@ createApp({ if (this.activeView === "common") { if (this.commonTool === "mysql") { this.mySqlAdmin.keyword = nextValue; - } else { + } else if (this.commonTool === "cdn") { this.cdnAdmin.keyword = nextValue; } } @@ -760,12 +816,19 @@ createApp({ return "搜索服务 / 主机 / 镜像"; } if (this.activeView === "common") { - return this.commonTool === "mysql" ? "搜索数据库 / 表" : "搜索 taskId / URL / 状态"; + if (this.commonTool === "mysql") { + return "搜索数据库 / 表"; + } + if (this.commonTool === "cdn") { + return "搜索 taskId / URL / 状态"; + } + return "chatapp-cron 部署无搜索"; } return "全局搜索"; }, globalSearchDisabled() { - return this.activeView === "config" && this.configTab === "go"; + return (this.activeView === "config" && this.configTab === "go") + || (this.activeView === "common" && this.commonTool === "cron"); }, dataFreshnessLabel() { const updatedAt = Date.parse(this.payload.updatedAt || ""); @@ -806,6 +869,7 @@ createApp({ || (this.restartOps.items || []).length > 0 || this.mySqlAdmin.loaded || this.cdnAdmin.loaded + || this.chatappCron.loaded ); }, showLoadingSkeleton() { @@ -1512,10 +1576,89 @@ createApp({ .includes(keyword) )); }, + chatappCronSettings() { + return this.chatappCron.overview.settings || createChatappCronDeployState().overview.settings; + }, + chatappCronMetadata() { + return this.chatappCron.overview.metadata || {}; + }, + chatappCronIsRunning() { + return Boolean( + this.chatappCron.deploying + || this.chatappCron.status === "queued" + || this.chatappCron.status === "running", + ); + }, + chatappCronStatusTone() { + if (this.chatappCron.error || this.chatappCron.status === "failed" || this.chatappCronMetadata.error) { + return "bad"; + } + if (this.chatappCronIsRunning) { + return "warn"; + } + if (this.chatappCron.status === "success" || this.chatappCron.overview.enabled) { + return "ok"; + } + if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) { + return "warn"; + } + return "neutral"; + }, + chatappCronStatusText() { + if (this.chatappCron.error || this.chatappCron.status === "failed") { + return "部署异常"; + } + if (this.chatappCron.status === "queued") { + return "排队中"; + } + if (this.chatappCron.status === "running" || this.chatappCron.deploying) { + return "部署中"; + } + if (this.chatappCron.status === "success") { + return "已完成"; + } + if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) { + return "未配置"; + } + return this.chatappCron.loaded ? "可部署" : "待加载"; + }, + chatappCronCurrentCommit() { + return this.chatappCronMetadata.commit || this.chatappCron.result?.build?.commit || ""; + }, + chatappCronLogText() { + return (this.chatappCron.logs || []) + .map((entry) => { + const time = this.formatDateTime(entry.time); + const stage = entry.stage ? ` [${entry.stage}]` : ""; + const level = entry.level && entry.level !== "info" ? ` (${entry.level})` : ""; + return `${time}${stage}${level} ${entry.message || ""}`.trim(); + }) + .join("\n"); + }, + recentChatappCronLogs() { + return (this.chatappCron.logs || []) + .slice(-5) + .reverse() + .map((entry, index) => ({ + ...entry, + key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`, + })); + }, + chatappCronLogDownloadUrl() { + if (!this.chatappCron.operationId) { + return ""; + } + const query = new URLSearchParams({ id: this.chatappCron.operationId }); + return `/api/chatapp-cron/deploy-log?${query.toString()}`; + }, commonToolContextLabel() { - return this.commonTool === "mysql" - ? "MySQL / 数据 / 写入 / 结构 / SQL" - : "CDN / URL 刷新 / 目录刷新 / 最近记录"; + if (this.commonTool === "mysql") { + return "MySQL / 数据 / 写入 / 结构 / SQL"; + } + if (this.commonTool === "cdn") { + return "CDN / URL 刷新 / 目录刷新 / 最近记录"; + } + return "chatapp-cron / 构建 / 上传 / 重启"; }, mySqlSqlShortcuts() { const tableName = this.selectedMySqlTableItem?.name || ""; @@ -2123,6 +2266,9 @@ createApp({ if (this.logViewer.kind === "service") { return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`; } + if (this.logViewer.kind === "chatapp-cron") { + return "chatapp-cron 部署日志"; + } return "执行日志"; }, activeLogViewerSubtitle() { @@ -2134,36 +2280,54 @@ createApp({ subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`); return subtitleParts.join(" · "); } + if (this.logViewer.kind === "chatapp-cron") { + return `任务 ${this.chatappCron.operationId || "-"} · ${this.chatappCronStatusText} · ${this.formatDateTime(this.chatappCron.finishedAt || this.chatappCron.startedAt)}`; + } return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`; }, activeLogViewerBadge() { if (this.logViewer.kind === "service") { return this.serviceLog.container || ""; } + if (this.logViewer.kind === "chatapp-cron") { + return this.chatappCron.operationId || ""; + } return this.updateOps.operationId || ""; }, activeLogViewerSecondaryBadge() { if (this.logViewer.kind === "service") { return `最近 ${this.serviceLog.previewLines} 条`; } + if (this.logViewer.kind === "chatapp-cron") { + return this.chatappCron.stage ? `阶段 ${this.chatappCron.stage}` : ""; + } return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : ""; }, activeLogViewerContent() { if (this.logViewer.kind === "service") { return this.serviceLog.content; } + if (this.logViewer.kind === "chatapp-cron") { + return this.chatappCronLogText; + } return this.updateLogText; }, activeLogViewerError() { if (this.logViewer.kind === "service") { return this.serviceLog.error; } + if (this.logViewer.kind === "chatapp-cron") { + return this.chatappCron.error ? this.explainError(this.chatappCron.error) : ""; + } return this.updateOps.error ? this.explainError(this.updateOps.error) : ""; }, activeLogViewerLoading() { if (this.logViewer.kind === "service") { return this.serviceLog.loading; } + if (this.logViewer.kind === "chatapp-cron") { + return this.chatappCron.statusRequestInFlight; + } return this.updateOps.statusRequestInFlight; }, activeLogViewerExporting() { @@ -2176,12 +2340,18 @@ createApp({ if (this.logViewer.kind === "service") { return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading; } + if (this.logViewer.kind === "chatapp-cron") { + return !this.chatappCron.operationId || this.chatappCron.statusRequestInFlight; + } return !this.updateOps.operationId || this.updateOps.statusRequestInFlight; }, activeLogViewerDownloadDisabled() { if (this.logViewer.kind === "service") { return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting; } + if (this.logViewer.kind === "chatapp-cron") { + return !this.chatappCron.operationId; + } return !this.updateOps.operationId; }, }, @@ -2233,8 +2403,15 @@ createApp({ return; } - if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) { - await this.refreshCdnOverview({ silent: true }); + if (tool === "cdn") { + if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) { + await this.refreshCdnOverview({ silent: true }); + } + return; + } + + if (tool === "cron" && !this.chatappCron.loaded && !this.chatappCron.loading) { + await this.refreshChatappCronOverview({ silent: true }); } }, async ensureReleaseWorkbenchLoaded({ force = false } = {}) { @@ -2643,6 +2820,11 @@ createApp({ this.logViewer.kind = "update"; this.$nextTick(() => this.scrollUpdateLogToBottom()); }, + openChatappCronLogViewer() { + this.logViewer.open = true; + this.logViewer.kind = "chatapp-cron"; + this.$nextTick(() => this.scrollUpdateLogToBottom()); + }, closeLogViewer() { const previousKind = this.logViewer.kind; this.logViewer.open = false; @@ -2659,6 +2841,14 @@ createApp({ await this.openServiceLog({ host: this.serviceLog.host }); return; } + if (this.logViewer.kind === "chatapp-cron") { + if (!this.chatappCron.operationId) { + return; + } + await this.fetchChatappCronDeployStatus(this.chatappCron.operationId); + this.scrollUpdateLogToBottom(); + return; + } if (!this.updateOps.operationId) { return; } @@ -2670,6 +2860,17 @@ createApp({ await this.downloadServiceLog(); return; } + if (this.logViewer.kind === "chatapp-cron") { + if (!this.chatappCron.operationId) { + return; + } + try { + await this.downloadFile(this.chatappCronLogDownloadUrl, `chatapp-cron-${this.chatappCron.operationId}.log`); + } catch (error) { + this.chatappCron.error = error instanceof Error ? error.message : String(error); + } + return; + } if (!this.updateOps.operationId) { return; } @@ -3516,6 +3717,7 @@ createApp({ this.auth.redirecting = true; this.stopUpdatePolling(); + this.stopChatappCronPolling(); if (this.timer) { window.clearInterval(this.timer); @@ -3700,13 +3902,15 @@ createApp({ this.error = ""; this.mySqlAdmin.error = ""; this.cdnAdmin.error = ""; + this.chatappCron.error = ""; try { await Promise.allSettled([ this.refreshMySqlDatabases({ preserveSelection: true, silent: true }), this.refreshCdnOverview({ silent: true }), + this.refreshChatappCronOverview({ silent: true }), ]); - this.error = this.mySqlAdmin.error || this.cdnAdmin.error || ""; + this.error = this.mySqlAdmin.error || this.cdnAdmin.error || this.chatappCron.error || ""; } finally { this.loading = false; } @@ -4000,6 +4204,69 @@ createApp({ this.cdnAdmin.message = message || ""; this.cdnAdmin.messageTone = tone || "neutral"; }, + setChatappCronMessage(message, tone = "neutral") { + this.chatappCron.message = message || ""; + this.chatappCron.messageTone = tone || "neutral"; + }, + applyChatappCronOverview(payload) { + const defaults = createChatappCronDeployState().overview; + const nextSettings = { + ...defaults.settings, + ...(payload?.settings || {}), + }; + this.chatappCron.overview = { + ...defaults, + ...(payload || {}), + settings: nextSettings, + metadata: payload?.metadata && typeof payload.metadata === "object" ? payload.metadata : {}, + }; + this.chatappCron.loaded = true; + this.chatappCron.error = payload?.error || ""; + if (!this.chatappCron.form.gitRef || this.chatappCron.form.gitRef === defaults.settings.defaultGitRef) { + this.chatappCron.form.gitRef = nextSettings.defaultGitRef || defaults.settings.defaultGitRef; + } + }, + applyChatappCronOperation(operation) { + if (!operation) { + return; + } + + this.chatappCron.operationId = operation.operationId || ""; + this.chatappCron.status = operation.status || ""; + this.chatappCron.stage = operation.stage || ""; + this.chatappCron.startedAt = operation.startedAt || ""; + this.chatappCron.finishedAt = operation.finishedAt || ""; + this.chatappCron.logs = Array.isArray(operation.logs) ? operation.logs : []; + this.chatappCron.result = operation.result || null; + this.chatappCron.deploying = operation.status === "queued" || operation.status === "running"; + + if (operation.status === "success") { + this.chatappCron.error = ""; + this.setChatappCronMessage(operation.message || "chatapp-cron 部署完成", "ok"); + } else if (operation.status === "failed") { + this.chatappCron.error = operation.error || operation.result?.error || "chatapp-cron 部署失败"; + this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn"); + } else { + this.setChatappCronMessage(operation.message || "chatapp-cron 部署执行中", "neutral"); + } + + this.scrollUpdateLogToBottom(); + }, + stopChatappCronPolling() { + if (this.chatappCron.pollTimer) { + window.clearInterval(this.chatappCron.pollTimer); + this.chatappCron.pollTimer = null; + } + }, + startChatappCronPolling() { + this.stopChatappCronPolling(); + if (!this.chatappCron.operationId) { + return; + } + this.chatappCron.pollTimer = window.setInterval(() => { + this.fetchChatappCronDeployStatus(this.chatappCron.operationId, { silent: true }); + }, 2000); + }, parseMultilineItems(text) { return String(text || "") .split(/\r?\n/) @@ -4148,6 +4415,125 @@ createApp({ this.cdnAdmin.submitting = false; } }, + async refreshChatappCronOverview({ silent = false } = {}) { + this.chatappCron.loading = true; + if (!silent) { + this.chatappCron.error = ""; + } + + try { + const { response, payload } = await this.requestJson("/api/chatapp-cron/overview"); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.applyChatappCronOverview(payload); + if (!silent) { + if (!payload.enabled) { + this.setChatappCronMessage("chatapp-cron 部署未配置", "warn"); + } else if (payload.metadata?.error) { + this.setChatappCronMessage("已加载配置,最近版本读取失败", "warn"); + } else { + this.setChatappCronMessage("chatapp-cron 配置已刷新", "neutral"); + } + } + } catch (error) { + this.chatappCron.error = error instanceof Error ? error.message : String(error); + if (!silent) { + this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn"); + } + } finally { + this.chatappCron.loading = false; + } + }, + async fetchChatappCronDeployStatus(operationId = this.chatappCron.operationId, { silent = false } = {}) { + if (!operationId || this.chatappCron.statusRequestInFlight) { + return; + } + + const wasRunning = this.chatappCron.deploying; + this.chatappCron.statusRequestInFlight = true; + + try { + const query = new URLSearchParams({ id: operationId }); + const { response, payload } = await this.requestJson(`/api/chatapp-cron/deploy-status?${query.toString()}`); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + if (this.chatappCron.operationId && this.chatappCron.operationId !== operationId) { + return; + } + + this.applyChatappCronOperation(payload.operation || {}); + if (!this.chatappCron.deploying) { + this.stopChatappCronPolling(); + if (wasRunning || this.chatappCron.status === "success") { + await this.refreshChatappCronOverview({ silent: true }); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const notFound = errorMessage.includes("chatapp-cron deploy operation not found") || errorMessage.includes("HTTP 404"); + if (silent && !notFound) { + return; + } + + this.stopChatappCronPolling(); + this.chatappCron.deploying = false; + this.chatappCron.status = "failed"; + this.chatappCron.error = notFound + ? "chatapp-cron 部署任务状态不存在,可能因为服务重启或旧任务已清理" + : errorMessage; + this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn"); + } finally { + this.chatappCron.statusRequestInFlight = false; + } + }, + async runChatappCronDeploy() { + if (this.chatappCronIsRunning) { + return; + } + + if (!this.chatappCron.overview.enabled) { + this.setChatappCronMessage("chatapp-cron 部署未配置", "warn"); + return; + } + + const gitRef = String(this.chatappCron.form.gitRef || "").trim() + || this.chatappCronSettings.defaultGitRef + || "main"; + + this.stopChatappCronPolling(); + this.chatappCron.deploying = true; + this.chatappCron.error = ""; + this.chatappCron.status = "queued"; + this.chatappCron.stage = "queued"; + this.chatappCron.logs = []; + this.chatappCron.result = null; + this.setChatappCronMessage("chatapp-cron 部署已提交", "neutral"); + + try { + const { response, payload } = await this.requestJson("/api/chatapp-cron/deploy", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ gitRef }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.applyChatappCronOperation(payload.operation || {}); + this.startChatappCronPolling(); + } catch (error) { + this.chatappCron.deploying = false; + this.chatappCron.status = "failed"; + this.chatappCron.error = error instanceof Error ? error.message : String(error); + this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn"); + } + }, clearMySqlSchemaState() { this.mySqlAdmin.schema = { updatedAt: "", @@ -6126,5 +6512,6 @@ createApp({ window.clearTimeout(this.copiedTokenTimer); } this.stopUpdatePolling(); + this.stopChatappCronPolling(); }, }).mount("#app"); diff --git a/static/index.html b/static/index.html index 3cc2d01..26e48be 100644 --- a/static/index.html +++ b/static/index.html @@ -241,7 +241,7 @@ {{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }} - @@ -2868,6 +2879,125 @@ + +