Add chatapp cron deployment
This commit is contained in:
parent
9c8faf2896
commit
05f0ecbfbc
20
.env.example
20
.env.example
@ -31,6 +31,9 @@ SERVICE_LOG_EXPORT_MAX_LINES=10000
|
|||||||
ROLLING_RESTART_TIMEOUT_SECONDS=180
|
ROLLING_RESTART_TIMEOUT_SECONDS=180
|
||||||
ROLLING_RESTART_POLL_SECONDS=2
|
ROLLING_RESTART_POLL_SECONDS=2
|
||||||
ROLLING_RESTART_STABILIZE_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_BUILD_TIMEOUT_SECONDS=3600
|
||||||
SERVICE_PULL_TIMEOUT_SECONDS=600
|
SERVICE_PULL_TIMEOUT_SECONDS=600
|
||||||
SERVICE_BUILD_CACHE_ROOT=/opt/deploy/build-cache
|
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_BUILD_SCRIPT=build:antdv-next
|
||||||
ADMIN_FRONTEND_DIST_DIR=apps/dist
|
ADMIN_FRONTEND_DIST_DIR=apps/dist
|
||||||
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
|
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_URI=
|
||||||
MONGO_DB_NAME=tarab_all
|
MONGO_DB_NAME=tarab_all
|
||||||
MONGO_INSTANCE_ID=ins-o9mmob66
|
MONGO_INSTANCE_ID=ins-o9mmob66
|
||||||
|
|||||||
24
README.md
24
README.md
@ -120,6 +120,28 @@ ADMIN_FRONTEND_DIST_DIR=apps/dist
|
|||||||
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
|
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
|
```bash
|
||||||
@ -205,7 +227,7 @@ python3 ops/bootstrap_ssh_via_tat.py
|
|||||||
3. 只允许 `deploy -> target:22`
|
3. 只允许 `deploy -> target:22`
|
||||||
4. 把目标机本机防火墙和安全组都限制到 `deploy` 私网 IP
|
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` 里。
|
||||||
|
|
||||||
如果你只需要放开监控面板端口,仍然可以使用:
|
如果你只需要放开监控面板端口,仍然可以使用:
|
||||||
|
|
||||||
|
|||||||
@ -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_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0)
|
||||||
# 单实例恢复后额外稳定等待秒数。
|
# 单实例恢复后额外稳定等待秒数。
|
||||||
ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_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)
|
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 连接串。
|
||||||
MONGO_URI = env_str("MONGO_URI", "")
|
MONGO_URI = env_str("MONGO_URI", "")
|
||||||
# Mongo 默认关注的业务库名。
|
# Mongo 默认关注的业务库名。
|
||||||
|
|||||||
648
monitors/yumi/chatapp_cron_deploy.py
Normal file
648
monitors/yumi/chatapp_cron_deploy.py
Normal file
@ -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 ""
|
||||||
@ -265,6 +265,19 @@ def build_service_definition(
|
|||||||
if restart_policy:
|
if restart_policy:
|
||||||
service_definition["restart"] = 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()
|
stop_signal = str(config.get("StopSignal") or "").strip()
|
||||||
if stop_signal:
|
if stop_signal:
|
||||||
service_definition["stop_signal"] = stop_signal
|
service_definition["stop_signal"] = stop_signal
|
||||||
|
|||||||
@ -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_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 .service_ops import build_restart_targets_payload, rolling_restart_payload
|
||||||
from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_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:
|
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||||
@ -254,6 +260,20 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
send_body=send_body,
|
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":
|
if local_path == "/api/mysql/tables":
|
||||||
database_name = self.query_value(query, "database")
|
database_name = self.query_value(query, "database")
|
||||||
@ -416,6 +436,58 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
send_body=send_body,
|
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 条容器日志。
|
# 返回单个服务实例最近 1000 条容器日志。
|
||||||
if local_path == "/api/ops/service-log":
|
if local_path == "/api/ops/service-log":
|
||||||
service_name = self.query_value(query, "service")
|
service_name = self.query_value(query, "service")
|
||||||
@ -533,6 +605,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
if local_path == "/api/cdn/purge":
|
if local_path == "/api/cdn/purge":
|
||||||
return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED)
|
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":
|
if local_path == "/api/ops/rolling-restart":
|
||||||
return self.handle_rolling_restart(payload)
|
return self.handle_rolling_restart(payload)
|
||||||
|
|
||||||
@ -746,6 +821,23 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
send_body=True,
|
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(
|
def handle_mongo_admin_action(
|
||||||
self,
|
self,
|
||||||
builder: Any,
|
builder: Any,
|
||||||
|
|||||||
@ -602,6 +602,32 @@ def catalog_instances(namespace_id: str, service_name: str, group_name: str) ->
|
|||||||
return list(payload.get("list") or [])
|
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:
|
def build_service_level(instance_total: int, healthy_total: int, enabled_total: int) -> str:
|
||||||
# 没有实例时直接标 bad。
|
# 没有实例时直接标 bad。
|
||||||
if instance_total == 0:
|
if instance_total == 0:
|
||||||
|
|||||||
@ -8,6 +8,9 @@ from typing import Any
|
|||||||
|
|
||||||
from .compose_templates import sync_remote_host_compose_template
|
from .compose_templates import sync_remote_host_compose_template
|
||||||
from core.config import (
|
from core.config import (
|
||||||
|
NACOS_RELEASE_DRAIN_SECONDS,
|
||||||
|
NACOS_RELEASE_DRAIN_SERVICES,
|
||||||
|
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS,
|
||||||
ROLLING_RESTART_POLL_SECONDS,
|
ROLLING_RESTART_POLL_SECONDS,
|
||||||
ROLLING_RESTART_STABILIZE_SECONDS,
|
ROLLING_RESTART_STABILIZE_SECONDS,
|
||||||
ROLLING_RESTART_TIMEOUT_SECONDS,
|
ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||||
@ -26,6 +29,7 @@ from .nacos import (
|
|||||||
current_namespace,
|
current_namespace,
|
||||||
list_instances,
|
list_instances,
|
||||||
nacos_truthy,
|
nacos_truthy,
|
||||||
|
set_instance_enabled,
|
||||||
)
|
)
|
||||||
from .ssh_remote import run_host_script
|
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']}")
|
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:
|
def clear_operation_caches() -> None:
|
||||||
# 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。
|
# 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。
|
||||||
SERVICE_CACHE.clear()
|
SERVICE_CACHE.clear()
|
||||||
@ -209,6 +307,7 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
|||||||
"kind": service["kind"],
|
"kind": service["kind"],
|
||||||
"status": "running",
|
"status": "running",
|
||||||
}
|
}
|
||||||
|
nacos_drained = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 单机第一次进入重启前,先把完整 compose 模板同步到远端。
|
# 单机第一次进入重启前,先把完整 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 ""),
|
"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)
|
restart_result = restart_service_instance(service)
|
||||||
step["remoteStatus"] = restart_result.get("status")
|
step["remoteStatus"] = restart_result.get("status")
|
||||||
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
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":
|
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:
|
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
||||||
time.sleep(ROLLING_RESTART_STABILIZE_SECONDS)
|
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)
|
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||||
steps.append(step)
|
steps.append(step)
|
||||||
except Exception as exc: # noqa: BLE001
|
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["status"] = "failed"
|
||||||
step["error"] = str(exc)
|
step["error"] = str(exc)
|
||||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||||
|
|||||||
@ -64,8 +64,11 @@ from core.config import (
|
|||||||
)
|
)
|
||||||
from .service_ops import (
|
from .service_ops import (
|
||||||
clear_operation_caches,
|
clear_operation_caches,
|
||||||
|
drain_java_registration,
|
||||||
restart_service_instance,
|
restart_service_instance,
|
||||||
|
restore_java_registration,
|
||||||
service_records_by_name,
|
service_records_by_name,
|
||||||
|
update_java_registration_enabled,
|
||||||
wait_http_ready,
|
wait_http_ready,
|
||||||
wait_java_registration_ready,
|
wait_java_registration_ready,
|
||||||
)
|
)
|
||||||
@ -1626,6 +1629,7 @@ def deploy_runtime_services_payload(
|
|||||||
gateway_clb_snapshot: dict[str, Any] | None = None
|
gateway_clb_snapshot: dict[str, Any] | None = None
|
||||||
gateway_clb_health_ready = False
|
gateway_clb_health_ready = False
|
||||||
gateway_clb_restored = False
|
gateway_clb_restored = False
|
||||||
|
nacos_drained = False
|
||||||
try:
|
try:
|
||||||
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
|
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:
|
if GATEWAY_CLB_DRAIN_SECONDS > 0:
|
||||||
emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s")
|
emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s")
|
||||||
time.sleep(GATEWAY_CLB_DRAIN_SECONDS)
|
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)
|
restart_result = restart_service_instance(service)
|
||||||
step["remoteStatus"] = restart_result.get("status")
|
step["remoteStatus"] = restart_result.get("status")
|
||||||
@ -1679,10 +1693,14 @@ def deploy_runtime_services_payload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if service["kind"] == "java":
|
if service["kind"] == "java":
|
||||||
step["nacos"] = wait_java_registration_ready(
|
step["nacos"] = (
|
||||||
|
restore_java_registration(service)
|
||||||
|
if nacos_drained
|
||||||
|
else wait_java_registration_ready(
|
||||||
service,
|
service,
|
||||||
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
|
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
emit_progress(
|
emit_progress(
|
||||||
progress,
|
progress,
|
||||||
step_stage,
|
step_stage,
|
||||||
@ -1730,6 +1748,13 @@ def deploy_runtime_services_payload(
|
|||||||
emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}")
|
emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}")
|
||||||
except Exception as restore_exc: # noqa: BLE001
|
except Exception as restore_exc: # noqa: BLE001
|
||||||
step.setdefault("clb", {})["restoreError"] = str(restore_exc)
|
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["status"] = "failed"
|
||||||
step["error"] = str(exc)
|
step["error"] = str(exc)
|
||||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||||
|
|||||||
@ -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_HOST = os.environ.get("ADMIN_FRONTEND_HOST", "").strip()
|
||||||
ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip()
|
ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip()
|
||||||
ADMIN_FRONTEND_INSTANCE_ID = os.environ.get("ADMIN_FRONTEND_INSTANCE_ID", "").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:
|
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,
|
"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
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4146,6 +4146,35 @@ body.auth-page {
|
|||||||
margin-bottom: 0;
|
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 {
|
.mongo-panel-grid.narrow {
|
||||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@ -5108,6 +5137,7 @@ body.auth-page {
|
|||||||
|
|
||||||
.mongo-panel-grid,
|
.mongo-panel-grid,
|
||||||
.common-cdn-grid,
|
.common-cdn-grid,
|
||||||
|
.common-cron-grid,
|
||||||
.mongo-query-grid,
|
.mongo-query-grid,
|
||||||
.mysql-filter-grid {
|
.mysql-filter-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -5274,6 +5304,7 @@ body.auth-page {
|
|||||||
.update-form-grid,
|
.update-form-grid,
|
||||||
.mongo-panel-grid,
|
.mongo-panel-grid,
|
||||||
.common-cdn-grid,
|
.common-cdn-grid,
|
||||||
|
.common-cron-grid,
|
||||||
.mongo-query-grid,
|
.mongo-query-grid,
|
||||||
.mysql-filter-grid,
|
.mysql-filter-grid,
|
||||||
.mysql-checkbox-grid,
|
.mysql-checkbox-grid,
|
||||||
|
|||||||
405
static/app.js
405
static/app.js
@ -46,6 +46,7 @@ const COMMON_MODULE_VIEW_TABS = [
|
|||||||
const COMMON_TOOL_TABS = [
|
const COMMON_TOOL_TABS = [
|
||||||
{ id: "mysql", label: "MySQL" },
|
{ id: "mysql", label: "MySQL" },
|
||||||
{ id: "cdn", label: "CDN 刷新" },
|
{ id: "cdn", label: "CDN 刷新" },
|
||||||
|
{ id: "cron", label: "Cron 部署" },
|
||||||
];
|
];
|
||||||
const PRIMARY_FILTERS = [
|
const PRIMARY_FILTERS = [
|
||||||
{ id: "all", label: "全部" },
|
{ 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({
|
createApp({
|
||||||
template: APP_TEMPLATE,
|
template: APP_TEMPLATE,
|
||||||
data() {
|
data() {
|
||||||
@ -552,6 +598,7 @@ createApp({
|
|||||||
mongoAdmin: createMongoAdminState(),
|
mongoAdmin: createMongoAdminState(),
|
||||||
mySqlAdmin: createMySqlAdminState(),
|
mySqlAdmin: createMySqlAdminState(),
|
||||||
cdnAdmin: createCdnAdminState(),
|
cdnAdmin: createCdnAdminState(),
|
||||||
|
chatappCron: createChatappCronDeployState(),
|
||||||
updateOps: {
|
updateOps: {
|
||||||
loading: false,
|
loading: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
@ -634,7 +681,7 @@ createApp({
|
|||||||
formattedUpdatedAt() {
|
formattedUpdatedAt() {
|
||||||
if (CURRENT_APP_CONTEXT.mode === "common") {
|
if (CURRENT_APP_CONTEXT.mode === "common") {
|
||||||
return this.formatDateTime(
|
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);
|
return this.formatDateTime(this.payload.updatedAt);
|
||||||
@ -653,6 +700,9 @@ createApp({
|
|||||||
if (this.commonTool === "cdn" && this.cdnAdmin.error) {
|
if (this.commonTool === "cdn" && this.cdnAdmin.error) {
|
||||||
return "bad";
|
return "bad";
|
||||||
}
|
}
|
||||||
|
if (this.commonTool === "cron" && this.chatappCron.error) {
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -706,7 +756,13 @@ createApp({
|
|||||||
return this.releaseKeyword;
|
return this.releaseKeyword;
|
||||||
}
|
}
|
||||||
if (this.activeView === "common") {
|
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 "";
|
return "";
|
||||||
},
|
},
|
||||||
@ -737,7 +793,7 @@ createApp({
|
|||||||
if (this.activeView === "common") {
|
if (this.activeView === "common") {
|
||||||
if (this.commonTool === "mysql") {
|
if (this.commonTool === "mysql") {
|
||||||
this.mySqlAdmin.keyword = nextValue;
|
this.mySqlAdmin.keyword = nextValue;
|
||||||
} else {
|
} else if (this.commonTool === "cdn") {
|
||||||
this.cdnAdmin.keyword = nextValue;
|
this.cdnAdmin.keyword = nextValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -760,12 +816,19 @@ createApp({
|
|||||||
return "搜索服务 / 主机 / 镜像";
|
return "搜索服务 / 主机 / 镜像";
|
||||||
}
|
}
|
||||||
if (this.activeView === "common") {
|
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 "全局搜索";
|
return "全局搜索";
|
||||||
},
|
},
|
||||||
globalSearchDisabled() {
|
globalSearchDisabled() {
|
||||||
return this.activeView === "config" && this.configTab === "go";
|
return (this.activeView === "config" && this.configTab === "go")
|
||||||
|
|| (this.activeView === "common" && this.commonTool === "cron");
|
||||||
},
|
},
|
||||||
dataFreshnessLabel() {
|
dataFreshnessLabel() {
|
||||||
const updatedAt = Date.parse(this.payload.updatedAt || "");
|
const updatedAt = Date.parse(this.payload.updatedAt || "");
|
||||||
@ -806,6 +869,7 @@ createApp({
|
|||||||
|| (this.restartOps.items || []).length > 0
|
|| (this.restartOps.items || []).length > 0
|
||||||
|| this.mySqlAdmin.loaded
|
|| this.mySqlAdmin.loaded
|
||||||
|| this.cdnAdmin.loaded
|
|| this.cdnAdmin.loaded
|
||||||
|
|| this.chatappCron.loaded
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
showLoadingSkeleton() {
|
showLoadingSkeleton() {
|
||||||
@ -1512,10 +1576,89 @@ createApp({
|
|||||||
.includes(keyword)
|
.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() {
|
commonToolContextLabel() {
|
||||||
return this.commonTool === "mysql"
|
if (this.commonTool === "mysql") {
|
||||||
? "MySQL / 数据 / 写入 / 结构 / SQL"
|
return "MySQL / 数据 / 写入 / 结构 / SQL";
|
||||||
: "CDN / URL 刷新 / 目录刷新 / 最近记录";
|
}
|
||||||
|
if (this.commonTool === "cdn") {
|
||||||
|
return "CDN / URL 刷新 / 目录刷新 / 最近记录";
|
||||||
|
}
|
||||||
|
return "chatapp-cron / 构建 / 上传 / 重启";
|
||||||
},
|
},
|
||||||
mySqlSqlShortcuts() {
|
mySqlSqlShortcuts() {
|
||||||
const tableName = this.selectedMySqlTableItem?.name || "";
|
const tableName = this.selectedMySqlTableItem?.name || "";
|
||||||
@ -2123,6 +2266,9 @@ createApp({
|
|||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
|
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return "chatapp-cron 部署日志";
|
||||||
|
}
|
||||||
return "执行日志";
|
return "执行日志";
|
||||||
},
|
},
|
||||||
activeLogViewerSubtitle() {
|
activeLogViewerSubtitle() {
|
||||||
@ -2134,36 +2280,54 @@ createApp({
|
|||||||
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
|
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
|
||||||
return subtitleParts.join(" · ");
|
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)}`;
|
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
|
||||||
},
|
},
|
||||||
activeLogViewerBadge() {
|
activeLogViewerBadge() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return this.serviceLog.container || "";
|
return this.serviceLog.container || "";
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return this.chatappCron.operationId || "";
|
||||||
|
}
|
||||||
return this.updateOps.operationId || "";
|
return this.updateOps.operationId || "";
|
||||||
},
|
},
|
||||||
activeLogViewerSecondaryBadge() {
|
activeLogViewerSecondaryBadge() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return `最近 ${this.serviceLog.previewLines} 条`;
|
return `最近 ${this.serviceLog.previewLines} 条`;
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return this.chatappCron.stage ? `阶段 ${this.chatappCron.stage}` : "";
|
||||||
|
}
|
||||||
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
|
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
|
||||||
},
|
},
|
||||||
activeLogViewerContent() {
|
activeLogViewerContent() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return this.serviceLog.content;
|
return this.serviceLog.content;
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return this.chatappCronLogText;
|
||||||
|
}
|
||||||
return this.updateLogText;
|
return this.updateLogText;
|
||||||
},
|
},
|
||||||
activeLogViewerError() {
|
activeLogViewerError() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return this.serviceLog.error;
|
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) : "";
|
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
|
||||||
},
|
},
|
||||||
activeLogViewerLoading() {
|
activeLogViewerLoading() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return this.serviceLog.loading;
|
return this.serviceLog.loading;
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return this.chatappCron.statusRequestInFlight;
|
||||||
|
}
|
||||||
return this.updateOps.statusRequestInFlight;
|
return this.updateOps.statusRequestInFlight;
|
||||||
},
|
},
|
||||||
activeLogViewerExporting() {
|
activeLogViewerExporting() {
|
||||||
@ -2176,12 +2340,18 @@ createApp({
|
|||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
|
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;
|
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
|
||||||
},
|
},
|
||||||
activeLogViewerDownloadDisabled() {
|
activeLogViewerDownloadDisabled() {
|
||||||
if (this.logViewer.kind === "service") {
|
if (this.logViewer.kind === "service") {
|
||||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
|
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
|
||||||
}
|
}
|
||||||
|
if (this.logViewer.kind === "chatapp-cron") {
|
||||||
|
return !this.chatappCron.operationId;
|
||||||
|
}
|
||||||
return !this.updateOps.operationId;
|
return !this.updateOps.operationId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -2233,9 +2403,16 @@ createApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool === "cdn") {
|
||||||
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
|
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
|
||||||
await this.refreshCdnOverview({ silent: true });
|
await this.refreshCdnOverview({ silent: true });
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "cron" && !this.chatappCron.loaded && !this.chatappCron.loading) {
|
||||||
|
await this.refreshChatappCronOverview({ silent: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
|
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
|
||||||
const shouldRefreshUpdate = force
|
const shouldRefreshUpdate = force
|
||||||
@ -2643,6 +2820,11 @@ createApp({
|
|||||||
this.logViewer.kind = "update";
|
this.logViewer.kind = "update";
|
||||||
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||||
},
|
},
|
||||||
|
openChatappCronLogViewer() {
|
||||||
|
this.logViewer.open = true;
|
||||||
|
this.logViewer.kind = "chatapp-cron";
|
||||||
|
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||||
|
},
|
||||||
closeLogViewer() {
|
closeLogViewer() {
|
||||||
const previousKind = this.logViewer.kind;
|
const previousKind = this.logViewer.kind;
|
||||||
this.logViewer.open = false;
|
this.logViewer.open = false;
|
||||||
@ -2659,6 +2841,14 @@ createApp({
|
|||||||
await this.openServiceLog({ host: this.serviceLog.host });
|
await this.openServiceLog({ host: this.serviceLog.host });
|
||||||
return;
|
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) {
|
if (!this.updateOps.operationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2670,6 +2860,17 @@ createApp({
|
|||||||
await this.downloadServiceLog();
|
await this.downloadServiceLog();
|
||||||
return;
|
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) {
|
if (!this.updateOps.operationId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -3516,6 +3717,7 @@ createApp({
|
|||||||
|
|
||||||
this.auth.redirecting = true;
|
this.auth.redirecting = true;
|
||||||
this.stopUpdatePolling();
|
this.stopUpdatePolling();
|
||||||
|
this.stopChatappCronPolling();
|
||||||
|
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
window.clearInterval(this.timer);
|
window.clearInterval(this.timer);
|
||||||
@ -3700,13 +3902,15 @@ createApp({
|
|||||||
this.error = "";
|
this.error = "";
|
||||||
this.mySqlAdmin.error = "";
|
this.mySqlAdmin.error = "";
|
||||||
this.cdnAdmin.error = "";
|
this.cdnAdmin.error = "";
|
||||||
|
this.chatappCron.error = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
this.refreshMySqlDatabases({ preserveSelection: true, silent: true }),
|
this.refreshMySqlDatabases({ preserveSelection: true, silent: true }),
|
||||||
this.refreshCdnOverview({ 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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@ -4000,6 +4204,69 @@ createApp({
|
|||||||
this.cdnAdmin.message = message || "";
|
this.cdnAdmin.message = message || "";
|
||||||
this.cdnAdmin.messageTone = tone || "neutral";
|
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) {
|
parseMultilineItems(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@ -4148,6 +4415,125 @@ createApp({
|
|||||||
this.cdnAdmin.submitting = false;
|
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() {
|
clearMySqlSchemaState() {
|
||||||
this.mySqlAdmin.schema = {
|
this.mySqlAdmin.schema = {
|
||||||
updatedAt: "",
|
updatedAt: "",
|
||||||
@ -6126,5 +6512,6 @@ createApp({
|
|||||||
window.clearTimeout(this.copiedTokenTimer);
|
window.clearTimeout(this.copiedTokenTimer);
|
||||||
}
|
}
|
||||||
this.stopUpdatePolling();
|
this.stopUpdatePolling();
|
||||||
|
this.stopChatappCronPolling();
|
||||||
},
|
},
|
||||||
}).mount("#app");
|
}).mount("#app");
|
||||||
|
|||||||
@ -241,7 +241,7 @@
|
|||||||
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
|
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="commonTool === 'cdn'">
|
||||||
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
|
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
|
||||||
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
|
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
|
||||||
</button>
|
</button>
|
||||||
@ -249,6 +249,17 @@
|
|||||||
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
|
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="refresh ghost" @click="refreshChatappCronOverview()" :disabled="chatappCron.loading">
|
||||||
|
{{ chatappCron.loading ? '刷新中...' : '刷新配置' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh ghost" @click="openChatappCronLogViewer" :disabled="!chatappCron.operationId">
|
||||||
|
查看日志
|
||||||
|
</button>
|
||||||
|
<button class="refresh" @click="runChatappCronDeploy" :disabled="chatappCronIsRunning || !chatappCron.overview.enabled">
|
||||||
|
{{ chatappCronIsRunning ? '部署中...' : '执行部署' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@ -2868,6 +2879,125 @@
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="activeView === 'common' && commonTool === 'cron'">
|
||||||
|
<section class="panel infra-panel common-cron-panel">
|
||||||
|
<div class="compact-workbench-head common-workbench-head">
|
||||||
|
<div class="metadata-strip metadata-strip-tight">
|
||||||
|
<span class="mono">{{ chatappCronSettings.serviceName || 'chatapp-cron' }}</span>
|
||||||
|
<span>{{ chatappCronSettings.host || '未配置目标机' }}</span>
|
||||||
|
<span class="mono">{{ chatappCronSettings.composeService || '-' }}</span>
|
||||||
|
<span>更新时间 {{ formatDateTime(chatappCron.overview.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<span :class="['badge', chatappCronStatusTone]">{{ chatappCronStatusText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="chatappCron.error || chatappCronMetadata.error" class="metric-error">
|
||||||
|
{{ explainError(chatappCron.error || chatappCronMetadata.error) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="summary-cards common-cron-summary">
|
||||||
|
<article class="summary-card tone-neutral">
|
||||||
|
<small>当前版本</small>
|
||||||
|
<strong class="mono">{{ truncateMiddle(chatappCronCurrentCommit, 14, 8) || '-' }}</strong>
|
||||||
|
<span>{{ formatDateTime(chatappCronMetadata.deployedAt) || '暂无部署记录' }}</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card tone-neutral">
|
||||||
|
<small>Git Ref</small>
|
||||||
|
<strong class="mono">{{ chatappCronMetadata.gitRef || chatappCron.form.gitRef || '-' }}</strong>
|
||||||
|
<span>{{ chatappCronSettings.repoUrl ? truncateMiddle(chatappCronSettings.repoUrl, 18, 12) : '仓库未配置' }}</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card tone-neutral">
|
||||||
|
<small>目标机</small>
|
||||||
|
<strong>{{ chatappCronSettings.host || '-' }}</strong>
|
||||||
|
<span class="mono">{{ chatappCronSettings.instanceId || chatappCronSettings.sshHost || '-' }}</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card tone-neutral">
|
||||||
|
<small>运行目录</small>
|
||||||
|
<strong class="mono">{{ truncateMiddle(chatappCronSettings.remoteDir, 16, 12) || '-' }}</strong>
|
||||||
|
<span class="mono">{{ truncateMiddle(chatappCronSettings.binaryPath, 16, 12) || '-' }}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mongo-panel-grid common-cron-grid">
|
||||||
|
<article class="mongo-action-panel common-cron-form-panel tone-write">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>部署参数</h2>
|
||||||
|
<p>{{ chatappCronSettings.dockerPlatform || 'linux/amd64' }}</p>
|
||||||
|
</div>
|
||||||
|
<span :class="['badge', chatappCron.overview.enabled ? 'ok' : 'warn']">
|
||||||
|
{{ chatappCron.overview.enabled ? '已配置' : '未配置' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<label class="update-field">
|
||||||
|
<span>Git Ref</span>
|
||||||
|
<input v-model.trim="chatappCron.form.gitRef" class="search mono" :placeholder="chatappCronSettings.defaultGitRef || 'main'" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="metadata-strip metadata-strip-tight common-cron-target-strip">
|
||||||
|
<span class="mono">{{ truncateMiddle(chatappCronSettings.repoRoot, 16, 12) || '-' }}</span>
|
||||||
|
<span class="mono">{{ truncateMiddle(chatappCronSettings.composeFile, 16, 12) || '-' }}</span>
|
||||||
|
<span>{{ chatappCronSettings.composeService || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="refresh ghost" @click="refreshChatappCronOverview()" :disabled="chatappCron.loading">
|
||||||
|
{{ chatappCron.loading ? '刷新中...' : '刷新配置' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh" @click="runChatappCronDeploy" :disabled="chatappCronIsRunning || !chatappCron.overview.enabled">
|
||||||
|
{{ chatappCronIsRunning ? '部署中...' : '部署 chatapp-cron' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="mongo-action-panel common-cron-log-panel tone-edit">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>部署状态</h2>
|
||||||
|
<p>{{ chatappCron.operationId ? '任务 ' + chatappCron.operationId : '未开始' }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="chatappCron.message" :class="['badge', chatappCron.messageTone === 'ok' ? 'ok' : chatappCron.messageTone === 'warn' ? 'warn' : 'neutral']">
|
||||||
|
{{ chatappCron.message }}
|
||||||
|
</span>
|
||||||
|
<span v-else :class="['badge', chatappCronStatusTone]">{{ chatappCronStatusText }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="metadata-strip metadata-strip-tight common-cron-status-strip">
|
||||||
|
<span>{{ chatappCron.stage || '-' }}</span>
|
||||||
|
<span>{{ formatDateTime(chatappCron.startedAt) || '未开始' }}</span>
|
||||||
|
<span>{{ formatDateTime(chatappCron.finishedAt) || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="release-log-preview common-cron-log-preview">
|
||||||
|
<div class="release-log-preview-head">
|
||||||
|
<strong>最近日志</strong>
|
||||||
|
<button class="refresh ghost compact-button" @click="openChatappCronLogViewer" :disabled="!chatappCron.operationId">
|
||||||
|
完整日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="release-log-list">
|
||||||
|
<div
|
||||||
|
v-for="entry in recentChatappCronLogs"
|
||||||
|
:key="entry.key"
|
||||||
|
:class="['release-log-entry', entry.level === 'error' ? 'tone-bad' : '']"
|
||||||
|
>
|
||||||
|
<strong>{{ formatDateTime(entry.time) }}</strong>
|
||||||
|
<span>{{ entry.stage || '-' }}</span>
|
||||||
|
<small>{{ entry.message }}</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="recentChatappCronLogs.length === 0" class="release-log-entry">
|
||||||
|
<strong>-</strong>
|
||||||
|
<span>idle</span>
|
||||||
|
<small>暂无部署日志</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="!showLoadingSkeleton && activeView === 'config'">
|
<template v-if="!showLoadingSkeleton && activeView === 'config'">
|
||||||
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
|
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
|
||||||
<div class="compact-workbench-head config-head-strip">
|
<div class="compact-workbench-head config-head-strip">
|
||||||
|
|||||||
84
tests/test_chatapp_cron_deploy.py
Normal file
84
tests/test_chatapp_cron_deploy.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from monitors.yumi import chatapp_cron_deploy as cron
|
||||||
|
|
||||||
|
|
||||||
|
class ChatappCronDeployTests(unittest.TestCase):
|
||||||
|
def test_overview_does_not_read_remote_metadata_when_disabled(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(cron, "CHATAPP_CRON_ENABLED", False),
|
||||||
|
patch.object(cron, "read_chatapp_cron_metadata") as read_metadata_mock,
|
||||||
|
):
|
||||||
|
payload = cron.build_chatapp_cron_overview_payload()
|
||||||
|
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertFalse(payload["enabled"])
|
||||||
|
self.assertEqual(payload["metadata"], {})
|
||||||
|
read_metadata_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_activate_uploads_binary_and_recreates_compose_service(self) -> None:
|
||||||
|
host = {
|
||||||
|
"host": "cron",
|
||||||
|
"ip": "10.3.1.14",
|
||||||
|
"instanceId": "ins-nt6bsay0",
|
||||||
|
"sshHost": "10.3.1.14",
|
||||||
|
"sshUser": "root",
|
||||||
|
"sshUseSudo": False,
|
||||||
|
"sshPort": 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
binary_path = Path(tmp_dir) / "week-star-job"
|
||||||
|
binary_path.write_text("binary", encoding="utf-8")
|
||||||
|
binary_path.chmod(0o755)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cron, "CHATAPP_CRON_SERVICE_NAME", "chatapp-cron"),
|
||||||
|
patch.object(cron, "CHATAPP_CRON_HOST", "cron"),
|
||||||
|
patch.object(cron, "CHATAPP_CRON_REMOTE_DIR", "/opt/deploy/runtime/current"),
|
||||||
|
patch.object(cron, "CHATAPP_CRON_COMPOSE_FILE", "/opt/deploy/runtime/current/docker-compose.yml"),
|
||||||
|
patch.object(cron, "CHATAPP_CRON_COMPOSE_SERVICE", "week-star-job"),
|
||||||
|
patch.object(cron, "CHATAPP_CRON_BINARY_PATH", "/opt/deploy/runtime/current/week-star-job/service"),
|
||||||
|
patch.object(
|
||||||
|
cron,
|
||||||
|
"CHATAPP_CRON_RELEASE_META_PATH",
|
||||||
|
"/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json",
|
||||||
|
),
|
||||||
|
patch.object(cron, "chatapp_cron_host_record", return_value=host),
|
||||||
|
patch.object(cron, "copy_local_path_to_host") as copy_mock,
|
||||||
|
patch.object(
|
||||||
|
cron,
|
||||||
|
"run_host_script_checked",
|
||||||
|
return_value={"target": "cron", "status": "ok", "output": "week-star-job running"},
|
||||||
|
) as run_remote_mock,
|
||||||
|
):
|
||||||
|
result = cron.activate_chatapp_cron_binary(
|
||||||
|
{
|
||||||
|
"binaryPath": str(binary_path),
|
||||||
|
"gitRef": "main",
|
||||||
|
"commit": "f0f6205abc1234567890",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
copy_mock.assert_called_once()
|
||||||
|
self.assertEqual(copy_mock.call_args.args[0], host)
|
||||||
|
self.assertEqual(copy_mock.call_args.args[1], binary_path)
|
||||||
|
|
||||||
|
run_remote_mock.assert_called_once()
|
||||||
|
remote_script = run_remote_mock.call_args.args[1]
|
||||||
|
self.assertIn("COMPOSE_SERVICE=week-star-job", remote_script)
|
||||||
|
self.assertIn("BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service", remote_script)
|
||||||
|
self.assertIn('up -d --no-deps --force-recreate "$COMPOSE_SERVICE"', remote_script)
|
||||||
|
self.assertIn("META_BLOB=", remote_script)
|
||||||
|
|
||||||
|
self.assertEqual(result["metadata"]["commit"], "f0f6205abc1234567890")
|
||||||
|
self.assertEqual(result["metadata"]["component"], "week-star-job")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -492,6 +492,115 @@ class GatewayReleaseFlowTests(unittest.TestCase):
|
|||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
prepare_gateway_release_clb_snapshot_mock.assert_not_called()
|
prepare_gateway_release_clb_snapshot_mock.assert_not_called()
|
||||||
|
|
||||||
|
@patch("monitors.yumi.service_release.build_service_images")
|
||||||
|
@patch("monitors.yumi.service_release.service_records_by_name")
|
||||||
|
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["other"])
|
||||||
|
@patch("monitors.yumi.service_release.host_image_plan")
|
||||||
|
@patch("monitors.yumi.service_release.update_local_service_image")
|
||||||
|
@patch("monitors.yumi.service_release.sync_remote_host_compose_template")
|
||||||
|
@patch("monitors.yumi.service_release.prepull_host_images")
|
||||||
|
@patch("monitors.yumi.service_release.drain_java_registration")
|
||||||
|
@patch("monitors.yumi.service_release.restart_service_instance")
|
||||||
|
@patch("monitors.yumi.service_release.wait_http_ready")
|
||||||
|
@patch("monitors.yumi.service_release.restore_java_registration")
|
||||||
|
def test_other_update_drains_nacos_before_restart(
|
||||||
|
self,
|
||||||
|
restore_java_registration_mock,
|
||||||
|
wait_http_ready_mock,
|
||||||
|
restart_service_instance_mock,
|
||||||
|
drain_java_registration_mock,
|
||||||
|
prepull_host_images_mock,
|
||||||
|
sync_remote_host_compose_template_mock,
|
||||||
|
update_local_service_image_mock,
|
||||||
|
host_image_plan_mock,
|
||||||
|
buildable_service_names_mock,
|
||||||
|
service_records_by_name_mock,
|
||||||
|
build_service_images_mock,
|
||||||
|
) -> None:
|
||||||
|
del buildable_service_names_mock
|
||||||
|
other_record = {
|
||||||
|
"host": "app-1",
|
||||||
|
"ip": "10.0.0.3",
|
||||||
|
"instanceId": "ins-app-1",
|
||||||
|
"port": 2400,
|
||||||
|
"path": "/actuator/health",
|
||||||
|
"service": "other",
|
||||||
|
"kind": "java",
|
||||||
|
}
|
||||||
|
service_records_by_name_mock.return_value = {"other": [other_record]}
|
||||||
|
build_service_images_mock.return_value = {
|
||||||
|
"serviceImages": {"other": "registry/other:new"},
|
||||||
|
"builds": [],
|
||||||
|
}
|
||||||
|
host_image_plan_mock.return_value = [
|
||||||
|
{
|
||||||
|
"host": "app-1",
|
||||||
|
"ip": "10.0.0.3",
|
||||||
|
"services": ["other"],
|
||||||
|
"images": ["registry/other:new"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
update_local_service_image_mock.return_value = {"host": "app-1", "service": "other"}
|
||||||
|
sync_remote_host_compose_template_mock.return_value = {
|
||||||
|
"path": "/tmp/docker-compose.yml",
|
||||||
|
"changed": False,
|
||||||
|
"source": "local",
|
||||||
|
"backupPath": "",
|
||||||
|
}
|
||||||
|
prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"}
|
||||||
|
wait_http_ready_mock.return_value = {"ok": True, "statusCode": 200, "latencyMs": 11, "detail": "ok"}
|
||||||
|
drain_java_registration_mock.return_value = {
|
||||||
|
"checked": True,
|
||||||
|
"enabled": False,
|
||||||
|
"source": "instance/list",
|
||||||
|
"drainSeconds": 8.0,
|
||||||
|
}
|
||||||
|
restore_java_registration_mock.return_value = {
|
||||||
|
"checked": True,
|
||||||
|
"healthy": True,
|
||||||
|
"enabled": True,
|
||||||
|
"source": "instance/list",
|
||||||
|
"restored": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
call_order: list[str] = []
|
||||||
|
drain_java_registration_mock.side_effect = lambda service: call_order.append("drain") or {
|
||||||
|
"checked": True,
|
||||||
|
"enabled": False,
|
||||||
|
"source": "instance/list",
|
||||||
|
"drainSeconds": 8.0,
|
||||||
|
}
|
||||||
|
restart_service_instance_mock.side_effect = lambda service: call_order.append("restart") or {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"output": "restarted",
|
||||||
|
}
|
||||||
|
wait_http_ready_mock.side_effect = lambda service, timeout_seconds: call_order.append("http") or {
|
||||||
|
"ok": True,
|
||||||
|
"statusCode": 200,
|
||||||
|
"latencyMs": 11,
|
||||||
|
"detail": "ok",
|
||||||
|
}
|
||||||
|
restore_java_registration_mock.side_effect = lambda service: call_order.append("restore") or {
|
||||||
|
"checked": True,
|
||||||
|
"healthy": True,
|
||||||
|
"enabled": True,
|
||||||
|
"source": "instance/list",
|
||||||
|
"restored": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = deploy_runtime_services_payload(
|
||||||
|
["other"],
|
||||||
|
java_git_ref="main",
|
||||||
|
go_git_ref="main",
|
||||||
|
image_tag="20260423",
|
||||||
|
skip_maven_build=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual(call_order, ["drain", "restart", "http", "restore"])
|
||||||
|
self.assertEqual(result["steps"][0]["nacosDrain"]["enabled"], False)
|
||||||
|
self.assertEqual(result["steps"][0]["nacos"]["restored"], True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user