Add chatapp cron deployment

This commit is contained in:
170-carry 2026-04-27 18:42:56 +08:00
parent 9c8faf2896
commit 05f0ecbfbc
15 changed files with 1805 additions and 17 deletions

View File

@ -31,6 +31,9 @@ SERVICE_LOG_EXPORT_MAX_LINES=10000
ROLLING_RESTART_TIMEOUT_SECONDS=180
ROLLING_RESTART_POLL_SECONDS=2
ROLLING_RESTART_STABILIZE_SECONDS=2
NACOS_RELEASE_DRAIN_SERVICES=other
NACOS_RELEASE_DRAIN_SECONDS=8
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS=30
SERVICE_BUILD_TIMEOUT_SECONDS=3600
SERVICE_PULL_TIMEOUT_SECONDS=600
SERVICE_BUILD_CACHE_ROOT=/opt/deploy/build-cache
@ -71,6 +74,23 @@ ADMIN_FRONTEND_RELEASE_META_PATH=/opt/yumi-admin/frontend-admin/.hy-app-monitor-
ADMIN_FRONTEND_BUILD_SCRIPT=build:antdv-next
ADMIN_FRONTEND_DIST_DIR=apps/dist
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
CHATAPP_CRON_ENABLED=1
CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git
CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron
CHATAPP_CRON_DEFAULT_GIT_REF=main
CHATAPP_CRON_SERVICE_NAME=chatapp-cron
CHATAPP_CRON_HOST=cron
CHATAPP_CRON_IP=10.3.1.14
CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0
CHATAPP_CRON_SSH_HOST=10.3.1.14
CHATAPP_CRON_SSH_USER=root
CHATAPP_CRON_SSH_PORT=22
CHATAPP_CRON_SSH_USE_SUDO=0
CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current
CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml
CHATAPP_CRON_COMPOSE_SERVICE=week-star-job
CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service
CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json
MONGO_URI=
MONGO_DB_NAME=tarab_all
MONGO_INSTANCE_ID=ins-o9mmob66

View File

@ -120,6 +120,28 @@ ADMIN_FRONTEND_DIST_DIR=apps/dist
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
```
通用模块里的 `Cron 部署` 用于发布 `chatapp-cron`,需要目标机已有 `docker-compose.yml``service.env`。部署流程会构建 `week-star-job` 二进制,上传到目标机,再执行 compose 重建:
```bash
CHATAPP_CRON_ENABLED=1
CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git
CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron
CHATAPP_CRON_DEFAULT_GIT_REF=main
CHATAPP_CRON_SERVICE_NAME=chatapp-cron
CHATAPP_CRON_HOST=cron
CHATAPP_CRON_IP=10.3.1.14
CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0
CHATAPP_CRON_SSH_HOST=10.3.1.14
CHATAPP_CRON_SSH_USER=root
CHATAPP_CRON_SSH_PORT=22
CHATAPP_CRON_SSH_USE_SUDO=0
CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current
CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml
CHATAPP_CRON_COMPOSE_SERVICE=week-star-job
CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service
CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json
```
可以直接复制模板:
```bash
@ -205,7 +227,7 @@ python3 ops/bootstrap_ssh_via_tat.py
3. 只允许 `deploy -> target:22`
4. 把目标机本机防火墙和安全组都限制到 `deploy` 私网 IP
如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1`,脚本也会把 admin 前端机器一起纳入这轮 SSH 引导,即使它不在 `config/targets.json` 里。
如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1``CHATAPP_CRON_ENABLED=1`,脚本也会把 admin 前端机器和定时任务服务器纳入这轮 SSH 引导,即使它们不在 `config/targets.json` 里。
如果你只需要放开监控面板端口,仍然可以使用:

View File

