mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: align online update flow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -12,7 +12,6 @@ permissions:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: zyycn/codex-proxy-rs
|
||||
UPDATER_IMAGE_NAME: zyycn/codex-proxy-rs-updater
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -32,6 +31,10 @@ jobs:
|
||||
echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT"
|
||||
echo "git_sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write VERSION file
|
||||
shell: bash
|
||||
run: printf '%s\n' '${{ steps.version.outputs.version }}' > VERSION
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -47,7 +50,7 @@ jobs:
|
||||
run: pnpm --dir web build
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.95
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -57,17 +60,17 @@ jobs:
|
||||
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
|
||||
CPR_BUILD_TYPE: release
|
||||
run: cargo build --release --locked --bin codex-proxy-rs
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
PACKAGE_DIR="dist-package/codex-proxy-rs"
|
||||
mkdir -p "${PACKAGE_DIR}"
|
||||
mkdir -p "${PACKAGE_DIR}/web"
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -95,6 +98,7 @@ jobs:
|
||||
CPR_VERSION=${{ steps.version.outputs.version }}
|
||||
CPR_GIT_SHA=${{ steps.version.outputs.git_sha }}
|
||||
CPR_BUILD_TIME=${{ steps.version.outputs.build_time }}
|
||||
CPR_BUILD_TYPE=release
|
||||
labels: |
|
||||
org.opencontainers.image.title=Codex Proxy RS
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
@@ -105,27 +109,6 @@ jobs:
|
||||
${{ 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:
|
||||
|
||||
Generated
+33
@@ -325,6 +325,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"config",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"indexmap",
|
||||
@@ -342,6 +343,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -802,6 +804,16 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2653,6 +2665,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3646,6 +3669,16 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.11.0"
|
||||
|
||||
@@ -16,6 +16,7 @@ bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde", "clock"] }
|
||||
config = "0.15.23"
|
||||
dirs = "5.0.1"
|
||||
flate2 = "1.1.9"
|
||||
futures = "0.3.32"
|
||||
hex = "0.4.3"
|
||||
indexmap = { version = "2.0", features = ["serde"] }
|
||||
@@ -32,6 +33,7 @@ serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
|
||||
subtle = "2.6.1"
|
||||
tar = "0.4.44"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
|
||||
|
||||
+8
-35
@@ -11,60 +11,33 @@ RUN pnpm build
|
||||
FROM rust:1.95-bookworm AS rust-builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG CPR_VERSION=dev
|
||||
ARG CPR_VERSION=
|
||||
ARG CPR_GIT_SHA=unknown
|
||||
ARG CPR_BUILD_TIME=unknown
|
||||
ARG CPR_BUILD_TYPE=release
|
||||
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 ./
|
||||
ENV CPR_BUILD_TYPE=${CPR_BUILD_TYPE}
|
||||
COPY Cargo.toml Cargo.lock build.rs VERSION ./
|
||||
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"]
|
||||
cargo build --release --locked --bin codex-proxy-rs \
|
||||
&& cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs
|
||||
|
||||
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}
|
||||
ENV CPR_DEPLOYMENT_MODE=docker
|
||||
COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
|
||||
COPY --from=web-builder /app/web/dist ./web/dist
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=VERSION");
|
||||
println!("cargo:rerun-if-env-changed=CPR_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=CPR_GIT_SHA");
|
||||
println!("cargo:rerun-if-env-changed=CPR_BUILD_TIME");
|
||||
println!("cargo:rerun-if-env-changed=CPR_BUILD_TYPE");
|
||||
|
||||
let version = version_env_value("CPR_VERSION")
|
||||
.or_else(version_file)
|
||||
.unwrap_or_else(|| {
|
||||
env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0-dev".to_string())
|
||||
});
|
||||
let git_sha = env_value("CPR_GIT_SHA").unwrap_or_else(|| "unknown".to_string());
|
||||
let build_time = env_value("CPR_BUILD_TIME").unwrap_or_else(|| "unknown".to_string());
|
||||
let build_type = env_value("CPR_BUILD_TYPE").unwrap_or_else(|| "source".to_string());
|
||||
|
||||
println!("cargo:rustc-env=CPR_VERSION={version}");
|
||||
println!("cargo:rustc-env=CPR_GIT_SHA={git_sha}");
|
||||
println!("cargo:rustc-env=CPR_BUILD_TIME={build_time}");
|
||||
println!("cargo:rustc-env=CPR_BUILD_TYPE={build_type}");
|
||||
}
|
||||
|
||||
fn env_value(key: &str) -> Option<String> {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn version_env_value(key: &str) -> Option<String> {
|
||||
env_value(key).map(|value| value.trim_start_matches('v').to_string())
|
||||
}
|
||||
|
||||
fn version_file() -> Option<String> {
|
||||
fs::read_to_string("VERSION")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_start_matches('v').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
+4
-24
@@ -13,27 +13,7 @@ services:
|
||||
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
|
||||
CPR_UPDATE_STATE_FILE: /app/data/update-state.json
|
||||
CPR_UPDATE_LOCK_FILE: /app/data/update.lock
|
||||
CPR_WEB_DIST_DIR: /app/web/dist
|
||||
CPR_ENABLE_SELF_RESTART: "true"
|
||||
|
||||
@@ -1,743 +0,0 @@
|
||||
# 在线更新方案
|
||||
|
||||
本文档定义 Codex Proxy RS 的应用包在线更新方案。目标是支持管理端检查更新,并在管理员点击按钮后完成一键更新。
|
||||
|
||||
## 目标
|
||||
|
||||
- 管理端可以显示当前版本、最新版本、更新说明和部署模式。
|
||||
- 管理端可以手动检查更新。
|
||||
- 管理端可以触发一键更新。
|
||||
- Docker 部署是主要场景。
|
||||
- 裸机二进制部署可以作为后续兼容场景。
|
||||
- 更新过程必须保留数据卷、配置和日志,不覆盖运行数据。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在运行中的 Rust 进程里热加载新代码。
|
||||
- 不在 Docker 容器内部直接替换当前容器的二进制作为主方案。
|
||||
- 不默认把宿主机 Docker socket 挂进主应用容器。
|
||||
- 不在没有迁移机制前执行高风险数据库结构变更。
|
||||
|
||||
## 部署模式
|
||||
|
||||
### Docker 模式
|
||||
|
||||
Docker 模式下,一键更新应更新镜像并重建容器:
|
||||
|
||||
1. 管理端调用后端更新接口。
|
||||
2. 后端完成管理员鉴权和并发锁控制。
|
||||
3. 后端调用 updater。
|
||||
4. updater 在宿主机或 sidecar 中执行镜像更新。
|
||||
5. updater 拉取新镜像。
|
||||
6. updater 重新创建 `codex-proxy-rs` 服务。
|
||||
7. 数据卷继续挂载,SQLite、日志和配置保持不变。
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
browser
|
||||
-> codex-proxy-rs admin api
|
||||
-> updater sidecar / host updater
|
||||
-> docker pull
|
||||
-> docker compose up -d codex-proxy-rs
|
||||
```
|
||||
|
||||
主应用容器不应直接拥有 Docker daemon 权限。updater 可以是一个独立容器,也可以是宿主机 systemd 服务。updater 只暴露给同一 Docker network 或 `127.0.0.1`,并使用共享 token 鉴权。
|
||||
|
||||
项目只保留生产用 `docker-compose.yml`,不维护本地 Docker 开发配置。compose 必须使用远端镜像,不能使用 `build: .`,否则管理端只能检查更新,无法安全地完成一键更新。
|
||||
|
||||
```yaml
|
||||
services:
|
||||
codex-proxy-rs:
|
||||
image: ghcr.io/zyycn/codex-proxy-rs:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
CPR_DEPLOYMENT_MODE: docker
|
||||
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 is required}
|
||||
```
|
||||
|
||||
### 二进制模式
|
||||
|
||||
二进制模式可以采用 Sub2API 类似方案:
|
||||
|
||||
1. 请求 GitHub Releases latest。
|
||||
2. 匹配当前 OS/arch 的压缩包。
|
||||
3. 下载 release asset。
|
||||
4. 校验 `checksums.txt`。
|
||||
5. 解压新二进制和 `web/dist`。
|
||||
6. 备份旧二进制和旧 `web/dist`。
|
||||
7. 原子替换。
|
||||
8. 返回 `needRestart: true`。
|
||||
9. 管理端触发重启,进程退出后由 systemd/supervisor 拉起。
|
||||
|
||||
二进制模式不是当前 Docker 部署的主路径,但可以复用检查更新、版本展示、更新锁和前端 UI。
|
||||
|
||||
## 版本与发布产物
|
||||
|
||||
发布时应同时产出 Docker 镜像和 release 元数据:
|
||||
|
||||
- Git tag:`v0.1.0`
|
||||
- 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`
|
||||
|
||||
后端应在构建时注入版本信息:
|
||||
|
||||
- `CPR_VERSION`
|
||||
- `CPR_GIT_SHA`
|
||||
- `CPR_BUILD_TIME`
|
||||
- `CPR_DEPLOYMENT_MODE`
|
||||
- `CPR_IMAGE_REPOSITORY`
|
||||
- `CPR_IMAGE_TAG`
|
||||
|
||||
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
|
||||
|
||||
建议新增管理端接口:
|
||||
|
||||
```http
|
||||
GET /api/admin/system/version
|
||||
GET /api/admin/system/check-updates?force=true
|
||||
POST /api/admin/system/update
|
||||
POST /api/admin/system/rollback
|
||||
POST /api/admin/system/restart
|
||||
```
|
||||
|
||||
所有接口必须要求管理员会话或管理员 API Key。
|
||||
|
||||
`GET /api/admin/system/version` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"gitSha": "abc1234",
|
||||
"buildTime": "2026-07-01T00:00:00Z",
|
||||
"deploymentMode": "docker",
|
||||
"image": "ghcr.io/zyycn/codex-proxy-rs:0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/admin/system/check-updates` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"currentVersion": "0.1.0",
|
||||
"latestVersion": "0.2.0",
|
||||
"hasUpdate": true,
|
||||
"deploymentMode": "docker",
|
||||
"releaseUrl": "https://github.com/zyycn/codex-proxy-rs/releases/tag/v0.2.0",
|
||||
"notes": "...",
|
||||
"cached": false,
|
||||
"updateSupported": true,
|
||||
"unsupportedReason": null
|
||||
}
|
||||
```
|
||||
|
||||
`POST /api/admin/system/update` 在 Docker 模式下返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"operationId": "sysop-...",
|
||||
"deploymentMode": "docker",
|
||||
"message": "Update started",
|
||||
"needReconnect": true
|
||||
}
|
||||
```
|
||||
|
||||
如果当前部署不支持一键更新,应返回明确原因:
|
||||
|
||||
```json
|
||||
{
|
||||
"updateSupported": false,
|
||||
"unsupportedReason": "Docker one-click update requires CPR_UPDATER_URL and a remote image deployment"
|
||||
}
|
||||
```
|
||||
|
||||
## Updater 服务
|
||||
|
||||
Docker 一键更新的关键是 updater。updater 负责真正操作 Docker:
|
||||
|
||||
```http
|
||||
POST /update
|
||||
Authorization: Bearer <CPR_UPDATER_TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"service": "codex-proxy-rs",
|
||||
"image": "ghcr.io/zyycn/codex-proxy-rs:0.2.0",
|
||||
"composeProject": "codex-proxy-rs"
|
||||
}
|
||||
```
|
||||
|
||||
updater 行为:
|
||||
|
||||
1. 校验 token。
|
||||
2. 校验 image 仓库白名单。
|
||||
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/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
|
||||
- ${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_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json
|
||||
networks:
|
||||
- default
|
||||
```
|
||||
|
||||
如果后续不想维护自研 updater,可以评估 Watchtower 的 HTTP API 模式,但仍需要后端把“检查更新、按钮触发、操作结果展示”封装到管理端。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 所有更新接口必须走管理员鉴权。
|
||||
- 更新任务必须有全局锁,避免并发更新。
|
||||
- GitHub release 下载 URL 必须限制为可信 HTTPS host。
|
||||
- Docker image 仓库必须白名单校验。
|
||||
- 下载包必须限制大小。
|
||||
- 二进制包必须校验 checksum。
|
||||
- Docker 更新必须保留 `data`、`logs`、`config.yaml` 挂载。
|
||||
- 不允许通过管理端传入任意 shell 命令。
|
||||
- updater token 只能通过环境变量或密钥文件配置,不能写入前端。
|
||||
|
||||
## 前端交互
|
||||
|
||||
管理端系统页建议包含:
|
||||
|
||||
- 当前版本。
|
||||
- 部署模式。
|
||||
- 最新版本。
|
||||
- 更新说明。
|
||||
- 检查更新按钮。
|
||||
- 一键更新按钮。
|
||||
- 更新中状态。
|
||||
- 更新后重连提示。
|
||||
- 不支持一键更新时的明确原因。
|
||||
|
||||
Docker 更新会导致容器重建,前端连接会短暂断开。UI 应在触发更新后轮询 `/api/admin/system/version`,服务恢复后提示已更新。
|
||||
|
||||
## 数据库迁移约束
|
||||
|
||||
当前项目启动时执行 `schema.sql`,主要依赖 `create table if not exists`。在加入在线更新前,建议补充迁移版本表,例如:
|
||||
|
||||
```sql
|
||||
create table if not exists schema_migrations (
|
||||
version integer primary key,
|
||||
applied_at text not null
|
||||
);
|
||||
```
|
||||
|
||||
发布新版本时:
|
||||
|
||||
- 向前兼容的字段新增可以自动迁移。
|
||||
- 删除字段、重命名字段、不可逆数据迁移需要显式 release note。
|
||||
- 一键更新前可以在检查结果里标注 `requiresBackup: true`。
|
||||
|
||||
## 推荐落地顺序
|
||||
|
||||
1. 建立 GitHub Release 和 GHCR 镜像发布流程。
|
||||
2. 为后端注入版本、commit、构建时间和部署模式。
|
||||
3. 增加系统版本与检查更新 API。
|
||||
4. 增加管理端系统更新页。
|
||||
5. 增加 Docker updater sidecar。
|
||||
6. 接入一键更新按钮。
|
||||
7. 补充数据库迁移机制。
|
||||
8. 再考虑二进制模式的原子替换和 rollback。
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
pub(crate) const ADMIN_OK_CODE: u32 = 200;
|
||||
pub(crate) const ADMIN_OK_MESSAGE: &str = "OK";
|
||||
@@ -148,6 +149,12 @@ impl IntoResponse for AdminError {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AdminError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端响应信封。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
+6
-1
@@ -27,7 +27,10 @@ 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},
|
||||
system::routes::{
|
||||
check_updates, perform_update, restart, rollback, update_event_stream, update_status,
|
||||
version,
|
||||
},
|
||||
};
|
||||
|
||||
/// 构造管理端路由。
|
||||
@@ -47,7 +50,9 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
.route("/api/admin/system/version", get(version))
|
||||
.route("/api/admin/system/check-updates", get(check_updates))
|
||||
.route("/api/admin/system/update-events", get(update_event_stream))
|
||||
.route("/api/admin/system/update", post(perform_update))
|
||||
.route("/api/admin/system/update-status", get(update_status))
|
||||
.route("/api/admin/system/rollback", post(rollback))
|
||||
.route("/api/admin/system/restart", post(restart))
|
||||
.route("/api/admin/dashboard/summary", get(dashboard_summary))
|
||||
|
||||
+1153
-215
File diff suppressed because it is too large
Load Diff
@@ -1,461 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub(crate) mod keys;
|
||||
pub(crate) mod monitoring;
|
||||
pub(crate) mod response;
|
||||
pub(crate) mod settings;
|
||||
pub(crate) mod system;
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
use std::{io::Write, path::Path, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header::CONTENT_TYPE, Request, StatusCode},
|
||||
};
|
||||
use codex_proxy_rs::{
|
||||
admin::auth::service::SqliteAdminSessionStore,
|
||||
admin::keys::service::SqliteClientKeyStore,
|
||||
admin::monitoring::usage_record_store::SqliteUsageRecordStore,
|
||||
infra::database::connect_sqlite,
|
||||
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
|
||||
runtime::services::{BackgroundTaskStores, Services},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::{
|
||||
cookies::SqliteCookieStore, store::SqliteAccountStore, token_refresh::RefreshLeaseStore,
|
||||
},
|
||||
upstream::fingerprint::FingerprintRepository,
|
||||
};
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::SqlitePool;
|
||||
use tar::{Builder, Header};
|
||||
use tokio::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
};
|
||||
|
||||
use crate::support::{
|
||||
admin::seed_admin_session, config::test_config, fingerprint::test_fingerprint,
|
||||
http::response_json,
|
||||
};
|
||||
|
||||
static SYSTEM_ENV_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
const VERSION_OLD: &str = "0.1.0";
|
||||
const VERSION_NEW: &str = "0.2.0";
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_should_return_backend_build_metadata() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
set_system_update_env("zyycn/codex-proxy-rs-version-route", "http://127.0.0.1:9");
|
||||
std::env::set_var("CPR_GIT_SHA", "version-test-sha");
|
||||
std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z");
|
||||
let (app, _dir) = admin_system_test_app("system-version.sqlite").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/system/version")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_version")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert!(
|
||||
body["data"]["version"] == VERSION_OLD
|
||||
&& body["data"]["gitSha"] == "version-test-sha"
|
||||
&& body["data"]["buildTime"] == "2026-07-01T00:00:00Z"
|
||||
&& body["data"]["deploymentMode"] == "docker"
|
||||
&& body["data"]["deploymentModeLabel"] == "Docker"
|
||||
&& body["data"]["updateChannel"] == "stable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_use_cached_release_when_not_forced() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-cache-route";
|
||||
let success_server = MockServer::start().await;
|
||||
mount_latest_release(&success_server, repository, "0.2.0").await;
|
||||
set_system_update_env(repository, &success_server.uri());
|
||||
let (app, _dir) = admin_system_test_app("system-cache.sqlite").await;
|
||||
let initial = get_check_updates(&app, true, "req_system_cache_initial").await;
|
||||
|
||||
let failing_server = MockServer::start().await;
|
||||
set_system_update_env(repository, &failing_server.uri());
|
||||
let cached = get_check_updates(&app, false, "req_system_cache_cached").await;
|
||||
|
||||
assert!(
|
||||
initial["hasUpdate"] == true
|
||||
&& cached["cached"] == true
|
||||
&& cached["latestVersion"] == "0.2.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-cache-forced-route";
|
||||
let success_server = MockServer::start().await;
|
||||
mount_latest_release(&success_server, repository, "0.3.0").await;
|
||||
set_system_update_env(repository, &success_server.uri());
|
||||
let (app, _dir) = admin_system_test_app("system-cache-forced.sqlite").await;
|
||||
let initial = get_check_updates(&app, true, "req_system_cache_forced_initial").await;
|
||||
|
||||
let failing_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/{repository}/releases/latest")))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&failing_server)
|
||||
.await;
|
||||
set_system_update_env(repository, &failing_server.uri());
|
||||
let fallback = get_check_updates(&app, true, "req_system_cache_forced_fallback").await;
|
||||
|
||||
assert!(
|
||||
initial["hasUpdate"] == true
|
||||
&& fallback["cached"] == true
|
||||
&& fallback["latestVersion"] == "0.3.0"
|
||||
&& fallback["warning"].is_null()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_report_source_build_as_unsupported() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-source-route";
|
||||
let github = MockServer::start().await;
|
||||
mount_latest_release(&github, repository, "0.2.0").await;
|
||||
set_system_update_env(repository, &github.uri());
|
||||
std::env::set_var("CPR_BUILD_TYPE", "source");
|
||||
let (app, _dir) = admin_system_test_app("system-source-build.sqlite").await;
|
||||
|
||||
let info = get_check_updates(&app, true, "req_system_source_build").await;
|
||||
|
||||
assert!(
|
||||
info["currentVersion"] == VERSION_OLD
|
||||
&& info["latestVersion"] == VERSION_NEW
|
||||
&& info["hasUpdate"] == true
|
||||
&& info["buildType"] == "source"
|
||||
&& info["buildTypeLabel"] == "源码构建"
|
||||
&& info["updateSupported"] == false
|
||||
&& info["unsupportedReason"] == "一键更新需要正式构建包"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_replace_local_release_files_with_latest_asset() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-update-route";
|
||||
let github = MockServer::start().await;
|
||||
let deploy = tempfile::tempdir().unwrap();
|
||||
let exe_path = deploy.path().join("codex-proxy-rs");
|
||||
let web_dist = deploy.path().join("web/dist");
|
||||
std::fs::create_dir_all(&web_dist).unwrap();
|
||||
std::fs::write(&exe_path, "old-binary").unwrap();
|
||||
std::fs::write(web_dist.join("index.html"), "old-web").unwrap();
|
||||
mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await;
|
||||
set_system_update_env(repository, &github.uri());
|
||||
set_system_update_paths(deploy.path(), &exe_path, &web_dist);
|
||||
let (app, _dir) = admin_system_test_app("system-update.sqlite").await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/system/update")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_latest")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
let status = wait_for_operation_status(&app, "succeeded").await;
|
||||
|
||||
assert!(
|
||||
body["data"]["operationId"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("sysop-update-"))
|
||||
&& body["data"]["targetVersion"] == "0.4.0"
|
||||
&& body["data"]["needRestart"] == true
|
||||
&& status["operation"]["status"] == "succeeded"
|
||||
&& status["currentVersion"] == "0.4.0"
|
||||
&& std::fs::read_to_string(&exe_path).unwrap() == "new-binary"
|
||||
&& std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "new-web"
|
||||
&& std::fs::read_to_string(deploy.path().join("codex-proxy-rs.backup")).unwrap()
|
||||
== "old-binary"
|
||||
&& std::fs::read_to_string(deploy.path().join("web/dist.backup/index.html")).unwrap()
|
||||
== "old-web"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_status_should_read_local_update_state() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let deploy = tempfile::tempdir().unwrap();
|
||||
set_system_update_env("zyycn/codex-proxy-rs-status-route", "http://127.0.0.1:9");
|
||||
set_system_update_paths(
|
||||
deploy.path(),
|
||||
&deploy.path().join("codex-proxy-rs"),
|
||||
&deploy.path().join("web/dist"),
|
||||
);
|
||||
std::fs::create_dir_all(deploy.path().join(".runtime")).unwrap();
|
||||
std::fs::write(
|
||||
deploy.path().join(".runtime/update-state.json"),
|
||||
json!({
|
||||
"previousVersion": VERSION_OLD,
|
||||
"currentVersion": VERSION_NEW,
|
||||
"operation": {
|
||||
"operationId": "system-update-status",
|
||||
"kind": "update",
|
||||
"status": "running",
|
||||
"targetVersion": VERSION_NEW,
|
||||
"message": "operation running",
|
||||
"error": null,
|
||||
"startedAt": "2026-07-01T00:00:00Z",
|
||||
"finishedAt": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let (app, _dir) = admin_system_test_app("system-status.sqlite").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/system/update-status")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_status")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert!(
|
||||
body["data"]["operation"]["operationId"] == "system-update-status"
|
||||
&& body["data"]["operation"]["status"] == "running"
|
||||
&& body["data"]["currentVersion"] == VERSION_NEW
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_events_should_open_authenticated_sse_stream() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let deploy = tempfile::tempdir().unwrap();
|
||||
set_system_update_env("zyycn/codex-proxy-rs-events-route", "http://127.0.0.1:9");
|
||||
set_system_update_paths(
|
||||
deploy.path(),
|
||||
&deploy.path().join("codex-proxy-rs"),
|
||||
&deploy.path().join("web/dist"),
|
||||
);
|
||||
let (app, _dir) = admin_system_test_app("system-events.sqlite").await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/system/update-events")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_events")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(response.status() == StatusCode::OK && content_type.starts_with("text/event-stream"));
|
||||
}
|
||||
|
||||
async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/api/admin/system/check-updates?force={force}"))
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", request_id)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response_json(response).await["data"].clone()
|
||||
}
|
||||
|
||||
async fn mount_latest_release(server: &MockServer, repository: &str, version: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/{repository}/releases/latest")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"tag_name": format!("v{version}"),
|
||||
"name": format!("v{version}"),
|
||||
"body": "## Changes\n- Update package",
|
||||
"html_url": format!("https://github.com/{repository}/releases/tag/v{version}"),
|
||||
"prerelease": false,
|
||||
"assets": []
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_latest_release_with_archive(
|
||||
server: &MockServer,
|
||||
repository: &str,
|
||||
version: &str,
|
||||
binary: &str,
|
||||
index: &str,
|
||||
) {
|
||||
let archive_name = format!(
|
||||
"codex-proxy-rs_{version}_{}_{}.tar.gz",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
);
|
||||
let archive = release_archive(binary, index);
|
||||
let checksum = format!(
|
||||
"{} {archive_name}\n",
|
||||
hex::encode(Sha256::digest(&archive))
|
||||
);
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/{repository}/releases/latest")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"tag_name": format!("v{version}"),
|
||||
"name": format!("v{version}"),
|
||||
"body": "## Changes\n- Update package",
|
||||
"html_url": format!("https://github.com/{repository}/releases/tag/v{version}"),
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": archive_name,
|
||||
"browser_download_url": format!("{}/download/{archive_name}", server.uri()),
|
||||
"size": archive.len()
|
||||
},
|
||||
{
|
||||
"name": "checksums.txt",
|
||||
"browser_download_url": format!("{}/download/checksums.txt", server.uri()),
|
||||
"size": checksum.len()
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/download/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/download/checksums.txt"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(checksum))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn release_archive(binary: &str, index: &str) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
{
|
||||
let encoder = GzEncoder::new(&mut data, Compression::default());
|
||||
let mut builder = Builder::new(encoder);
|
||||
append_tar_file(&mut builder, "codex-proxy-rs", binary.as_bytes());
|
||||
append_tar_file(&mut builder, "web/dist/index.html", index.as_bytes());
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn append_tar_file<W: Write>(builder: &mut Builder<W>, path: &str, bytes: &[u8]) {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn set_system_update_env(repository: &str, github_api_base: &str) {
|
||||
std::env::set_var("CPR_VERSION", "0.1.0");
|
||||
std::env::set_var("CPR_DEPLOYMENT_MODE", "docker");
|
||||
std::env::set_var("CPR_BUILD_TYPE", "release");
|
||||
std::env::set_var("CPR_UPDATE_CHANNEL", "stable");
|
||||
std::env::set_var("CPR_UPDATE_REPOSITORY", repository);
|
||||
std::env::set_var("CPR_GITHUB_API_BASE", format!("{github_api_base}/repos"));
|
||||
std::env::set_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS", "true");
|
||||
std::env::remove_var("CPR_UPDATE_EXE_PATH");
|
||||
std::env::remove_var("CPR_WEB_DIST_DIR");
|
||||
std::env::remove_var("CPR_UPDATE_STATE_FILE");
|
||||
std::env::remove_var("CPR_UPDATE_LOCK_FILE");
|
||||
}
|
||||
|
||||
fn set_system_update_paths(root: &Path, exe_path: &Path, web_dist: &Path) {
|
||||
std::env::set_var("CPR_UPDATE_EXE_PATH", exe_path);
|
||||
std::env::set_var("CPR_WEB_DIST_DIR", web_dist);
|
||||
std::env::set_var(
|
||||
"CPR_UPDATE_STATE_FILE",
|
||||
root.join(".runtime/update-state.json"),
|
||||
);
|
||||
std::env::set_var("CPR_UPDATE_LOCK_FILE", root.join(".runtime/update.lock"));
|
||||
}
|
||||
|
||||
async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/system/update-status")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_system_update_status_poll")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let data = response_json(response).await["data"].clone();
|
||||
assert_eq!(data["operation"]["status"], expected);
|
||||
data
|
||||
}
|
||||
|
||||
async fn admin_system_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join(db_name);
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
seed_admin_session(&pool, "session_1").await;
|
||||
let config = test_config(url);
|
||||
let stores = stores(pool);
|
||||
let services = Arc::new(Services::new(&config, stores, test_fingerprint()));
|
||||
let state = AppState {
|
||||
config,
|
||||
services: (*services).clone(),
|
||||
};
|
||||
let app = codex_proxy_rs::http::router::router().with_state(state);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
fn stores(pool: SqlitePool) -> BackgroundTaskStores {
|
||||
BackgroundTaskStores {
|
||||
accounts: SqliteAccountStore::new(pool.clone()),
|
||||
admin_sessions: SqliteAdminSessionStore::new(pool.clone()),
|
||||
cookies: SqliteCookieStore::new(pool.clone()),
|
||||
fingerprints: FingerprintRepository::new(pool.clone()),
|
||||
session_affinity: SqliteSessionAffinityStore::new(pool.clone()),
|
||||
refresh_leases: RefreshLeaseStore::new(pool.clone()),
|
||||
client_keys: SqliteClientKeyStore::new(pool.clone()),
|
||||
usage_records: SqliteUsageRecordStore::new(pool),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import request, { type ApiPayload } from '../request'
|
||||
import request from '../request'
|
||||
import { API_BASE_URL } from '../constants'
|
||||
|
||||
export const SYSTEM_UPDATE_EVENTS_URL = `${API_BASE_URL}/api/admin/system/update-events`
|
||||
|
||||
export interface SystemVersion {
|
||||
version: string
|
||||
gitSha: string
|
||||
buildTime: string
|
||||
deploymentMode: string
|
||||
image?: string
|
||||
deploymentModeLabel: string
|
||||
updateChannel: string
|
||||
}
|
||||
|
||||
@@ -14,13 +17,14 @@ export interface SystemUpdateInfo {
|
||||
latestVersion: string
|
||||
hasUpdate: boolean
|
||||
deploymentMode: string
|
||||
deploymentModeLabel: string
|
||||
buildType: string
|
||||
buildTypeLabel: string
|
||||
releaseUrl?: string
|
||||
notes?: string
|
||||
cached: boolean
|
||||
updateSupported: boolean
|
||||
unsupportedReason?: string
|
||||
targetImage?: string
|
||||
requiresBackup: boolean
|
||||
warning?: string
|
||||
}
|
||||
|
||||
@@ -28,9 +32,14 @@ export interface SystemUpdateStarted {
|
||||
operationId: string
|
||||
deploymentMode: string
|
||||
message: string
|
||||
needReconnect: boolean
|
||||
needRestart: boolean
|
||||
targetVersion: string
|
||||
targetImage?: string
|
||||
}
|
||||
|
||||
export interface SystemOperationStarted {
|
||||
operationId?: string
|
||||
message: string
|
||||
needRestart?: boolean
|
||||
}
|
||||
|
||||
export function getSystemVersion() {
|
||||
@@ -48,23 +57,15 @@ export function checkSystemUpdates(force = false) {
|
||||
})
|
||||
}
|
||||
|
||||
export function performSystemUpdate(data: ApiPayload = {}) {
|
||||
export function performSystemUpdate() {
|
||||
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({
|
||||
return request<SystemOperationStarted>({
|
||||
url: '/api/admin/system/restart',
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import {
|
||||
SYSTEM_UPDATE_EVENTS_URL,
|
||||
checkSystemUpdates,
|
||||
getSystemVersion,
|
||||
performSystemUpdate,
|
||||
restartSystem,
|
||||
type SystemUpdateInfo,
|
||||
type SystemVersion,
|
||||
} from '@/api'
|
||||
|
||||
export type SystemUpdateLogLevel = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
export interface SystemUpdateLog {
|
||||
id: string
|
||||
operationId?: string
|
||||
level: SystemUpdateLogLevel
|
||||
step?: string
|
||||
message: string
|
||||
at: string
|
||||
}
|
||||
|
||||
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 restarting = shallowRef(false)
|
||||
const restartCountdown = shallowRef(0)
|
||||
const updateError = shallowRef('')
|
||||
const updateSuccess = shallowRef(false)
|
||||
const needRestart = shallowRef(false)
|
||||
const loadedOnce = shallowRef(false)
|
||||
const updateLogs = ref<SystemUpdateLog[]>([])
|
||||
const updateStreaming = shallowRef(false)
|
||||
const updateStreamError = shallowRef('')
|
||||
const updateEventSource = shallowRef<EventSource | null>(null)
|
||||
|
||||
let restartTimer: number | undefined
|
||||
const maxUpdateLogs = 200
|
||||
|
||||
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
|
||||
const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release')
|
||||
const canUpdate = computed(
|
||||
() =>
|
||||
hasUpdate.value &&
|
||||
isReleaseBuild.value &&
|
||||
Boolean(updateInfo.value?.updateSupported) &&
|
||||
!updating.value &&
|
||||
!checking.value &&
|
||||
!restarting.value,
|
||||
)
|
||||
|
||||
function resetUpdateResult() {
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
needRestart.value = false
|
||||
}
|
||||
|
||||
function isUpdateLog(value: unknown): value is SystemUpdateLog {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'id' in value &&
|
||||
'level' in value &&
|
||||
'message' in value &&
|
||||
'at' in value
|
||||
)
|
||||
}
|
||||
|
||||
function appendUpdateLog(log: SystemUpdateLog) {
|
||||
updateLogs.value = [...updateLogs.value, log].slice(-maxUpdateLogs)
|
||||
}
|
||||
|
||||
function clearUpdateLogs() {
|
||||
updateLogs.value = []
|
||||
updateStreamError.value = ''
|
||||
}
|
||||
|
||||
function connectUpdateEvents() {
|
||||
if (updateEventSource.value) return
|
||||
|
||||
if (typeof EventSource === 'undefined') {
|
||||
updateStreamError.value = '当前浏览器不支持实时更新日志'
|
||||
return
|
||||
}
|
||||
|
||||
updateStreamError.value = ''
|
||||
const source = new EventSource(SYSTEM_UPDATE_EVENTS_URL, { withCredentials: true })
|
||||
updateEventSource.value = source
|
||||
|
||||
source.onopen = () => {
|
||||
updateStreaming.value = true
|
||||
updateStreamError.value = ''
|
||||
}
|
||||
|
||||
source.addEventListener('update', (event) => {
|
||||
try {
|
||||
const log = JSON.parse((event as MessageEvent<string>).data)
|
||||
if (isUpdateLog(log)) {
|
||||
appendUpdateLog(log)
|
||||
}
|
||||
} catch {
|
||||
updateStreamError.value = '更新日志解析失败'
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
updateStreaming.value = false
|
||||
updateStreamError.value = '更新日志连接中断'
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
updateEventSource.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectUpdateEvents() {
|
||||
updateEventSource.value?.close()
|
||||
updateEventSource.value = null
|
||||
updateStreaming.value = false
|
||||
}
|
||||
|
||||
async function loadSystem(force = false) {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [versionData, updateData] = await Promise.all([
|
||||
getSystemVersion(),
|
||||
checkSystemUpdates(force),
|
||||
])
|
||||
version.value = versionData
|
||||
updateInfo.value = updateData
|
||||
loadedOnce.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates(force = true) {
|
||||
if (checking.value) return updateInfo.value
|
||||
|
||||
checking.value = true
|
||||
resetUpdateResult()
|
||||
try {
|
||||
updateInfo.value = await checkSystemUpdates(force)
|
||||
if (!version.value) {
|
||||
version.value = await getSystemVersion()
|
||||
}
|
||||
loadedOnce.value = true
|
||||
return updateInfo.value
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNow() {
|
||||
if (!canUpdate.value || !updateInfo.value || updating.value) return null
|
||||
|
||||
clearUpdateLogs()
|
||||
connectUpdateEvents()
|
||||
updating.value = true
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
try {
|
||||
const result = await performSystemUpdate()
|
||||
updateSuccess.value = true
|
||||
needRestart.value = result.needRestart
|
||||
return result
|
||||
} catch (error: any) {
|
||||
updateError.value = error.message || '更新失败'
|
||||
appendUpdateLog({
|
||||
id: `update-client-error-${Date.now()}`,
|
||||
level: 'error',
|
||||
message: updateError.value,
|
||||
at: new Date().toISOString(),
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearRestartTimer() {
|
||||
if (restartTimer !== undefined) {
|
||||
window.clearInterval(restartTimer)
|
||||
restartTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServiceAndReload() {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
await getSystemVersion()
|
||||
window.location.reload()
|
||||
return
|
||||
} catch {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function restartNow() {
|
||||
if (restarting.value) return
|
||||
|
||||
restarting.value = true
|
||||
restartCountdown.value = 8
|
||||
clearRestartTimer()
|
||||
|
||||
try {
|
||||
await restartSystem()
|
||||
} catch {
|
||||
// The connection can drop immediately after the restart request is accepted.
|
||||
}
|
||||
|
||||
restartTimer = window.setInterval(() => {
|
||||
restartCountdown.value -= 1
|
||||
if (restartCountdown.value <= 0) {
|
||||
clearRestartTimer()
|
||||
void waitForServiceAndReload()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
export function useSystemUpdate() {
|
||||
return {
|
||||
version,
|
||||
updateInfo,
|
||||
loading,
|
||||
checking,
|
||||
updating,
|
||||
restarting,
|
||||
restartCountdown,
|
||||
updateError,
|
||||
updateSuccess,
|
||||
needRestart,
|
||||
loadedOnce,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
hasUpdate,
|
||||
isReleaseBuild,
|
||||
canUpdate,
|
||||
loadSystem,
|
||||
checkUpdates,
|
||||
updateNow,
|
||||
restartNow,
|
||||
clearRestartTimer,
|
||||
connectUpdateEvents,
|
||||
disconnectUpdateEvents,
|
||||
clearUpdateLogs,
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,20 @@ import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
Cat,
|
||||
ChartNoAxesColumn,
|
||||
ArrowUpCircle,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PackageCheck,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import { useSystemUpdate } from '@/composables/useSystemUpdate'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
@@ -32,6 +33,7 @@ const uiStore = useUiStore()
|
||||
const { effectiveTheme } = storeToRefs(uiStore)
|
||||
const { toggleTheme } = uiStore
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const { version, hasUpdate, loadSystem } = useSystemUpdate()
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
||||
@@ -75,6 +77,10 @@ const systemUpdateOpen = shallowRef(false)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
const versionLabel = computed(() => `v${version.value?.version || '...'}`)
|
||||
const updateButtonLabel = computed(() =>
|
||||
hasUpdate.value ? '发现新版本,打开系统更新' : '打开系统更新',
|
||||
)
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return preferredMotion.value === 'reduce'
|
||||
@@ -194,6 +200,7 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
animateSidebarLabels(Boolean(props.collapsed))
|
||||
void loadSystem(false).catch(() => undefined)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -222,11 +229,11 @@ watch(
|
||||
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
|
||||
>
|
||||
<div
|
||||
class="mt-5 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="collapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
>
|
||||
<Cat :size="27" stroke-width="2" />
|
||||
</span>
|
||||
@@ -235,10 +242,28 @@ watch(
|
||||
ref="brandLabelEl"
|
||||
class="grid min-w-33 content-center overflow-hidden"
|
||||
>
|
||||
<strong class="text-base leading-[1.1] font-[760] text-(--cp-text-primary)">Codex</strong>
|
||||
<span class="mt-1 text-xs leading-[1.1] font-semibold text-(--cp-text-secondary)"
|
||||
>Proxy RS · v0.1.0</span
|
||||
>
|
||||
<strong class="text-base leading-[1.1] font-[760] text-(--cp-text-primary)">
|
||||
Codex Proxy
|
||||
</strong>
|
||||
<span class="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 text-xs leading-none font-[650] text-(--cp-text-secondary)">
|
||||
Rust build
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4.5 min-w-0 items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-[720] cursor-pointer transition-colors"
|
||||
:class="
|
||||
hasUpdate
|
||||
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)'
|
||||
"
|
||||
:title="updateButtonLabel"
|
||||
@click="systemUpdateOpen = true"
|
||||
>
|
||||
<span>{{ versionLabel }}</span>
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -284,13 +309,14 @@ watch(
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
v-if="collapsed && hasUpdate"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
label="系统更新"
|
||||
:variant="hasUpdate ? 'success' : 'ghost'"
|
||||
size="default"
|
||||
:label="updateButtonLabel"
|
||||
@click="systemUpdateOpen = true"
|
||||
>
|
||||
<PackageCheck :size="collapsed ? 19 : 18" />
|
||||
<ArrowUpCircle :size="19" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
|
||||
@@ -1,178 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, shallowRef, watch } from 'vue'
|
||||
import { ExternalLink, PackageCheck, Power, RefreshCw, RotateCcw } from '@lucide/vue'
|
||||
|
||||
import { computed, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
checkSystemUpdates,
|
||||
getSystemVersion,
|
||||
performSystemUpdate,
|
||||
restartSystem,
|
||||
rollbackSystemUpdate,
|
||||
type SystemUpdateInfo,
|
||||
type SystemVersion,
|
||||
} from '@/api'
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
XCircle,
|
||||
} from '@lucide/vue'
|
||||
|
||||
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'
|
||||
import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate'
|
||||
|
||||
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 {
|
||||
version,
|
||||
updateInfo,
|
||||
loading,
|
||||
checking,
|
||||
updating,
|
||||
restarting,
|
||||
restartCountdown,
|
||||
updateError,
|
||||
updateSuccess,
|
||||
needRestart,
|
||||
loadedOnce,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
hasUpdate,
|
||||
isReleaseBuild,
|
||||
canUpdate,
|
||||
loadSystem,
|
||||
checkUpdates,
|
||||
updateNow,
|
||||
restartNow,
|
||||
clearRestartTimer,
|
||||
connectUpdateEvents,
|
||||
disconnectUpdateEvents,
|
||||
} = useSystemUpdate()
|
||||
|
||||
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 statusView = computed(() => {
|
||||
if (updating.value) {
|
||||
return {
|
||||
label: '更新中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
iconClass: 'text-(--cp-info)',
|
||||
}
|
||||
}
|
||||
if (updateError.value || updateInfo.value?.warning) {
|
||||
return {
|
||||
label: '异常',
|
||||
icon: XCircle,
|
||||
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
iconClass: 'text-(--cp-danger)',
|
||||
}
|
||||
}
|
||||
if (updateSuccess.value) {
|
||||
return {
|
||||
label: '已更新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
}
|
||||
}
|
||||
if (hasUpdate.value) {
|
||||
return {
|
||||
label: '有新版本',
|
||||
icon: ArrowUpCircle,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: updateInfo.value ? '已是最新' : '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
|
||||
iconClass: 'text-(--cp-text-muted)',
|
||||
}
|
||||
})
|
||||
|
||||
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 summaryItems = computed(() => [
|
||||
{
|
||||
label: '当前版本',
|
||||
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
|
||||
title: version.value?.version,
|
||||
},
|
||||
{
|
||||
label: '最新版本',
|
||||
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
|
||||
title: updateInfo.value?.latestVersion,
|
||||
},
|
||||
{
|
||||
label: '构建',
|
||||
value: displayValue(updateInfo.value?.buildTypeLabel),
|
||||
title: updateInfo.value?.buildType,
|
||||
},
|
||||
{
|
||||
label: '部署',
|
||||
value: displayValue(version.value?.deploymentModeLabel),
|
||||
title: version.value?.deploymentMode,
|
||||
},
|
||||
])
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
if (updateError.value) return updateError.value
|
||||
if (updateSuccess.value && needRestart.value) return '更新已完成,等待重启生效'
|
||||
if (updating.value) return '正在替换应用包'
|
||||
if (updateInfo.value?.unsupportedReason) return updateInfo.value.unsupportedReason
|
||||
if (updateInfo.value?.warning) return updateInfo.value.warning
|
||||
if (hasUpdate.value) return `可更新到 v${updateInfo.value?.latestVersion}`
|
||||
if (updateInfo.value) return '当前版本已是最新'
|
||||
return '尚未检查更新'
|
||||
})
|
||||
|
||||
const deploymentStatusClass = computed(() =>
|
||||
version.value?.deploymentMode === 'docker'
|
||||
? 'bg-(--cp-info-bg) text-(--cp-info-text)'
|
||||
: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
|
||||
const statusToneClass = computed(() => {
|
||||
if (updateError.value || updateInfo.value?.warning) {
|
||||
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
|
||||
}
|
||||
if (updateInfo.value?.unsupportedReason || (hasUpdate.value && !isReleaseBuild.value)) {
|
||||
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
|
||||
}
|
||||
if (updateSuccess.value || hasUpdate.value) {
|
||||
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
|
||||
}
|
||||
return 'bg-(--cp-bg-muted) text-(--cp-text-secondary)'
|
||||
})
|
||||
|
||||
const hasStatusNotice = computed(() =>
|
||||
Boolean(
|
||||
updateError.value ||
|
||||
updateSuccess.value ||
|
||||
updateInfo.value?.unsupportedReason ||
|
||||
updateInfo.value?.warning ||
|
||||
(hasUpdate.value && !isReleaseBuild.value),
|
||||
),
|
||||
)
|
||||
|
||||
const updateLogRows = computed(() =>
|
||||
updateLogs.value.map((item) => ({
|
||||
...item,
|
||||
time: formatLogTime(item.at),
|
||||
})),
|
||||
)
|
||||
|
||||
const streamStatusLabel = computed(() => {
|
||||
if (updateStreaming.value) return '实时'
|
||||
if (updateStreamError.value) return '断开'
|
||||
return '待连接'
|
||||
})
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
return value ? String(value) : '-'
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
function formatLogTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '--:--:--'
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
function logMarkerClass(level: SystemUpdateLogLevel) {
|
||||
if (level === 'success') return 'text-(--cp-success)'
|
||||
if (level === 'warning') return 'text-(--cp-warning)'
|
||||
if (level === 'error') return 'text-(--cp-danger)'
|
||||
return 'text-(--cp-info)'
|
||||
}
|
||||
|
||||
function logTextClass(level: SystemUpdateLogLevel) {
|
||||
if (level === 'success') return 'text-(--cp-success)'
|
||||
if (level === 'warning') return 'text-(--cp-warning)'
|
||||
if (level === 'error') return 'text-(--cp-danger)'
|
||||
return 'text-(--cp-text-primary)'
|
||||
}
|
||||
|
||||
async function handleCheckUpdates(force = true) {
|
||||
if (checking.value) return
|
||||
|
||||
try {
|
||||
checking.value = true
|
||||
updateInfo.value = await checkSystemUpdates(force)
|
||||
toast.success(updateInfo.value.hasUpdate ? '发现可用更新' : '当前已是最新版本')
|
||||
const data = await checkUpdates(force)
|
||||
toast.success(data?.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)
|
||||
const result = await updateNow()
|
||||
if (result?.needRestart) {
|
||||
toast.success('更新完成,请重启服务')
|
||||
}
|
||||
} 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
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
toast.success('正在重启服务')
|
||||
await restartNow()
|
||||
}
|
||||
|
||||
watch(open, (visible) => {
|
||||
if (visible && !loadedOnce) {
|
||||
void loadSystem()
|
||||
if (visible) {
|
||||
connectUpdateEvents()
|
||||
} else if (!updating.value && !restarting.value) {
|
||||
disconnectUpdateEvents()
|
||||
}
|
||||
|
||||
if (visible && !loadedOnce.value) {
|
||||
void loadSystem(false).catch((error: any) => {
|
||||
toast.error(error.message || '加载系统更新信息失败')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
clearRestartTimer()
|
||||
disconnectUpdateEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -180,124 +231,151 @@ onUnmounted(() => {
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="系统更新"
|
||||
description="检查版本并触发 Docker 在线更新。"
|
||||
variant="info"
|
||||
width="780px"
|
||||
description="版本、更新状态与实时进度。"
|
||||
variant="success"
|
||||
width="820px"
|
||||
scrollable
|
||||
body-max-height="68vh"
|
||||
body-view-class="pr-4"
|
||||
:close-disabled="updating || rollingBack || restarting"
|
||||
body-max-height="72vh"
|
||||
body-view-class="pr-3"
|
||||
:close-disabled="updating || 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="grid gap-3.5">
|
||||
<section class="grid gap-4 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) }}
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
|
||||
Codex Proxy RS
|
||||
</p>
|
||||
<p class="mt-2 mb-0 text-lg leading-none font-[780] text-(--cp-text-primary)">
|
||||
应用包更新
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-7 items-center rounded-full px-2.5 text-[12px] font-[760]"
|
||||
:class="deploymentStatusClass"
|
||||
class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-[760]"
|
||||
:class="statusView.badge"
|
||||
>
|
||||
{{ loading ? '加载中...' : displayValue(version?.deploymentMode) }}
|
||||
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
|
||||
{{ statusView.label }}
|
||||
</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) }}
|
||||
<div class="grid gap-2.5 sm:grid-cols-4">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
|
||||
>
|
||||
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
|
||||
{{ item.label }}
|
||||
</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)"
|
||||
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-[720] text-(--cp-text-primary)"
|
||||
:title="item.title || item.value"
|
||||
>
|
||||
{{ displayValue(version?.gitSha) }}
|
||||
{{ item.value }}
|
||||
</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>
|
||||
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<div
|
||||
v-if="!hasStatusNotice || updateInfo?.releaseUrl"
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">状态</p>
|
||||
<p
|
||||
v-if="!hasStatusNotice"
|
||||
class="mt-1.5 mb-0 wrap-break-word text-[13px] leading-normal font-[720] text-(--cp-text-primary)"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-(--cp-input-radius-base) bg-(--cp-info-bg) px-2.5 text-[12px] font-[720] text-(--cp-info-text) no-underline hover:bg-(--cp-info-bg-hover)"
|
||||
>
|
||||
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)"
|
||||
v-if="hasStatusNotice"
|
||||
class="flex items-center gap-2.5 rounded-(--cp-input-radius-base) px-3 py-2.5"
|
||||
:class="statusToneClass"
|
||||
>
|
||||
{{ updateInfo.unsupportedReason }}
|
||||
<GitBranch
|
||||
v-if="hasUpdate && !isReleaseBuild && !updateError && !updateInfo?.warning"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<component v-else :is="statusView.icon" class="size-3.5 shrink-0" />
|
||||
<p class="m-0 wrap-break-word text-[12px] font-[650] leading-none">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
|
||||
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
|
||||
<p class="m-0 text-[13px] leading-none font-[760] text-(--cp-text-primary)">更新进度</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-(--cp-bg-subtle) px-2 text-[11px] leading-none font-[720] text-(--cp-text-secondary)"
|
||||
:title="updateStreamError || streamStatusLabel"
|
||||
>
|
||||
<i
|
||||
class="size-1.5 rounded-full"
|
||||
:class="updateStreaming ? 'bg-(--cp-success)' : 'bg-(--cp-text-muted)'"
|
||||
/>
|
||||
{{ streamStatusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<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 || '-'"
|
||||
<BaseScrollbar max-height="260px" view-class="px-4 pb-4">
|
||||
<div v-if="updateLogRows.length" class="grid gap-2">
|
||||
<div
|
||||
v-for="log in updateLogRows"
|
||||
:key="log.id"
|
||||
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2 font-mono text-[11px] leading-[1.55]"
|
||||
>
|
||||
{{ displayValue(updateInfo?.targetImage) }}
|
||||
</p>
|
||||
<span class="tabular-nums text-(--cp-text-muted)">{{ log.time }}</span>
|
||||
<Circle
|
||||
class="mt-1 size-2.5"
|
||||
:class="logMarkerClass(log.level)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
|
||||
<span v-if="log.step" class="mr-1 text-(--cp-text-muted)">[{{ log.step }}]</span>
|
||||
{{ log.message }}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<p v-else class="m-0 py-8 text-center text-[12px] font-[650] text-(--cp-text-muted)">
|
||||
暂无进度
|
||||
</p>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="updateInfo?.notes"
|
||||
class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-4"
|
||||
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
>
|
||||
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
|
||||
<BaseScrollbar max-height="260px" view-class="pr-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
|
||||
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar max-height="160px" 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)"
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-[620] text-(--cp-text-primary)"
|
||||
>{{ updateInfo.notes }}</pre
|
||||
>
|
||||
</BaseScrollbar>
|
||||
@@ -308,36 +386,37 @@ onUnmounted(() => {
|
||||
<BaseButton
|
||||
variant="default"
|
||||
:loading="checking"
|
||||
:disabled="loading"
|
||||
:disabled="loading || updating || restarting"
|
||||
@click="handleCheckUpdates(true)"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" />
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
检查
|
||||
检查更新
|
||||
</BaseButton>
|
||||
<BaseButton variant="default" :loading="rollingBack" @click="handleRollback">
|
||||
<template #icon>
|
||||
<RotateCcw class="size-4" />
|
||||
</template>
|
||||
回滚
|
||||
</BaseButton>
|
||||
<BaseButton variant="default" :loading="restarting" @click="handleRestart">
|
||||
<BaseButton
|
||||
v-if="updateSuccess && needRestart"
|
||||
variant="success"
|
||||
:loading="restarting"
|
||||
:disabled="updating"
|
||||
@click="handleRestart"
|
||||
>
|
||||
<template #icon>
|
||||
<Power class="size-4" />
|
||||
</template>
|
||||
重启
|
||||
{{ restarting ? `重启中 ${restartCountdown}s` : '立即重启' }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
v-else
|
||||
variant="success"
|
||||
:loading="updating"
|
||||
:disabled="!canUpdate"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
<template #icon>
|
||||
<PackageCheck class="size-4" />
|
||||
<ArrowUpCircle class="size-4" />
|
||||
</template>
|
||||
一键更新
|
||||
立即更新
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
Reference in New Issue
Block a user