feat(update): 支持 Docker Compose 页面一键升级并发布 v1.3.0

This commit is contained in:
2026-09-27 18:43:34 +08:00
parent 32dfe3b6de
commit 163df0c5b0
29 changed files with 1541 additions and 10 deletions
+21
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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;未挂载时自动禁用 |
## 注意事项
+1 -1
View File
@@ -1 +1 @@
1.2.21
1.3.0
+40
View File
@@ -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
}
+34
View File
@@ -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:
+31
View File
@@ -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
View File
@@ -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 三项不做 |
+3 -1
View File
@@ -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` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 |
---
+187
View File
@@ -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 操作。
+2
View File
@@ -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
}
+2 -1
View File
@@ -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 {
+4
View File
@@ -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
}
+45
View File
@@ -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)
}
+29
View File
@@ -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)
}
}
+3
View File
@@ -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,
})
+2
View File
@@ -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"),
}
}
+98
View File
@@ -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
}
+95
View File
@@ -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
}
+33
View File
@@ -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)
}
}
}
+246
View File
@@ -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
}
+284
View File
@@ -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)
}
+43
View File
@@ -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 应被拒绝")
}
}
+2 -2
View File
@@ -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
View File
@@ -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": {
+3
View File
@@ -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', {
+13
View File
@@ -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
}
+257
View File
@@ -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>
+33 -2
View File
@@ -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;
}