@ -93,6 +93,12 @@ ROLLING_RESTART_TIMEOUT_SECONDS = env_int("ROLLING_RESTART_TIMEOUT_SECONDS", 180
ROLLING_RESTART_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0)
# 单实例恢复后额外稳定等待秒数。
ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_SECONDS", 2.0)
# 依赖方较多的服务发布前先在 Nacos 摘掉当前实例,避免调用方继续打到正在重启的节点。
NACOS_RELEASE_DRAIN_SERVICES = tuple(env_str_list("NACOS_RELEASE_DRAIN_SERVICES", ["other"]))
# Nacos 摘实例后额外等待调用方本地缓存刷新。
NACOS_RELEASE_DRAIN_SECONDS = env_float("NACOS_RELEASE_DRAIN_SECONDS", 8.0)
# 等待 Nacos enabled 状态变化的最长秒数。
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS = env_int("NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS", 30)
# 日志查看固定读取最近多少条。
SERVICE_LOG_PREVIEW_LINES = env_int("SERVICE_LOG_PREVIEW_LINES", 1000)
# 日志导出最大允许条数,避免单次下载打爆页面进程。
@ -281,6 +287,70 @@ ADMIN_FRONTEND_ENABLED = truthy(
),
)
# chatapp-cron 仓库地址;仓库缺失时允许按这里自动 clone。
CHATAPP_CRON_REPO_URL = env_str("CHATAPP_CRON_REPO_URL", "git@gitea.haiyihy.com:hy/chatapp-cron.git")
# chatapp-cron 仓库路径,默认兼容本地工作区和远端 deploy 机。
CHATAPP_CRON_REPO_ROOT = env_or_candidate_path(
"CHATAPP_CRON_REPO_ROOT",
[
WORKSPACE_ROOT / "chatapp3" / "chatapp-cron",
WORKSPACE_ROOT / "chatapp-cron",
Path("/opt/deploy/sources/chatapp-cron"),
],
)
# chatapp-cron 默认拉取的 Git ref。
CHATAPP_CRON_DEFAULT_GIT_REF = env_str("CHATAPP_CRON_DEFAULT_GIT_REF", "main")
# chatapp-cron 在通用部署页里的服务名。
CHATAPP_CRON_SERVICE_NAME = env_str("CHATAPP_CRON_SERVICE_NAME", "chatapp-cron").strip() or "chatapp-cron"
# chatapp-cron 远端主机名称。
CHATAPP_CRON_HOST = env_str("CHATAPP_CRON_HOST", "cron").strip() or "cron"
# chatapp-cron 远端主机私网 IP。
# 定时任务服务器ins-nt6bsay0 / cron / 10.3.1.14。
CHATAPP_CRON_IP = env_str("CHATAPP_CRON_IP", "10.3.1.14")
# chatapp-cron 云服务器实例 ID主要供 TAT SSH 引导使用。
CHATAPP_CRON_INSTANCE_ID = env_str("CHATAPP_CRON_INSTANCE_ID", "ins-nt6bsay0")
# chatapp-cron SSH 主机,默认沿用私网 IP。
CHATAPP_CRON_SSH_HOST = env_str("CHATAPP_CRON_SSH_HOST", CHATAPP_CRON_IP).strip() or CHATAPP_CRON_IP
# chatapp-cron SSH 用户。
CHATAPP_CRON_SSH_USER = env_str("CHATAPP_CRON_SSH_USER", SSH_USER).strip() or SSH_USER
# chatapp-cron SSH 端口。
CHATAPP_CRON_SSH_PORT = env_int("CHATAPP_CRON_SSH_PORT", SSH_PORT)
# chatapp-cron 是否需要 sudo。
CHATAPP_CRON_SSH_USE_SUDO = env_str("CHATAPP_CRON_SSH_USE_SUDO", "0").lower() in {"1", "true", "yes", "on"}
# chatapp-cron 远端运行目录。
CHATAPP_CRON_REMOTE_DIR = env_str("CHATAPP_CRON_REMOTE_DIR", "/opt/deploy/runtime/current").strip() or "/opt/deploy/runtime/current"
# chatapp-cron compose 文件路径。
CHATAPP_CRON_COMPOSE_FILE = (
env_str("CHATAPP_CRON_COMPOSE_FILE", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml").strip()
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml"
)
# chatapp-cron compose 服务名。
CHATAPP_CRON_COMPOSE_SERVICE = env_str("CHATAPP_CRON_COMPOSE_SERVICE", "week-star-job").strip() or "week-star-job"
# chatapp-cron 远端二进制路径。
CHATAPP_CRON_BINARY_PATH = (
env_str("CHATAPP_CRON_BINARY_PATH", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service").strip()
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service"
)
# chatapp-cron 当前发布元数据文件路径。
CHATAPP_CRON_RELEASE_META_PATH = (
env_str(
"CHATAPP_CRON_RELEASE_META_PATH",
f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json",
).strip()
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json"
)
# chatapp-cron 是否启用;未显式指定时,只要目标机和仓库信息齐备就自动开启。
CHATAPP_CRON_ENABLED = truthy(
os.environ.get("CHATAPP_CRON_ENABLED"),
bool(
CHATAPP_CRON_SERVICE_NAME
and CHATAPP_CRON_HOST
and (CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP)
and CHATAPP_CRON_REMOTE_DIR
and (CHATAPP_CRON_REPO_URL or str(CHATAPP_CRON_REPO_ROOT).strip())
),
)
# Mongo 连接串。
MONGO_URI = env_str("MONGO_URI", "")
# Mongo 默认关注的业务库名。

View 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 ""

View File

@ -265,6 +265,19 @@ def build_service_definition(
if restart_policy:
service_definition["restart"] = restart_policy
log_config = host_config.get("LogConfig") or {}
log_driver = str(log_config.get("Type") or "").strip()
log_options = {
str(key): str(value)
for key, value in (log_config.get("Config") or {}).items()
if str(key).strip() and value is not None
}
if log_driver and (log_driver != "json-file" or log_options):
logging_definition: dict[str, Any] = {"driver": log_driver}
if log_options:
logging_definition["options"] = log_options
service_definition["logging"] = logging_definition
stop_signal = str(config.get("StopSignal") or "").strip()
if stop_signal:
service_definition["stop_signal"] = stop_signal

View File

@ -55,6 +55,12 @@ from .service_release import build_update_targets_payload
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
from .service_ops import build_restart_targets_payload, rolling_restart_payload
from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload
from .chatapp_cron_deploy import (
build_chatapp_cron_overview_payload,
get_chatapp_cron_deploy_job_payload,
get_chatapp_cron_deploy_log_text,
start_chatapp_cron_deploy_job,
)
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
@ -254,6 +260,20 @@ class MonitorHandler(BaseHTTPRequestHandler):
send_body=send_body,
)
# 返回 chatapp-cron 部署配置和最近版本。
if local_path == "/api/chatapp-cron/overview":
try:
payload = build_chatapp_cron_overview_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回某个库下的表列表。
if local_path == "/api/mysql/tables":
database_name = self.query_value(query, "database")
@ -416,6 +436,58 @@ class MonitorHandler(BaseHTTPRequestHandler):
send_body=send_body,
)
# 返回 chatapp-cron 部署任务状态。
if local_path == "/api/chatapp-cron/deploy-status":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
payload = get_chatapp_cron_deploy_job_payload(operation_id)
except KeyError:
return self.send_error_payload(
HTTPStatus.NOT_FOUND,
"chatapp-cron deploy operation not found",
send_body=send_body,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 chatapp-cron 部署任务完整日志。
if local_path == "/api/chatapp-cron/deploy-log":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
content = get_chatapp_cron_deploy_log_text(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "chatapp-cron deploy log not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
)
# 返回单个服务实例最近 1000 条容器日志。
if local_path == "/api/ops/service-log":
service_name = self.query_value(query, "service")
@ -533,6 +605,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
if local_path == "/api/cdn/purge":
return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED)
if local_path == "/api/chatapp-cron/deploy":
return self.handle_chatapp_cron_deploy(payload)
if local_path == "/api/ops/rolling-restart":
return self.handle_rolling_restart(payload)
@ -746,6 +821,23 @@ class MonitorHandler(BaseHTTPRequestHandler):
send_body=True,
)
def handle_chatapp_cron_deploy(self, payload: dict[str, Any]) -> None:
git_ref = str(payload.get("gitRef") or "").strip()
try:
result = start_chatapp_cron_deploy_job(git_ref)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.ACCEPTED,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_mongo_admin_action(
self,
builder: Any,

View File

@ -602,6 +602,32 @@ def catalog_instances(namespace_id: str, service_name: str, group_name: str) ->
return list(payload.get("list") or [])
def set_instance_enabled(
namespace_id: str,
service_name: str,
group_name: str,
*,
ip: str,
port: int,
enabled: bool,
ephemeral: bool | None = None,
) -> str:
# 发布链路用 Nacos enabled 做摘流;这里只改当前 ip:port 这个实例。
params: dict[str, Any] = {
"serviceName": service_name,
"groupName": group_name,
"namespaceId": namespace_id or None,
"clusterName": NACOS_DISCOVERY_CLUSTER_NAME or None,
"ip": ip,
"port": str(int(port)),
"enabled": "true" if enabled else "false",
}
if ephemeral is not None:
params["ephemeral"] = "true" if bool(ephemeral) else "false"
return nacos_request_text("PUT", "/nacos/v1/ns/instance", params=params).strip()
def build_service_level(instance_total: int, healthy_total: int, enabled_total: int) -> str:
# 没有实例时直接标 bad。
if instance_total == 0:

View File

@ -8,6 +8,9 @@ from typing import Any
from .compose_templates import sync_remote_host_compose_template
from core.config import (
NACOS_RELEASE_DRAIN_SECONDS,
NACOS_RELEASE_DRAIN_SERVICES,
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS,
ROLLING_RESTART_POLL_SECONDS,
ROLLING_RESTART_STABILIZE_SECONDS,
ROLLING_RESTART_TIMEOUT_SECONDS,
@ -26,6 +29,7 @@ from .nacos import (
current_namespace,
list_instances,
nacos_truthy,
set_instance_enabled,
)
from .ssh_remote import run_host_script
@ -179,6 +183,100 @@ def wait_java_registration_ready(service: dict[str, Any], timeout_seconds: int)
raise RuntimeError(f"nacos registration not ready on {service['host']} {service['service']}")
def should_drain_java_registration(service: dict[str, Any]) -> bool:
# 默认只对 other 做发布前摘流;其他服务需要时可通过 .env 扩展。
return service.get("kind") == "java" and str(service.get("service") or "").strip() in NACOS_RELEASE_DRAIN_SERVICES
def current_java_registration_record(service: dict[str, Any]) -> dict[str, Any] | None:
# 返回当前 ip:port 对应的 Nacos 实例,供摘流和恢复复用。
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
if not registry_name:
return None
namespace_id, _ = current_namespace()
return matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service)
def update_java_registration_enabled(service: dict[str, Any], enabled: bool) -> dict[str, Any]:
# 只更新当前服务实例的 enabled 状态,不影响同服务其他宿主机。
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
if not registry_name:
return {"checked": False, "reason": "service not managed by nacos registry map"}
namespace_id, _ = current_namespace()
current_record = current_java_registration_record(service)
ephemeral = current_record.get("ephemeral") if current_record and "ephemeral" in current_record else True
response_text = set_instance_enabled(
namespace_id,
registry_name,
NACOS_DISCOVERY_GROUP,
ip=str(service["ip"]),
port=int(service["port"]),
enabled=enabled,
ephemeral=bool(ephemeral),
)
NACOS_CACHE.clear()
return {
"checked": True,
"serviceName": registry_name,
"enabled": enabled,
"response": response_text,
"ephemeral": bool(ephemeral),
}
def wait_java_registration_enabled_state(service: dict[str, Any], enabled: bool, timeout_seconds: int) -> dict[str, Any]:
# 等到 Nacos 读回当前实例 enabled 状态,避免刚写完就立刻重启。
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
if not registry_name:
return {"checked": False, "reason": "service not managed by nacos registry map"}
namespace_id, _ = current_namespace()
deadline = time.time() + timeout_seconds
last_record: dict[str, Any] | None = None
while time.time() < deadline:
last_record = matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service)
if last_record is not None and nacos_truthy(last_record.get("enabled", True)) == enabled:
return {
"checked": True,
"enabled": enabled,
"healthy": nacos_truthy(last_record.get("healthy", False)),
"source": "instance/list",
}
time.sleep(ROLLING_RESTART_POLL_SECONDS)
raise RuntimeError(f"nacos enabled={enabled} not ready on {service['host']} {service['service']}")
def drain_java_registration(service: dict[str, Any]) -> dict[str, Any]:
# 摘掉当前实例后给调用方一点缓存刷新时间,避免 live 继续打到正在重启的 other。
if not should_drain_java_registration(service):
return {"checked": False, "reason": "service not configured for nacos release drain"}
update_result = update_java_registration_enabled(service, False)
state_result = wait_java_registration_enabled_state(service, False, NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS)
if NACOS_RELEASE_DRAIN_SECONDS > 0:
time.sleep(NACOS_RELEASE_DRAIN_SECONDS)
return {
**state_result,
"drainSeconds": NACOS_RELEASE_DRAIN_SECONDS,
"update": update_result,
}
def restore_java_registration(service: dict[str, Any]) -> dict[str, Any]:
# 容器 HTTP 恢复后再打开 Nacos enabled并读回健康状态。
update_result = update_java_registration_enabled(service, True)
ready_result = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
return {
**ready_result,
"restored": True,
"update": update_result,
}
def clear_operation_caches() -> None:
# 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。
SERVICE_CACHE.clear()
@ -209,6 +307,7 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
"kind": service["kind"],
"status": "running",
}
nacos_drained = False
try:
# 单机第一次进入重启前,先把完整 compose 模板同步到远端。
@ -223,6 +322,12 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
"source": str(prepared_hosts[service["host"]].get("source") or ""),
}
if service["kind"] == "java":
drain_result = drain_java_registration(service)
if drain_result.get("checked"):
nacos_drained = True
step["nacosDrain"] = drain_result
restart_result = restart_service_instance(service)
step["remoteStatus"] = restart_result.get("status")
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
@ -236,7 +341,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
}
if service["kind"] == "java":
step["nacos"] = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
step["nacos"] = (
restore_java_registration(service)
if nacos_drained
else wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
)
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
time.sleep(ROLLING_RESTART_STABILIZE_SECONDS)
@ -245,6 +354,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
step["durationSeconds"] = round(time.time() - started_at, 2)
steps.append(step)
except Exception as exc: # noqa: BLE001
if nacos_drained:
try:
step["nacosRestoreRecovered"] = update_java_registration_enabled(service, True)
except Exception as restore_exc: # noqa: BLE001
step["nacosRestoreError"] = str(restore_exc)
step["status"] = "failed"
step["error"] = str(exc)
step["durationSeconds"] = round(time.time() - started_at, 2)

