diff --git a/deploy/k3s/metapi-deploy-helper.yaml b/deploy/k3s/metapi-deploy-helper.yaml new file mode 100644 index 0000000..531f6a4 --- /dev/null +++ b/deploy/k3s/metapi-deploy-helper.yaml @@ -0,0 +1,88 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metapi-deploy-helper + namespace: ai +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: metapi-deploy-helper-admin + namespace: ai +subjects: + - kind: ServiceAccount + name: metapi-deploy-helper + namespace: ai +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: admin +--- +apiVersion: v1 +kind: Secret +metadata: + name: metapi-deploy-helper-auth + namespace: ai +type: Opaque +stringData: + DEPLOY_HELPER_TOKEN: replace-with-a-shared-long-random-token +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metapi-deploy-helper + namespace: ai +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: metapi-deploy-helper + template: + metadata: + labels: + app.kubernetes.io/name: metapi-deploy-helper + spec: + serviceAccountName: metapi-deploy-helper + containers: + - name: helper + image: 1467078763/metapi:latest + imagePullPolicy: Always + command: ["npm", "run", "start:deploy-helper"] + ports: + - containerPort: 9850 + name: http + env: + - name: DEPLOY_HELPER_HOST + value: 0.0.0.0 + - name: DEPLOY_HELPER_PORT + value: "9850" + - name: DEPLOY_HELPER_TOKEN + valueFrom: + secretKeyRef: + name: metapi-deploy-helper-auth + key: DEPLOY_HELPER_TOKEN + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 +--- +apiVersion: v1 +kind: Service +metadata: + name: metapi-deploy-helper + namespace: ai +spec: + selector: + app.kubernetes.io/name: metapi-deploy-helper + ports: + - name: http + port: 9850 + targetPort: http diff --git a/docker/Dockerfile b/docker/Dockerfile index 9350db6..5fe3ac1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,6 +21,21 @@ FROM node:22-bookworm-slim WORKDIR /app +ARG TARGETARCH +ARG KUBECTL_VERSION=v1.31.8 +ARG HELM_VERSION=v3.18.6 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tar gzip \ + && case "$TARGETARCH" in amd64|arm64) export ARCH="$TARGETARCH" ;; *) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; esac \ + && curl -fsSL -o /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \ + && chmod +x /usr/local/bin/kubectl \ + && curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" -o /tmp/helm.tgz \ + && tar -xzf /tmp/helm.tgz -C /tmp \ + && mv "/tmp/linux-${ARCH}/helm" /usr/local/bin/helm \ + && chmod +x /usr/local/bin/helm \ + && rm -rf /tmp/helm.tgz "/tmp/linux-${ARCH}" /var/lib/apt/lists/* + COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e79909c..9c1f3c7 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -76,6 +76,7 @@ export default withMermaid( items: [ { text: '上游接入', link: '/upstream-integration' }, { text: '配置说明', link: '/configuration' }, + { text: 'K3s 更新中心(高级)', link: '/k3s-update-center' }, { text: '客户端接入', link: '/client-integration' }, { text: '运维手册', link: '/operations' }, { text: '常见问题 FAQ', link: '/faq' }, diff --git a/docs/README.md b/docs/README.md index 8af7261..dab96d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ npm run docs:build | 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 | | 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 | | 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 | +| K3s / Helm 高级升级面板 | [K3s 更新中心(高级)](./k3s-update-center.md) | 需要说明谁适合使用更新中心、helper 怎么配、K3s/Helm 发布链路怎么接入时 | | 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 | | 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 | | 运维排障与日常维护 | [运维手册](./operations.md) / [常见问题](./faq.md) | 新排障案例、备份恢复、健康检查、典型报错变化时 | diff --git a/docs/configuration.md b/docs/configuration.md index 2019fa8..7c94531 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,49 @@ | `SYSTEM_PROXY_URL` | 系统代理 URL(留空表示不使用) | 空 | | `ACCOUNT_CREDENTIAL_SECRET` | 账号凭证加密密钥(用于加密存储的上游账号密码) | 默认使用 `AUTH_TOKEN` | +## 更新中心与 Deploy Helper + +如果你现在只是一个普通 Docker / Docker Compose 部署,没有 K3s / Kubernetes、没有 Helm release、也没有 Deploy Helper,那么这一节可以先跳过。 + +这一节只写给已经准备使用“K3s 更新中心”的用户。除了设置页里的运行时字段,还需要补齐主服务和 helper 的环境变量。 + +如果你是老用户,正在从 Docker Compose 迁到 K3s / Helm,这一节可以提前看,先把迁移后的环境变量和组件关系理解清楚。 + +### 主 Metapi 服务 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `DEPLOY_HELPER_TOKEN` | 主服务访问 Deploy Helper 的 Bearer Token | 空 | +| `UPDATE_CENTER_HELPER_TOKEN` | `DEPLOY_HELPER_TOKEN` 的兼容别名,二选一即可 | 空 | + +> [!IMPORTANT] +> 主 Metapi 和 Deploy Helper 必须使用同一个 token,否则更新中心可以显示页面但无法完成 helper 状态查询或部署。 + +### Deploy Helper 服务 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `DEPLOY_HELPER_HOST` | helper 监听地址 | `0.0.0.0` | +| `DEPLOY_HELPER_PORT` | helper 监听端口 | `9850` | +| `DEPLOY_HELPER_TOKEN` | helper Bearer Token,必须和主服务一致 | 空 | + +### 设置页里的持久化字段 + +更新中心页面本身还会把下面这些字段持久化到运行数据库: + +- `helperBaseUrl` +- `namespace` +- `releaseName` +- `chartRef` +- `imageRepository` +- `githubReleasesEnabled` +- `dockerHubTagsEnabled` +- `defaultDeploySource` + +完整接入步骤见: + +- [K3s 更新中心](./k3s-update-center.md) + ## 数据库配置 | 变量名 | 说明 | 默认值 | diff --git a/docs/deployment.md b/docs/deployment.md index 9e11788..4e89c1c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -17,6 +17,34 @@ > - 当前不再提供 `Release` 压缩包 + Node.js 运行时的独立部署路径。 > - 生产/长期运行请用 Docker 系列方案;桌面版面向单机本地使用;源码运行请走本地开发流程。 +## K3s / Helm 更新中心 + +如果你现在只是一个最普通的 Docker / Docker Compose 部署,请先跳过这节。 + +这套能力只适用于: + +- 你已经在 K3s / Kubernetes 中部署了 Metapi +- 而且当前 Metapi 是通过 Helm release 管理的 + +它不适用于: + +- 只有一个裸 Docker Compose 容器 +- 想直接从管理后台更新外部 Docker 主机上的容器 + +但如果你是老用户,**正在计划从 Docker Compose 迁到 K3s / Helm,以获得滚动更新能力**,那么这一节和对应专题页是值得提前看的。它写的不是“怎么原地升级 Compose”,而是“迁移完成后你会如何使用更新中心”。 + +如果你已经通过 Helm 在 K3s / Kubernetes 中部署 Metapi,并希望在管理后台中: + +- 查看当前运行版本 +- 检查 GitHub Releases / Docker Hub 的稳定版 +- 通过集群内 helper 手动触发一次升级 + +请直接阅读: + +- [K3s 更新中心](./k3s-update-center.md) + +这页会单独说明 helper 部署、主服务 token 对齐、设置页字段含义、实际升级顺序和已知限制。 + ## Zeabur 一键部署 diff --git a/docs/k3s-update-center.md b/docs/k3s-update-center.md new file mode 100644 index 0000000..8257ad1 --- /dev/null +++ b/docs/k3s-update-center.md @@ -0,0 +1,284 @@ +# ☸️ K3s 更新中心(高级) + +[返回部署指南](./deployment.md) · [返回配置说明](./configuration.md) · [返回文档中心](./README.md) + +--- + +这页写给已经把 Metapi 部署到 **K3s / Kubernetes + Helm** 的用户。 + +如果你现在的环境只是一个最普通的 **Docker Compose 安装**,只有一个 Metapi 容器,其他什么都没有,那么先记住一句话: + +> [!IMPORTANT] +> **如果你只是想原地给现有 Compose 容器加一个“后台点按钮更新”的能力,那你现在大概率用不上这页。** +> +> 当前“更新中心”不会直接帮你更新一台普通 Docker 主机上的 Compose 容器。它依赖集群里的 Deploy Helper 去执行 `helm upgrade` 和 `kubectl rollout status`,所以只适用于已经有 K3s / Kubernetes / Helm 的环境。 +> +> 但如果你是老用户,正打算 **从 Docker Compose 迁到 K3s / Helm,以获得滚动更新能力**,那这页仍然值得看,因为它描述的就是迁移完成后的目标形态。 + +## 先判断:你需不需要看这页 + +| 你的现状 | 要不要看这页 | 你现在该怎么做 | +|------|------------|--------------| +| 只有一个 `docker compose up -d` 跑起来的 Metapi 容器 | 不需要 | 继续用普通 Docker 升级方式 | +| 目前是 Docker Compose,准备迁到 K3s / Helm 来获得滚动更新 | 需要 | 把这页当成迁移后的目标架构说明,先完成迁移,再启用更新中心 | +| 有 K3s / Kubernetes 集群,但 Metapi 不是用 Helm release 部署的 | 暂时不建议 | 这套更新中心无法直接接管现有部署 | +| Metapi 已经是 Helm release,想在后台里看版本并手动点一次升级 | 需要 | 继续看下文 | + +## 如果你现在只有一个 Docker Compose 容器 + +这就是大多数用户的真实情况。 + +对这类用户来说,当前版本最实用的升级路径仍然是普通 Docker 流程: + +```bash +docker compose pull +docker compose up -d +``` + +如果你还想更稳一点,可以先看镜像版本或仓库 release,再决定要不要拉新镜像。 + +你现在**不需要额外准备**这些东西: + +- K3s / Kubernetes 集群 +- Helm +- Deploy Helper +- 额外的 Bearer Token +- 集群内 Service 地址 + +也就是说,除非你本来就在用 K3s / Helm,否则这页可以直接跳过。 + +## 如果你正准备从 Docker Compose 迁到 K3s + +这其实是一个很合理的升级方向。 + +很多老用户想迁到 K3s / Helm,核心诉求通常不是“为了多一个页面”,而是为了获得这些能力: + +- 发布时尽量减少停机时间 +- 用 Helm 管理版本和回滚 +- 后续在后台里看版本来源,并人工触发一次升级 + +对这类用户来说,这页不是“现在立刻就能用”的操作手册,而是: + +- “迁移完成后,你会得到什么能力” +- “为了用上这个能力,集群侧要提前满足什么前提” + +### 推荐的迁移顺序 + +如果你现在还是 Docker Compose 用户,建议按下面的顺序规划: + +1. 先备份当前实例数据。 + - 如果当前运行库还是 SQLite,先备份 `data/` 目录。 + - 具体备份方式见 [运维手册](./operations.md)。 +2. 评估迁移后的运行数据库。 + - 如果你迁到 K3s 只是想“单副本 + 更规范部署”,SQLite 仍然可以先用。 + - 如果你更看重后续可维护性、滚动发布体验和外部持久化,通常更建议切到 MySQL / PostgreSQL。 +3. 在新环境里先把 Metapi 作为 Helm release 跑起来。 +4. 确认新实例可正常登录、数据正常、代理请求正常。 +5. 再部署 Deploy Helper,并启用更新中心。 + +### 一个很重要的认知 + +“迁到 K3s” 和 “启用更新中心” 是两件相关但不同的事: + +- 迁到 K3s / Helm + - 是部署形态升级 +- 启用更新中心 + - 是迁移完成后,额外获得的一个后台升级入口 + +所以更准确的理解应该是: + +- **先把 Docker Compose 用户迁成 Helm 用户,再谈更新中心。** + +## 那这个功能到底是干什么的 + +对已经在 K3s / Kubernetes 里跑 Metapi 的用户,这个“更新中心”主要解决的是: + +- 在后台里看当前运行版本 +- 看官方 GitHub Releases / Docker Hub 有没有新的稳定版 +- 确认 Deploy Helper 是否健康 +- 点一次按钮,触发一轮 Helm 升级 +- 在页面里回看部署日志 + +它的定位更接近: + +- “集群内人工触发升级面板” + +而不是: + +- “单容器自动升级器” +- “Docker Compose 一键更新” +- “无人值守自动发布系统” + +## 使用它之前,你至少要已经有这些东西 + +- 一个可用的 K3s / Kubernetes 集群 +- 用 Helm 部署的 Metapi release +- 你的 chart 支持下面两个 values: + - `image.repository` + - `image.tag` +- 目标 Deployment 带有 `app.kubernetes.io/instance=` 标签 +- 主 Metapi 服务可以访问: + - GitHub API + - Docker Hub API + - Deploy Helper 的集群内地址 + +如果上面有任何一项不成立,就先不要配更新中心。 + +## helper 是什么 + +Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Metapi 功能,只负责接受主 Metapi 发来的部署请求,然后在集群里执行: + +- `helm history` +- `helm upgrade` +- `kubectl rollout status` +- 必要时 `helm rollback` + +所以你可以把它理解成: + +- “一个专门替管理后台执行 Helm/Kubectl 的集群内代理” + +## 什么时候你才应该去配 helper + +只有当你已经满足下面这个目标时,才值得去配: + +- “我已经在 K3s / Helm 里跑 Metapi 了,现在想在后台里看版本,并且人工点一下完成升级” + +如果你现在只是: + +- “我有一个 Compose 容器,想在后台里点按钮更新它” + +那答案是: + +- 当前版本还不支持这个场景。 + +如果你现在是: + +- “我正在把 Compose 老实例迁到 K3s,希望迁完以后用后台来做后续版本升级” + +那答案是: + +- 这是一个合理场景,这页正是给你看迁移后该怎么接入的。 + +## K3s / Helm 用户的接入步骤 + +如果你确实已经是 K3s / Helm 用户,接入顺序建议如下。 + +### 1. 先部署 Deploy Helper + +仓库里带了一个最小示例清单: + +- `deploy/k3s/metapi-deploy-helper.yaml` + +使用前至少要改这些值: + +- `namespace` +- `DEPLOY_HELPER_TOKEN` +- `image` + +最小部署命令示例: + +```bash +kubectl create namespace ai +kubectl apply -f deploy/k3s/metapi-deploy-helper.yaml +``` + +### 2. 让主 Metapi 和 helper 用同一个 token + +主 Metapi 端支持这两个环境变量,设置一个即可: + +- `DEPLOY_HELPER_TOKEN` +- `UPDATE_CENTER_HELPER_TOKEN` + +helper 端使用: + +- `DEPLOY_HELPER_TOKEN` + +它们的值必须完全一致。 + +### 3. 在后台“设置 → 更新中心”里填配置 + +需要填写的核心字段有: + +| 字段 | 怎么理解 | 典型示例 | +|------|--------|----------| +| `Deploy Helper URL` | 主 Metapi 访问 helper 的地址 | `http://metapi-deploy-helper.ai.svc.cluster.local:9850` | +| `Namespace` | 目标 release 所在命名空间 | `ai` | +| `Release Name` | Helm release 名 | `metapi` | +| `Chart Ref` | 你的 Helm chart 引用 | `oci://ghcr.io/cita-777/charts/metapi` | +| `Image Repository` | 升级时写入 chart 的镜像仓库 | `1467078763/metapi` | +| `默认部署来源` | 默认从 GitHub 还是 Docker Hub 取版本 | `GitHub Releases` | + +另外还有 3 个开关: + +- `启用更新中心` +- `GitHub Releases` +- `Docker Hub` + +### 4. 实际操作顺序 + +推荐这样用: + +1. 先保存配置 +2. 再点“检查更新” +3. 确认页面状态都正常: + - 当前运行版本正常显示 + - 版本来源发现了可部署版本 + - Deploy Helper 显示健康 +4. 再点部署按钮 +5. 在页面下方看部署日志 + +如果实时日志流断开,页面会自动回退到最近任务快照。 + +## 这套能力当前不适合什么场景 + +- 只有一台 Docker 主机、一个 Compose 文件、一个 Metapi 容器 +- 想让后台直接远程更新 Docker Compose 服务 +- 没有 Helm release,只是手工 apply 的 Deployment +- 需要全自动无人值守升级 +- 需要灰度、分批、审批、回滚编排等更完整的发布系统 + +## About 页和更新中心的关系 + +About 页里的“更新提醒”更像一个轻量入口: + +- 让你知道大概有没有新版本 + +真正的配置和部署入口仍然在: + +- “设置 → 更新中心” + +所以即使你是普通 Docker Compose 用户,也可以把 About 页里的版本提醒当成“看看最近有没有新版本”的地方,但不要把它理解成“马上就能自动更新我这台机器”。 + +## 常见问题 + +### 为什么我在设置页里能看到更新中心,但还是没法用 + +因为这个页面会跟着功能一起出现,但真正能否部署,取决于你有没有: + +- K3s / Kubernetes +- Helm release +- Deploy Helper +- 对齐的 token + +缺其中任何一项,都只能看,不能真正部署。 + +### 为什么我只有 Docker Compose,按钮没有意义 + +因为当前部署链路不是去操作外部 Docker 主机,而是去调用集群里的 helper,然后由 helper 执行 `helm` / `kubectl`。 + +### 那普通 Docker Compose 用户现在该怎么升级 + +继续使用正常 Docker 流程即可: + +```bash +docker compose pull +docker compose up -d +``` + +如果你担心直接升级,可以先看仓库 Releases 或镜像 tag,再决定是否升级。 + +## 相关入口 + +- [部署指南](./deployment.md) +- [配置说明](./configuration.md) +- [运维手册](./operations.md) diff --git a/package.json b/package.json index f9d944a..6ebfa0f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "start": "node dist/server/index.js", + "start:deploy-helper": "node dist/server/update-helper/index.js", "start:desktop": "electron dist/desktop/main.js", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/server/db/migrate.ts", diff --git a/src/server/index.ts b/src/server/index.ts index d32b4cb..6a2d259 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,6 +20,7 @@ import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; import { oauthRoutes } from './routes/api/oauth.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; +import { updateCenterRoutes } from './routes/api/updateCenter.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js'; @@ -439,9 +440,10 @@ await app.register(authRoutes); await app.register(settingsRoutes); await app.register(accountTokensRoutes); await app.register(searchRoutes); - await app.register(eventsRoutes); - await app.register(siteAnnouncementsRoutes); - await app.register(taskRoutes); +await app.register(eventsRoutes); +await app.register(siteAnnouncementsRoutes); +await app.register(updateCenterRoutes); +await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts new file mode 100644 index 0000000..b9ff351 --- /dev/null +++ b/src/server/routes/api/updateCenter.test.ts @@ -0,0 +1,356 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; + +const { + fetchLatestStableGitHubReleaseMock, + fetchLatestDockerHubTagMock, + getUpdateCenterHelperStatusMock, + streamUpdateCenterDeployMock, +} = vi.hoisted(() => ({ + fetchLatestStableGitHubReleaseMock: vi.fn(), + fetchLatestDockerHubTagMock: vi.fn(), + getUpdateCenterHelperStatusMock: vi.fn(), + streamUpdateCenterDeployMock: vi.fn(), +})); + +vi.mock('../../services/updateCenterVersionService.js', async () => { + const actual = await vi.importActual('../../services/updateCenterVersionService.js'); + return { + ...actual, + fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args), + fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args), + }; +}); + +vi.mock('../../services/updateCenterHelperClient.js', () => ({ + getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args), + streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('update center routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let resetBackgroundTasks: (() => void) | null = null; + let getBackgroundTask: ((taskId: string) => { status: string; logs?: Array<{ message: string }> } | null) | null = null; + + async function saveValidConfig() { + const response = await app.inject({ + method: 'PUT', + url: '/api/update-center/config', + payload: { + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + }); + + expect(response.statusCode).toBe(200); + } + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-')); + process.env.DATA_DIR = dataDir; + process.env.DEPLOY_HELPER_TOKEN = 'helper-token'; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./updateCenter.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + + db = dbModule.db; + schema = dbModule.schema; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + + app = Fastify(); + await app.register(routesModule.updateCenterRoutes); + }); + + beforeEach(async () => { + fetchLatestStableGitHubReleaseMock.mockReset(); + fetchLatestDockerHubTagMock.mockReset(); + getUpdateCenterHelperStatusMock.mockReset(); + streamUpdateCenterDeployMock.mockReset(); + resetBackgroundTasks?.(); + + await db.delete(schema.events).run(); + await db.delete(schema.settings).run(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + delete process.env.DATA_DIR; + delete process.env.DEPLOY_HELPER_TOKEN; + }); + + it('persists config and returns status with both version channels and helper summary', async () => { + fetchLatestStableGitHubReleaseMock.mockResolvedValue({ + source: 'github-release', + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + }); + fetchLatestDockerHubTagMock.mockResolvedValue({ + source: 'docker-hub-tag', + rawVersion: '1.3.1', + normalizedVersion: '1.3.1', + url: null, + }); + getUpdateCenterHelperStatusMock.mockResolvedValue({ + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: '1.2.3', + healthy: true, + }); + + const saveResponse = await app.inject({ + method: 'PUT', + url: '/api/update-center/config', + payload: { + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + }); + + expect(saveResponse.statusCode).toBe(200); + const savedRow = await db.select().from(schema.settings).where(eq(schema.settings.key, 'update_center_k3s_config_v1')).get(); + expect(savedRow?.value).toContain('metapi-deploy-helper.ai.svc.cluster.local'); + + const statusResponse = await app.inject({ + method: 'GET', + url: '/api/update-center/status', + }); + + expect(statusResponse.statusCode).toBe(200); + expect(statusResponse.json()).toMatchObject({ + currentVersion: '1.2.3', + config: { + enabled: true, + namespace: 'ai', + releaseName: 'metapi', + defaultDeploySource: 'github-release', + }, + githubRelease: { + normalizedVersion: '1.3.0', + }, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + helper: { + ok: true, + healthy: true, + releaseName: 'metapi', + }, + }); + }); + + it('returns partial status when a single version source lookup fails', async () => { + fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out')); + fetchLatestDockerHubTagMock.mockResolvedValue({ + source: 'docker-hub-tag', + rawVersion: '1.3.1', + normalizedVersion: '1.3.1', + url: null, + }); + getUpdateCenterHelperStatusMock.mockResolvedValue({ + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: '1.2.3', + healthy: true, + }); + + await saveValidConfig(); + + const statusResponse = await app.inject({ + method: 'GET', + url: '/api/update-center/status', + }); + + expect(statusResponse.statusCode).toBe(200); + expect(statusResponse.json()).toMatchObject({ + githubRelease: null, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + helper: { + ok: true, + healthy: true, + }, + }); + }); + + it('dedupes deploy requests while a task is already running', async () => { + await saveValidConfig(); + + let releaseDeploy: (() => void) | null = null; + const deployGate = new Promise((resolve) => { + releaseDeploy = resolve; + }); + + streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => { + onLog?.('Running helm upgrade'); + await deployGate; + onLog?.('Deployment complete'); + return { + success: true, + targetSource: 'github-release', + targetTag: '1.3.0', + previousRevision: '12', + finalRevision: '13', + rolledBack: false, + logLines: ['Running helm upgrade', 'Deployment complete'], + }; + }); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + source: 'github-release', + targetVersion: '1.3.0', + }, + }); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + source: 'github-release', + targetVersion: '1.3.0', + }, + }); + + expect(firstResponse.statusCode).toBe(202); + expect(secondResponse.statusCode).toBe(202); + + const firstBody = firstResponse.json() as { task?: { id: string }; reused?: boolean }; + const secondBody = secondResponse.json() as { task?: { id: string }; reused?: boolean }; + expect(firstBody.task?.id).toBeTruthy(); + expect(secondBody.task?.id).toBe(firstBody.task?.id); + expect(secondBody.reused).toBe(true); + + releaseDeploy?.(); + }); + + it('rejects deploy requests when the update center is disabled', async () => { + const saveResponse = await app.inject({ + method: 'PUT', + url: '/api/update-center/config', + payload: { + enabled: false, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + }); + expect(saveResponse.statusCode).toBe(200); + + const deployResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + source: 'github-release', + targetVersion: '1.3.0', + }, + }); + + expect(deployResponse.statusCode).toBe(400); + expect(deployResponse.json()).toMatchObject({ + success: false, + message: 'update center is disabled', + }); + }); + + it('streams deployment logs for known tasks and rejects unknown task ids', async () => { + await saveValidConfig(); + + const missingResponse = await app.inject({ + method: 'GET', + url: '/api/update-center/tasks/missing-task/stream', + }); + + expect(missingResponse.statusCode).toBe(404); + + streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => { + onLog?.('Resolving target version'); + onLog?.('Waiting for rollout'); + return { + success: true, + targetSource: 'docker-hub-tag', + targetTag: '1.3.1', + previousRevision: '13', + finalRevision: '14', + rolledBack: false, + logLines: ['Resolving target version', 'Waiting for rollout'], + }; + }); + + const deployResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + source: 'docker-hub-tag', + targetVersion: '1.3.1', + }, + }); + + const deployBody = deployResponse.json() as { task: { id: string } }; + + for (let attempt = 0; attempt < 20; attempt += 1) { + const task = getBackgroundTask?.(deployBody.task.id); + if (task?.status === 'succeeded') break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(getBackgroundTask?.(deployBody.task.id)?.logs).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: 'Resolving target version' }), + expect.objectContaining({ message: 'Waiting for rollout' }), + ])); + + const streamResponse = await app.inject({ + method: 'GET', + url: `/api/update-center/tasks/${deployBody.task.id}/stream`, + }); + + expect(streamResponse.statusCode).toBe(200); + expect(streamResponse.headers['content-type']).toContain('text/event-stream'); + expect(streamResponse.body).toContain('event: log'); + expect(streamResponse.body).toContain('Resolving target version'); + expect(streamResponse.body).toContain('Waiting for rollout'); + expect(streamResponse.body).toContain('event: done'); + }); +}); diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts new file mode 100644 index 0000000..55230a2 --- /dev/null +++ b/src/server/routes/api/updateCenter.ts @@ -0,0 +1,267 @@ +import { FastifyInstance } from 'fastify'; + +import { + appendBackgroundTaskLog, + getBackgroundTask, + listBackgroundTasks, + startBackgroundTask, + subscribeToBackgroundTaskLogs, +} from '../../services/backgroundTaskService.js'; +import { + fetchLatestDockerHubTag, + fetchLatestStableGitHubRelease, + getCurrentRuntimeVersion, + type UpdateCenterVersionCandidate, + type UpdateCenterVersionSource, +} from '../../services/updateCenterVersionService.js'; +import { + getDefaultUpdateCenterConfig, + loadUpdateCenterConfig, + normalizeUpdateCenterConfig, + saveUpdateCenterConfig, + type UpdateCenterConfig, +} from '../../services/updateCenterConfigService.js'; +import { + getUpdateCenterHelperStatus, + streamUpdateCenterDeploy, +} from '../../services/updateCenterHelperClient.js'; + +type DeployBody = { + source?: UpdateCenterVersionSource; + targetVersion?: string; +}; + +const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy'; +const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy'; + +function getUpdateCenterHelperToken(): string { + return String(process.env.DEPLOY_HELPER_TOKEN || process.env.UPDATE_CENTER_HELPER_TOKEN || '').trim(); +} + +function assertDeployableConfig(config: UpdateCenterConfig) { + if (!config.enabled) throw new Error('update center is disabled'); + if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required'); + if (!config.namespace) throw new Error('namespace is required'); + if (!config.releaseName) throw new Error('releaseName is required'); + if (!config.chartRef) throw new Error('chartRef is required'); + if (!config.imageRepository) throw new Error('imageRepository is required'); + if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required'); +} + +function getDeployTasks() { + return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE); +} + +function summarizeHelperError(error: unknown) { + if (error instanceof Error && error.message) return error.message; + return String(error || 'unknown helper error'); +} + +async function settleOptional(enabled: boolean, loader: () => Promise): Promise<{ + value: T | null; + error: string | null; +}> { + if (!enabled) { + return { + value: null, + error: null, + }; + } + + try { + return { + value: await loader(), + error: null, + }; + } catch (error) { + return { + value: null, + error: summarizeHelperError(error), + }; + } +} + +async function buildUpdateCenterStatus() { + const config = await loadUpdateCenterConfig(); + const helperToken = getUpdateCenterHelperToken(); + + const [githubLookup, dockerLookup, helperLookup] = await Promise.all([ + settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()), + settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()), + settleOptional(!!config.helperBaseUrl, async () => { + if (!helperToken) { + throw new Error('DEPLOY_HELPER_TOKEN is required'); + } + return await getUpdateCenterHelperStatus(config, helperToken); + }), + ]); + + const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null; + const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null; + const helper: Record = helperLookup.value || { + ok: false, + healthy: false, + error: helperLookup.error, + }; + + const tasks = getDeployTasks(); + const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null; + const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null; + + return { + currentVersion: getCurrentRuntimeVersion(), + config, + githubRelease, + dockerHubTag, + helper, + runningTask, + lastFinishedTask, + }; +} + +function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +export async function updateCenterRoutes(app: FastifyInstance) { + app.get('/api/update-center/status', async () => { + return await buildUpdateCenterStatus(); + }); + + app.post('/api/update-center/check', async () => { + return await buildUpdateCenterStatus(); + }); + + app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => { + const next = normalizeUpdateCenterConfig(request.body || getDefaultUpdateCenterConfig()); + const saved = await saveUpdateCenterConfig(next); + return { + success: true, + config: saved, + }; + }); + + app.post<{ Body: DeployBody }>('/api/update-center/deploy', async (request, reply) => { + const config = await loadUpdateCenterConfig(); + try { + assertDeployableConfig(config); + } catch (error) { + return reply.code(400).send({ + success: false, + message: summarizeHelperError(error), + }); + } + + const source = request.body?.source === 'docker-hub-tag' + ? 'docker-hub-tag' + : request.body?.source === 'github-release' + ? 'github-release' + : config.defaultDeploySource; + const targetVersion = String(request.body?.targetVersion || '').trim(); + if (!targetVersion) { + return reply.code(400).send({ + success: false, + message: 'targetVersion is required', + }); + } + + let taskId = ''; + const { task, reused } = startBackgroundTask( + { + type: UPDATE_CENTER_DEPLOY_TASK_TYPE, + title: '更新中心部署', + dedupeKey: UPDATE_CENTER_DEPLOY_DEDUPE_KEY, + successTitle: '更新中心部署已完成', + failureTitle: '更新中心部署失败', + }, + async () => { + await Promise.resolve(); + appendBackgroundTaskLog(taskId, `Resolving target version: ${targetVersion}`); + appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`); + + const result = await streamUpdateCenterDeploy( + { + config, + helperToken: getUpdateCenterHelperToken(), + source, + targetVersion, + }, + (message) => { + appendBackgroundTaskLog(taskId, message); + }, + ); + + if (!result.success) { + throw new Error('deploy helper reported a failed deployment'); + } + appendBackgroundTaskLog(taskId, result.rolledBack ? 'Deployment rolled back' : 'Deployment finished successfully'); + return result; + }, + ); + taskId = task.id; + + return reply.code(202).send({ + success: true, + reused, + task, + }); + }); + + app.get<{ Params: { id: string } }>('/api/update-center/tasks/:id/stream', async (request, reply) => { + const taskId = String(request.params.id || '').trim(); + const task = getBackgroundTask(taskId); + if (!task) { + return reply.code(404).send({ + success: false, + message: 'task not found', + }); + } + + reply.hijack(); + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + + const writeDone = () => { + const latest = getBackgroundTask(taskId); + writeSseEvent(reply, 'done', { + status: latest?.status || 'unknown', + }); + reply.raw.end(); + }; + + for (const entry of task.logs) { + writeSseEvent(reply, 'log', entry); + } + + if (task.status !== 'pending' && task.status !== 'running') { + writeDone(); + return; + } + + const unsubscribe = subscribeToBackgroundTaskLogs(taskId, (entry) => { + writeSseEvent(reply, 'log', entry); + }); + + const interval = setInterval(() => { + const latest = getBackgroundTask(taskId); + if (!latest) { + clearInterval(interval); + unsubscribe(); + writeDone(); + return; + } + if (latest.status !== 'pending' && latest.status !== 'running') { + clearInterval(interval); + unsubscribe(); + writeDone(); + } + }, 25); + interval.unref?.(); + + request.raw.on('close', () => { + clearInterval(interval); + unsubscribe(); + }); + }); +} diff --git a/src/server/services/backgroundTaskService.logs.test.ts b/src/server/services/backgroundTaskService.logs.test.ts new file mode 100644 index 0000000..3d74b84 --- /dev/null +++ b/src/server/services/backgroundTaskService.logs.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + __resetBackgroundTasksForTests, + appendBackgroundTaskLog, + getBackgroundTask, + startBackgroundTask, + subscribeToBackgroundTaskLogs, +} from './backgroundTaskService.js'; + +describe('background task log streaming', () => { + afterEach(() => { + __resetBackgroundTasksForTests(); + }); + + it('appends log entries in order and exposes them through task lookups', async () => { + let releaseRunner: (() => void) | null = null; + const runnerGate = new Promise((resolve) => { + releaseRunner = resolve; + }); + + const { task } = startBackgroundTask( + { + type: 'update-center.deploy', + title: '更新部署', + }, + async () => { + await runnerGate; + return { success: true }; + }, + ); + + appendBackgroundTaskLog(task.id, 'Resolving target version'); + appendBackgroundTaskLog(task.id, 'Running helm upgrade'); + + const currentTask = getBackgroundTask(task.id); + expect(currentTask?.logs).toEqual([ + expect.objectContaining({ seq: 1, message: 'Resolving target version' }), + expect.objectContaining({ seq: 2, message: 'Running helm upgrade' }), + ]); + + releaseRunner?.(); + }); + + it('notifies subscribers when new log entries arrive', async () => { + let releaseRunner: (() => void) | null = null; + const runnerGate = new Promise((resolve) => { + releaseRunner = resolve; + }); + + const { task } = startBackgroundTask( + { + type: 'update-center.deploy', + title: '更新部署', + }, + async () => { + await runnerGate; + return { success: true }; + }, + ); + + const received: string[] = []; + const unsubscribe = subscribeToBackgroundTaskLogs(task.id, (entry) => { + received.push(entry.message); + }); + + appendBackgroundTaskLog(task.id, 'Waiting for rollout'); + appendBackgroundTaskLog(task.id, 'Deployment complete'); + + expect(received).toEqual([ + 'Waiting for rollout', + 'Deployment complete', + ]); + + unsubscribe(); + releaseRunner?.(); + }); + + it('trims old log entries to a bounded buffer', async () => { + let releaseRunner: (() => void) | null = null; + const runnerGate = new Promise((resolve) => { + releaseRunner = resolve; + }); + + const { task } = startBackgroundTask( + { + type: 'update-center.deploy', + title: '更新部署', + }, + async () => { + await runnerGate; + return { success: true }; + }, + ); + + for (let index = 1; index <= 250; index += 1) { + appendBackgroundTaskLog(task.id, `line-${index}`); + } + + const currentTask = getBackgroundTask(task.id); + expect(currentTask?.logs).toHaveLength(200); + expect(currentTask?.logs[0]).toMatchObject({ + seq: 51, + message: 'line-51', + }); + expect(currentTask?.logs.at(-1)).toMatchObject({ + seq: 250, + message: 'line-250', + }); + + releaseRunner?.(); + }); +}); diff --git a/src/server/services/backgroundTaskService.ts b/src/server/services/backgroundTaskService.ts index 04e2740..3684b52 100644 --- a/src/server/services/backgroundTaskService.ts +++ b/src/server/services/backgroundTaskService.ts @@ -4,6 +4,12 @@ import { sendNotification } from './notifyService.js'; export type BackgroundTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed'; +export type BackgroundTaskLogEntry = { + seq: number; + message: string; + createdAt: string; +}; + export type BackgroundTask = { id: string; type: string; @@ -18,6 +24,7 @@ export type BackgroundTask = { startedAt: string | null; finishedAt: string | null; expiresAtMs: number; + logs: BackgroundTaskLogEntry[]; }; type TaskMessageTemplate = string | ((task: BackgroundTask) => string); @@ -37,9 +44,12 @@ type BackgroundTaskStartOptions = { const TASK_TTL_MS = 6 * 60 * 60 * 1000; const TASK_CLEANUP_INTERVAL_MS = 60 * 1000; +const TASK_LOG_LIMIT = 200; const tasks = new Map(); const dedupeTaskIds = new Map(); +const taskLogSeq = new Map(); +const taskLogSubscribers = new Map void>>(); let cleanupTimer: ReturnType | null = null; function nowIso() { @@ -72,8 +82,9 @@ function resolveTaskMessage(template: TaskMessageTemplate | undefined, task: Bac } function setTaskStatus(task: BackgroundTask, patch: Partial) { + const currentTask = tasks.get(task.id) || task; const next: BackgroundTask = { - ...task, + ...currentTask, ...patch, updatedAt: nowIso(), }; @@ -81,6 +92,67 @@ function setTaskStatus(task: BackgroundTask, patch: Partial) { return next; } +function cleanupTaskInternals(taskId: string) { + taskLogSeq.delete(taskId); + taskLogSubscribers.delete(taskId); +} + +export function appendBackgroundTaskLog(taskId: string, message: string): BackgroundTaskLogEntry | null { + const task = tasks.get(taskId); + const normalizedMessage = String(message || '').trim(); + if (!task || !normalizedMessage) return null; + + const nextSeq = (taskLogSeq.get(taskId) || 0) + 1; + taskLogSeq.set(taskId, nextSeq); + + const entry: BackgroundTaskLogEntry = { + seq: nextSeq, + message: normalizedMessage, + createdAt: nowIso(), + }; + + const nextLogs = [...task.logs, entry]; + const trimmedLogs = nextLogs.length > TASK_LOG_LIMIT + ? nextLogs.slice(nextLogs.length - TASK_LOG_LIMIT) + : nextLogs; + + tasks.set(taskId, { + ...task, + logs: trimmedLogs, + updatedAt: nowIso(), + }); + + const subscribers = taskLogSubscribers.get(taskId); + if (subscribers) { + for (const subscriber of subscribers) { + subscriber(entry); + } + } + + return entry; +} + +export function subscribeToBackgroundTaskLogs( + taskId: string, + listener: (entry: BackgroundTaskLogEntry) => void, +): () => void { + let subscribers = taskLogSubscribers.get(taskId); + if (!subscribers) { + subscribers = new Set(); + taskLogSubscribers.set(taskId, subscribers); + } + subscribers.add(listener); + + return () => { + const current = taskLogSubscribers.get(taskId); + if (!current) return; + current.delete(listener); + if (current.size <= 0) { + taskLogSubscribers.delete(taskId); + } + }; +} + async function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) { try { await db.insert(schema.events).values({ @@ -154,6 +226,7 @@ function cleanupExpiredTasks() { if (task.dedupeKey && dedupeTaskIds.get(task.dedupeKey) === taskId) { dedupeTaskIds.delete(task.dedupeKey); } + cleanupTaskInternals(taskId); } } } @@ -196,9 +269,11 @@ export function startBackgroundTask( startedAt: null, finishedAt: null, expiresAtMs: Date.now() + Math.max(60_000, options.keepMs ?? TASK_TTL_MS), + logs: [], }; tasks.set(task.id, task); + taskLogSeq.set(task.id, 0); if (dedupeKey) dedupeTaskIds.set(dedupeKey, task.id); appendTaskEvent('info', `${task.title}已开始`, `${task.title} 已开始执行`, task.id); @@ -246,6 +321,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot export function __resetBackgroundTasksForTests() { tasks.clear(); dedupeTaskIds.clear(); + taskLogSeq.clear(); + taskLogSubscribers.clear(); if (cleanupTimer) { clearInterval(cleanupTimer); cleanupTimer = null; diff --git a/src/server/services/updateCenterConfigService.ts b/src/server/services/updateCenterConfigService.ts new file mode 100644 index 0000000..91aafa1 --- /dev/null +++ b/src/server/services/updateCenterConfigService.ts @@ -0,0 +1,81 @@ +import { eq } from 'drizzle-orm'; + +import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; +import type { UpdateCenterVersionSource } from './updateCenterVersionService.js'; + +export type UpdateCenterConfig = { + enabled: boolean; + helperBaseUrl: string; + namespace: string; + releaseName: string; + chartRef: string; + imageRepository: string; + githubReleasesEnabled: boolean; + dockerHubTagsEnabled: boolean; + defaultDeploySource: UpdateCenterVersionSource; +}; + +export const UPDATE_CENTER_CONFIG_SETTING_KEY = 'update_center_k3s_config_v1'; + +export function getDefaultUpdateCenterConfig(): UpdateCenterConfig { + return { + enabled: false, + helperBaseUrl: '', + namespace: 'default', + releaseName: '', + chartRef: '', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }; +} + +function normalizeString(value: unknown, fallback = ''): string { + if (typeof value !== 'string') return fallback; + return value.trim(); +} + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +export function normalizeUpdateCenterConfig(input: unknown): UpdateCenterConfig { + const defaults = getDefaultUpdateCenterConfig(); + const record = input && typeof input === 'object' ? input as Record : {}; + const defaultDeploySource = record.defaultDeploySource === 'docker-hub-tag' + ? 'docker-hub-tag' + : 'github-release'; + + return { + enabled: normalizeBoolean(record.enabled, defaults.enabled), + helperBaseUrl: normalizeString(record.helperBaseUrl, defaults.helperBaseUrl), + namespace: normalizeString(record.namespace, defaults.namespace) || defaults.namespace, + releaseName: normalizeString(record.releaseName, defaults.releaseName), + chartRef: normalizeString(record.chartRef, defaults.chartRef), + imageRepository: normalizeString(record.imageRepository, defaults.imageRepository) || defaults.imageRepository, + githubReleasesEnabled: normalizeBoolean(record.githubReleasesEnabled, defaults.githubReleasesEnabled), + dockerHubTagsEnabled: normalizeBoolean(record.dockerHubTagsEnabled, defaults.dockerHubTagsEnabled), + defaultDeploySource, + }; +} + +export async function loadUpdateCenterConfig(): Promise { + const row = await db.select().from(schema.settings).where(eq(schema.settings.key, UPDATE_CENTER_CONFIG_SETTING_KEY)).get(); + if (!row?.value) { + return getDefaultUpdateCenterConfig(); + } + + try { + return normalizeUpdateCenterConfig(JSON.parse(row.value)); + } catch { + return getDefaultUpdateCenterConfig(); + } +} + +export async function saveUpdateCenterConfig(input: unknown): Promise { + const next = normalizeUpdateCenterConfig(input); + await upsertSetting(UPDATE_CENTER_CONFIG_SETTING_KEY, next); + return next; +} diff --git a/src/server/services/updateCenterHelperClient.test.ts b/src/server/services/updateCenterHelperClient.test.ts new file mode 100644 index 0000000..91b9fbe --- /dev/null +++ b/src/server/services/updateCenterHelperClient.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js'; + +describe('update center helper client', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it('maps helper status aborts to a timeout error', async () => { + const abortError = new Error('aborted'); + (abortError as Error & { name: string }).name = 'AbortError'; + fetchMock.mockRejectedValue(abortError); + + await expect(getUpdateCenterHelperStatus({ + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, 'helper-token')).rejects.toThrow('deploy helper status timeout'); + }); +}); diff --git a/src/server/services/updateCenterHelperClient.ts b/src/server/services/updateCenterHelperClient.ts new file mode 100644 index 0000000..b79f946 --- /dev/null +++ b/src/server/services/updateCenterHelperClient.ts @@ -0,0 +1,192 @@ +import { fetch, type RequestInit as UndiciRequestInit } from 'undici'; + +import type { UpdateCenterConfig } from './updateCenterConfigService.js'; +import type { UpdateCenterVersionSource } from './updateCenterVersionService.js'; + +const UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS = 5_000; + +export type UpdateCenterHelperStatus = { + ok: boolean; + releaseName: string | null; + namespace: string | null; + revision: string | null; + imageRepository: string | null; + imageTag: string | null; + healthy: boolean; + error?: string; +}; + +export type UpdateCenterDeploySummary = { + success: boolean; + targetSource: UpdateCenterVersionSource; + targetTag: string; + previousRevision: string | null; + finalRevision: string | null; + rolledBack: boolean; + logLines: string[]; +}; + +type HelperDeployLogEvent = { + message: string; +}; + +async function fetchHelperJsonWithTimeout(url: string, init: UndiciRequestInit): Promise { + const controller = new AbortController(); + let timeoutHandle: ReturnType | null = setTimeout(() => { + controller.abort(); + }, UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`helper status failed with HTTP ${response.status}`); + } + return await response.json(); + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`deploy helper status timeout (${Math.round(UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS / 1000)}s)`); + } + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + } +} + +function buildHelperHeaders(token: string, accept = 'application/json') { + return { + authorization: `Bearer ${token}`, + accept, + }; +} + +function encodeSseEvent(event: string, data: unknown) { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +type SseChunkHandler = (event: string, data: unknown) => void; + +function parseSseBuffer(buffer: string, onChunk: SseChunkHandler) { + const events = buffer.split('\n\n'); + const remainder = events.pop() || ''; + + for (const block of events) { + const lines = block.split('\n'); + let eventName = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim() || 'message'; + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trim()); + } + } + + if (dataLines.length <= 0) continue; + const rawData = dataLines.join('\n'); + try { + onChunk(eventName, JSON.parse(rawData)); + } catch { + onChunk(eventName, rawData); + } + } + + return remainder; +} + +export async function getUpdateCenterHelperStatus( + config: UpdateCenterConfig, + helperToken: string, +): Promise { + const query = new URLSearchParams({ + namespace: config.namespace, + releaseName: config.releaseName, + }); + return await fetchHelperJsonWithTimeout(`${config.helperBaseUrl.replace(/\/$/, '')}/status?${query.toString()}`, { + headers: buildHelperHeaders(helperToken), + }) as UpdateCenterHelperStatus; +} + +export async function streamUpdateCenterDeploy( + input: { + config: UpdateCenterConfig; + helperToken: string; + source: UpdateCenterVersionSource; + targetVersion: string; + }, + onLog?: (message: string) => void, +): Promise { + const response = await fetch(`${input.config.helperBaseUrl.replace(/\/$/, '')}/deploy`, { + method: 'POST', + headers: { + ...buildHelperHeaders(input.helperToken, 'text/event-stream'), + 'content-type': 'application/json', + }, + body: JSON.stringify({ + namespace: input.config.namespace, + releaseName: input.config.releaseName, + chartRef: input.config.chartRef, + imageRepository: input.config.imageRepository, + source: input.source, + targetVersion: input.targetVersion, + }), + }); + + if (!response.ok) { + throw new Error(`helper deploy failed with HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error('helper deploy response did not include a stream body'); + } + + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + let buffer = ''; + let finalResult: UpdateCenterDeploySummary | null = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + buffer = parseSseBuffer(buffer, (event, data) => { + if (event === 'log' && data && typeof data === 'object') { + const message = String((data as HelperDeployLogEvent).message || '').trim(); + if (message) onLog?.(message); + return; + } + if (event === 'result') { + finalResult = data as UpdateCenterDeploySummary; + } + }); + } + + if (buffer.trim()) { + parseSseBuffer(`${buffer}\n\n`, (event, data) => { + if (event === 'log' && data && typeof data === 'object') { + const message = String((data as HelperDeployLogEvent).message || '').trim(); + if (message) onLog?.(message); + return; + } + if (event === 'result') { + finalResult = data as UpdateCenterDeploySummary; + } + }); + } + + if (!finalResult) { + throw new Error('helper deploy stream ended without a result event'); + } + + return finalResult; +} + +export const __updateCenterHelperClientTestUtils = { + encodeSseEvent, +}; diff --git a/src/server/services/updateCenterVersionService.test.ts b/src/server/services/updateCenterVersionService.test.ts new file mode 100644 index 0000000..dd71faf --- /dev/null +++ b/src/server/services/updateCenterVersionService.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +import { + compareStableSemVer, + fetchLatestDockerHubTag, + fetchLatestStableGitHubRelease, + parseStableSemVer, + resolvePreferredDeploySource, + selectLatestDockerHubTag, + selectLatestStableGitHubRelease, +} from './updateCenterVersionService.js'; + +describe('update center version service', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + describe('parseStableSemVer', () => { + it('accepts stable semver strings with optional leading v', () => { + expect(parseStableSemVer('1.2.3')).toMatchObject({ + raw: '1.2.3', + normalized: '1.2.3', + major: 1, + minor: 2, + patch: 3, + }); + expect(parseStableSemVer('v2.4.6')).toMatchObject({ + raw: 'v2.4.6', + normalized: '2.4.6', + major: 2, + minor: 4, + patch: 6, + }); + }); + + it('rejects prerelease and invalid versions', () => { + expect(parseStableSemVer('v1.2.3-alpha.1')).toBeNull(); + expect(parseStableSemVer('latest')).toBeNull(); + expect(parseStableSemVer('2026-03-29')).toBeNull(); + expect(parseStableSemVer('1.2')).toBeNull(); + }); + }); + + describe('compareStableSemVer', () => { + it('compares semver numerically instead of lexicographically', () => { + const v120 = parseStableSemVer('1.2.0'); + const v1100 = parseStableSemVer('1.10.0'); + const v200 = parseStableSemVer('2.0.0'); + + expect(v120).not.toBeNull(); + expect(v1100).not.toBeNull(); + expect(v200).not.toBeNull(); + expect(compareStableSemVer(v120!, v1100!)).toBeLessThan(0); + expect(compareStableSemVer(v200!, v1100!)).toBeGreaterThan(0); + }); + }); + + describe('selectLatestStableGitHubRelease', () => { + it('ignores drafts and prereleases and returns the highest stable semver release', () => { + const latest = selectLatestStableGitHubRelease([ + { + tag_name: 'v1.2.3-alpha.1', + html_url: 'https://example.com/pre', + draft: false, + prerelease: true, + published_at: '2026-03-29T00:00:00Z', + name: 'v1.2.3-alpha.1', + }, + { + tag_name: 'v1.9.9', + html_url: 'https://example.com/older', + draft: false, + prerelease: false, + published_at: '2026-03-28T00:00:00Z', + name: 'v1.9.9', + }, + { + tag_name: 'v1.10.0', + html_url: 'https://example.com/latest', + draft: false, + prerelease: false, + published_at: '2026-03-27T00:00:00Z', + name: 'v1.10.0', + }, + { + tag_name: 'v9.9.9', + html_url: 'https://example.com/draft', + draft: true, + prerelease: false, + published_at: '2026-03-30T00:00:00Z', + name: 'v9.9.9', + }, + ]); + + expect(latest).toMatchObject({ + rawVersion: 'v1.10.0', + normalizedVersion: '1.10.0', + source: 'github-release', + url: 'https://example.com/latest', + }); + }); + }); + + describe('selectLatestDockerHubTag', () => { + it('ignores non-semver tags and prereleases', () => { + const latest = selectLatestDockerHubTag([ + 'latest', + 'main', + 'v1.2.3-beta.1', + 'v1.2.3', + '1.10.0', + '20260329', + ]); + + expect(latest).toMatchObject({ + rawVersion: '1.10.0', + normalizedVersion: '1.10.0', + source: 'docker-hub-tag', + }); + }); + }); + + describe('resolvePreferredDeploySource', () => { + it('prefers the configured default source when both channels have valid candidates', () => { + const preferred = resolvePreferredDeploySource({ + defaultSource: 'github-release', + githubRelease: { + source: 'github-release', + rawVersion: 'v1.10.0', + normalizedVersion: '1.10.0', + url: 'https://example.com/release', + }, + dockerHubTag: { + source: 'docker-hub-tag', + rawVersion: '1.10.1', + normalizedVersion: '1.10.1', + url: null, + }, + }); + + expect(preferred?.source).toBe('github-release'); + expect(preferred?.normalizedVersion).toBe('1.10.0'); + }); + }); + + describe('fetchLatestStableGitHubRelease', () => { + it('fetches releases and returns the latest stable semver record', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([ + { + tag_name: 'v1.2.3-beta.1', + html_url: 'https://example.com/beta', + draft: false, + prerelease: true, + }, + { + tag_name: 'v1.2.9', + html_url: 'https://example.com/older', + draft: false, + prerelease: false, + }, + { + tag_name: 'v1.10.0', + html_url: 'https://example.com/latest', + draft: false, + prerelease: false, + }, + ]), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const latest = await fetchLatestStableGitHubRelease(); + expect(latest).toMatchObject({ + source: 'github-release', + normalizedVersion: '1.10.0', + url: 'https://example.com/latest', + }); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/repos/cita-777/metapi/releases'); + }); + + it('maps aborted GitHub lookups to a timeout error', async () => { + const abortError = new Error('aborted'); + (abortError as Error & { name: string }).name = 'AbortError'; + fetchMock.mockRejectedValue(abortError); + + await expect(fetchLatestStableGitHubRelease()).rejects.toThrow('GitHub releases lookup timeout'); + }); + }); + + describe('fetchLatestDockerHubTag', () => { + it('fetches tags and returns the latest semver tag only', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + results: [ + { name: 'latest' }, + { name: 'v1.2.3-rc.1' }, + { name: 'v1.2.3' }, + { name: '1.10.0' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const latest = await fetchLatestDockerHubTag(); + expect(latest).toMatchObject({ + source: 'docker-hub-tag', + normalizedVersion: '1.10.0', + }); + expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v2/repositories/1467078763/metapi/tags'); + }); + }); +}); diff --git a/src/server/services/updateCenterVersionService.ts b/src/server/services/updateCenterVersionService.ts new file mode 100644 index 0000000..414c1b0 --- /dev/null +++ b/src/server/services/updateCenterVersionService.ts @@ -0,0 +1,176 @@ +import { fetch, type RequestInit as UndiciRequestInit } from 'undici'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export type StableSemVer = { + raw: string; + normalized: string; + major: number; + minor: number; + patch: number; +}; + +export type UpdateCenterVersionSource = 'github-release' | 'docker-hub-tag'; + +export type UpdateCenterVersionCandidate = { + source: UpdateCenterVersionSource; + rawVersion: string; + normalizedVersion: string; + url: string | null; +}; + +export type GitHubReleaseRecord = { + tag_name?: string | null; + html_url?: string | null; + draft?: boolean; + prerelease?: boolean; + published_at?: string | null; + name?: string | null; +}; + +const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i; +const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases'; +const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100'; +const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000; + +async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise { + const controller = new AbortController(); + let timeoutHandle: ReturnType | null = setTimeout(() => { + controller.abort(); + }, UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`${timeoutLabel} failed with HTTP ${response.status}`); + } + return await response.json(); + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`${timeoutLabel} timeout (${Math.round(UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS / 1000)}s)`); + } + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + } +} + +export function parseStableSemVer(input: string | null | undefined): StableSemVer | null { + const raw = String(input || '').trim(); + if (!raw) return null; + const match = raw.match(STABLE_SEMVER_PATTERN); + if (!match) return null; + const major = Number.parseInt(match[1], 10); + const minor = Number.parseInt(match[2], 10); + const patch = Number.parseInt(match[3], 10); + if (![major, minor, patch].every(Number.isFinite)) return null; + return { + raw, + normalized: `${major}.${minor}.${patch}`, + major, + minor, + patch, + }; +} + +export function compareStableSemVer(a: StableSemVer, b: StableSemVer): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +export function selectLatestStableGitHubRelease( + releases: GitHubReleaseRecord[], +): UpdateCenterVersionCandidate | null { + let selected: { semver: StableSemVer; release: GitHubReleaseRecord } | null = null; + + for (const release of releases) { + if (release?.draft || release?.prerelease) continue; + const semver = parseStableSemVer(release?.tag_name); + if (!semver) continue; + if (!selected || compareStableSemVer(semver, selected.semver) > 0) { + selected = { semver, release }; + } + } + + if (!selected) return null; + + return { + source: 'github-release', + rawVersion: selected.release.tag_name || selected.semver.raw, + normalizedVersion: selected.semver.normalized, + url: selected.release.html_url || null, + }; +} + +export function selectLatestDockerHubTag(tags: string[]): UpdateCenterVersionCandidate | null { + let selected: StableSemVer | null = null; + + for (const tag of tags) { + const semver = parseStableSemVer(tag); + if (!semver) continue; + if (!selected || compareStableSemVer(semver, selected) > 0) { + selected = semver; + } + } + + if (!selected) return null; + + return { + source: 'docker-hub-tag', + rawVersion: selected.raw, + normalizedVersion: selected.normalized, + url: null, + }; +} + +export function resolvePreferredDeploySource(input: { + defaultSource: UpdateCenterVersionSource; + githubRelease: UpdateCenterVersionCandidate | null; + dockerHubTag: UpdateCenterVersionCandidate | null; +}): UpdateCenterVersionCandidate | null { + if (input.defaultSource === 'github-release') { + return input.githubRelease || input.dockerHubTag; + } + return input.dockerHubTag || input.githubRelease; +} + +export async function fetchLatestStableGitHubRelease(): Promise { + const releases = await fetchJsonWithTimeout(GITHUB_RELEASES_URL, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': 'metapi-update-center/1.0', + }, + }, 'GitHub releases lookup') as GitHubReleaseRecord[]; + return selectLatestStableGitHubRelease(Array.isArray(releases) ? releases : []); +} + +export async function fetchLatestDockerHubTag(): Promise { + const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, { + headers: { + accept: 'application/json', + 'user-agent': 'metapi-update-center/1.0', + }, + }, 'Docker Hub tag lookup') as { results?: Array<{ name?: string | null }> }; + const tags = Array.isArray(payload?.results) + ? payload.results.map((item) => String(item?.name || '')).filter(Boolean) + : []; + return selectLatestDockerHubTag(tags); +} + +export function getCurrentRuntimeVersion(): string { + try { + const packageJsonPath = resolve(process.cwd(), 'package.json'); + const payload = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; + const version = String(payload?.version || '').trim(); + return version || '0.0.0'; + } catch { + return '0.0.0'; + } +} diff --git a/src/server/update-helper/app.test.ts b/src/server/update-helper/app.test.ts new file mode 100644 index 0000000..e16207a --- /dev/null +++ b/src/server/update-helper/app.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildUpdateHelperApp } from './app.js'; + +describe('update helper app', () => { + const apps: Array>> = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it('requires bearer auth for protected helper routes', async () => { + const app = await buildUpdateHelperApp({ + token: 'helper-token', + getStatus: vi.fn(), + deploy: vi.fn(), + }); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/status?namespace=ai&releaseName=metapi', + }); + + expect(response.statusCode).toBe(401); + }); + + it('allows unauthenticated health checks', async () => { + const app = await buildUpdateHelperApp({ + token: 'helper-token', + getStatus: vi.fn(), + deploy: vi.fn(), + }); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/health', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + }); + + it('returns helper status for authorized requests', async () => { + const getStatus = vi.fn().mockResolvedValue({ + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '17', + imageRepository: '1467078763/metapi', + imageTag: '1.3.1', + healthy: true, + }); + + const app = await buildUpdateHelperApp({ + token: 'helper-token', + getStatus, + deploy: vi.fn(), + }); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/status?namespace=ai&releaseName=metapi', + headers: { + authorization: 'Bearer helper-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + healthy: true, + releaseName: 'metapi', + namespace: 'ai', + }); + expect(getStatus).toHaveBeenCalledWith({ + namespace: 'ai', + releaseName: 'metapi', + }); + }); + + it('streams deploy logs and final result as SSE when requested', async () => { + const deploy = vi.fn().mockImplementation(async (input, onLog) => { + onLog?.('Running helm upgrade'); + onLog?.('Waiting for rollout'); + return { + success: true, + targetSource: input.targetSource, + targetTag: input.targetTag, + previousRevision: '17', + finalRevision: '18', + rolledBack: false, + logLines: ['Running helm upgrade', 'Waiting for rollout'], + }; + }); + + const app = await buildUpdateHelperApp({ + token: 'helper-token', + getStatus: vi.fn(), + deploy, + }); + apps.push(app); + + const response = await app.inject({ + method: 'POST', + url: '/deploy', + headers: { + authorization: 'Bearer helper-token', + accept: 'text/event-stream', + }, + payload: { + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + source: 'github-release', + targetVersion: '1.3.0', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('event: log'); + expect(response.body).toContain('Running helm upgrade'); + expect(response.body).toContain('event: result'); + expect(response.body).toContain('"targetTag":"1.3.0"'); + }); +}); diff --git a/src/server/update-helper/app.ts b/src/server/update-helper/app.ts new file mode 100644 index 0000000..e3161eb --- /dev/null +++ b/src/server/update-helper/app.ts @@ -0,0 +1,100 @@ +import Fastify, { type FastifyInstance } from 'fastify'; + +import { + executeUpdateHelperDeploy, + getUpdateHelperStatus, + type UpdateHelperDeployInput, + type UpdateHelperDeploySummary, + type UpdateHelperStatus, +} from './service.js'; + +type BuildUpdateHelperAppOptions = { + token: string; + getStatus?: (input: { namespace: string; releaseName: string }) => Promise; + deploy?: ( + input: UpdateHelperDeployInput, + onLog?: (message: string) => void, + ) => Promise; +}; + +function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) { + const raw = String(requestAuthHeader || '').trim(); + if (!raw.startsWith('Bearer ')) return false; + return raw.slice('Bearer '.length).trim() === expectedToken; +} + +function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function toDeployInput(body: Record): UpdateHelperDeployInput { + return { + namespace: String(body.namespace || '').trim(), + releaseName: String(body.releaseName || '').trim(), + chartRef: String(body.chartRef || '').trim(), + imageRepository: String(body.imageRepository || '').trim(), + targetSource: body.source === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release', + targetTag: String(body.targetVersion || '').trim(), + }; +} + +export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions): Promise { + const app = Fastify(); + const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input)); + const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog)); + + app.addHook('onRequest', async (request, reply) => { + const path = String(request.raw.url || '').split('?')[0]; + if (path === '/health') { + return; + } + if (!requireBearerToken(String(request.headers.authorization || ''), options.token)) { + return reply.code(401).send({ + success: false, + message: 'unauthorized', + }); + } + }); + + app.get('/health', async () => ({ ok: true })); + + app.get<{ Querystring: { namespace?: string; releaseName?: string } }>('/status', async (request, reply) => { + const namespace = String(request.query.namespace || '').trim(); + const releaseName = String(request.query.releaseName || '').trim(); + if (!namespace || !releaseName) { + return reply.code(400).send({ + success: false, + message: 'namespace and releaseName are required', + }); + } + return await getStatus({ namespace, releaseName }); + }); + + app.post<{ Body: Record }>('/deploy', async (request, reply) => { + const input = toDeployInput(request.body || {}); + if (!input.namespace || !input.releaseName || !input.chartRef || !input.imageRepository || !input.targetTag) { + return reply.code(400).send({ + success: false, + message: 'namespace, releaseName, chartRef, imageRepository, and targetVersion are required', + }); + } + + const wantsSse = String(request.headers.accept || '').includes('text/event-stream'); + if (!wantsSse) { + return await deploy(input); + } + + reply.hijack(); + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + + const result = await deploy(input, (message) => { + writeSseEvent(reply, 'log', { message }); + }); + writeSseEvent(reply, 'result', result); + reply.raw.end(); + }); + + return app; +} diff --git a/src/server/update-helper/index.ts b/src/server/update-helper/index.ts new file mode 100644 index 0000000..bb4a162 --- /dev/null +++ b/src/server/update-helper/index.ts @@ -0,0 +1,19 @@ +import { buildUpdateHelperApp } from './app.js'; + +const port = Number.parseInt(process.env.DEPLOY_HELPER_PORT || '9850', 10) || 9850; +const host = (process.env.DEPLOY_HELPER_HOST || '0.0.0.0').trim() || '0.0.0.0'; +const token = String(process.env.DEPLOY_HELPER_TOKEN || '').trim(); + +if (!token) { + throw new Error('DEPLOY_HELPER_TOKEN is required'); +} + +const app = await buildUpdateHelperApp({ token }); + +try { + await app.listen({ port, host }); + app.log.info(`Deploy helper listening on http://${host}:${port}`); +} catch (error) { + app.log.error(error); + process.exit(1); +} diff --git a/src/server/update-helper/service.test.ts b/src/server/update-helper/service.test.ts new file mode 100644 index 0000000..ed5ffcb --- /dev/null +++ b/src/server/update-helper/service.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; + +import { + executeUpdateHelperDeploy, + getUpdateHelperStatus, +} from './service.js'; + +describe('update helper service', () => { + it('builds helm upgrade and rollout verification commands with the configured release metadata', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const summary = await executeUpdateHelperDeploy( + { + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + targetSource: 'github-release', + targetTag: '1.3.0', + }, + { + runCommand: async ({ command, args }) => { + calls.push({ command, args }); + if (command === 'helm' && args[0] === 'history') { + return { + stdout: JSON.stringify([{ revision: '12' }]), + }; + } + if (command === 'helm' && args[0] === 'status') { + return { + stdout: JSON.stringify({ + version: 13, + }), + }; + } + return { stdout: '' }; + }, + }, + ); + + expect(calls).toEqual([ + { + command: 'helm', + args: ['history', 'metapi', '-n', 'ai', '-o', 'json'], + }, + { + command: 'helm', + args: [ + 'upgrade', + 'metapi', + 'oci://ghcr.io/cita-777/charts/metapi', + '--namespace', + 'ai', + '--reuse-values', + '--set', + 'image.repository=1467078763/metapi', + '--set', + 'image.tag=1.3.0', + ], + }, + { + command: 'kubectl', + args: [ + 'rollout', + 'status', + 'deployment', + '-n', + 'ai', + '-l', + 'app.kubernetes.io/instance=metapi', + '--timeout=300s', + ], + }, + { + command: 'helm', + args: ['status', 'metapi', '-n', 'ai', '-o', 'json'], + }, + ]); + expect(summary).toMatchObject({ + success: true, + targetSource: 'github-release', + targetTag: '1.3.0', + previousRevision: '12', + finalRevision: '13', + rolledBack: false, + }); + }); + + it('rolls back to the previous revision when rollout verification fails', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + + await expect(() => executeUpdateHelperDeploy( + { + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + targetSource: 'docker-hub-tag', + targetTag: '1.3.1', + }, + { + runCommand: async ({ command, args }) => { + calls.push({ command, args }); + if (command === 'helm' && args[0] === 'history') { + return { + stdout: JSON.stringify([{ revision: '22' }]), + }; + } + if (command === 'kubectl' && args[0] === 'rollout') { + throw new Error('rollout status timed out'); + } + return { stdout: '' }; + }, + }, + )).rejects.toThrow('rollout status timed out'); + + expect(calls).toEqual(expect.arrayContaining([ + { + command: 'helm', + args: ['rollback', 'metapi', '22', '-n', 'ai'], + }, + ])); + }); + + it('parses helper status from helm status and values output', async () => { + const status = await getUpdateHelperStatus( + { + namespace: 'ai', + releaseName: 'metapi', + }, + { + runCommand: async ({ command, args }) => { + if (command === 'helm' && args[0] === 'status') { + return { + stdout: JSON.stringify({ + version: 17, + info: { + status: 'deployed', + }, + }), + }; + } + if (command === 'helm' && args[0] === 'get') { + return { + stdout: JSON.stringify({ + image: { + repository: '1467078763/metapi', + tag: '1.3.1', + }, + }), + }; + } + throw new Error('unexpected command'); + }, + }, + ); + + expect(status).toEqual({ + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '17', + imageRepository: '1467078763/metapi', + imageTag: '1.3.1', + healthy: true, + }); + }); +}); diff --git a/src/server/update-helper/service.ts b/src/server/update-helper/service.ts new file mode 100644 index 0000000..992aef2 --- /dev/null +++ b/src/server/update-helper/service.ts @@ -0,0 +1,232 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; + +import type { UpdateCenterVersionSource } from '../services/updateCenterVersionService.js'; + +type RunCommandInput = { + command: string; + args: string[]; + onLine?: (line: string) => void; +}; + +type RunCommandResult = { + stdout: string; +}; + +type RunCommand = (input: RunCommandInput) => Promise; + +export type UpdateHelperDeployInput = { + namespace: string; + releaseName: string; + chartRef: string; + imageRepository: string; + targetSource: UpdateCenterVersionSource; + targetTag: string; +}; + +export type UpdateHelperStatusInput = { + namespace: string; + releaseName: string; +}; + +export type UpdateHelperDeploySummary = { + success: boolean; + targetSource: UpdateCenterVersionSource; + targetTag: string; + previousRevision: string | null; + finalRevision: string | null; + rolledBack: boolean; + logLines: string[]; +}; + +export type UpdateHelperStatus = { + ok: boolean; + releaseName: string; + namespace: string; + revision: string | null; + imageRepository: string | null; + imageTag: string | null; + healthy: boolean; +}; + +async function runCommand(input: RunCommandInput): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(input.command, input.args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + const handleLine = (line: string) => { + const normalized = line.trim(); + if (!normalized) return; + input.onLine?.(normalized); + }; + + if (child.stdout) { + const stdoutReader = createInterface({ input: child.stdout }); + stdoutReader.on('line', (line) => { + stdout += `${line}\n`; + handleLine(line); + }); + } + + if (child.stderr) { + const stderrReader = createInterface({ input: child.stderr }); + stderrReader.on('line', (line) => { + stderr += `${line}\n`; + handleLine(line); + }); + } + + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) { + resolve({ stdout: stdout.trim() }); + return; + } + reject(new Error(stderr.trim() || stdout.trim() || `${input.command} exited with code ${code}`)); + }); + }); +} + +function parseLatestRevision(stdout: string): string | null { + try { + const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>; + const last = Array.isArray(rows) && rows.length > 0 ? rows[rows.length - 1] : null; + if (!last?.revision) return null; + return String(last.revision); + } catch { + return null; + } +} + +function parseStatusRevision(stdout: string): string | null { + try { + const payload = JSON.parse(stdout) as { version?: string | number }; + if (payload?.version === undefined || payload?.version === null) return null; + return String(payload.version); + } catch { + return null; + } +} + +export async function executeUpdateHelperDeploy( + input: UpdateHelperDeployInput, + deps: { + runCommand?: RunCommand; + } = {}, + onLog?: (line: string) => void, +): Promise { + const commandRunner = deps.runCommand || runCommand; + const logLines: string[] = []; + const captureLine = (line: string) => { + logLines.push(line); + onLog?.(line); + }; + + const history = await commandRunner({ + command: 'helm', + args: ['history', input.releaseName, '-n', input.namespace, '-o', 'json'], + }); + const previousRevision = parseLatestRevision(history.stdout); + + await commandRunner({ + command: 'helm', + args: [ + 'upgrade', + input.releaseName, + input.chartRef, + '--namespace', + input.namespace, + '--reuse-values', + '--set', + `image.repository=${input.imageRepository}`, + '--set', + `image.tag=${input.targetTag}`, + ], + onLine: captureLine, + }); + + try { + await commandRunner({ + command: 'kubectl', + args: [ + 'rollout', + 'status', + 'deployment', + '-n', + input.namespace, + '-l', + `app.kubernetes.io/instance=${input.releaseName}`, + '--timeout=300s', + ], + onLine: captureLine, + }); + } catch (error) { + if (previousRevision) { + await commandRunner({ + command: 'helm', + args: ['rollback', input.releaseName, previousRevision, '-n', input.namespace], + onLine: captureLine, + }); + } + throw error; + } + + const status = await commandRunner({ + command: 'helm', + args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'], + }); + + return { + success: true, + targetSource: input.targetSource, + targetTag: input.targetTag, + previousRevision, + finalRevision: parseStatusRevision(status.stdout), + rolledBack: false, + logLines, + }; +} + +export async function getUpdateHelperStatus( + input: UpdateHelperStatusInput, + deps: { + runCommand?: RunCommand; + } = {}, +): Promise { + const commandRunner = deps.runCommand || runCommand; + const status = await commandRunner({ + command: 'helm', + args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'], + }); + const values = await commandRunner({ + command: 'helm', + args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'], + }); + + const statusPayload = JSON.parse(status.stdout || '{}') as { + version?: string | number; + info?: { + status?: string; + }; + }; + const valuesPayload = JSON.parse(values.stdout || '{}') as { + image?: { + repository?: string; + tag?: string; + }; + }; + + return { + ok: true, + releaseName: input.releaseName, + namespace: input.namespace, + revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version), + imageRepository: valuesPayload.image?.repository || null, + imageTag: valuesPayload.image?.tag || null, + healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed', + }; +} diff --git a/src/web/api.ts b/src/web/api.ts index 5d2847f..e32672b 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -136,6 +136,80 @@ async function request(url: string, options: RequestOptions = {}) { return res.json(); } +async function streamSse( + url: string, + handlers: { + onLog?: (entry: any) => void; + onDone?: (payload: any) => void; + signal?: AbortSignal; + }, +) { + const response = await fetchAuthenticatedResponse(url, { + method: 'GET', + signal: handlers.signal, + headers: { + Accept: 'text/event-stream', + }, + timeoutMs: 120_000, + }); + + if (!response.ok) { + throw new Error(await extractResponseErrorMessage(response)); + } + if (!response.body) { + throw new Error('响应未返回流式内容'); + } + + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + let buffer = ''; + + const flushBuffer = (final = false) => { + const chunks = final ? [...buffer.split('\n\n'), ''] : buffer.split('\n\n'); + if (!final) buffer = chunks.pop() || ''; + else buffer = ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + let eventName = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim() || 'message'; + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trim()); + } + } + + if (dataLines.length <= 0) continue; + let payload: any = dataLines.join('\n'); + try { + payload = JSON.parse(payload); + } catch { + // keep string payload + } + + if (eventName === 'log') { + handlers.onLog?.(payload); + } else if (eventName === 'done') { + handlers.onDone?.(payload); + } + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + flushBuffer(false); + } + + if (buffer.trim()) { + flushBuffer(true); + } +} + function buildQueryString(params?: Record) { if (!params) return ''; const searchParams = new URLSearchParams(); @@ -677,6 +751,27 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + getUpdateCenterStatus: () => request('/api/update-center/status'), + saveUpdateCenterConfig: (data: any) => request('/api/update-center/config', { + method: 'PUT', + body: JSON.stringify(data), + }), + checkUpdateCenter: () => request('/api/update-center/check', { + method: 'POST', + body: JSON.stringify({}), + }), + deployUpdateCenter: (data: { source: 'github-release' | 'docker-hub-tag'; targetVersion: string }) => request('/api/update-center/deploy', { + method: 'POST', + body: JSON.stringify(data), + }), + streamUpdateCenterTaskLogs: ( + taskId: string, + handlers: { + onLog?: (entry: any) => void; + onDone?: (payload: any) => void; + signal?: AbortSignal; + }, + ) => streamSse(`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`, handlers), testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', { method: 'POST', body: JSON.stringify(data), diff --git a/src/web/pages/About.tsx b/src/web/pages/About.tsx index b3c5e62..971ace3 100644 --- a/src/web/pages/About.tsx +++ b/src/web/pages/About.tsx @@ -1,3 +1,7 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { api } from '../api.js'; import { tr } from '../i18n.js'; import { SITE_DOCS_URL } from '../docsLink.js'; @@ -30,6 +34,37 @@ const LINKS = [ ]; export default function About() { + const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`); + const [latestGitHubVersion, setLatestGitHubVersion] = useState(''); + const [latestDockerHubVersion, setLatestDockerHubVersion] = useState(''); + + useEffect(() => { + let cancelled = false; + + const loadStatus = async () => { + try { + const status = await api.getUpdateCenterStatus() as { + currentVersion?: string; + githubRelease?: { normalizedVersion?: string } | null; + dockerHubTag?: { normalizedVersion?: string } | null; + }; + if (cancelled) return; + if (status.currentVersion) { + setCurrentVersion(`v${status.currentVersion}`); + } + setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || '')); + setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || '')); + } catch { + // ignore update-center lookup failures on about page + } + }; + + void loadStatus(); + return () => { + cancelled = true; + }; + }, []); + return (
{/* Header */} @@ -47,7 +82,7 @@ export default function About() { />
Metapi
-
v{VERSION}
+
{currentVersion}
@@ -55,6 +90,19 @@ export default function About() {
+
+

更新提醒

+
+
GitHub 稳定版:{latestGitHubVersion || '暂无数据'}
+
Docker Hub:{latestDockerHubVersion || '暂无数据'}
+
+ + 前往更新中心 + +
+
+
+ {/* Features */}

{tr('核心特色')}

diff --git a/src/web/pages/About.update-center.test.tsx b/src/web/pages/About.update-center.test.tsx new file mode 100644 index 0000000..861ce2c --- /dev/null +++ b/src/web/pages/About.update-center.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; + +import About from './About.js'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + getUpdateCenterStatus: vi.fn(), + }, +})); + +vi.mock('../api.js', () => ({ + api: apiMock, +})); + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('About update center', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMock.getUpdateCenterStatus.mockResolvedValue({ + currentVersion: '1.2.3', + githubRelease: { + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + }, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('shows current version and newer release summaries', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root.root); + expect(text).toContain('v1.2.3'); + expect(text).toContain('GitHub 稳定版'); + expect(text).toContain('1.3.0'); + expect(text).toContain('Docker Hub'); + expect(text).toContain('1.3.1'); + expect(text).toContain('前往更新中心'); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 2f701de..bfd5fb5 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -9,6 +9,7 @@ import ModernSelect from '../components/ModernSelect.js'; import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'; import FactoryResetModal from './settings/FactoryResetModal.js'; import RouteSelectorModal from './settings/RouteSelectorModal.js'; +import UpdateCenterSection from './settings/UpdateCenterSection.js'; import { applyRoutingProfilePreset, resolveRoutingProfilePreset, @@ -1759,6 +1760,8 @@ export default function Settings() { )}
+ +
维护工具
diff --git a/src/web/pages/settings.schedule-log-cleanup.test.tsx b/src/web/pages/settings.schedule-log-cleanup.test.tsx index c5a26f8..ebbbbbd 100644 --- a/src/web/pages/settings.schedule-log-cleanup.test.tsx +++ b/src/web/pages/settings.schedule-log-cleanup.test.tsx @@ -168,9 +168,14 @@ describe('Settings log cleanup schedule', () => { && typeof node.props.onClick === 'function' && collectText(node).trim() === '测试一次签到' )); + const scheduleCard = root.root.find((node) => ( + node.type === 'div' + && String(node.props.className || '').includes('card') + && collectText(node).includes('定时任务') + )); - expect(root.root.findAllByType('select')).toHaveLength(0); - expect(root.root.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(3); + expect(scheduleCard.findAllByType('select')).toHaveLength(0); + expect(scheduleCard.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(2); expect(String(triggerButton.props.className || '')).toContain('btn-ghost'); } finally { root?.unmount(); diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx new file mode 100644 index 0000000..1d0bf16 --- /dev/null +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast.js'; + +import UpdateCenterSection from './UpdateCenterSection.js'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + getUpdateCenterStatus: vi.fn(), + saveUpdateCenterConfig: vi.fn(), + checkUpdateCenter: vi.fn(), + deployUpdateCenter: vi.fn(), + streamUpdateCenterTaskLogs: vi.fn(), + }, +})); + +vi.mock('../../api.js', () => ({ + api: apiMock, +})); + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('UpdateCenterSection', () => { + const originalDocument = globalThis.document; + + beforeEach(() => { + vi.clearAllMocks(); + globalThis.document = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Document; + apiMock.getUpdateCenterStatus.mockResolvedValue({ + currentVersion: '1.2.3', + config: { + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + githubRelease: { + normalizedVersion: '1.3.0', + }, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + helper: { + ok: true, + healthy: true, + }, + runningTask: null, + lastFinishedTask: null, + }); + apiMock.saveUpdateCenterConfig.mockResolvedValue({ + success: true, + config: { + enabled: true, + helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + }); + apiMock.checkUpdateCenter.mockResolvedValue({ + githubRelease: { + normalizedVersion: '1.3.0', + }, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + }); + apiMock.deployUpdateCenter.mockResolvedValue({ + success: true, + reused: false, + task: { + id: 'task-1', + }, + }); + apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => { + handlers.onLog?.({ message: 'Running helm upgrade' }); + handlers.onLog?.({ message: 'Waiting for rollout' }); + handlers.onDone?.({ status: 'succeeded' }); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + globalThis.document = originalDocument; + }); + + it('loads status, saves config updates, and renders streamed deploy logs', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const helperInput = root.root.find((node) => ( + node.type === 'input' + && node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850' + )); + + await act(async () => { + helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } }); + }); + const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox'); + + await act(async () => { + checkboxInputs[1].props.onChange({ target: { checked: false } }); + checkboxInputs[2].props.onChange({ target: { checked: false } }); + }); + + const defaultSourceTrigger = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.className === 'string' + && node.props.className.includes('modern-select-trigger') + )); + + await act(async () => { + defaultSourceTrigger.props.onClick(); + }); + + const dockerHubOption = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.className === 'string' + && node.props.className.includes('modern-select-option') + && collectText(node).includes('Docker Hub Tags') + )); + + await act(async () => { + dockerHubOption.props.onClick(); + }); + + const saveButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('保存更新中心配置') + )); + + await act(async () => { + await saveButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({ + helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850', + githubReleasesEnabled: false, + dockerHubTagsEnabled: false, + defaultDeploySource: 'docker-hub-tag', + })); + + const deployButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('部署 GitHub 稳定版') + )); + + await act(async () => { + await deployButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({ + source: 'github-release', + targetVersion: '1.3.0', + }); + expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object)); + + const text = collectText(root.root); + expect(text).toContain('Running helm upgrade'); + expect(text).toContain('Waiting for rollout'); + expect(text).toContain('任务状态 · 已完成'); + } finally { + root?.unmount(); + } + }); + + it('disables deploy actions when the helper is unhealthy', async () => { + apiMock.getUpdateCenterStatus.mockResolvedValueOnce({ + currentVersion: '1.2.3', + config: { + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, + githubRelease: { + normalizedVersion: '1.3.0', + }, + dockerHubTag: { + normalizedVersion: '1.3.1', + }, + helper: { + ok: false, + healthy: false, + error: 'helper unavailable', + }, + runningTask: null, + lastFinishedTask: null, + }); + + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const githubDeployButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.className === 'string' + && node.props.className.includes('btn') + && collectText(node).includes('部署 GitHub 稳定版') + )); + const dockerDeployButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.className === 'string' + && node.props.className.includes('btn') + && collectText(node).includes('部署 Docker Hub 标签') + )); + + expect(githubDeployButton.props.disabled).toBe(true); + expect(dockerDeployButton.props.disabled).toBe(true); + + await act(async () => { + githubDeployButton.props.onClick?.(); + dockerDeployButton.props.onClick?.(); + }); + + expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled(); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx new file mode 100644 index 0000000..ea30f66 --- /dev/null +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -0,0 +1,679 @@ +import React, { useEffect, useRef, useState, type CSSProperties } from 'react'; + +import { api } from '../../api.js'; +import ModernSelect from '../../components/ModernSelect.js'; +import { useToast } from '../../components/Toast.js'; +import { useIsMobile } from '../../components/useIsMobile.js'; + +type UpdateCenterStatus = { + currentVersion?: string; + config?: { + enabled: boolean; + helperBaseUrl: string; + namespace: string; + releaseName: string; + chartRef: string; + imageRepository: string; + githubReleasesEnabled: boolean; + dockerHubTagsEnabled: boolean; + defaultDeploySource: 'github-release' | 'docker-hub-tag'; + }; + githubRelease?: { + normalizedVersion?: string; + } | null; + dockerHubTag?: { + normalizedVersion?: string; + } | null; + helper?: { + ok?: boolean; + healthy?: boolean; + error?: string | null; + } | null; + runningTask?: { + id?: string; + status?: string; + } | null; + lastFinishedTask?: { + id?: string; + status?: string; + finishedAt?: string | null; + } | null; +}; + +const DEFAULT_CONFIG: NonNullable = { + enabled: false, + helperBaseUrl: '', + namespace: 'default', + releaseName: '', + chartRef: '', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', +}; + +const DEPLOY_SOURCE_OPTIONS = [ + { + value: 'github-release', + label: 'GitHub Releases', + description: '优先跟踪仓库稳定版 release。', + }, + { + value: 'docker-hub-tag', + label: 'Docker Hub Tags', + description: '适合直接跟随镜像标签推进部署。', + }, +] as const; + +const inputStyle: CSSProperties = { + width: '100%', + padding: '10px 14px', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: 13, + outline: 'none', + background: 'var(--color-bg)', + color: 'var(--color-text-primary)', +}; + +const sectionPanelStyle: CSSProperties = { + border: '1px solid var(--color-border-light)', + borderRadius: 'var(--radius-md)', + padding: 14, + background: 'var(--color-bg)', +}; + +const summaryLabelStyle: CSSProperties = { + fontSize: 12, + color: 'var(--color-text-muted)', + marginBottom: 6, +}; + +const summaryValueStyle: CSSProperties = { + fontSize: 14, + fontWeight: 600, + color: 'var(--color-text-primary)', + lineHeight: 1.4, +}; + +const fieldLabelStyle: CSSProperties = { + fontSize: 12, + color: 'var(--color-text-muted)', + marginBottom: 6, +}; + +const fieldHintStyle: CSSProperties = { + fontSize: 12, + color: 'var(--color-text-muted)', + lineHeight: 1.5, +}; + +function formatTaskTime(value?: string | null) { + if (!value) return '暂无完成记录'; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + return new Date(timestamp).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getTaskBadge(status?: string | null) { + switch (status) { + case 'running': + return { className: 'badge badge-info', label: '进行中' }; + case 'pending': + return { className: 'badge badge-warning', label: '排队中' }; + case 'succeeded': + return { className: 'badge badge-success', label: '已完成' }; + case 'failed': + return { className: 'badge badge-error', label: '失败' }; + default: + return { className: 'badge badge-muted', label: '空闲' }; + } +} + +function getHelperBadge(helper?: UpdateCenterStatus['helper'] | null, helperBaseUrl?: string) { + if (!helperBaseUrl) { + return { className: 'badge badge-muted', label: '未配置' }; + } + if (helper?.healthy) { + return { className: 'badge badge-success', label: 'Healthy' }; + } + if (helper?.ok) { + return { className: 'badge badge-warning', label: '可达但未就绪' }; + } + return { className: 'badge badge-error', label: '不可用' }; +} + +function getSourceBadge(enabled: boolean, version?: string) { + if (!enabled) { + return { className: 'badge badge-muted', label: '已停用' }; + } + if (version) { + return { className: 'badge badge-success', label: '可部署' }; + } + return { className: 'badge badge-warning', label: '未发现版本' }; +} + +export default function UpdateCenterSection() { + const toast = useToast(); + const isMobile = useIsMobile(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [checking, setChecking] = useState(false); + const [deploying, setDeploying] = useState(false); + const [status, setStatus] = useState(null); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [logs, setLogs] = useState([]); + const [taskStatus, setTaskStatus] = useState(''); + const streamAbortRef = useRef(null); + + const loadStatus = async () => { + setLoading(true); + try { + const next = await api.getUpdateCenterStatus() as UpdateCenterStatus; + setStatus(next); + setConfig(next.config || DEFAULT_CONFIG); + } catch (error: any) { + toast.error(error?.message || '加载更新中心失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadStatus(); + return () => { + streamAbortRef.current?.abort(); + }; + }, []); + + const saveConfig = async () => { + setSaving(true); + try { + const result = await api.saveUpdateCenterConfig(config) as { config?: UpdateCenterStatus['config'] }; + const nextConfig = result.config || config; + setConfig(nextConfig); + setStatus((prev) => ({ + ...(prev || {}), + config: nextConfig, + })); + toast.success('更新中心配置已保存'); + } catch (error: any) { + toast.error(error?.message || '保存更新中心配置失败'); + } finally { + setSaving(false); + } + }; + + const checkNow = async () => { + setChecking(true); + try { + const next = await api.checkUpdateCenter() as UpdateCenterStatus; + setStatus(next); + setConfig(next.config || DEFAULT_CONFIG); + toast.success('已刷新更新信息'); + } catch (error: any) { + toast.error(error?.message || '检查更新失败'); + } finally { + setChecking(false); + } + }; + + const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => { + if (!targetVersion) return; + setDeploying(true); + setLogs([]); + setTaskStatus('running'); + streamAbortRef.current?.abort(); + streamAbortRef.current = new AbortController(); + let taskId = ''; + + try { + const response = await api.deployUpdateCenter({ source, targetVersion }) as { task?: { id: string } }; + taskId = response.task?.id || ''; + if (!taskId) { + throw new Error('部署任务未返回 taskId'); + } + + await api.streamUpdateCenterTaskLogs(taskId, { + signal: streamAbortRef.current.signal, + onLog: (entry) => { + const message = String(entry?.message || '').trim(); + if (!message) return; + setLogs((prev) => [...prev, message].slice(-200)); + }, + onDone: (payload) => { + setTaskStatus(String(payload?.status || 'unknown')); + }, + }); + } catch (error: any) { + if (taskId) { + try { + const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } }; + const task = taskResponse.task; + if (task) { + setTaskStatus(String(task.status || 'unknown')); + setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []); + toast.info('实时日志流已断开,已回退到任务详情快照'); + return; + } + } catch { + // fall through to the generic error state + } + } + setTaskStatus('failed'); + toast.error(error?.message || '部署失败'); + } finally { + setDeploying(false); + void loadStatus(); + } + }; + + const helperHealthy = !!status?.helper?.healthy; + const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl); + const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined); + const lastFinishedTaskBadge = getTaskBadge(status?.lastFinishedTask?.status || undefined); + const visibleTaskStatus = taskStatus || status?.runningTask?.status || status?.lastFinishedTask?.status || 'idle'; + + const canDeployGithub = Boolean( + !deploying + && config.enabled + && helperHealthy + && config.githubReleasesEnabled + && status?.githubRelease?.normalizedVersion, + ); + const canDeployDocker = Boolean( + !deploying + && config.enabled + && helperHealthy + && config.dockerHubTagsEnabled + && status?.dockerHubTag?.normalizedVersion, + ); + + const renderDeployReason = (enabled: boolean, version?: string, helperError?: string | null) => { + if (!config.enabled) return '更新中心已关闭,保存启用后才能部署。'; + if (!enabled) return '当前来源已停用,开启后才会参与检查和部署。'; + if (!helperHealthy) return helperError || 'Deploy Helper 未健康,先修复 helper 再部署。'; + if (!version) return '当前来源还没有可部署版本。'; + return '版本可用,点击按钮即可通过 helper 发起滚动更新。'; + }; + + if (loading) { + return ( +
+
更新中心
+
+ 正在加载部署来源、版本状态和 helper 健康检查... +
+
+ ); + } + + return ( +
+
+
更新中心
+
+ 在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。 +
+
+ +
+
+
当前运行版本
+
+ {status?.currentVersion || '-'} +
+
以当前容器内运行版本为准。
+
+
+
Deploy Helper
+
+ {helperBadge.label} +
+
+ {config.helperBaseUrl || '尚未配置 Helper URL'} +
+
+
+
默认部署来源
+
+ {DEPLOY_SOURCE_OPTIONS.find((item) => item.value === config.defaultDeploySource)?.label || 'GitHub Releases'} +
+
保存配置后,手动部署默认优先使用这里的来源。
+
+
+
最近任务
+
+ + {status?.runningTask ? `运行中 · ${runningTaskBadge.label}` : lastFinishedTaskBadge.label} + +
+
+ {status?.runningTask?.id + ? `任务 ID: ${status.runningTask.id}` + : formatTaskTime(status?.lastFinishedTask?.finishedAt)} +
+
+
+ +
+ + + +
+ +
+
部署配置
+
+ 和现有 Settings 区块保持同一套表单密度。这里保存的是 helper 和目标 release 的持久化配置。 +
+ +
+ + + + + + +
+ +
+ + +
+
+ +
+
可部署版本
+
+ 默认来源会用主按钮强调。Helper 未就绪时,部署入口会自动禁用,避免触发无效任务。 +
+ +
+
+
+
GitHub Releases
+
+ + {getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label} + + {config.defaultDeploySource === 'github-release' ? ( + 默认来源 + ) : null} +
+
+
优先使用仓库稳定版 release,适合保留语义化版本节奏。
+
+ {status?.githubRelease?.normalizedVersion || '未发现'} +
+
+ {renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)} +
+ +
+ +
+
+
Docker Hub
+
+ + {getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label} + + {config.defaultDeploySource === 'docker-hub-tag' ? ( + 默认来源 + ) : null} +
+
+
适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。
+
+ {status?.dockerHubTag?.normalizedVersion || '未发现'} +
+
+ {renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)} +
+ +
+
+ +
+
+
Helper 健康摘要
+
+ {helperBadge.label} + 当前任务 · {runningTaskBadge.label} +
+
+ {status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'} +
+
+ +
+
任务快照
+
+
+ 运行中任务: + + {status?.runningTask?.id ? `${status.runningTask.id} · ${status.runningTask.status || '-'}` : '无'} + +
+
+ 最近完成: + + {status?.lastFinishedTask?.id + ? `${status.lastFinishedTask.id} · ${status.lastFinishedTask.status || '-'}` + : '无'} + +
+
+ 完成时间:{formatTaskTime(status?.lastFinishedTask?.finishedAt)} +
+
+
+
+
+ +
+
+
部署日志
+ + 任务状态 · {getTaskBadge(visibleTaskStatus).label} + +
+
+ 日志流断开时会自动回退到任务快照。这里只保留最近 200 条输出,方便直接判断 Helm 与 rollout 卡在哪一步。 +
+
+ {logs.length > 0 ? ( +
+              {logs.join('\n')}
+            
+ ) : ( +
+ 部署开始后,这里会实时显示 helm upgrade、kubectl rollout status 和回滚日志。 +
+ )} +
+
+ {status?.helper?.error ? ( +
+ Helper 错误:{status.helper.error} +
+ ) : null} +
+ 最近状态:{visibleTaskStatus} +
+
+ ); +}