From 163df0c5b05d453329c0095bff07f739f8eb1e68 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Sun, 27 Sep 2026 18:43:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E6=94=AF=E6=8C=81=20Docker=20C?= =?UTF-8?q?ompose=20=E9=A1=B5=E9=9D=A2=E4=B8=80=E9=94=AE=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=B8=83=20v1.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 ++ Dockerfile | 19 ++ README.md | 9 +- VERSION | 2 +- cmd/fonu-updater/main.go | 40 ++++ docker-compose.hub.yml | 34 ++++ docker-compose.yml | 31 +++ docs/README.md | 3 +- docs/container-deploy-and-data.md | 4 +- docs/system-upgrade.md | 187 ++++++++++++++++++ internal/api/deps.go | 2 + internal/api/middleware.go | 3 +- internal/api/router.go | 4 + internal/api/update_handler.go | 45 +++++ internal/api/update_handler_test.go | 29 +++ internal/app/app.go | 3 + internal/config/config.go | 2 + internal/updater/client.go | 98 ++++++++++ internal/updater/model.go | 95 ++++++++++ internal/updater/model_test.go | 33 ++++ internal/updater/runner.go | 246 ++++++++++++++++++++++++ internal/updater/server.go | 284 ++++++++++++++++++++++++++++ internal/updater/server_test.go | 43 +++++ web/package-lock.json | 4 +- web/package.json | 2 +- web/src/api/client.ts | 3 + web/src/api/types.ts | 13 ++ web/src/components/UpdateModal.vue | 257 +++++++++++++++++++++++++ web/src/layouts/AppLayout.vue | 35 +++- 29 files changed, 1541 insertions(+), 10 deletions(-) create mode 100644 cmd/fonu-updater/main.go create mode 100644 docs/system-upgrade.md create mode 100644 internal/api/update_handler.go create mode 100644 internal/api/update_handler_test.go create mode 100644 internal/updater/client.go create mode 100644 internal/updater/model.go create mode 100644 internal/updater/model_test.go create mode 100644 internal/updater/runner.go create mode 100644 internal/updater/server.go create mode 100644 internal/updater/server_test.go create mode 100644 web/src/components/UpdateModal.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2bde3..b71bb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 修复 diff --git a/Dockerfile b/Dockerfile index 28aeb4f..3c27dd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 22935da..42e87f2 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ docker compose up -d --build +### 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;未挂载时自动禁用 | ## 注意事项 diff --git a/VERSION b/VERSION index 9728bd6..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.21 +1.3.0 diff --git a/cmd/fonu-updater/main.go b/cmd/fonu-updater/main.go new file mode 100644 index 0000000..e2067a0 --- /dev/null +++ b/cmd/fonu-updater/main.go @@ -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 +} diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 6f5772b..496885c 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -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 # 留空即自动:应用会在数据目录里生成并持久化 /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: diff --git a/docker-compose.yml b/docker-compose.yml index 263ccf6..2358773 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 # 留空即自动:应用会在数据目录里生成并持久化 /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: diff --git a/docs/README.md b/docs/README.md index db6129f..6f0e2c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 三项不做 | diff --git a/docs/container-deploy-and-data.md b/docs/container-deploy-and-data.md index 0adcf06..77fc544 100644 --- a/docs/container-deploy-and-data.md +++ b/docs/container-deploy-and-data.md @@ -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` | 国内镜像源 | 构建用的基础镜像,被加速器拦截时用来覆盖 | --- diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md new file mode 100644 index 0000000..321743b --- /dev/null +++ b/docs/system-upgrade.md @@ -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 操作。 diff --git a/internal/api/deps.go b/internal/api/deps.go index e9407f4..adc903c 100644 --- a/internal/api/deps.go +++ b/internal/api/deps.go @@ -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 } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 5a90bd6..54d4aa9 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 { diff --git a/internal/api/router.go b/internal/api/router.go index 3f05dfb..41dcd38 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 } diff --git a/internal/api/update_handler.go b/internal/api/update_handler.go new file mode 100644 index 0000000..f716de8 --- /dev/null +++ b/internal/api/update_handler.go @@ -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) +} diff --git a/internal/api/update_handler_test.go b/internal/api/update_handler_test.go new file mode 100644 index 0000000..56bc17c --- /dev/null +++ b/internal/api/update_handler_test.go @@ -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) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index efaf469..562aab2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, }) diff --git a/internal/config/config.go b/internal/config/config.go index e020748..6f3bfff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), } } diff --git a/internal/updater/client.go b/internal/updater/client.go new file mode 100644 index 0000000..5e0be02 --- /dev/null +++ b/internal/updater/client.go @@ -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 +} diff --git a/internal/updater/model.go b/internal/updater/model.go new file mode 100644 index 0000000..3e5790f --- /dev/null +++ b/internal/updater/model.go @@ -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 +} diff --git a/internal/updater/model_test.go b/internal/updater/model_test.go new file mode 100644 index 0000000..5b5588b --- /dev/null +++ b/internal/updater/model_test.go @@ -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) + } + } +} diff --git a/internal/updater/runner.go b/internal/updater/runner.go new file mode 100644 index 0000000..49460c7 --- /dev/null +++ b/internal/updater/runner.go @@ -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 +} diff --git a/internal/updater/server.go b/internal/updater/server.go new file mode 100644 index 0000000..90e8ee5 --- /dev/null +++ b/internal/updater/server.go @@ -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) +} diff --git a/internal/updater/server_test.go b/internal/updater/server_test.go new file mode 100644 index 0000000..fa4cd3a --- /dev/null +++ b/internal/updater/server_test.go @@ -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 应被拒绝") + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 9209373..7a3c60c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 5369272..02ec91e 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a8f1623..2173f26 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -36,6 +36,7 @@ import type { SettingsMap, SystemLogEntry, NotifyTestPayload, + UpdateStatus, } from './types' export function asList(value: T[] | null | undefined): T[] { @@ -96,6 +97,8 @@ async function requestWithSignal(path: string, init?: RequestInit, signal?: A export const api = { getVersion: () => request('/api/version'), + getUpdateStatus: () => request('/api/system/update/status'), + applyUpdate: () => request('/api/system/update/apply', { method: 'POST' }), authStatus: () => request('/api/auth/status'), login: (username: string, password: string) => request<{ message: string }>('/api/auth/login', { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b6da6be..eaf1e40 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 } diff --git a/web/src/components/UpdateModal.vue b/web/src/components/UpdateModal.vue new file mode 100644 index 0000000..5dc1f8f --- /dev/null +++ b/web/src/components/UpdateModal.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 442e83d..137869c 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -35,7 +35,16 @@ v{{ appVersion }} + + 一键升级 + + +