feat: add docker online update flow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-01 12:21:47 +08:00
co-authored by Codex
parent 65d9154b21
commit ea0f3af9d7
20 changed files with 2186 additions and 58 deletions
+3
View File
@@ -1,10 +1,13 @@
target
web/node_modules
web/dist
.runtime
data
logs
local.yaml
local.yml
.env
config.yaml
.git
.codegraph
.worktrees
+135
View File
@@ -0,0 +1,135 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: zyycn/codex-proxy-rs
UPDATER_IMAGE_NAME: zyycn/codex-proxy-rs-updater
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version
id: version
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT"
echo "git_sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm
run: corepack enable
- name: Install frontend dependencies
run: pnpm --dir web install --frozen-lockfile
- name: Build frontend
run: pnpm --dir web build
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.95
- name: Cache Rust
uses: Swatinem/rust-cache@v2
- name: Build release binaries
env:
CPR_VERSION: ${{ steps.version.outputs.version }}
CPR_GIT_SHA: ${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME: ${{ steps.version.outputs.build_time }}
run: cargo build --release --locked --bins
- name: Package release assets
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
PACKAGE_DIR="dist-package/codex-proxy-rs"
mkdir -p "${PACKAGE_DIR}"
cp target/release/codex-proxy-rs "${PACKAGE_DIR}/"
cp target/release/codex-proxy-rs-updater "${PACKAGE_DIR}/"
cp -r web/dist "${PACKAGE_DIR}/web-dist"
tar -czf "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" -C dist-package codex-proxy-rs
sha256sum "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" > checksums.txt
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push app image
uses: docker/build-push-action@v6
with:
context: .
target: runtime
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CPR_VERSION=${{ steps.version.outputs.version }}
CPR_GIT_SHA=${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME=${{ steps.version.outputs.build_time }}
labels: |
org.opencontainers.image.title=Codex Proxy RS
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.revision=${{ steps.version.outputs.git_sha }}
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
- name: Build and push updater image
uses: docker/build-push-action@v6
with:
context: .
target: updater-runtime
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CPR_VERSION=${{ steps.version.outputs.version }}
CPR_GIT_SHA=${{ steps.version.outputs.git_sha }}
CPR_BUILD_TIME=${{ steps.version.outputs.build_time }}
labels: |
org.opencontainers.image.title=Codex Proxy RS Updater
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.revision=${{ steps.version.outputs.git_sha }}
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
tags: |
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:sha-${{ github.sha }}
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
codex-proxy-rs_${{ steps.version.outputs.version }}_linux_x86_64.tar.gz
checksums.txt
+1
View File
@@ -14,6 +14,7 @@
# Local configuration.
/local.yaml
/local.yml
/.env
# Local workspaces and tooling state.
/.worktrees/
Generated
+7
View File
@@ -335,6 +335,7 @@ dependencies = [
"rustls-native-certs",
"rustls-pki-types",
"secrecy",
"semver",
"serde",
"serde_json",
"serde_yml",
@@ -2193,6 +2194,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
+6 -1
View File
@@ -26,6 +26,7 @@ rustls = { version = "=0.23.36", default-features = false, features = ["ring", "
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.14.1"
secrecy = "0.10.3"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.11.0"
@@ -67,4 +68,8 @@ needless_collect = { level = "deny", priority = 7 }
debug = true
[profile.release]
debug = true
debug = false
strip = "symbols"
lto = "thin"
codegen-units = 1
panic = "abort"
+56 -6
View File
@@ -1,20 +1,70 @@
FROM rust:1.95-bookworm AS builder
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim AS web-builder
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY web ./
RUN pnpm build
FROM rust:1.95-bookworm AS rust-builder
WORKDIR /app
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
ENV CPR_VERSION=${CPR_VERSION}
ENV CPR_GIT_SHA=${CPR_GIT_SHA}
ENV CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY src ./src
COPY web/dist ./web/dist
RUN cargo build --release --locked
COPY --from=web-builder /app/web/dist ./web/dist
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --locked --bins \
&& cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs \
&& cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater \
&& strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater
FROM debian:bookworm-slim
FROM debian:bookworm-slim AS updater-runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater
EXPOSE 8090
CMD ["codex-proxy-rs-updater"]
FROM debian:bookworm-slim AS runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY config.yaml ./config.yaml
ENV CPR_DEPLOYMENT_MODE=docker \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
EXPOSE 8080
+32 -1
View File
@@ -1,8 +1,39 @@
services:
codex-proxy-rs:
build: .
image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest}
container_name: codex-proxy-rs
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./data:/app/data
- ./logs:/app/logs
environment:
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_UPDATE_CHANNEL: stable
CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_IMAGE_TAG: latest
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
depends_on:
- codex-proxy-rs-updater
codex-proxy-rs-updater:
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
container_name: codex-proxy-rs-updater
restart: unless-stopped
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_COMPOSE_SERVICE: codex-proxy-rs
CPR_DOCKER_COMPOSE_BIN: docker-compose
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
+492 -18
View File
@@ -44,12 +44,12 @@ browser
主应用容器不应直接拥有 Docker daemon 权限。updater 可以是一个独立容器,也可以是宿主机 systemd 服务。updater 只暴露给同一 Docker network 或 `127.0.0.1`,并使用共享 token 鉴权。
当前 `docker-compose.yml` 使用 `build: .`,这种模式无法可靠在线更新到远端版本。要支持 Docker 一键更新,compose 应改为使用远端镜像:
项目只保留生产用 `docker-compose.yml`,不维护本地 Docker 开发配置。compose 必须使用远端镜像,不能使用 `build: .`,否则管理端只能检查更新,无法安全地完成一键更新。
```yaml
services:
codex-proxy-rs:
image: ghcr.io/<owner>/codex-proxy-rs:latest
image: ghcr.io/zyycn/codex-proxy-rs:latest
ports:
- "8080:8080"
volumes:
@@ -61,11 +61,9 @@ services:
CPR_VERSION: 0.1.0
CPR_UPDATE_CHANNEL: stable
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN}
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
```
如果继续使用 `build: .`,管理端只能检查上游是否有新版本,不能安全地完成远端包更新。
### 二进制模式
二进制模式可以采用 Sub2API 类似方案:
@@ -87,8 +85,8 @@ services:
发布时应同时产出 Docker 镜像和 release 元数据:
- Git tag:`v0.1.0`
- Docker image:`ghcr.io/<owner>/codex-proxy-rs:0.1.0`
- Docker image:`ghcr.io/<owner>/codex-proxy-rs:latest`
- Docker image:`ghcr.io/zyycn/codex-proxy-rs:0.1.0`
- Docker image:`ghcr.io/zyycn/codex-proxy-rs:latest`
- Release archive:`codex-proxy-rs_0.1.0_linux_x86_64.tar.gz`
- Checksum:`checksums.txt`
@@ -103,6 +101,477 @@ services:
Docker 模式下,检查更新可以优先使用 GitHub Releases 的 tag 做语义版本比较;真正更新时由 updater 拉取指定镜像 tag 或 digest。
## 版本发布流程
版本发布以 Git tag 为唯一入口,tag、Docker 镜像、GitHub Release 和后端版本信息必须保持一致。
### 版本号规则
使用语义化版本:
```text
MAJOR.MINOR.PATCH
```
示例:
```text
0.2.0
0.2.1
1.0.0
```
Git tag 使用 `v` 前缀:
```text
v0.2.0
```
代码中的版本来源:
- `Cargo.toml` 的 `[package].version` 是本地构建默认版本。
- `web/package.json` 的 `version` 应与后端版本保持一致。
- CI 发布时以 Git tag 为准,并通过 build args 注入 `CPR_VERSION`。
发布前必须同步:
```text
Cargo.toml version = "0.2.0"
web/package.json "version": "0.2.0"
Git tag v0.2.0
Docker image tag 0.2.0
GitHub Release tag v0.2.0
```
如果后续希望避免手动同步,可以增加 `scripts/release-version`,统一更新 `Cargo.toml`、`Cargo.lock` 和 `web/package.json`。
### 发布通道
推荐先只维护 `stable` 通道:
```text
latest -> 最新 stable 版本
0.2.0 -> 固定版本
sha-* -> commit 固定构建
```
后续需要预发布时再增加:
```text
next
beta
rc
```
管理端检查更新默认只看 stable release。带 `prerelease: true` 的 GitHub Release 不应推送给普通更新检查,除非配置了 `CPR_UPDATE_CHANNEL=beta`。
### 发布前检查
发布前必须完成:
```bash
pnpm --dir web build
cargo fmt --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --test main
```
发布前还应确认:
- `Cargo.toml` release profile 已关闭 debug symbols。
- Dockerfile 不复制本地 `config.yaml`。
- Dockerfile 在容器内构建 `web/dist`。
- `.dockerignore` 排除了 `target`、`node_modules`、数据库和日志。
- `docker-compose.yml` 使用远端 `image`,不是 `build: .`。
- release note 写明数据库迁移风险。
### 发布步骤
标准发布步骤:
```bash
VERSION=0.2.0
# 1. 更新版本号
# Cargo.toml: version = "0.2.0"
# web/package.json: "version": "0.2.0"
# 2. 更新 Cargo.lock 中的本包版本
cargo check --locked
# 3. 提交版本变更
git add Cargo.toml Cargo.lock web/package.json
git commit -m "chore: release v${VERSION}"
# 4. 创建 tag
git tag "v${VERSION}"
git push origin HEAD
git push origin "v${VERSION}"
```
tag 推送后由 `.github/workflows/release.yml` 自动构建并发布:
1. 构建前端。
2. 构建 Rust release。
3. 生成二进制压缩包。
4. 生成 `checksums.txt`。
5. 构建并推送 Docker 多架构镜像。
6. 构建并推送 updater 多架构镜像。
7. 创建 GitHub Release。
8. 上传 release assets。
### Release Note 格式
GitHub Release body 建议固定结构,方便管理端展示:
```markdown
## Highlights
- ...
## Changes
- ...
## Upgrade Notes
- ...
## Database
- Migration: none
- Backup required: no
```
如果需要用户备份,必须写明:
```markdown
## Database
- Migration: required
- Backup required: yes
```
后端检查更新时可以从 release body 或额外 manifest 中解析 `requiresBackup`,并在管理端一键更新前要求确认。
### 发布后验证
发布完成后验证:
```bash
docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0
docker run --rm ghcr.io/zyycn/codex-proxy-rs:0.2.0 --version
```
如果暂时没有 `--version` 命令,则使用管理端接口验证:
```bash
curl http://127.0.0.1:8080/api/admin/system/version
```
还需要验证:
- GitHub Release latest 指向新版本。
- `latest` 镜像 tag 指向新版本。
- 固定版本 tag 可以拉取。
- 管理端检查更新能看到新版本。
- Docker 一键更新能从旧版本升级到新版本。
## Docker 构建方案
Docker 镜像应由 CI 直接完成前端构建、后端构建和 runtime 镜像组装。发布镜像不能依赖本地已经存在的 `web/dist`,否则 CI、开发机和发布机之间容易产出不一致。
推荐 Dockerfile 结构:
```dockerfile
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim AS web-builder
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY web .
RUN pnpm build
FROM rust:1.95-bookworm AS rust-builder
WORKDIR /app
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
ENV CPR_VERSION=${CPR_VERSION}
ENV CPR_GIT_SHA=${CPR_GIT_SHA}
ENV CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY src ./src
COPY --from=web-builder /app/web/dist ./web/dist
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --locked --bins && \
cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs && \
cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater && \
strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater
FROM debian:bookworm-slim AS updater-runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater
EXPOSE 8090
CMD ["codex-proxy-rs-updater"]
FROM debian:bookworm-slim AS runtime
ARG CPR_VERSION=dev
ARG CPR_GIT_SHA=unknown
ARG CPR_BUILD_TIME=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV CPR_DEPLOYMENT_MODE=docker \
CPR_VERSION=${CPR_VERSION} \
CPR_GIT_SHA=${CPR_GIT_SHA} \
CPR_BUILD_TIME=${CPR_BUILD_TIME}
COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
EXPOSE 8080
CMD ["codex-proxy-rs"]
```
构建上下文必须增加 `.dockerignore`,避免把本地编译产物、数据库、日志和依赖目录传入 Docker daemon:
```gitignore
.git
target
node_modules
web/node_modules
.runtime
data
logs
config.yaml
.env
*.sqlite
*.sqlite-*
*.db
```
发布镜像不应复制本地 `config.yaml`。配置文件应由 compose volume 挂载,避免镜像更新时覆盖用户配置:
```yaml
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./data:/app/data
- ./logs:/app/logs
```
后端 release profile 应关闭 debug symbols,并 strip 符号,避免 Docker 应用层过大:
```toml
[profile.release]
debug = false
strip = "symbols"
lto = "thin"
codegen-units = 1
panic = "abort"
```
如果后续仍需要 debug symbols,应把 symbols 作为单独 CI artifact 上传,不放进 runtime 镜像。
## Docker 发布方案
发布以 Git tag 为入口,例如 `v0.2.0`。CI 应完成以下产物:
1. 多架构 Docker 镜像。
2. GitHub Release。
3. 二进制 tar.gz 包。
4. `checksums.txt`。
推荐镜像标签:
```text
ghcr.io/zyycn/codex-proxy-rs:0.2.0
ghcr.io/zyycn/codex-proxy-rs:latest
ghcr.io/zyycn/codex-proxy-rs:sha-<git-sha>
```
推荐多架构平台:
```text
linux/amd64
linux/arm64
```
CI 发布命令形态:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg CPR_VERSION="${VERSION}" \
--build-arg CPR_GIT_SHA="${GIT_SHA}" \
--build-arg CPR_BUILD_TIME="${BUILD_TIME}" \
--label org.opencontainers.image.title="Codex Proxy RS" \
--label org.opencontainers.image.version="${VERSION}" \
--label org.opencontainers.image.revision="${GIT_SHA}" \
--label org.opencontainers.image.source="https://github.com/zyycn/codex-proxy-rs" \
-t "ghcr.io/zyycn/codex-proxy-rs:${VERSION}" \
-t "ghcr.io/zyycn/codex-proxy-rs:latest" \
--push \
.
```
updater 镜像使用同一个 Dockerfile 的 `updater-runtime` target:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--target updater-runtime \
--build-arg CPR_VERSION="${VERSION}" \
--build-arg CPR_GIT_SHA="${GIT_SHA}" \
--build-arg CPR_BUILD_TIME="${BUILD_TIME}" \
-t "ghcr.io/zyycn/codex-proxy-rs-updater:${VERSION}" \
-t "ghcr.io/zyycn/codex-proxy-rs-updater:latest" \
--push \
.
```
GitHub Release 的 tag 是检查更新的权威来源。Release body 用于管理端展示更新说明。Docker 更新实际拉取的镜像应来自 release tag 对应的版本,不从用户输入拼接任意镜像地址。
生产 compose 使用仓库根目录的 `docker-compose.yml`。主服务镜像由 `.env` 的 `CPR_IMAGE` 控制,updater 更新时会改写这个变量;`.env` 是本机部署状态文件,不提交到仓库。
```dotenv
CPR_UPDATER_TOKEN=change-me
CPR_DEPLOY_DIR=/opt/codex-proxy-rs
CPR_IMAGE=ghcr.io/zyycn/codex-proxy-rs:latest
```
```yaml
services:
codex-proxy-rs:
image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest}
container_name: codex-proxy-rs
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./data:/app/data
- ./logs:/app/logs
environment:
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_IMAGE_TAG: latest
CPR_UPDATE_CHANNEL: stable
CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
codex-proxy-rs-updater:
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
restart: unless-stopped
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_COMPOSE_SERVICE: codex-proxy-rs
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
```
`CPR_DEPLOY_DIR` 必须是宿主机上的绝对路径,并且 updater 容器内也挂载到同一个绝对路径。这样 updater 通过 Docker socket 调用 compose 时,`./config.yaml`、`./data`、`./logs` 等 bind mount 路径会被 host Docker 正确解析。
## Docker 在线一键更新流程
Docker 一键更新分为检查、触发、执行、恢复四个阶段。
### 1. 检查更新
后端调用 GitHub Releases latest:
```text
GET https://api.github.com/repos/zyycn/codex-proxy-rs/releases/latest
```
后端比较:
- 当前版本:构建时注入的 `CPR_VERSION`。
- 最新版本:GitHub Release tag。
- 当前部署模式:`CPR_DEPLOYMENT_MODE=docker`。
- 当前镜像仓库:`CPR_IMAGE_REPOSITORY`。
- updater 可用性:`CPR_UPDATER_URL` 和 `CPR_UPDATER_TOKEN` 是否存在。
只有满足以下条件时,`updateSupported` 才返回 `true`:
- 当前部署模式是 `docker`。
- 当前 compose 使用远端 `image`,不是 `build: .`。
- 已配置 updater URL 和 token。
- release tag 能映射到允许的镜像仓库。
### 2. 触发更新
管理员点击一键更新后,主应用后端:
1. 校验管理员会话或管理员 API Key。
2. 获取全局系统更新锁。
3. 重新检查 latest release,避免使用过期缓存。
4. 生成目标镜像,例如 `ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
5. 调用 updater 的 `/update`。
6. 立即返回 `operationId` 和 `needReconnect: true`。
主应用后端不能接收前端传入的任意镜像地址。前端最多传 `targetVersion`,后端根据 release 信息和仓库白名单推导目标镜像。
### 3. 执行更新
updater 收到请求后:
1. 校验 bearer token。
2. 校验目标镜像仓库必须等于 `CPR_ALLOWED_IMAGE_REPOSITORY`。
3. 立即返回 `update started`,让主应用可以先响应前端。
4. 后台执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
5. 将 `.env` 中的 `CPR_IMAGE` 改成目标镜像。
6. 执行 `docker compose up -d codex-proxy-rs`。
7. 记录当前和上一个镜像,供回滚使用。
updater 只允许执行固定动作,不提供通用 shell 执行能力。
### 4. 服务恢复
Docker 重建主应用容器时,前端连接会中断。管理端 UI 应:
1. 显示更新中。
2. 每 2-3 秒轮询 `/api/admin/system/version`。
3. 服务恢复后比较返回版本。
4. 版本等于目标版本时显示更新成功。
5. 超时后提示用户查看容器日志。
推荐超时时间:120 秒。
## 更新失败与回滚
Docker 模式下的回滚以镜像标签或 digest 为单位:
1. 更新前记录当前镜像 digest。
2. 更新失败时 updater 可以重新部署旧 digest。
3. 如果服务已启动但健康检查失败,也回滚到旧 digest。
4. 回滚只替换容器镜像,不回滚 SQLite 数据。
因此涉及数据库结构变更的版本必须谨慎发布。若 release 声明需要备份,管理端一键更新前应显示确认状态,后端可要求请求带上 `confirmBackup: true`。
## 后端 API
建议新增管理端接口:
@@ -125,7 +594,7 @@ POST /api/admin/system/restart
"gitSha": "abc1234",
"buildTime": "2026-07-01T00:00:00Z",
"deploymentMode": "docker",
"image": "ghcr.io/<owner>/codex-proxy-rs:0.1.0"
"image": "ghcr.io/zyycn/codex-proxy-rs:0.1.0"
}
```
@@ -137,7 +606,7 @@ POST /api/admin/system/restart
"latestVersion": "0.2.0",
"hasUpdate": true,
"deploymentMode": "docker",
"releaseUrl": "https://github.com/<owner>/codex-proxy-rs/releases/tag/v0.2.0",
"releaseUrl": "https://github.com/zyycn/codex-proxy-rs/releases/tag/v0.2.0",
"notes": "...",
"cached": false,
"updateSupported": true,
@@ -180,7 +649,7 @@ Content-Type: application/json
```json
{
"service": "codex-proxy-rs",
"image": "ghcr.io/<owner>/codex-proxy-rs:0.2.0",
"image": "ghcr.io/zyycn/codex-proxy-rs:0.2.0",
"composeProject": "codex-proxy-rs"
}
```
@@ -189,22 +658,28 @@ updater 行为:
1. 校验 token。
2. 校验 image 仓库白名单。
3. 执行 `docker pull ghcr.io/<owner>/codex-proxy-rs:0.2.0`。
4. 执行 `docker compose up -d codex-proxy-rs` 或 Docker API 等价操作。
5. 记录当前镜像 digest,支持失败诊断。
3. 执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。
4. 将 `CPR_COMPOSE_ENV_FILE` 中的 `CPR_COMPOSE_IMAGE_ENV` 更新为目标镜像。
5. 执行 `docker compose up -d codex-proxy-rs` 或 `docker-compose up -d codex-proxy-rs`。
6. 记录当前和上一个镜像,用于回滚。
updater 不应暴露到公网。若使用 sidecar 容器,建议挂载 Docker socket 到 updater,而不是主应用:
```yaml
services:
codex-proxy-rs-updater:
image: ghcr.io/<owner>/codex-proxy-rs-updater:latest
image: ghcr.io/zyycn/codex-proxy-rs-updater:latest
working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-compose.yml:/workspace/docker-compose.yml:ro
- ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}
environment:
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/<owner>/codex-proxy-rs
CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required}
CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs
CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml
CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env
CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE
CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
networks:
- default
```
@@ -266,4 +741,3 @@ create table if not exists schema_migrations (
6. 接入一键更新按钮。
7. 补充数据库迁移机制。
8. 再考虑二进制模式的原子替换和 rollback。
+2 -3
View File
@@ -436,9 +436,8 @@ impl AdminAccountService {
ids.as_ref()
.is_none_or(|ids| ids.contains(account.id.as_str()))
})
.map(|account| account.id)
.collect::<Vec<_>>();
let results = stream::iter(candidate_ids.into_iter().enumerate().map(
.map(|account| account.id);
let results = stream::iter(candidate_ids.enumerate().map(
|(index, account_id)| async move {
if index > 0 && stagger_ms > 0 {
let base_delay = stagger_ms.saturating_mul(index.min(concurrency) as u64);
+2
View File
@@ -14,3 +14,5 @@ pub mod response;
pub mod router;
/// 设置处理器。
pub mod settings;
/// 系统版本与在线更新处理器。
pub mod system;
+6
View File
@@ -27,6 +27,7 @@ use super::{
admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings,
update_settings,
},
system::routes::{check_updates, perform_update, restart, rollback, version},
};
/// 构造管理端路由。
@@ -44,6 +45,11 @@ pub fn router() -> Router<AppState> {
"/api/admin/settings/admin-api-key/regenerate",
post(regenerate_admin_api_key),
)
.route("/api/admin/system/version", get(version))
.route("/api/admin/system/check-updates", get(check_updates))
.route("/api/admin/system/update", post(perform_update))
.route("/api/admin/system/rollback", post(rollback))
.route("/api/admin/system/restart", post(restart))
.route("/api/admin/dashboard/summary", get(dashboard_summary))
.route("/api/admin/dashboard/trend", get(dashboard_trend))
.route("/api/admin/accounts", get(accounts).post(create_account))
+3
View File
@@ -0,0 +1,3 @@
//! 系统版本与在线更新处理器。
pub mod routes;
+515
View File
@@ -0,0 +1,515 @@
//! 管理端系统版本与在线更新路由。
use std::{env, time::Duration};
use axum::{
body::Bytes,
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use semver::Version;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::{
admin::auth::session::require_admin_auth,
admin::response::{AdminEnvelope, AdminError, AdminResponse},
runtime::state::AppState,
};
const DEFAULT_SERVICE_NAME: &str = "codex-proxy-rs";
const GITHUB_API_BASE: &str = "https://api.github.com/repos";
static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CheckUpdateQuery {
force: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateRequest {
target_version: Option<String>,
confirm_backup: Option<bool>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionData {
version: String,
git_sha: String,
build_time: String,
deployment_mode: String,
image: Option<String>,
update_channel: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateInfoData {
current_version: String,
latest_version: String,
has_update: bool,
deployment_mode: String,
release_url: Option<String>,
notes: Option<String>,
cached: bool,
update_supported: bool,
unsupported_reason: Option<String>,
target_image: Option<String>,
requires_backup: bool,
warning: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateStartedData {
operation_id: String,
deployment_mode: String,
message: String,
need_reconnect: bool,
target_version: String,
target_image: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
html_url: Option<String>,
prerelease: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdaterUpdateRequest {
service: String,
image: String,
compose_project: Option<String>,
target_version: String,
confirm_backup: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdaterRollbackRequest {
service: String,
compose_project: Option<String>,
}
#[derive(Debug, Clone)]
struct SystemUpdateConfig {
version: String,
git_sha: String,
build_time: String,
deployment_mode: String,
update_channel: String,
update_repository: Option<String>,
image_repository: Option<String>,
image_tag: Option<String>,
updater_url: Option<String>,
updater_token: Option<String>,
compose_project: Option<String>,
compose_service: String,
}
/// `GET /api/admin/system/version`
pub(crate) async fn version(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let config = SystemUpdateConfig::from_env();
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(config.version_data()),
))
}
/// `GET /api/admin/system/check-updates`
pub(crate) async fn check_updates(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<CheckUpdateQuery>,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let config = SystemUpdateConfig::from_env();
let _force = query.force.unwrap_or(false);
let info = check_latest_release(&config).await;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info)))
}
/// `POST /api/admin/system/update`
pub(crate) async fn perform_update(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let _guard = SYSTEM_OPERATION_LOCK
.try_lock()
.map_err(|_| AdminError::conflict("System update already running"))?;
let payload = parse_update_request(&body)?;
let config = SystemUpdateConfig::from_env();
let info = check_latest_release(&config).await;
let reason = info.unsupported_reason.clone();
if !info.update_supported {
return Err(AdminError::conflict(reason.unwrap_or_else(|| {
"System update is not supported in this deployment".into()
})));
}
if !info.has_update && payload.target_version.is_none() {
return Err(AdminError::conflict("Already up to date"));
}
if info.requires_backup && payload.confirm_backup != Some(true) {
return Err(AdminError::conflict(
"This update requires backup confirmation before continuing",
));
}
let target_version = payload
.target_version
.as_deref()
.map(normalize_version_tag)
.unwrap_or_else(|| normalize_version_tag(&info.latest_version));
let target_image = config
.target_image_for_version(&target_version)
.ok_or_else(|| AdminError::conflict("Docker image repository is not configured"))?;
call_updater_update(
&config,
UpdaterUpdateRequest {
service: config.compose_service.clone(),
image: target_image.clone(),
compose_project: config.compose_project.clone(),
target_version: target_version.clone(),
confirm_backup: payload.confirm_backup.unwrap_or(false),
},
)
.await?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(UpdateStartedData {
operation_id: format!("sysop-update-{}", chrono::Utc::now().timestamp_millis()),
deployment_mode: config.deployment_mode,
message: "Update started".to_string(),
need_reconnect: true,
target_version,
target_image: Some(target_image),
}),
))
}
/// `POST /api/admin/system/rollback`
pub(crate) async fn rollback(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
let _guard = SYSTEM_OPERATION_LOCK
.try_lock()
.map_err(|_| AdminError::conflict("System operation already running"))?;
let config = SystemUpdateConfig::from_env();
call_updater_rollback(
&config,
UpdaterRollbackRequest {
service: config.compose_service.clone(),
compose_project: config.compose_project.clone(),
},
)
.await?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"message": "Rollback started",
"needReconnect": true
})),
))
}
/// `POST /api/admin/system/restart`
pub(crate) async fn restart(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
require_admin_auth(&state, &headers).await?;
if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") {
return Err(AdminError::conflict(
"Self restart is disabled; set CPR_ENABLE_SELF_RESTART=true to enable it",
));
}
tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(500)).await;
std::process::exit(0);
});
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"message": "Restart scheduled",
"needReconnect": true
})),
))
}
impl SystemUpdateConfig {
fn from_env() -> Self {
let image_repository = env_string("CPR_IMAGE_REPOSITORY");
let update_repository = env_string("CPR_UPDATE_REPOSITORY").or_else(|| {
image_repository
.as_deref()
.and_then(github_repo_from_image)
.map(str::to_string)
});
Self {
version: env_string("CPR_VERSION").unwrap_or_else(|| {
option_env!("CPR_VERSION")
.unwrap_or(env!("CARGO_PKG_VERSION"))
.to_string()
}),
git_sha: env_string("CPR_GIT_SHA")
.unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()),
build_time: env_string("CPR_BUILD_TIME").unwrap_or_else(|| {
option_env!("CPR_BUILD_TIME")
.unwrap_or("unknown")
.to_string()
}),
deployment_mode: env_string("CPR_DEPLOYMENT_MODE")
.unwrap_or_else(|| "source".to_string()),
update_channel: env_string("CPR_UPDATE_CHANNEL")
.unwrap_or_else(|| "stable".to_string()),
update_repository,
image_repository,
image_tag: env_string("CPR_IMAGE_TAG"),
updater_url: env_string("CPR_UPDATER_URL"),
updater_token: env_string("CPR_UPDATER_TOKEN"),
compose_project: env_string("CPR_COMPOSE_PROJECT"),
compose_service: env_string("CPR_COMPOSE_SERVICE")
.unwrap_or_else(|| DEFAULT_SERVICE_NAME.to_string()),
}
}
fn version_data(&self) -> VersionData {
VersionData {
version: self.version.clone(),
git_sha: self.git_sha.clone(),
build_time: self.build_time.clone(),
deployment_mode: self.deployment_mode.clone(),
image: self.current_image(),
update_channel: self.update_channel.clone(),
}
}
fn current_image(&self) -> Option<String> {
let repository = self.image_repository.as_ref()?;
let tag = self.image_tag.as_deref().unwrap_or(self.version.as_str());
Some(format!("{repository}:{tag}"))
}
fn target_image_for_version(&self, version: &str) -> Option<String> {
self.image_repository
.as_ref()
.map(|repository| format!("{repository}:{}", normalize_version_tag(version)))
}
fn update_support_error(&self) -> Option<String> {
if self.deployment_mode != "docker" {
return Some("One-click update requires CPR_DEPLOYMENT_MODE=docker".to_string());
}
if self.image_repository.is_none() {
return Some("Docker one-click update requires CPR_IMAGE_REPOSITORY".to_string());
}
if self.updater_url.is_none() || self.updater_token.is_none() {
return Some(
"Docker one-click update requires CPR_UPDATER_URL and CPR_UPDATER_TOKEN"
.to_string(),
);
}
None
}
}
async fn check_latest_release(config: &SystemUpdateConfig) -> UpdateInfoData {
let Some(repository) = config.update_repository.as_deref() else {
return UpdateInfoData {
current_version: config.version.clone(),
latest_version: config.version.clone(),
has_update: false,
deployment_mode: config.deployment_mode.clone(),
release_url: None,
notes: None,
cached: false,
update_supported: false,
unsupported_reason: Some(
"Update checks require CPR_UPDATE_REPOSITORY or a GHCR image repository"
.to_string(),
),
target_image: None,
requires_backup: false,
warning: None,
};
};
match fetch_latest_release(repository).await {
Ok(release) => update_info_from_release(config, release),
Err(error) => UpdateInfoData {
current_version: config.version.clone(),
latest_version: config.version.clone(),
has_update: false,
deployment_mode: config.deployment_mode.clone(),
release_url: None,
notes: None,
cached: false,
update_supported: false,
unsupported_reason: config.update_support_error(),
target_image: None,
requires_backup: false,
warning: Some(error),
},
}
}
async fn fetch_latest_release(repository: &str) -> Result<GitHubRelease, String> {
let url = format!("{GITHUB_API_BASE}/{repository}/releases/latest");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| error.to_string())?;
let response = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header(reqwest::header::USER_AGENT, "codex-proxy-rs-updater")
.send()
.await
.map_err(|error| error.to_string())?;
let status = response.status();
if !status.is_success() {
return Err(format!("GitHub release check failed with {status}"));
}
response
.json::<GitHubRelease>()
.await
.map_err(|error| error.to_string())
}
fn update_info_from_release(config: &SystemUpdateConfig, release: GitHubRelease) -> UpdateInfoData {
let latest_version = normalize_version_tag(&release.tag_name);
let has_update = release_allowed(config, &release)
&& version_is_newer(&config.version, &latest_version).unwrap_or(false);
let unsupported_reason = config.update_support_error();
let update_supported = unsupported_reason.is_none();
let target_image = update_supported
.then(|| config.target_image_for_version(&latest_version))
.flatten();
let notes = release.body.or(release.name);
let requires_backup = notes
.as_deref()
.is_some_and(|notes| notes.to_ascii_lowercase().contains("backup required: yes"));
UpdateInfoData {
current_version: config.version.clone(),
latest_version,
has_update,
deployment_mode: config.deployment_mode.clone(),
release_url: release.html_url,
notes,
cached: false,
update_supported,
unsupported_reason,
target_image,
requires_backup,
warning: None,
}
}
fn release_allowed(config: &SystemUpdateConfig, release: &GitHubRelease) -> bool {
config.update_channel != "stable" || !release.prerelease
}
fn version_is_newer(current: &str, latest: &str) -> Option<bool> {
let current = Version::parse(&normalize_version_tag(current)).ok()?;
let latest = Version::parse(&normalize_version_tag(latest)).ok()?;
Some(latest > current)
}
async fn call_updater_update(
config: &SystemUpdateConfig,
payload: UpdaterUpdateRequest,
) -> Result<(), AdminError> {
call_updater(config, "/update", &payload).await
}
async fn call_updater_rollback(
config: &SystemUpdateConfig,
payload: UpdaterRollbackRequest,
) -> Result<(), AdminError> {
call_updater(config, "/rollback", &payload).await
}
async fn call_updater<T: Serialize>(
config: &SystemUpdateConfig,
path: &str,
payload: &T,
) -> Result<(), AdminError> {
let updater_url = config
.updater_url
.as_deref()
.ok_or_else(|| AdminError::conflict("CPR_UPDATER_URL is not configured"))?;
let token = config
.updater_token
.as_deref()
.ok_or_else(|| AdminError::conflict("CPR_UPDATER_TOKEN is not configured"))?;
let url = format!("{}{}", updater_url.trim_end_matches('/'), path);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|_| AdminError::internal("Failed to create updater client"))?;
let response = client
.post(url)
.bearer_auth(token)
.json(payload)
.send()
.await
.map_err(|error| AdminError::bad_gateway(format!("Updater request failed: {error}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(AdminError::bad_gateway(format!(
"Updater returned {status}: {}",
body.trim()
)));
}
Ok(())
}
fn parse_update_request(body: &[u8]) -> Result<UpdateRequest, AdminError> {
if body.trim_ascii().is_empty() {
return Ok(UpdateRequest {
target_version: None,
confirm_backup: None,
});
}
serde_json::from_slice(body).map_err(|_| AdminError::malformed_json("Malformed JSON body"))
}
fn env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_version_tag(version: &str) -> String {
version.trim().trim_start_matches('v').to_string()
}
fn github_repo_from_image(image_repository: &str) -> Option<&str> {
image_repository.strip_prefix("ghcr.io/")
}
+461
View File
@@ -0,0 +1,461 @@
use std::{
collections::BTreeMap,
env, fs, io,
net::SocketAddr,
path::{Path, PathBuf},
process::Command,
};
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = UpdaterConfig::from_env()?;
let bind = config.bind;
let app = Router::new()
.route("/update", post(update))
.route("/rollback", post(rollback))
.with_state(config);
let listener = tokio::net::TcpListener::bind(bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
#[derive(Debug, Clone)]
struct UpdaterConfig {
bind: SocketAddr,
token: String,
allowed_image_repository: String,
docker_bin: String,
docker_compose_bin: Option<String>,
compose_file: PathBuf,
compose_project: Option<String>,
compose_env_file: Option<PathBuf>,
compose_image_env: String,
state_file: PathBuf,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateRequest {
service: String,
image: String,
compose_project: Option<String>,
target_version: Option<String>,
confirm_backup: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RollbackRequest {
service: String,
compose_project: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct UpdaterState {
previous_image: Option<String>,
current_image: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdaterResponse {
message: String,
service: String,
image: Option<String>,
}
#[derive(Debug)]
struct UpdaterError {
status: StatusCode,
message: String,
}
impl UpdaterConfig {
fn from_env() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let bind = env_string("CPR_UPDATER_BIND")
.unwrap_or_else(|| "0.0.0.0:8090".to_string())
.parse::<SocketAddr>()?;
let token = required_env("CPR_UPDATER_TOKEN")?;
let allowed_image_repository = required_env("CPR_ALLOWED_IMAGE_REPOSITORY")?;
let docker_bin = env_string("CPR_DOCKER_BIN").unwrap_or_else(|| "docker".to_string());
let docker_compose_bin = env_string("CPR_DOCKER_COMPOSE_BIN");
let compose_file = PathBuf::from(
env_string("CPR_COMPOSE_FILE")
.unwrap_or_else(|| "/workspace/docker-compose.yml".into()),
);
let compose_project = env_string("CPR_COMPOSE_PROJECT");
let compose_env_file = env_string("CPR_COMPOSE_ENV_FILE").map(PathBuf::from);
let compose_image_env =
env_string("CPR_COMPOSE_IMAGE_ENV").unwrap_or_else(|| "CPR_IMAGE".to_string());
let state_file = PathBuf::from(
env_string("CPR_UPDATER_STATE_FILE")
.unwrap_or_else(|| "/tmp/codex-proxy-rs-updater-state.json".to_string()),
);
Ok(Self {
bind,
token,
allowed_image_repository,
docker_bin,
docker_compose_bin,
compose_file,
compose_project,
compose_env_file,
compose_image_env,
state_file,
})
}
fn project_for(&self, requested: Option<&str>) -> Option<String> {
requested
.and_then(non_empty)
.map(ToString::to_string)
.or_else(|| self.compose_project.clone())
}
}
async fn update(
State(config): State<UpdaterConfig>,
headers: HeaderMap,
Json(payload): Json<UpdateRequest>,
) -> Result<impl IntoResponse, UpdaterError> {
require_token(&config, &headers)?;
let service = validate_name("service", &payload.service)?;
let image = validate_image(&config, &payload.image)?;
let _target_version = payload.target_version.as_deref().and_then(non_empty);
let _confirm_backup = payload.confirm_backup.unwrap_or(false);
let compose_project = payload.compose_project;
let task_config = config;
let task_service = service.clone();
let task_image = image.clone();
tokio::spawn(async move {
if let Err(error) = run_update(
&task_config,
&task_service,
&task_image,
compose_project.as_deref(),
)
.await
{
eprintln!("updater update failed: {}", error.message);
}
});
Ok(Json(UpdaterResponse {
message: "update started".to_string(),
service,
image: Some(image),
}))
}
async fn rollback(
State(config): State<UpdaterConfig>,
headers: HeaderMap,
Json(payload): Json<RollbackRequest>,
) -> Result<impl IntoResponse, UpdaterError> {
require_token(&config, &headers)?;
let service = validate_name("service", &payload.service)?;
let state = read_state(&config.state_file)?;
let image = state
.previous_image
.ok_or_else(|| UpdaterError::conflict("No previous image recorded for rollback"))?;
let image = validate_image(&config, &image)?;
let compose_project = payload.compose_project;
let task_config = config;
let task_service = service.clone();
let task_image = image.clone();
tokio::spawn(async move {
if let Err(error) = run_update(
&task_config,
&task_service,
&task_image,
compose_project.as_deref(),
)
.await
{
eprintln!("updater rollback failed: {}", error.message);
}
});
Ok(Json(UpdaterResponse {
message: "rollback started".to_string(),
service,
image: Some(image),
}))
}
async fn run_update(
config: &UpdaterConfig,
service: &str,
image: &str,
compose_project: Option<&str>,
) -> Result<(), UpdaterError> {
let previous_image = current_image(config)?;
run_command(config.docker_command(["pull", image]), "docker pull").await?;
if let Some(env_file) = &config.compose_env_file {
upsert_env_file(env_file, &config.compose_image_env, image)?;
}
let state = UpdaterState {
previous_image,
current_image: Some(image.to_string()),
};
write_state(&config.state_file, &state)?;
let mut args = vec![
"-f".to_string(),
config.compose_file.to_string_lossy().to_string(),
];
if let Some(project) = config.project_for(compose_project) {
args.push("-p".to_string());
args.push(project);
}
args.extend(["up".to_string(), "-d".to_string(), service.to_string()]);
run_command(config.compose_command(args), "docker compose up").await
}
impl UpdaterConfig {
fn docker_command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut command = Command::new(&self.docker_bin);
for arg in args {
command.arg(arg.as_ref());
}
command
}
fn compose_command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
if let Some(compose_bin) = &self.docker_compose_bin {
let mut command = Command::new(compose_bin);
for arg in args {
command.arg(arg.as_ref());
}
return command;
}
let mut command = Command::new(&self.docker_bin);
command.arg("compose");
for arg in args {
command.arg(arg.as_ref());
}
command
}
}
async fn run_command(mut command: Command, label: &'static str) -> Result<(), UpdaterError> {
let output = tokio::task::spawn_blocking(move || command.output())
.await
.map_err(|error| UpdaterError::internal(format!("{label} join error: {error}")))?
.map_err(|error| UpdaterError::internal(format!("{label} failed to start: {error}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(UpdaterError::internal(format!(
"{label} failed: {}{}",
stderr.trim(),
stdout.trim()
)))
}
fn current_image(config: &UpdaterConfig) -> Result<Option<String>, UpdaterError> {
let Some(env_file) = &config.compose_env_file else {
return Ok(read_state(&config.state_file)?.current_image);
};
if !env_file.exists() {
return Ok(read_state(&config.state_file)?.current_image);
}
let values = read_env_file(env_file)?;
Ok(values.get(&config.compose_image_env).cloned().or_else(|| {
read_state(&config.state_file)
.ok()
.and_then(|state| state.current_image)
}))
}
fn read_state(path: &Path) -> Result<UpdaterState, UpdaterError> {
if !path.exists() {
return Ok(UpdaterState::default());
}
let data = fs::read_to_string(path).map_err(|error| {
UpdaterError::internal(format!("Failed to read updater state: {error}"))
})?;
serde_json::from_str(&data)
.map_err(|error| UpdaterError::internal(format!("Invalid updater state: {error}")))
}
fn write_state(path: &Path, state: &UpdaterState) -> Result<(), UpdaterError> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
UpdaterError::internal(format!("Failed to create updater state directory: {error}"))
})?;
}
let data = serde_json::to_string_pretty(state).map_err(|error| {
UpdaterError::internal(format!("Failed to encode updater state: {error}"))
})?;
fs::write(path, data)
.map_err(|error| UpdaterError::internal(format!("Failed to write updater state: {error}")))
}
fn upsert_env_file(path: &Path, key: &str, value: &str) -> Result<(), UpdaterError> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
UpdaterError::internal(format!("Failed to create env dir: {error}"))
})?;
}
let mut values = if path.exists() {
read_env_file(path)?
} else {
BTreeMap::new()
};
values.insert(key.to_string(), value.to_string());
let data = values
.into_iter()
.map(|(key, value)| format!("{key}={value}\n"))
.collect::<String>();
fs::write(path, data)
.map_err(|error| UpdaterError::internal(format!("Failed to write env file: {error}")))
}
fn read_env_file(path: &Path) -> Result<BTreeMap<String, String>, UpdaterError> {
let data = fs::read_to_string(path)
.map_err(|error| UpdaterError::internal(format!("Failed to read env file: {error}")))?;
let mut values = BTreeMap::new();
for line in data.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.insert(key.trim().to_string(), value.trim().to_string());
}
Ok(values)
}
fn require_token(config: &UpdaterConfig, headers: &HeaderMap) -> Result<(), UpdaterError> {
let Some(header) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(UpdaterError::unauthorized("Missing authorization header"));
};
let Ok(value) = header.to_str() else {
return Err(UpdaterError::unauthorized("Invalid authorization header"));
};
let token = value.strip_prefix("Bearer ").unwrap_or_default().trim();
if token == config.token {
return Ok(());
}
Err(UpdaterError::unauthorized("Invalid updater token"))
}
fn validate_image(config: &UpdaterConfig, image: &str) -> Result<String, UpdaterError> {
let image = validate_name("image", image)?;
let allowed_prefix = format!("{}:", config.allowed_image_repository);
let allowed_digest_prefix = format!("{}@", config.allowed_image_repository);
if image.starts_with(&allowed_prefix) || image.starts_with(&allowed_digest_prefix) {
return Ok(image);
}
Err(UpdaterError::bad_request(
"Image repository is not allowed for update",
))
}
fn validate_name(field: &str, value: &str) -> Result<String, UpdaterError> {
let Some(value) = non_empty(value) else {
return Err(UpdaterError::bad_request(format!(
"{field} must not be empty"
)));
};
if value
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace())
{
return Err(UpdaterError::bad_request(format!(
"{field} must not contain whitespace or control characters"
)));
}
Ok(value.to_string())
}
fn env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn required_env(key: &str) -> Result<String, io::Error> {
env_string(key)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{key} is required")))
}
fn non_empty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
impl UpdaterError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.into(),
}
}
fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
impl IntoResponse for UpdaterError {
fn into_response(self) -> Response {
(
self.status,
Json(serde_json::json!({
"message": self.message
})),
)
.into_response()
}
}
+20 -18
View File
@@ -114,6 +114,17 @@ pub struct ResponseDispatchService {
cloudflare: CloudflareRecovery,
}
pub(crate) struct ResponseDispatchServiceParts {
pub account_pool: Arc<RuntimeAccountPoolService>,
pub models: Arc<crate::upstream::models::ModelService>,
pub codex: Arc<CodexBackendClient>,
pub session_affinity: Arc<RuntimeSessionAffinityService>,
pub usage_records: Arc<AdminUsageRecordService>,
pub token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
pub installation_id: Option<String>,
pub cloudflare: CloudflareRecovery,
}
/// 默认 reasoning replay TTL 秒数。
const DEFAULT_REASONING_REPLAY_TTL_SECS: i64 = 55 * 60;
const IMPLICIT_RESUME_MAX_AGE_SECS: i64 = DEFAULT_REASONING_REPLAY_TTL_SECS;
@@ -151,28 +162,19 @@ impl Stream for MpscResponseBodyStream {
use axum::body::Bytes;
impl ResponseDispatchService {
pub(crate) fn new(
account_pool: Arc<RuntimeAccountPoolService>,
models: Arc<crate::upstream::models::ModelService>,
codex: Arc<CodexBackendClient>,
session_affinity: Arc<RuntimeSessionAffinityService>,
usage_records: Arc<AdminUsageRecordService>,
token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
installation_id: Option<String>,
cloudflare: CloudflareRecovery,
) -> Self {
pub(crate) fn new(parts: ResponseDispatchServiceParts) -> Self {
Self {
account_pool,
models,
codex,
session_affinity,
account_pool: parts.account_pool,
models: parts.models,
codex: parts.codex,
session_affinity: parts.session_affinity,
reasoning_replay: Arc::new(Mutex::new(ReasoningReplayCache::new(Duration::seconds(
DEFAULT_REASONING_REPLAY_TTL_SECS,
)))),
usage_records,
token_refresh,
installation_id,
cloudflare,
usage_records: parts.usage_records,
token_refresh: parts.token_refresh,
installation_id: parts.installation_id,
cloudflare: parts.cloudflare,
}
}
+11 -11
View File
@@ -25,7 +25,7 @@ use crate::{
},
proxy::dispatch::{
chat::ChatDispatchService,
responses::ResponseDispatchService,
responses::{ResponseDispatchService, ResponseDispatchServiceParts},
session_affinity::{RuntimeSessionAffinityService, SqliteSessionAffinityStore},
},
upstream::accounts::{
@@ -264,16 +264,16 @@ impl Services {
installation_id.clone(),
cloudflare_recovery.clone(),
));
let responses = StdArc::new(ResponseDispatchService::new(
account_pool.clone(),
models.clone(),
codex.clone(),
session_affinity.clone(),
usage_records.clone(),
token_refresh.clone(),
installation_id.clone(),
cloudflare_recovery,
));
let responses = StdArc::new(ResponseDispatchService::new(ResponseDispatchServiceParts {
account_pool: account_pool.clone(),
models: models.clone(),
codex: codex.clone(),
session_affinity: session_affinity.clone(),
usage_records: usage_records.clone(),
token_refresh: token_refresh.clone(),
installation_id: installation_id.clone(),
cloudflare: cloudflare_recovery,
}));
Ok(Self {
models,
+3
View File
@@ -16,5 +16,8 @@ export * from './modules/dashboard'
// 设置管理
export * from './modules/settings'
// 系统更新
export * from './modules/system'
// 请求工具
export { default as request } from './request'
+71
View File
@@ -0,0 +1,71 @@
import request, { type ApiPayload } from '../request'
export interface SystemVersion {
version: string
gitSha: string
buildTime: string
deploymentMode: string
image?: string
updateChannel: string
}
export interface SystemUpdateInfo {
currentVersion: string
latestVersion: string
hasUpdate: boolean
deploymentMode: string
releaseUrl?: string
notes?: string
cached: boolean
updateSupported: boolean
unsupportedReason?: string
targetImage?: string
requiresBackup: boolean
warning?: string
}
export interface SystemUpdateStarted {
operationId: string
deploymentMode: string
message: string
needReconnect: boolean
targetVersion: string
targetImage?: string
}
export function getSystemVersion() {
return request<SystemVersion>({
url: '/api/admin/system/version',
method: 'GET',
})
}
export function checkSystemUpdates(force = false) {
return request<SystemUpdateInfo>({
url: '/api/admin/system/check-updates',
method: 'GET',
params: { force },
})
}
export function performSystemUpdate(data: ApiPayload = {}) {
return request<SystemUpdateStarted>({
url: '/api/admin/system/update',
method: 'POST',
data,
})
}
export function rollbackSystemUpdate() {
return request({
url: '/api/admin/system/rollback',
method: 'POST',
})
}
export function restartSystem() {
return request({
url: '/api/admin/system/restart',
method: 'POST',
})
}
+16
View File
@@ -13,6 +13,7 @@ import {
Moon,
PanelLeftClose,
PanelLeftOpen,
PackageCheck,
Settings,
Sun,
Users,
@@ -22,6 +23,8 @@ import BaseButton from '@/components/base/BaseButton.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { useUiStore } from '@/stores/modules/ui'
import SystemUpdateModal from './SystemUpdateModal.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -68,6 +71,7 @@ defineEmits<{
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const brandLabelVisible = shallowRef(!props.collapsed)
const systemUpdateOpen = shallowRef(false)
const themeToggleLabel = computed(() =>
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
)
@@ -279,6 +283,16 @@ watch(
</span>
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
<BaseButton
icon-only
variant="ghost"
:size="collapsed ? 'default' : 'sm'"
label="系统更新"
@click="systemUpdateOpen = true"
>
<PackageCheck :size="collapsed ? 19 : 18" />
</BaseButton>
<BaseButton
icon-only
variant="ghost"
@@ -316,4 +330,6 @@ watch(
</div>
</div>
</aside>
<SystemUpdateModal v-model="systemUpdateOpen" />
</template>
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onUnmounted, shallowRef, watch } from 'vue'
import { ExternalLink, PackageCheck, Power, RefreshCw, RotateCcw } from '@lucide/vue'
import {
checkSystemUpdates,
getSystemVersion,
performSystemUpdate,
restartSystem,
rollbackSystemUpdate,
type SystemUpdateInfo,
type SystemVersion,
} from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
const open = defineModel<boolean>({ default: false })
const version = shallowRef<SystemVersion | null>(null)
const updateInfo = shallowRef<SystemUpdateInfo | null>(null)
const loading = shallowRef(false)
const checking = shallowRef(false)
const updating = shallowRef(false)
const rollingBack = shallowRef(false)
const restarting = shallowRef(false)
const reconnecting = shallowRef(false)
const reconnectMessage = shallowRef('')
let disposed = false
let loadedOnce = false
const canUpdate = computed(
() =>
Boolean(updateInfo.value?.hasUpdate) &&
Boolean(updateInfo.value?.updateSupported) &&
!updating.value &&
!checking.value,
)
const updateStatus = computed(() => {
if (updateInfo.value?.warning) return '检查失败'
if (!updateInfo.value) return '未检查'
if (updateInfo.value.hasUpdate) return '发现新版本'
return '已是最新'
})
const updateStatusClass = computed(() => {
if (updateInfo.value?.warning) return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
if (updateInfo.value?.hasUpdate) return 'bg-(--cp-info-bg) text-(--cp-info-text)'
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
})
const deploymentStatusClass = computed(() =>
version.value?.deploymentMode === 'docker'
? 'bg-(--cp-info-bg) text-(--cp-info-text)'
: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
)
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
async function loadSystem() {
try {
loading.value = true
const [versionData, updateData] = await Promise.all([
getSystemVersion(),
checkSystemUpdates(false),
])
version.value = versionData
updateInfo.value = updateData
loadedOnce = true
} catch (error: any) {
toast.error(error.message || '加载系统更新信息失败')
} finally {
loading.value = false
}
}
async function handleCheckUpdates(force = true) {
if (checking.value) return
try {
checking.value = true
updateInfo.value = await checkSystemUpdates(force)
toast.success(updateInfo.value.hasUpdate ? '发现可用更新' : '当前已是最新版本')
} catch (error: any) {
toast.error(error.message || '检查更新失败')
} finally {
checking.value = false
}
}
async function waitForReconnect(targetVersion: string) {
reconnecting.value = true
reconnectMessage.value = '等待服务重启...'
for (let attempt = 0; attempt < 40 && !disposed; attempt += 1) {
await sleep(3000)
try {
const data = await getSystemVersion()
version.value = data
reconnectMessage.value = `服务已响应,当前版本 ${data.version}`
if (!targetVersion || data.version === targetVersion) {
toast.success('系统已恢复')
await handleCheckUpdates(false)
break
}
} catch {
reconnectMessage.value = '服务暂时不可用,继续等待...'
}
}
reconnecting.value = false
}
async function handleUpdate() {
if (!canUpdate.value || !updateInfo.value) return
try {
updating.value = true
const result = await performSystemUpdate({
confirmBackup: updateInfo.value.requiresBackup,
})
toast.success('更新任务已启动')
if (result.needReconnect) {
await waitForReconnect(result.targetVersion)
}
} catch (error: any) {
toast.error(error.message || '启动更新失败')
} finally {
updating.value = false
}
}
async function handleRollback() {
if (rollingBack.value) return
try {
rollingBack.value = true
await rollbackSystemUpdate()
toast.success('回滚任务已启动')
} catch (error: any) {
toast.error(error.message || '启动回滚失败')
} finally {
rollingBack.value = false
}
}
async function handleRestart() {
if (restarting.value) return
try {
restarting.value = true
await restartSystem()
toast.success('重启已触发')
await waitForReconnect(version.value?.version || '')
} catch (error: any) {
toast.error(error.message || '重启失败')
} finally {
restarting.value = false
}
}
watch(open, (visible) => {
if (visible && !loadedOnce) {
void loadSystem()
}
})
onUnmounted(() => {
disposed = true
})
</script>
<template>
<BaseModal
v-model="open"
title="系统更新"
description="检查版本并触发 Docker 在线更新。"
variant="info"
width="780px"
scrollable
body-max-height="68vh"
body-view-class="pr-4"
:close-disabled="updating || rollingBack || restarting"
>
<div class="grid gap-4">
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">运行版本</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-[650] text-(--cp-text-secondary)">
{{ loading ? '加载中...' : displayValue(version?.version) }}
</p>
</div>
<span
class="inline-flex h-7 items-center rounded-full px-2.5 text-[12px] font-[760]"
:class="deploymentStatusClass"
>
{{ loading ? '加载中...' : displayValue(version?.deploymentMode) }}
</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">镜像</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
:title="version?.image || '-'"
>
{{ displayValue(version?.image) }}
</p>
</div>
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">Git SHA</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
>
{{ displayValue(version?.gitSha) }}
</p>
</div>
</div>
</section>
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4">
<div class="flex flex-wrap items-center gap-3">
<span
class="inline-flex h-8 items-center rounded-full px-3 text-[13px] font-[760]"
:class="updateStatusClass"
>
{{ updateStatus }}
</span>
<span class="font-mono text-[13px] font-[650] text-(--cp-text-secondary)">
{{ displayValue(updateInfo?.currentVersion) }} ->
{{ displayValue(updateInfo?.latestVersion) }}
</span>
<a
v-if="updateInfo?.releaseUrl"
:href="updateInfo.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1.5 text-[13px] font-[720] text-(--cp-info-text) no-underline"
>
Release
<ExternalLink class="size-3.5" />
</a>
</div>
<div
v-if="updateInfo?.unsupportedReason"
class="rounded-(--cp-input-radius-base) bg-(--cp-warning-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-warning-text)"
>
{{ updateInfo.unsupportedReason }}
</div>
<div
v-if="updateInfo?.warning"
class="rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-danger-text)"
>
{{ updateInfo.warning }}
</div>
<div
v-if="reconnecting"
class="rounded-(--cp-input-radius-base) bg-(--cp-info-bg) px-3 py-2.5 text-[12px] leading-normal font-[650] text-(--cp-info-text)"
>
{{ reconnectMessage }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="min-w-0">
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">目标镜像</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
:title="updateInfo?.targetImage || '-'"
>
{{ displayValue(updateInfo?.targetImage) }}
</p>
</div>
<div>
<p class="m-0 text-[11px] font-[760] text-(--cp-text-muted)">备份确认</p>
<p class="mt-1.5 mb-0 text-[12px] font-[720] text-(--cp-text-primary)">
{{ updateInfo?.requiresBackup ? '需要' : '不需要' }}
</p>
</div>
</div>
</section>
<section
v-if="updateInfo?.notes"
class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4"
>
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
<BaseScrollbar max-height="260px" view-class="pr-2">
<pre
class="m-0 whitespace-pre-wrap break-words font-mono text-[11px] leading-[1.65] font-[620] text-(--cp-text-primary)"
>{{ updateInfo.notes }}</pre
>
</BaseScrollbar>
</section>
</div>
<template #footer>
<BaseButton
variant="default"
:loading="checking"
:disabled="loading"
@click="handleCheckUpdates(true)"
>
<template #icon>
<RefreshCw class="size-4" />
</template>
检查
</BaseButton>
<BaseButton variant="default" :loading="rollingBack" @click="handleRollback">
<template #icon>
<RotateCcw class="size-4" />
</template>
回滚
</BaseButton>
<BaseButton variant="default" :loading="restarting" @click="handleRestart">
<template #icon>
<Power class="size-4" />
</template>
重启
</BaseButton>
<BaseButton
variant="primary"
:loading="updating"
:disabled="!canUpdate"
@click="handleUpdate"
>
<template #icon>
<PackageCheck class="size-4" />
</template>
一键更新
</BaseButton>
</template>
</BaseModal>
</template>