This commit is contained in:
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.3.0] - 2026-09-27
|
||||
|
||||
### 新增
|
||||
|
||||
- **Web 一键升级(Docker Compose)**:新增受限的 `fonu-updater` 升级 sidecar,支持 `docker-compose.yml`(本机源码构建)与 `docker-compose.hub.yml`(Git 仓库构建)两种部署。主程序只通过共享 Unix Socket 调用 sidecar,不直接挂载 Docker Socket;升级时只重建 `fonu` 服务,构建或健康检查失败会自动尝试回滚到升级前的镜像 tag
|
||||
- 前端侧栏在检测到可升级版本时显示「一键升级」入口,升级弹窗展示当前版本、目标版本、升级方式、阶段消息与最近日志;服务重启期间浏览器会自动等待恢复
|
||||
- 新增 `docs/system-upgrade.md`,说明首次部署 sidecar、安全边界、升级流程、回滚和常见问题
|
||||
|
||||
### 变更
|
||||
|
||||
- 版本号提升到 1.3.0;`web/package.json` 与 `web/package-lock.json` 同步对齐
|
||||
- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化
|
||||
|
||||
### 注意
|
||||
|
||||
- 已经运行旧版本的实例,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build`,让 `fonu-updater` sidecar 启动。此后才可以在 Web 页面一键升级
|
||||
|
||||
### 测试
|
||||
|
||||
- `internal/updater`:版本比较、compose 路径白名单、配置校验
|
||||
|
||||
## [v1.2.21] - 2026-09-27
|
||||
|
||||
### 修复
|
||||
|
||||
+19
@@ -11,6 +11,7 @@ ARG VERSION_FILE=VERSION
|
||||
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm
|
||||
ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim
|
||||
ARG UPDATER_IMAGE=docker.m.daocloud.io/library/docker:27-cli
|
||||
# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
@@ -44,6 +45,24 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
|
||||
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
|
||||
-o /fonu ./cmd/fonu
|
||||
|
||||
# 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它
|
||||
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder
|
||||
ARG TARGETARCH
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -o /fonu-updater ./cmd/fonu-updater
|
||||
|
||||
# updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用
|
||||
FROM ${UPDATER_IMAGE} AS updater
|
||||
RUN apk add --no-cache git docker-cli-compose docker-cli-buildx ca-certificates
|
||||
COPY --from=updater-builder /fonu-updater /usr/local/bin/fonu-updater
|
||||
ENTRYPOINT ["/usr/local/bin/fonu-updater"]
|
||||
|
||||
# 运行时 stage
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -112,6 +112,12 @@ docker compose up -d --build
|
||||
|
||||
</details>
|
||||
|
||||
### Web 一键升级
|
||||
|
||||
`docker-compose.yml` 与 `docker-compose.hub.yml` 会同时启动受限的 `fonu-updater` sidecar。检测到新版本后,侧栏会出现「一键升级」入口,可在管理页面完成构建、重建与健康检查。
|
||||
|
||||
> 从旧版本升级时,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build` 启动 sidecar。详见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。
|
||||
|
||||
### 首次登录
|
||||
|
||||
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
|
||||
@@ -149,12 +155,13 @@ docker compose up -d --build
|
||||
| ----------------------- | ------------------------- | ------------------------------------------ |
|
||||
| `FONU_LISTEN` | `:6893` | 管理后台监听地址 |
|
||||
| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) |
|
||||
| `FONU_SESSION_SECRET` | `change-me-in-production` | 会话加密密钥,生产环境请自行修改 |
|
||||
| `FONU_SESSION_SECRET` | 空(自动生成) | 会话与凭据加密密钥;有数据后不要更换 |
|
||||
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
||||
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
||||
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
|
||||
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
|
||||
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
|
||||
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket;未挂载时自动禁用 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server, err := updater.NewServer(updater.Config{
|
||||
SocketPath: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
|
||||
ProjectDir: envOr("FONU_UPDATE_PROJECT_DIR", "/workspace"),
|
||||
ComposeFile: envOr("FONU_UPDATE_COMPOSE_FILE", "docker-compose.yml"),
|
||||
Service: envOr("FONU_UPDATE_SERVICE", "fonu"),
|
||||
Mode: envOr("FONU_UPDATE_MODE", "local"),
|
||||
RepoURL: envOr("FONU_UPDATE_REPO", ""),
|
||||
Branch: envOr("FONU_UPDATE_BRANCH", "main"),
|
||||
AppURL: envOr("FONU_UPDATE_APP_URL", "http://fonu:6893"),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := server.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
image: fonu:${FONU_VERSION:-local}
|
||||
# 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "fonu Pulling" 就是),
|
||||
@@ -32,6 +33,8 @@ services:
|
||||
pull_policy: build
|
||||
container_name: fonu
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- updater
|
||||
ports:
|
||||
- "6893:6893"
|
||||
- "18080:80"
|
||||
@@ -41,14 +44,45 @@ services:
|
||||
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||
updater:
|
||||
build:
|
||||
context: http://45.207.223.190:10082/mrlee/-Fonu.git#main
|
||||
target: updater
|
||||
args:
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
image: fonu-updater:${FONU_VERSION:-local}
|
||||
pull_policy: build
|
||||
container_name: fonu-updater
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
- .:/workspace
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
FONU_UPDATE_PROJECT_DIR: /workspace
|
||||
FONU_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
|
||||
FONU_UPDATE_SERVICE: fonu
|
||||
FONU_UPDATE_MODE: git
|
||||
FONU_UPDATE_REPO: http://45.207.223.190:10082/mrlee/-Fonu.git
|
||||
FONU_UPDATE_BRANCH: main
|
||||
FONU_UPDATE_APP_URL: http://fonu:6893
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
volumes:
|
||||
fonu-data:
|
||||
fonu-updater-sock:
|
||||
|
||||
@@ -13,10 +13,13 @@ services:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
image: fonu:${FONU_VERSION:-local}
|
||||
container_name: fonu
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- updater
|
||||
ports:
|
||||
- "6893:6893"
|
||||
- "18080:80"
|
||||
@@ -26,14 +29,42 @@ services:
|
||||
# 想落到指定目录就在同层 .env 里设 FONU_DATA_PATH=/vol1/docker/fonu-data ——
|
||||
# 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。
|
||||
- ${FONU_DATA_PATH:-fonu-data}:/data
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||
updater:
|
||||
build:
|
||||
context: .
|
||||
target: updater
|
||||
args:
|
||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
image: fonu-updater:${FONU_VERSION:-local}
|
||||
container_name: fonu-updater
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
- .:/workspace
|
||||
- fonu-updater-sock:/run/fonu-updater
|
||||
environment:
|
||||
FONU_UPDATER_SOCKET: /run/fonu-updater/updater.sock
|
||||
FONU_UPDATE_PROJECT_DIR: /workspace
|
||||
FONU_UPDATE_COMPOSE_FILE: docker-compose.yml
|
||||
FONU_UPDATE_SERVICE: fonu
|
||||
FONU_UPDATE_MODE: local
|
||||
FONU_UPDATE_APP_URL: http://fonu:6893
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
volumes:
|
||||
fonu-data:
|
||||
fonu-updater-sock:
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
# Fonu 文档索引
|
||||
|
||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||
> 最近更新:2026-09-27(v1.2.21)
|
||||
> 最近更新:2026-09-27(v1.3.0)
|
||||
|
||||
## 方案与设计(本目录)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
| --- | --- | --- |
|
||||
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
|
||||
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3 已完成,其余待实施 |
|
||||
| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚与故障排查 | 已实施(v1.3.0) |
|
||||
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
|
||||
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
|
||||
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
|
||||
|
||||
@@ -172,8 +172,10 @@ FONU_VERSION=1.2.18 docker compose up -d
|
||||
| `FONU_LISTEN` | `:6893` | 面板监听地址 |
|
||||
| `FONU_SESSION_SECRET` | 空 | 留空即自动生成并持久化;**它是凭据加密密钥,有数据后不要更换** |
|
||||
| `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` | `80` / `443` | 反代监听端口(host 网络那份默认 18080/9443) |
|
||||
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 一键升级 sidecar 的 Unix Socket |
|
||||
| `DOCKER_SOCK` | `/var/run/docker.sock` | 宿主机 Docker Socket;rootless Docker 或自定义路径时覆盖 |
|
||||
| `TZ` | `Asia/Shanghai` | 通知与日志显示用的时区 |
|
||||
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
|
||||
| `NODE_IMAGE` / `GO_IMAGE` / `RUNTIME_IMAGE` / `UPDATER_IMAGE` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Fonu Web 一键升级方案
|
||||
|
||||
> 实施版本:v1.3.0
|
||||
> 适用范围:`docker-compose.yml`、`docker-compose.hub.yml`
|
||||
> 不适用范围:`docker-compose.host.yml`、原生 systemd 安装、Windows 直接运行
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
在管理后台检测到新版本后,用户点击「一键升级」即可完成:
|
||||
|
||||
1. 读取远端最新版本;
|
||||
2. 构建新的 `fonu` 镜像;
|
||||
3. 只重建 `fonu` 容器;
|
||||
4. 等待 `/api/version` 返回目标版本;
|
||||
5. 失败时尝试回滚到升级前的镜像 tag;
|
||||
6. 把升级过程和结果返回给前端。
|
||||
|
||||
## 2. 安全边界
|
||||
|
||||
主程序容器**不挂载** `/var/run/docker.sock`。
|
||||
|
||||
只有 `fonu-updater` sidecar 持有:
|
||||
|
||||
- Docker Socket;
|
||||
- Docker CLI 与 Compose;
|
||||
- 项目目录;
|
||||
- Git。
|
||||
|
||||
主程序与 sidecar 通过一个共享 Unix Socket 通信:
|
||||
|
||||
```text
|
||||
浏览器
|
||||
→ fonu 后端 /api/system/update/*
|
||||
→ /run/fonu-updater/updater.sock
|
||||
→ fonu-updater
|
||||
→ docker compose build/up
|
||||
```
|
||||
|
||||
这表示:
|
||||
|
||||
- 管理后台被攻破时,攻击者仍需通过 sidecar 的固定接口才能触发升级;
|
||||
- sidecar 只接受固定的 Compose 文件名和固定的 `fonu` 服务名;
|
||||
- 不提供任意命令执行接口;
|
||||
- 不支持自定义 shell 命令。
|
||||
|
||||
## 3. 两种升级模式
|
||||
|
||||
| Compose 文件 | 模式 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `docker-compose.yml` | `local` | 在项目目录检查 Git 工作区,干净时执行 `git pull --ff-only`,再构建 |
|
||||
| `docker-compose.hub.yml` | `git` | Compose 的 build context 直接从 Git 仓库拉取,updater 只负责构建与重建 |
|
||||
|
||||
两种模式都会:
|
||||
|
||||
- 读取目标版本;
|
||||
- 构建 `fonu` 镜像;
|
||||
- 执行 `docker compose up -d --no-deps --force-recreate fonu`;
|
||||
- 轮询 `/api/version` 验证升级结果。
|
||||
|
||||
## 4. 首次部署
|
||||
|
||||
一键升级依赖 `fonu-updater` 已经运行。
|
||||
|
||||
旧版本升级到 v1.3.0 时,需要先手动执行一次:
|
||||
|
||||
```bash
|
||||
# 本地源码部署
|
||||
docker compose up -d --build
|
||||
|
||||
# 单文件 Git 构建部署
|
||||
docker compose -f docker-compose.hub.yml up -d --build
|
||||
```
|
||||
|
||||
完成这一轮后,后续版本即可在 Web 页面一键升级。
|
||||
|
||||
## 5. 环境变量
|
||||
|
||||
### 主程序
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `FONU_UPDATER_SOCKET` | `/run/fonu-updater/updater.sock` | 与 sidecar 通信的 Unix Socket |
|
||||
|
||||
### updater sidecar
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `FONU_UPDATER_SOCKET` | sidecar 监听的 Unix Socket |
|
||||
| `FONU_UPDATE_PROJECT_DIR` | Compose 项目目录,容器内为 `/workspace` |
|
||||
| `FONU_UPDATE_COMPOSE_FILE` | 只允许 `docker-compose.yml`、`docker-compose.hub.yml`、`docker-compose.host.yml` |
|
||||
| `FONU_UPDATE_SERVICE` | 要重建的服务,默认 `fonu` |
|
||||
| `FONU_UPDATE_MODE` | `local` 或 `git` |
|
||||
| `FONU_UPDATE_REPO` | `git` 模式下的仓库地址 |
|
||||
| `FONU_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` |
|
||||
| `FONU_UPDATE_APP_URL` | 健康检查地址,默认 `http://fonu:6893` |
|
||||
| `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` |
|
||||
|
||||
## 6. 前端行为
|
||||
|
||||
侧栏版本区在满足以下条件时显示「一键升级」:
|
||||
|
||||
- sidecar 可用;
|
||||
- 远端版本高于当前版本;
|
||||
- 当前没有正在执行的升级任务。
|
||||
|
||||
点击后弹窗显示:
|
||||
|
||||
- 当前版本;
|
||||
- 目标版本;
|
||||
- 升级方式;
|
||||
- 当前阶段;
|
||||
- 最近日志。
|
||||
|
||||
升级开始后,页面会持续轮询升级状态。容器重建期间请求会短暂失败,前端会显示「服务正在重启,等待恢复」,恢复成功后自动刷新页面。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
升级前会记录当前容器镜像。升级后健康检查超时,sidecar 会尝试:
|
||||
|
||||
```bash
|
||||
FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate fonu
|
||||
```
|
||||
|
||||
回滚是否成功会写入升级日志。
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### 侧栏没有「一键升级」
|
||||
|
||||
检查:
|
||||
|
||||
1. 当前是否使用 `docker-compose.yml` 或 `docker-compose.hub.yml`;
|
||||
2. `fonu-updater` 容器是否在运行;
|
||||
3. `FONU_UPDATER_SOCKET` 是否在两侧指向同一路径;
|
||||
4. 共享卷 `fonu-updater-sock` 是否存在。
|
||||
|
||||
### `docker-compose.yml` 模式提示存在未提交改动
|
||||
|
||||
升级前的 Git 工作区检查要求干净,避免覆盖用户修改。先处理工作区,或手动执行:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### `docker-compose.hub.yml` 模式无法读取远端版本
|
||||
|
||||
检查 sidecar 容器内是否可以访问 Git 仓库:
|
||||
|
||||
```bash
|
||||
docker exec -it fonu-updater sh -lc 'git ls-remote http://45.207.223.190:10082/mrlee/-Fonu.git main'
|
||||
```
|
||||
|
||||
### 升级卡在 `running`
|
||||
|
||||
查看 sidecar 日志:
|
||||
|
||||
```bash
|
||||
docker logs -f fonu-updater
|
||||
```
|
||||
|
||||
以及 Compose 输出:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail 100 fonu
|
||||
```
|
||||
|
||||
### 回滚失败
|
||||
|
||||
确认升级前的镜像 tag 仍然存在:
|
||||
|
||||
```bash
|
||||
docker images fonu
|
||||
```
|
||||
|
||||
然后用明确的版本 tag 手动切回:
|
||||
|
||||
```bash
|
||||
FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
||||
```
|
||||
|
||||
## 9. 暂不支持
|
||||
|
||||
- `docker-compose.host.yml`:需要确认 host 网络与端口占用后再设计;
|
||||
- 原生 systemd 安装:需要单独的受控升级 helper;
|
||||
- 回滚到远端不存在的镜像:需要额外发布流程;
|
||||
- 升级 `fonu-updater` 自身:当前只重建 `fonu` 服务,sidecar 更新仍需一次手动 Compose 操作。
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
@@ -34,6 +35,7 @@ type Deps struct {
|
||||
FRPMulti *frp.Service
|
||||
Traffic *traffic.Collector
|
||||
Notify *notify.Service
|
||||
Updater *updater.Client
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ func tokenDeniedPath(path string) bool {
|
||||
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, "/api/settings/tokens")
|
||||
return strings.HasPrefix(path, "/api/settings/tokens") ||
|
||||
strings.HasPrefix(path, "/api/system/update")
|
||||
}
|
||||
|
||||
func sessionIDFromContext(ctx context.Context) string {
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewRouter(deps Deps) http.Handler {
|
||||
metricsHandler: NewMetricsHandler(deps.Metrics),
|
||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
||||
updateHandler: NewUpdateHandler(deps.Updater),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
}
|
||||
@@ -94,6 +95,8 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/logs/system", r.logsHandler.System)
|
||||
protect("GET /api/metrics/history", r.metricsHandler.History)
|
||||
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
||||
protect("GET /api/system/update/status", r.updateHandler.Status)
|
||||
protect("POST /api/system/update/apply", r.updateHandler.Apply)
|
||||
protect("GET /api/backup/export", r.backupHandler.Export)
|
||||
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
||||
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
|
||||
@@ -190,6 +193,7 @@ type Router struct {
|
||||
metricsHandler *MetricsHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
updateHandler *UpdateHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
type UpdateHandler struct {
|
||||
client *updater.Client
|
||||
}
|
||||
|
||||
func NewUpdateHandler(client *updater.Client) *UpdateHandler {
|
||||
return &UpdateHandler{client: client}
|
||||
}
|
||||
|
||||
func (h *UpdateHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil || !h.client.Enabled() {
|
||||
writeJSON(w, http.StatusOK, updater.Status{
|
||||
Enabled: false,
|
||||
Phase: updater.PhaseIdle,
|
||||
Message: "当前部署未启用一键升级",
|
||||
})
|
||||
return
|
||||
}
|
||||
status, err := h.client.Status(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, "读取升级状态失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (h *UpdateHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil || !h.client.Enabled() {
|
||||
writeError(r, w, http.StatusServiceUnavailable, "当前部署未启用一键升级")
|
||||
return
|
||||
}
|
||||
status, err := h.client.Apply(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, "启动升级失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, status)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateHandlerDisabled(t *testing.T) {
|
||||
handler := NewUpdateHandler(nil)
|
||||
|
||||
statusRecorder := httptest.NewRecorder()
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/api/system/update/status", nil)
|
||||
handler.Status(statusRecorder, statusReq)
|
||||
if statusRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("状态接口期望 200,实际 %d", statusRecorder.Code)
|
||||
}
|
||||
if body := statusRecorder.Body.String(); !strings.Contains(body, `"enabled":false`) {
|
||||
t.Fatalf("未返回 enabled=false: %s", body)
|
||||
}
|
||||
|
||||
applyRecorder := httptest.NewRecorder()
|
||||
applyReq := httptest.NewRequest(http.MethodPost, "/api/system/update/apply", nil)
|
||||
handler.Apply(applyRecorder, applyReq)
|
||||
if applyRecorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("升级接口期望 503,实际 %d", applyRecorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
@@ -113,6 +114,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
}
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
||||
|
||||
handler := api.NewRouter(api.Deps{
|
||||
Config: cfg,
|
||||
@@ -129,6 +131,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
Discovery: discoverySvc,
|
||||
FRPMulti: frpMultiSvc,
|
||||
Traffic: trafficCollector,
|
||||
Updater: updaterClient,
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
NginxDefaultHTTPSPort int
|
||||
FrpcBin string
|
||||
FrpPIDFile string
|
||||
UpdaterSocket string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -39,6 +40,7 @@ func Load() Config {
|
||||
NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client 通过共享 Unix Socket 调用 fonu-updater sidecar。
|
||||
type Client struct {
|
||||
socketPath string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(socketPath string) *Client {
|
||||
socketPath = strings.TrimSpace(socketPath)
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
}
|
||||
return &Client{
|
||||
socketPath: socketPath,
|
||||
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
if c == nil || c.socketPath == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(c.socketPath)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func (c *Client) Status(ctx context.Context) (Status, error) {
|
||||
var status Status
|
||||
if err := c.request(ctx, http.MethodGet, "/status", nil, &status); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *Client) Apply(ctx context.Context) (Status, error) {
|
||||
var status Status
|
||||
if err := c.request(ctx, http.MethodPost, "/apply", map[string]string{}, &status); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method, path string, payload any, result any) error {
|
||||
if !c.Enabled() {
|
||||
return fmt.Errorf("一键升级服务未启用")
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://unix"+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("updater 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return fmt.Errorf("updater 响应格式无效: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = "idle"
|
||||
PhaseScheduled Phase = "scheduled"
|
||||
PhaseRunning Phase = "running"
|
||||
PhaseSuccess Phase = "success"
|
||||
PhaseFailed Phase = "failed"
|
||||
)
|
||||
|
||||
// Status 是主程序与 fonu-updater sidecar 共享的更新状态。
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
LatestVersion string `json:"latest_version,omitempty"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
Busy bool `json:"busy"`
|
||||
Phase Phase `json:"phase"`
|
||||
Message string `json:"message"`
|
||||
Log []string `json:"log,omitempty"`
|
||||
CheckedAt string `json:"checked_at,omitempty"`
|
||||
}
|
||||
|
||||
func busyPhase(phase Phase) bool {
|
||||
return phase == PhaseScheduled || phase == PhaseRunning
|
||||
}
|
||||
|
||||
// CompareVersions 比较形如 1.2.3、v1.2.3 的版本号;返回值大于 0 表示 a 更新。
|
||||
func CompareVersions(a, b string) int {
|
||||
left := parseVersion(a)
|
||||
right := parseVersion(b)
|
||||
length := len(left)
|
||||
if len(right) > length {
|
||||
length = len(right)
|
||||
}
|
||||
for index := 0; index < length; index++ {
|
||||
var lv, rv int
|
||||
if index < len(left) {
|
||||
lv = left[index]
|
||||
}
|
||||
if index < len(right) {
|
||||
rv = right[index]
|
||||
}
|
||||
if lv != rv {
|
||||
if lv > rv {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseVersion(value string) []int {
|
||||
clean := strings.TrimSpace(strings.TrimPrefix(value, "v"))
|
||||
if index := strings.IndexAny(clean, "-+"); index >= 0 {
|
||||
clean = clean[:index]
|
||||
}
|
||||
parts := strings.Split(clean, ".")
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
number, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
out = append(out, number)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSemver(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("版本号为空")
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(value, "v"), ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("版本号格式无效: %s", value)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if _, err := strconv.Atoi(part); err != nil {
|
||||
return fmt.Errorf("版本号格式无效: %s", value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package updater
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
left string
|
||||
right string
|
||||
want int
|
||||
}{
|
||||
{"1.2.20", "1.2.19", 1},
|
||||
{"v1.2.19", "1.2.20", -1},
|
||||
{"1.3.0", "1.3.0", 0},
|
||||
{"1.2.21-beta.1", "1.2.21", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := CompareVersions(tc.left, tc.right)
|
||||
if got != tc.want {
|
||||
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.left, tc.right, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSemver(t *testing.T) {
|
||||
if err := validateSemver("1.2.21"); err != nil {
|
||||
t.Fatalf("合法版本被拒绝: %v", err)
|
||||
}
|
||||
for _, value := range []string{"", "1.2", "main", "1.2.x"} {
|
||||
if err := validateSemver(value); err == nil {
|
||||
t.Fatalf("非法版本未被拒绝: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxCommandOutput = 64 << 10
|
||||
|
||||
func (s *Server) runUpdate(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
s.state.Phase = PhaseRunning
|
||||
s.state.Message = "正在准备升级"
|
||||
s.mu.Unlock()
|
||||
|
||||
latest, err := s.latestVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSemver(latest); err != nil {
|
||||
return err
|
||||
}
|
||||
current, _ := readCurrentVersion(ctx, s.cfg.AppURL)
|
||||
if current != "" && CompareVersions(latest, current) <= 0 {
|
||||
return fmt.Errorf("当前已是最新版本 %s", current)
|
||||
}
|
||||
previousImage, _ := s.currentImage()
|
||||
s.logf("当前容器镜像:%s", previousImage)
|
||||
s.logf("目标版本:%s", latest)
|
||||
|
||||
if s.cfg.Mode == "local" {
|
||||
if err := s.prepareLocalSource(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.build(ctx, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.recreate(ctx, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.waitHealthy(ctx, latest); err != nil {
|
||||
s.rollback(previousImage)
|
||||
return fmt.Errorf("升级后健康检查失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) build(ctx context.Context, version string) error {
|
||||
s.logf("开始构建 fonu:%s,这一步可能持续几分钟", version)
|
||||
buildCtx, cancel := context.WithTimeout(ctx, s.cfg.BuildTimeout)
|
||||
defer cancel()
|
||||
if _, err := s.compose(buildCtx, version, "build", s.cfg.Service); err != nil {
|
||||
return fmt.Errorf("docker compose build 失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) recreate(ctx context.Context, version string) error {
|
||||
s.logf("重建容器 %s", s.cfg.Service)
|
||||
upCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.compose(upCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
|
||||
return fmt.Errorf("docker compose up 失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) waitHealthy(ctx context.Context, expected string) error {
|
||||
s.logf("等待服务恢复并返回版本 %s", expected)
|
||||
deadline := time.Now().Add(s.cfg.HealthTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
version, err := readCurrentVersion(checkCtx, s.cfg.AppURL)
|
||||
cancel()
|
||||
if err == nil && version == expected {
|
||||
s.logf("健康检查通过:%s", version)
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("等待 %s 超时", expected)
|
||||
}
|
||||
|
||||
func (s *Server) rollback(previousImage string) {
|
||||
version := imageTag(previousImage)
|
||||
if version == "" {
|
||||
s.logf("无法确定上一个镜像 tag,跳过自动回滚")
|
||||
return
|
||||
}
|
||||
s.logf("发现升级失败,尝试回滚到 fonu:%s", version)
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.compose(rollbackCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
|
||||
s.logf("自动回滚失败:%v", err)
|
||||
return
|
||||
}
|
||||
s.logf("已切回 fonu:%s", version)
|
||||
}
|
||||
|
||||
func (s *Server) prepareLocalSource(ctx context.Context) error {
|
||||
if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
|
||||
s.logf("项目目录不是 Git 仓库,跳过源码拉取")
|
||||
return nil
|
||||
}
|
||||
status, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "status", "--porcelain")
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 Git 工作区失败:%w", err)
|
||||
}
|
||||
if strings.TrimSpace(status) != "" {
|
||||
return fmt.Errorf("本地源码存在未提交改动,已拒绝自动拉取")
|
||||
}
|
||||
s.logf("拉取远端源码")
|
||||
if _, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "pull", "--ff-only"); err != nil {
|
||||
return fmt.Errorf("git pull 失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) latestVersion(ctx context.Context) (string, error) {
|
||||
if s.cfg.Mode == "git" {
|
||||
return s.latestVersionFromGitClone(ctx)
|
||||
}
|
||||
return s.latestVersionFromLocal(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) latestVersionFromLocal(ctx context.Context) (string, error) {
|
||||
localPath := filepath.Join(s.cfg.ProjectDir, "VERSION")
|
||||
if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
|
||||
return readVersionFile(localPath)
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := runCommand(fetchCtx, s.cfg.ProjectDir, nil, "git", "fetch", "origin", s.cfg.Branch, "--prune"); err != nil {
|
||||
return readVersionFile(localPath)
|
||||
}
|
||||
value, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "show", "origin/"+s.cfg.Branch+":VERSION")
|
||||
if err != nil {
|
||||
return readVersionFile(localPath)
|
||||
}
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
func (s *Server) latestVersionFromGitClone(ctx context.Context) (string, error) {
|
||||
dir, err := os.MkdirTemp("", "fonu-update-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
cloneCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := runCommand(cloneCtx, "", nil, "git", "clone", "--depth", "1", "--branch", s.cfg.Branch, s.cfg.RepoURL, dir); err != nil {
|
||||
return "", fmt.Errorf("读取远端版本失败:%w", err)
|
||||
}
|
||||
return readVersionFile(filepath.Join(dir, "VERSION"))
|
||||
}
|
||||
|
||||
func readVersionFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
version := strings.TrimSpace(string(data))
|
||||
if err := validateSemver(version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func readCurrentVersion(ctx context.Context, appURL string) (string, error) {
|
||||
url := strings.TrimRight(appURL, "/") + "/api/version"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(payload.Version), nil
|
||||
}
|
||||
|
||||
func (s *Server) currentImage() (string, error) {
|
||||
output, err := runCommand(context.Background(), "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", s.cfg.Service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(output), nil
|
||||
}
|
||||
|
||||
func imageTag(image string) string {
|
||||
image = strings.TrimSpace(image)
|
||||
if image == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(image, ":")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func (s *Server) compose(ctx context.Context, version string, args ...string) (string, error) {
|
||||
fullArgs := append([]string{"compose", "-f", s.composePath}, args...)
|
||||
env := []string{"FONU_VERSION=" + version}
|
||||
return runCommand(ctx, s.cfg.ProjectDir, env, "docker", fullArgs...)
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, error) {
|
||||
command := exec.CommandContext(ctx, name, args...)
|
||||
command.Dir = dir
|
||||
if len(extraEnv) > 0 {
|
||||
command.Env = append(os.Environ(), extraEnv...)
|
||||
}
|
||||
output, err := command.CombinedOutput()
|
||||
text := string(output)
|
||||
if len(text) > maxCommandOutput {
|
||||
text = text[len(text)-maxCommandOutput:]
|
||||
}
|
||||
if err != nil {
|
||||
return text, fmt.Errorf("%s: %w: %s", strings.Join(append([]string{name}, args...), " "), err, strings.TrimSpace(text))
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrBusy = errors.New("升级任务正在进行中")
|
||||
|
||||
var serviceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
|
||||
|
||||
// Config 是 fonu-updater sidecar 的运行配置。
|
||||
type Config struct {
|
||||
SocketPath string
|
||||
ProjectDir string
|
||||
ComposeFile string
|
||||
Service string
|
||||
Mode string
|
||||
RepoURL string
|
||||
Branch string
|
||||
AppURL string
|
||||
BuildTimeout time.Duration
|
||||
HealthTimeout time.Duration
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
composePath string
|
||||
|
||||
mu sync.Mutex
|
||||
state Status
|
||||
lastChecked time.Time
|
||||
}
|
||||
|
||||
// NewServer 校验 sidecar 配置;参数不正确时拒绝启动。
|
||||
func NewServer(cfg Config, logger *slog.Logger) (*Server, error) {
|
||||
cfg.SocketPath = strings.TrimSpace(cfg.SocketPath)
|
||||
if cfg.SocketPath == "" {
|
||||
cfg.SocketPath = "/run/fonu-updater/updater.sock"
|
||||
}
|
||||
if strings.TrimSpace(cfg.ProjectDir) == "" {
|
||||
cfg.ProjectDir = "/workspace"
|
||||
}
|
||||
if strings.TrimSpace(cfg.ComposeFile) == "" {
|
||||
cfg.ComposeFile = "docker-compose.yml"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Service) == "" {
|
||||
cfg.Service = "fonu"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Mode) == "" {
|
||||
cfg.Mode = "local"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Branch) == "" {
|
||||
cfg.Branch = "main"
|
||||
}
|
||||
if strings.TrimSpace(cfg.AppURL) == "" {
|
||||
cfg.AppURL = "http://fonu:6893"
|
||||
}
|
||||
if cfg.BuildTimeout <= 0 {
|
||||
cfg.BuildTimeout = 30 * time.Minute
|
||||
}
|
||||
if cfg.HealthTimeout <= 0 {
|
||||
cfg.HealthTimeout = 3 * time.Minute
|
||||
}
|
||||
if cfg.Mode != "local" && cfg.Mode != "git" {
|
||||
return nil, fmt.Errorf("FONU_UPDATE_MODE 只支持 local 或 git")
|
||||
}
|
||||
if !serviceNamePattern.MatchString(cfg.Service) {
|
||||
return nil, fmt.Errorf("FONU_UPDATE_SERVICE 含非法字符")
|
||||
}
|
||||
composePath, err := resolveComposePath(cfg.ProjectDir, cfg.ComposeFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Mode == "git" && strings.TrimSpace(cfg.RepoURL) == "" {
|
||||
return nil, fmt.Errorf("git 模式必须设置 FONU_UPDATE_REPO")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
composePath: composePath,
|
||||
state: Status{
|
||||
Enabled: true,
|
||||
Mode: cfg.Mode,
|
||||
Phase: PhaseIdle,
|
||||
Message: "尚未检查更新",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /status", s.handleStatus)
|
||||
mux.HandleFunc("POST /apply", s.handleApply)
|
||||
return mux
|
||||
}
|
||||
|
||||
// Serve 在共享 Unix Socket 上提供服务。
|
||||
func (s *Server) Serve(ctx context.Context) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.cfg.SocketPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(s.cfg.SocketPath)
|
||||
listener, err := net.Listen("unix", s.cfg.SocketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(s.cfg.SocketPath)
|
||||
}()
|
||||
_ = os.Chmod(s.cfg.SocketPath, 0o660)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
err = server.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := s.Apply(r.Context())
|
||||
if errors.Is(err, ErrBusy) {
|
||||
writeJSON(w, http.StatusConflict, status)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
// Status 返回当前版本、远端版本和任务状态。
|
||||
func (s *Server) Status(ctx context.Context) Status {
|
||||
s.mu.Lock()
|
||||
if busyPhase(s.state.Phase) {
|
||||
status := cloneStatus(s.state)
|
||||
s.mu.Unlock()
|
||||
return status
|
||||
}
|
||||
if !s.lastChecked.IsZero() && time.Since(s.lastChecked) < time.Minute {
|
||||
status := cloneStatus(s.state)
|
||||
s.mu.Unlock()
|
||||
return status
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
current, currentErr := readCurrentVersion(checkCtx, s.cfg.AppURL)
|
||||
latest, latestErr := s.latestVersion(checkCtx)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastChecked = time.Now()
|
||||
s.state.CurrentVersion = current
|
||||
s.state.LatestVersion = latest
|
||||
s.state.UpdateAvailable = latest != "" && current != "" && CompareVersions(latest, current) > 0
|
||||
s.state.CheckedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
switch {
|
||||
case currentErr != nil:
|
||||
s.state.Message = "无法读取当前运行版本:" + currentErr.Error()
|
||||
case latestErr != nil:
|
||||
s.state.Message = "无法读取远端最新版本:" + latestErr.Error()
|
||||
case s.state.UpdateAvailable:
|
||||
s.state.Message = fmt.Sprintf("发现新版本 %s,当前 %s", latest, current)
|
||||
default:
|
||||
s.state.Message = "当前已是最新版本"
|
||||
}
|
||||
return cloneStatus(s.state)
|
||||
}
|
||||
|
||||
// Apply 立即返回排队状态,后台完成构建、重建、健康检查和必要回滚。
|
||||
func (s *Server) Apply(_ context.Context) (Status, error) {
|
||||
s.mu.Lock()
|
||||
if busyPhase(s.state.Phase) {
|
||||
status := cloneStatus(s.state)
|
||||
s.mu.Unlock()
|
||||
return status, ErrBusy
|
||||
}
|
||||
s.state.Phase = PhaseScheduled
|
||||
s.state.Busy = true
|
||||
s.state.Message = "升级任务已排队,即将重启 Fonu"
|
||||
s.state.Log = []string{"升级任务已排队"}
|
||||
status := cloneStatus(s.state)
|
||||
s.mu.Unlock()
|
||||
go s.runScheduled()
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Server) runScheduled() {
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := s.runUpdate(context.Background()); err != nil {
|
||||
s.logf("升级失败:%v", err)
|
||||
s.finish(PhaseFailed, err.Error())
|
||||
return
|
||||
}
|
||||
s.finish(PhaseSuccess, "升级完成")
|
||||
}
|
||||
|
||||
func (s *Server) finish(phase Phase, message string) {
|
||||
s.mu.Lock()
|
||||
s.state.Phase = phase
|
||||
s.state.Busy = false
|
||||
s.state.Message = message
|
||||
s.lastChecked = time.Time{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) logf(format string, args ...any) {
|
||||
line := fmt.Sprintf(format, args...)
|
||||
s.logger.Info(line, "module", "UPDATER")
|
||||
s.mu.Lock()
|
||||
s.state.Log = append(s.state.Log, line)
|
||||
if len(s.state.Log) > 200 {
|
||||
s.state.Log = s.state.Log[len(s.state.Log)-200:]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func cloneStatus(status Status) Status {
|
||||
status.Log = append([]string(nil), status.Log...)
|
||||
return status
|
||||
}
|
||||
|
||||
func resolveComposePath(projectDir, composeFile string) (string, error) {
|
||||
dir, err := filepath.Abs(strings.TrimSpace(projectDir))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(composeFile)
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(dir, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("compose 文件必须位于项目目录内")
|
||||
}
|
||||
switch filepath.Base(path) {
|
||||
case "docker-compose.yml", "docker-compose.hub.yml", "docker-compose.host.yml":
|
||||
return path, nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的 compose 文件:%s", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveComposePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := resolveComposePath(dir, "docker-compose.hub.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dir, "docker-compose.hub.yml")
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
if _, err := resolveComposePath(dir, "../docker-compose.yml"); err == nil {
|
||||
t.Fatal("路径上跳应被拒绝")
|
||||
}
|
||||
if _, err := resolveComposePath(dir, "compose.yml"); err == nil {
|
||||
t.Fatal("不在白名单中的 compose 文件应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerDefaultsAndValidation(t *testing.T) {
|
||||
server, err := NewServer(Config{
|
||||
ProjectDir: t.TempDir(),
|
||||
ComposeFile: "docker-compose.yml",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !server.state.Enabled || server.cfg.Mode != "local" {
|
||||
t.Fatalf("默认状态不正确: %#v", server.state)
|
||||
}
|
||||
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid"}, nil); err == nil {
|
||||
t.Fatal("非法 mode 应被拒绝")
|
||||
}
|
||||
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git"}, nil); err == nil {
|
||||
t.Fatal("git 模式缺少 RepoURL 应被拒绝")
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"version": "1.2.21",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fonu-web",
|
||||
"version": "1.2.21",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"private": true,
|
||||
"version": "1.2.21",
|
||||
"version": "1.3.0",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
SettingsMap,
|
||||
SystemLogEntry,
|
||||
NotifyTestPayload,
|
||||
UpdateStatus,
|
||||
} from './types'
|
||||
|
||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
@@ -96,6 +97,8 @@ async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: A
|
||||
|
||||
export const api = {
|
||||
getVersion: () => request<AppVersion>('/api/version'),
|
||||
getUpdateStatus: () => request<UpdateStatus>('/api/system/update/status'),
|
||||
applyUpdate: () => request<UpdateStatus>('/api/system/update/apply', { method: 'POST' }),
|
||||
authStatus: () => request<AuthStatus>('/api/auth/status'),
|
||||
login: (username: string, password: string) =>
|
||||
request<{ message: string }>('/api/auth/login', {
|
||||
|
||||
@@ -121,6 +121,19 @@ export interface AppVersion {
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
enabled: boolean
|
||||
mode: string
|
||||
current_version?: string
|
||||
latest_version?: string
|
||||
update_available: boolean
|
||||
busy: boolean
|
||||
phase: 'idle' | 'scheduled' | 'running' | 'success' | 'failed' | string
|
||||
message: string
|
||||
log?: string[]
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<n-modal v-model:show="show" :mask-closable="false">
|
||||
<div class="update-modal">
|
||||
<div class="update-modal__header">
|
||||
<div>
|
||||
<h3 class="update-modal__title">升级 Fonu</h3>
|
||||
<p class="update-modal__subtitle">升级完成后容器会重启,数据目录不会被删除</p>
|
||||
</div>
|
||||
<n-button quaternary size="small" :disabled="applying" @click="show = false">关闭</n-button>
|
||||
</div>
|
||||
|
||||
<div class="update-modal__body">
|
||||
<n-alert :type="alertType" :bordered="false" class="update-modal__alert">
|
||||
{{ displayMessage }}
|
||||
</n-alert>
|
||||
|
||||
<div class="update-modal__grid">
|
||||
<div>
|
||||
<span class="update-modal__label">当前版本</span>
|
||||
<strong class="mono">{{ status?.current_version || appVersion || '未知' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="update-modal__label">目标版本</span>
|
||||
<strong class="mono">{{ status?.latest_version || '检查中…' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="update-modal__label">升级方式</span>
|
||||
<strong>{{ modeLabel }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="update-modal__label">服务状态</span>
|
||||
<strong>{{ enabledLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="update-modal__hint">
|
||||
升级会重新构建应用镜像并只重建 <span class="mono">fonu</span> 容器。构建期间管理页面可能短暂无法访问,
|
||||
浏览器会自动等待服务恢复。
|
||||
</p>
|
||||
|
||||
<pre v-if="logText" class="update-modal__log">{{ logText }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="update-modal__footer">
|
||||
<n-button :disabled="applying" @click="show = false">取消</n-button>
|
||||
<n-button type="primary" :loading="applying || loading" :disabled="!canApply" @click="startUpdate">
|
||||
{{ applying ? '正在升级…' : '确认升级' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NModal, useMessage } from 'naive-ui'
|
||||
import { api } from '../api/client'
|
||||
import type { UpdateStatus } from '../api/types'
|
||||
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const status = ref<UpdateStatus | null>(null)
|
||||
const appVersion = ref(__APP_VERSION__)
|
||||
const loading = ref(false)
|
||||
const applying = ref(false)
|
||||
const localMessage = ref('')
|
||||
let pollTimer: number | undefined
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (status.value?.mode === 'git') return 'Git 仓库构建'
|
||||
if (status.value?.mode === 'local') return '本机源码构建'
|
||||
return '未启用'
|
||||
})
|
||||
|
||||
const enabledLabel = computed(() => (status.value?.enabled ? '侧车可用' : '不可用'))
|
||||
|
||||
const displayMessage = computed(() => {
|
||||
if (localMessage.value) return localMessage.value
|
||||
if (!status.value) return loading.value ? '正在检查更新状态…' : '尚未检查更新状态'
|
||||
return status.value.message || '未知状态'
|
||||
})
|
||||
|
||||
const alertType = computed(() => {
|
||||
if (status.value?.phase === 'failed') return 'error'
|
||||
if (status.value?.phase === 'success') return 'success'
|
||||
if (applying.value || status.value?.busy) return 'warning'
|
||||
return status.value?.update_available ? 'info' : 'default'
|
||||
})
|
||||
|
||||
const logText = computed(() => (status.value?.log ?? []).slice(-20).join('\n'))
|
||||
|
||||
const canApply = computed(() => Boolean(
|
||||
status.value?.enabled &&
|
||||
status.value.update_available &&
|
||||
!applying.value &&
|
||||
!status.value.busy
|
||||
))
|
||||
|
||||
async function loadStatus() {
|
||||
loading.value = true
|
||||
localMessage.value = ''
|
||||
try {
|
||||
status.value = await api.getUpdateStatus()
|
||||
} catch (error) {
|
||||
localMessage.value = error instanceof Error ? error.message : '读取升级状态失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
applying.value = true
|
||||
localMessage.value = '升级任务已启动,正在等待容器重建…'
|
||||
try {
|
||||
status.value = await api.applyUpdate()
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
applying.value = false
|
||||
localMessage.value = error instanceof Error ? error.message : '启动升级失败'
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => void pollStatus(), 2000)
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
status.value = await api.getUpdateStatus()
|
||||
localMessage.value = ''
|
||||
if (status.value.phase === 'success') {
|
||||
stopPolling()
|
||||
applying.value = false
|
||||
message.success('Fonu 已升级完成')
|
||||
emit('updated')
|
||||
window.setTimeout(() => window.location.reload(), 1500)
|
||||
return
|
||||
}
|
||||
if (status.value.phase === 'failed') {
|
||||
stopPolling()
|
||||
applying.value = false
|
||||
message.error(status.value.message || '升级失败')
|
||||
}
|
||||
} catch {
|
||||
localMessage.value = '服务正在重启,等待恢复…'
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer === undefined) return
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
|
||||
watch(show, (visible) => {
|
||||
if (!visible) return
|
||||
void loadStatus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-modal {
|
||||
width: 560px;
|
||||
max-width: 94vw;
|
||||
background: var(--fonu-surface);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.update-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fonu-space-3);
|
||||
padding: var(--fonu-space-5) var(--fonu-space-5) var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.update-modal__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text);
|
||||
}
|
||||
|
||||
.update-modal__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.update-modal__body {
|
||||
display: grid;
|
||||
gap: var(--fonu-space-4);
|
||||
padding: 0 var(--fonu-space-5) var(--fonu-space-4);
|
||||
}
|
||||
|
||||
.update-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--fonu-space-3);
|
||||
}
|
||||
|
||||
.update-modal__grid > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
|
||||
.update-modal__label {
|
||||
font-size: 12px;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.update-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.update-modal__log {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border-radius: var(--fonu-radius-sm);
|
||||
background: var(--fonu-bg-muted);
|
||||
color: var(--fonu-text-secondary);
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.update-modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--fonu-space-3);
|
||||
padding: var(--fonu-space-4) var(--fonu-space-5);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.update-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,7 +35,16 @@
|
||||
<span class="sider-footer__version">v{{ appVersion }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="hasUpdate"
|
||||
v-if="canOneClickUpdate"
|
||||
class="sider-footer__update"
|
||||
href="#"
|
||||
@click.prevent="showUpdateModal = true"
|
||||
>
|
||||
<n-icon :component="ArrowUpOutline" />
|
||||
一键升级
|
||||
</a>
|
||||
<a
|
||||
v-else-if="hasUpdate"
|
||||
class="sider-footer__update"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
@@ -90,6 +99,7 @@
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
<UpdateModal v-model:show="showUpdateModal" @updated="checkForUpdate" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -129,7 +139,9 @@ import { api } from '../api/client'
|
||||
import { GITHUB_REPO, GITHUB_REPO_URL } from '../constants/app'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { fetchLatestRelease, isNewerVersion } from '../utils/version'
|
||||
import type { UpdateStatus } from '../api/types'
|
||||
import logoImg from '../assets/brand/logo.png'
|
||||
import UpdateModal from '../components/UpdateModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -141,8 +153,14 @@ const appVersion = ref(__APP_VERSION__)
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('')
|
||||
const hasUpdate = ref(false)
|
||||
const showUpdateModal = ref(false)
|
||||
const updateStatus = ref<UpdateStatus | null>(null)
|
||||
let clockTimer: number | undefined
|
||||
|
||||
const canOneClickUpdate = computed(() => Boolean(
|
||||
updateStatus.value?.enabled && updateStatus.value.update_available
|
||||
))
|
||||
|
||||
async function checkForUpdate() {
|
||||
const release = await fetchLatestRelease()
|
||||
if (!release) {
|
||||
@@ -164,7 +182,15 @@ async function loadVersion() {
|
||||
// keep build-time fallback
|
||||
}
|
||||
|
||||
await checkForUpdate()
|
||||
await Promise.all([checkForUpdate(), loadUpdateStatus()])
|
||||
}
|
||||
|
||||
async function loadUpdateStatus() {
|
||||
try {
|
||||
updateStatus.value = await api.getUpdateStatus()
|
||||
} catch {
|
||||
updateStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const menuDefs = [
|
||||
@@ -363,11 +389,16 @@ onUnmounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--fonu-brand);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user