mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: add docker online update flow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
target
|
||||
web/node_modules
|
||||
web/dist
|
||||
.runtime
|
||||
data
|
||||
logs
|
||||
local.yaml
|
||||
local.yml
|
||||
.env
|
||||
config.yaml
|
||||
.git
|
||||
.codegraph
|
||||
.worktrees
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@
|
||||
# Local configuration.
|
||||
/local.yaml
|
||||
/local.yml
|
||||
/.env
|
||||
|
||||
# Local workspaces and tooling state.
|
||||
/.worktrees/
|
||||
|
||||
Generated
+7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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。
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,3 +14,5 @@ pub mod response;
|
||||
pub mod router;
|
||||
/// 设置处理器。
|
||||
pub mod settings;
|
||||
/// 系统版本与在线更新处理器。
|
||||
pub mod system;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! 系统版本与在线更新处理器。
|
||||
|
||||
pub mod routes;
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -16,5 +16,8 @@ export * from './modules/dashboard'
|
||||
// 设置管理
|
||||
export * from './modules/settings'
|
||||
|
||||
// 系统更新
|
||||
export * from './modules/system'
|
||||
|
||||
// 请求工具
|
||||
export { default as request } from './request'
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user