View File

@ -64,8 +64,11 @@ from core.config import (
)
from .service_ops import (
clear_operation_caches,
drain_java_registration,
restart_service_instance,
restore_java_registration,
service_records_by_name,
update_java_registration_enabled,
wait_http_ready,
wait_java_registration_ready,
)
@ -1626,6 +1629,7 @@ def deploy_runtime_services_payload(
gateway_clb_snapshot: dict[str, Any] | None = None
gateway_clb_health_ready = False
gateway_clb_restored = False
nacos_drained = False
try:
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
@ -1655,6 +1659,16 @@ def deploy_runtime_services_payload(
if GATEWAY_CLB_DRAIN_SECONDS > 0:
emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s")
time.sleep(GATEWAY_CLB_DRAIN_SECONDS)
elif service["kind"] == "java":
drain_result = drain_java_registration(service)
if drain_result.get("checked"):
nacos_drained = True
step["nacosDrain"] = drain_result
emit_progress(
progress,
step_stage,
f"Nacos 摘流完成enabled=false drain={drain_result.get('drainSeconds')}s",
)
restart_result = restart_service_instance(service)
step["remoteStatus"] = restart_result.get("status")
@ -1679,9 +1693,13 @@ def deploy_runtime_services_payload(
)
if service["kind"] == "java":
step["nacos"] = wait_java_registration_ready(
service,
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
step["nacos"] = (
restore_java_registration(service)
if nacos_drained
else wait_java_registration_ready(
service,
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
)
)
emit_progress(
progress,
@ -1730,6 +1748,13 @@ def deploy_runtime_services_payload(
emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}")
except Exception as restore_exc: # noqa: BLE001
step.setdefault("clb", {})["restoreError"] = str(restore_exc)
if nacos_drained:
try:
restore_result = update_java_registration_enabled(service, True)
step["nacosRestoreRecovered"] = restore_result
emit_progress(progress, step_stage, "失败收尾时已恢复 Nacos enabled=true")
except Exception as restore_exc: # noqa: BLE001
step["nacosRestoreError"] = str(restore_exc)
step["status"] = "failed"
step["error"] = str(exc)
step["durationSeconds"] = round(time.time() - started_at, 2)

View File

@ -100,6 +100,11 @@ ADMIN_FRONTEND_ENABLED = env_truthy("ADMIN_FRONTEND_ENABLED", False)
ADMIN_FRONTEND_HOST = os.environ.get("ADMIN_FRONTEND_HOST", "").strip()
ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip()
ADMIN_FRONTEND_INSTANCE_ID = os.environ.get("ADMIN_FRONTEND_INSTANCE_ID", "").strip()
# 独立 chatapp-cron 目标机配置;配置齐备时也会加入 SSH 引导。
CHATAPP_CRON_ENABLED = env_truthy("CHATAPP_CRON_ENABLED", False)
CHATAPP_CRON_HOST = os.environ.get("CHATAPP_CRON_HOST", "").strip()
CHATAPP_CRON_IP = os.environ.get("CHATAPP_CRON_IP", "").strip()
CHATAPP_CRON_INSTANCE_ID = os.environ.get("CHATAPP_CRON_INSTANCE_ID", "").strip()
def parse_args() -> argparse.Namespace:
@ -234,6 +239,18 @@ def load_target_hosts(host_names: list[str]) -> list[dict[str, str]]:
"instanceId": ADMIN_FRONTEND_INSTANCE_ID,
}
)
# 定时任务机器也不在 targets.json 时,通过 .env 并入同一轮 SSH 引导。
if CHATAPP_CRON_ENABLED and CHATAPP_CRON_HOST and CHATAPP_CRON_INSTANCE_ID:
if (not selected) or CHATAPP_CRON_HOST in selected:
if CHATAPP_CRON_INSTANCE_ID not in seen_instance_ids:
seen_instance_ids.add(CHATAPP_CRON_INSTANCE_ID)
items.append(
{
"host": CHATAPP_CRON_HOST,
"ip": CHATAPP_CRON_IP,
"instanceId": CHATAPP_CRON_INSTANCE_ID,
}
)
return items

View File

@ -4146,6 +4146,35 @@ body.auth-page {
margin-bottom: 0;
}
.common-cron-grid {
align-items: start;
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
}
.common-cron-summary {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.common-cron-target-strip,
.common-cron-status-strip {
margin-bottom: 0;
}
.common-cron-log-preview {
margin-top: 0;
}
.common-cron-log-panel .release-log-list {
max-height: 360px;
overflow: auto;
}
.compact-button {
min-height: 30px;
padding: 0 10px;
font-size: 12px;
}
.mongo-panel-grid.narrow {
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
}
@ -5108,6 +5137,7 @@ body.auth-page {
.mongo-panel-grid,
.common-cdn-grid,
.common-cron-grid,
.mongo-query-grid,
.mysql-filter-grid {
grid-template-columns: 1fr;
@ -5274,6 +5304,7 @@ body.auth-page {
.update-form-grid,
.mongo-panel-grid,
.common-cdn-grid,
.common-cron-grid,
.mongo-query-grid,
.mysql-filter-grid,
.mysql-checkbox-grid,

View File

@ -46,6 +46,7 @@ const COMMON_MODULE_VIEW_TABS = [
const COMMON_TOOL_TABS = [
{ id: "mysql", label: "MySQL" },
{ id: "cdn", label: "CDN 刷新" },
{ id: "cron", label: "Cron 部署" },
];
const PRIMARY_FILTERS = [
{ id: "all", label: "全部" },
@ -395,6 +396,51 @@ function createCdnAdminState() {
};
}
function createChatappCronDeployState() {
return {
loaded: false,
loading: false,
deploying: false,
error: "",
message: "",
messageTone: "neutral",
form: {
gitRef: "main",
},
overview: {
ok: false,
enabled: false,
configured: false,
updatedAt: "",
settings: {
enabled: false,
serviceName: "chatapp-cron",
host: "",
instanceId: "",
sshHost: "",
repoRoot: "",
repoUrl: "",
defaultGitRef: "main",
remoteDir: "",
composeFile: "",
composeService: "week-star-job",
binaryPath: "",
dockerPlatform: "linux/amd64",
},
metadata: {},
},
operationId: "",
status: "",
stage: "",
startedAt: "",
finishedAt: "",
logs: [],
result: null,
pollTimer: null,
statusRequestInFlight: false,
};
}
createApp({
template: APP_TEMPLATE,
data() {
@ -552,6 +598,7 @@ createApp({
mongoAdmin: createMongoAdminState(),
mySqlAdmin: createMySqlAdminState(),
cdnAdmin: createCdnAdminState(),
chatappCron: createChatappCronDeployState(),
updateOps: {
loading: false,
loaded: false,
@ -634,7 +681,7 @@ createApp({
formattedUpdatedAt() {
if (CURRENT_APP_CONTEXT.mode === "common") {
return this.formatDateTime(
this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
this.chatappCron.overview.updatedAt || this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
);
}
return this.formatDateTime(this.payload.updatedAt);
@ -653,6 +700,9 @@ createApp({
if (this.commonTool === "cdn" && this.cdnAdmin.error) {
return "bad";
}
if (this.commonTool === "cron" && this.chatappCron.error) {
return "bad";
}
return "neutral";
}
@ -706,7 +756,13 @@ createApp({
return this.releaseKeyword;
}
if (this.activeView === "common") {
return this.commonTool === "mysql" ? this.mySqlAdmin.keyword : this.cdnAdmin.keyword;
if (this.commonTool === "mysql") {
return this.mySqlAdmin.keyword;
}
if (this.commonTool === "cdn") {
return this.cdnAdmin.keyword;
}
return "";
}
return "";
},
@ -737,7 +793,7 @@ createApp({
if (this.activeView === "common") {
if (this.commonTool === "mysql") {
this.mySqlAdmin.keyword = nextValue;
} else {
} else if (this.commonTool === "cdn") {
this.cdnAdmin.keyword = nextValue;
}
}
@ -760,12 +816,19 @@ createApp({
return "搜索服务 / 主机 / 镜像";
}
if (this.activeView === "common") {
return this.commonTool === "mysql" ? "搜索数据库 / 表" : "搜索 taskId / URL / 状态";
if (this.commonTool === "mysql") {
return "搜索数据库 / 表";
}
if (this.commonTool === "cdn") {
return "搜索 taskId / URL / 状态";
}
return "chatapp-cron 部署无搜索";
}
return "全局搜索";
},
globalSearchDisabled() {
return this.activeView === "config" && this.configTab === "go";
return (this.activeView === "config" && this.configTab === "go")
|| (this.activeView === "common" && this.commonTool === "cron");
},
dataFreshnessLabel() {
const updatedAt = Date.parse(this.payload.updatedAt || "");
@ -806,6 +869,7 @@ createApp({
|| (this.restartOps.items || []).length > 0
|| this.mySqlAdmin.loaded
|| this.cdnAdmin.loaded
|| this.chatappCron.loaded
);
},
showLoadingSkeleton() {
@ -1512,10 +1576,89 @@ createApp({
.includes(keyword)
));
},
chatappCronSettings() {
return this.chatappCron.overview.settings || createChatappCronDeployState().overview.settings;
},
chatappCronMetadata() {
return this.chatappCron.overview.metadata || {};
},
chatappCronIsRunning() {
return Boolean(
this.chatappCron.deploying
|| this.chatappCron.status === "queued"
|| this.chatappCron.status === "running",
);
},
chatappCronStatusTone() {
if (this.chatappCron.error || this.chatappCron.status === "failed" || this.chatappCronMetadata.error) {
return "bad";
}
if (this.chatappCronIsRunning) {
return "warn";
}
if (this.chatappCron.status === "success" || this.chatappCron.overview.enabled) {
return "ok";
}
if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) {
return "warn";
}
return "neutral";
},
chatappCronStatusText() {
if (this.chatappCron.error || this.chatappCron.status === "failed") {
return "部署异常";
}
if (this.chatappCron.status === "queued") {
return "排队中";
}
if (this.chatappCron.status === "running" || this.chatappCron.deploying) {
return "部署中";
}
if (this.chatappCron.status === "success") {
return "已完成";
}
if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) {
return "未配置";
}
return this.chatappCron.loaded ? "可部署" : "待加载";
},
chatappCronCurrentCommit() {
return this.chatappCronMetadata.commit || this.chatappCron.result?.build?.commit || "";
},
chatappCronLogText() {
return (this.chatappCron.logs || [])
.map((entry) => {
const time = this.formatDateTime(entry.time);
const stage = entry.stage ? ` [${entry.stage}]` : "";
const level = entry.level && entry.level !== "info" ? ` (${entry.level})` : "";
return `${time}${stage}${level} ${entry.message || ""}`.trim();
})
.join("\n");
},
recentChatappCronLogs() {
return (this.chatappCron.logs || [])
.slice(-5)
.reverse()
.map((entry, index) => ({
...entry,
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
}));
},
chatappCronLogDownloadUrl() {
if (!this.chatappCron.operationId) {
return "";
}
const query = new URLSearchParams({ id: this.chatappCron.operationId });
return `/api/chatapp-cron/deploy-log?${query.toString()}`;
},
commonToolContextLabel() {
return this.commonTool === "mysql"
? "MySQL / 数据 / 写入 / 结构 / SQL"
: "CDN / URL 刷新 / 目录刷新 / 最近记录";
if (this.commonTool === "mysql") {
return "MySQL / 数据 / 写入 / 结构 / SQL";
}
if (this.commonTool === "cdn") {
return "CDN / URL 刷新 / 目录刷新 / 最近记录";
}
return "chatapp-cron / 构建 / 上传 / 重启";
},
mySqlSqlShortcuts() {
const tableName = this.selectedMySqlTableItem?.name || "";
@ -2123,6 +2266,9 @@ createApp({
if (this.logViewer.kind === "service") {
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
}
if (this.logViewer.kind === "chatapp-cron") {
return "chatapp-cron 部署日志";
}
return "执行日志";
},
activeLogViewerSubtitle() {
@ -2134,36 +2280,54 @@ createApp({
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
return subtitleParts.join(" · ");
}
if (this.logViewer.kind === "chatapp-cron") {
return `任务 ${this.chatappCron.operationId || "-"} · ${this.chatappCronStatusText} · ${this.formatDateTime(this.chatappCron.finishedAt || this.chatappCron.startedAt)}`;
}
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
},
activeLogViewerBadge() {
if (this.logViewer.kind === "service") {
return this.serviceLog.container || "";
}
if (this.logViewer.kind === "chatapp-cron") {
return this.chatappCron.operationId || "";
}
return this.updateOps.operationId || "";
},
activeLogViewerSecondaryBadge() {
if (this.logViewer.kind === "service") {
return `最近 ${this.serviceLog.previewLines}`;
}
if (this.logViewer.kind === "chatapp-cron") {
return this.chatappCron.stage ? `阶段 ${this.chatappCron.stage}` : "";
}
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
},
activeLogViewerContent() {
if (this.logViewer.kind === "service") {
return this.serviceLog.content;
}
if (this.logViewer.kind === "chatapp-cron") {
return this.chatappCronLogText;
}
return this.updateLogText;
},
activeLogViewerError() {
if (this.logViewer.kind === "service") {
return this.serviceLog.error;
}
if (this.logViewer.kind === "chatapp-cron") {
return this.chatappCron.error ? this.explainError(this.chatappCron.error) : "";
}
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
},
activeLogViewerLoading() {
if (this.logViewer.kind === "service") {
return this.serviceLog.loading;
}
if (this.logViewer.kind === "chatapp-cron") {
return this.chatappCron.statusRequestInFlight;
}
return this.updateOps.statusRequestInFlight;
},
activeLogViewerExporting() {
@ -2176,12 +2340,18 @@ createApp({
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
}
if (this.logViewer.kind === "chatapp-cron") {
return !this.chatappCron.operationId || this.chatappCron.statusRequestInFlight;
}
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
},
activeLogViewerDownloadDisabled() {
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
}
if (this.logViewer.kind === "chatapp-cron") {
return !this.chatappCron.operationId;
}
return !this.updateOps.operationId;
},
},
@ -2233,8 +2403,15 @@ createApp({
return;
}
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
await this.refreshCdnOverview({ silent: true });
if (tool === "cdn") {
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
await this.refreshCdnOverview({ silent: true });
}
return;
}
if (tool === "cron" && !this.chatappCron.loaded && !this.chatappCron.loading) {
await this.refreshChatappCronOverview({ silent: true });
}
},
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
@ -2643,6 +2820,11 @@ createApp({
this.logViewer.kind = "update";
this.$nextTick(() => this.scrollUpdateLogToBottom());
},
openChatappCronLogViewer() {
this.logViewer.open = true;
this.logViewer.kind = "chatapp-cron";
this.$nextTick(() => this.scrollUpdateLogToBottom());
},
closeLogViewer() {
const previousKind = this.logViewer.kind;
this.logViewer.open = false;
@ -2659,6 +2841,14 @@ createApp({
await this.openServiceLog({ host: this.serviceLog.host });
return;
}
if (this.logViewer.kind === "chatapp-cron") {
if (!this.chatappCron.operationId) {
return;
}
await this.fetchChatappCronDeployStatus(this.chatappCron.operationId);
this.scrollUpdateLogToBottom();
return;
}
if (!this.updateOps.operationId) {
return;
}
@ -2670,6 +2860,17 @@ createApp({
await this.downloadServiceLog();
return;
}
if (this.logViewer.kind === "chatapp-cron") {
if (!this.chatappCron.operationId) {
return;
}
try {
await this.downloadFile(this.chatappCronLogDownloadUrl, `chatapp-cron-${this.chatappCron.operationId}.log`);
} catch (error) {
this.chatappCron.error = error instanceof Error ? error.message : String(error);
}
return;
}
if (!this.updateOps.operationId) {
return;
}
@ -3516,6 +3717,7 @@ createApp({
this.auth.redirecting = true;
this.stopUpdatePolling();
this.stopChatappCronPolling();
if (this.timer) {
window.clearInterval(this.timer);
@ -3700,13 +3902,15 @@ createApp({
this.error = "";
this.mySqlAdmin.error = "";
this.cdnAdmin.error = "";
this.chatappCron.error = "";
try {
await Promise.allSettled([
this.refreshMySqlDatabases({ preserveSelection: true, silent: true }),
this.refreshCdnOverview({ silent: true }),
this.refreshChatappCronOverview({ silent: true }),
]);
this.error = this.mySqlAdmin.error || this.cdnAdmin.error || "";
this.error = this.mySqlAdmin.error || this.cdnAdmin.error || this.chatappCron.error || "";
} finally {
this.loading = false;
}
@ -4000,6 +4204,69 @@ createApp({
this.cdnAdmin.message = message || "";
this.cdnAdmin.messageTone = tone || "neutral";
},
setChatappCronMessage(message, tone = "neutral") {
this.chatappCron.message = message || "";
this.chatappCron.messageTone = tone || "neutral";
},
applyChatappCronOverview(payload) {
const defaults = createChatappCronDeployState().overview;
const nextSettings = {
...defaults.settings,
...(payload?.settings || {}),
};
this.chatappCron.overview = {
...defaults,
...(payload || {}),
settings: nextSettings,
metadata: payload?.metadata && typeof payload.metadata === "object" ? payload.metadata : {},
};
this.chatappCron.loaded = true;
this.chatappCron.error = payload?.error || "";
if (!this.chatappCron.form.gitRef || this.chatappCron.form.gitRef === defaults.settings.defaultGitRef) {
this.chatappCron.form.gitRef = nextSettings.defaultGitRef || defaults.settings.defaultGitRef;
}
},
applyChatappCronOperation(operation) {
if (!operation) {
return;
}
this.chatappCron.operationId = operation.operationId || "";
this.chatappCron.status = operation.status || "";
this.chatappCron.stage = operation.stage || "";
this.chatappCron.startedAt = operation.startedAt || "";
this.chatappCron.finishedAt = operation.finishedAt || "";
this.chatappCron.logs = Array.isArray(operation.logs) ? operation.logs : [];
this.chatappCron.result = operation.result || null;
this.chatappCron.deploying = operation.status === "queued" || operation.status === "running";
if (operation.status === "success") {
this.chatappCron.error = "";
this.setChatappCronMessage(operation.message || "chatapp-cron 部署完成", "ok");
} else if (operation.status === "failed") {
this.chatappCron.error = operation.error || operation.result?.error || "chatapp-cron 部署失败";
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
} else {
this.setChatappCronMessage(operation.message || "chatapp-cron 部署执行中", "neutral");
}
this.scrollUpdateLogToBottom();
},
stopChatappCronPolling() {
if (this.chatappCron.pollTimer) {
window.clearInterval(this.chatappCron.pollTimer);
this.chatappCron.pollTimer = null;
}
},
startChatappCronPolling() {
this.stopChatappCronPolling();
if (!this.chatappCron.operationId) {
return;
}
this.chatappCron.pollTimer = window.setInterval(() => {
this.fetchChatappCronDeployStatus(this.chatappCron.operationId, { silent: true });
}, 2000);
},
parseMultilineItems(text) {
return String(text || "")
.split(/\r?\n/)
@ -4148,6 +4415,125 @@ createApp({
this.cdnAdmin.submitting = false;
}
},
async refreshChatappCronOverview({ silent = false } = {}) {
this.chatappCron.loading = true;
if (!silent) {
this.chatappCron.error = "";
}
try {
const { response, payload } = await this.requestJson("/api/chatapp-cron/overview");
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.applyChatappCronOverview(payload);
if (!silent) {
if (!payload.enabled) {
this.setChatappCronMessage("chatapp-cron 部署未配置", "warn");
} else if (payload.metadata?.error) {
this.setChatappCronMessage("已加载配置,最近版本读取失败", "warn");
} else {
this.setChatappCronMessage("chatapp-cron 配置已刷新", "neutral");
}
}
} catch (error) {
this.chatappCron.error = error instanceof Error ? error.message : String(error);
if (!silent) {
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
}
} finally {
this.chatappCron.loading = false;
}
},
async fetchChatappCronDeployStatus(operationId = this.chatappCron.operationId, { silent = false } = {}) {
if (!operationId || this.chatappCron.statusRequestInFlight) {
return;
}
const wasRunning = this.chatappCron.deploying;
this.chatappCron.statusRequestInFlight = true;
try {
const query = new URLSearchParams({ id: operationId });
const { response, payload } = await this.requestJson(`/api/chatapp-cron/deploy-status?${query.toString()}`);
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
if (this.chatappCron.operationId && this.chatappCron.operationId !== operationId) {
return;
}
this.applyChatappCronOperation(payload.operation || {});
if (!this.chatappCron.deploying) {
this.stopChatappCronPolling();
if (wasRunning || this.chatappCron.status === "success") {
await this.refreshChatappCronOverview({ silent: true });
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const notFound = errorMessage.includes("chatapp-cron deploy operation not found") || errorMessage.includes("HTTP 404");
if (silent && !notFound) {
return;
}
this.stopChatappCronPolling();
this.chatappCron.deploying = false;
this.chatappCron.status = "failed";
this.chatappCron.error = notFound
? "chatapp-cron 部署任务状态不存在,可能因为服务重启或旧任务已清理"
: errorMessage;
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
} finally {
this.chatappCron.statusRequestInFlight = false;
}
},
async runChatappCronDeploy() {
if (this.chatappCronIsRunning) {
return;
}
if (!this.chatappCron.overview.enabled) {
this.setChatappCronMessage("chatapp-cron 部署未配置", "warn");
return;
}
const gitRef = String(this.chatappCron.form.gitRef || "").trim()
|| this.chatappCronSettings.defaultGitRef
|| "main";
this.stopChatappCronPolling();
this.chatappCron.deploying = true;
this.chatappCron.error = "";
this.chatappCron.status = "queued";
this.chatappCron.stage = "queued";
this.chatappCron.logs = [];
this.chatappCron.result = null;
this.setChatappCronMessage("chatapp-cron 部署已提交", "neutral");
try {
const { response, payload } = await this.requestJson("/api/chatapp-cron/deploy", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ gitRef }),
});
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
this.applyChatappCronOperation(payload.operation || {});
this.startChatappCronPolling();
} catch (error) {
this.chatappCron.deploying = false;
this.chatappCron.status = "failed";
this.chatappCron.error = error instanceof Error ? error.message : String(error);
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
}
},
clearMySqlSchemaState() {
this.mySqlAdmin.schema = {
updatedAt: "",
@ -6126,5 +6512,6 @@ createApp({
window.clearTimeout(this.copiedTokenTimer);
}
this.stopUpdatePolling();
this.stopChatappCronPolling();
},
}).mount("#app");

View File

@ -241,7 +241,7 @@
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
</button>
</template>
<template v-else>
<template v-else-if="commonTool === 'cdn'">
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
</button>
@ -249,6 +249,17 @@
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
</button>
</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>
</section>
</template>
@ -2868,6 +2879,125 @@
</section>
</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'">
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
<div class="compact-workbench-head config-head-strip">

View 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()

View File

@ -492,6 +492,115 @@ class GatewayReleaseFlowTests(unittest.TestCase):
self.assertTrue(result["ok"])
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__":
unittest.main()