diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f9407a99..7bb47c66c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/Cargo.lock b/Cargo.lock index e08dc7c43..97acb2fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6bd067833..d03152048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Dockerfile b/Dockerfile index cf00cad36..80fa8c104 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..a8157956f --- /dev/null +++ b/build.rs @@ -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 { + env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn version_env_value(key: &str) -> Option { + env_value(key).map(|value| value.trim_start_matches('v').to_string()) +} + +fn version_file() -> Option { + fs::read_to_string("VERSION") + .ok() + .map(|value| value.trim().trim_start_matches('v').to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/docker-compose.yml b/docker-compose.yml index b7a5f0c97..90abc57d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docs/online-update.md b/docs/online-update.md deleted file mode 100644 index 0377f4fcf..000000000 --- a/docs/online-update.md +++ /dev/null @@ -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- -``` - -推荐多架构平台: - -```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 -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。 diff --git a/src/admin/response.rs b/src/admin/response.rs index 2b1148c60..e4032d1e2 100644 --- a/src/admin/response.rs +++ b/src/admin/response.rs @@ -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")] diff --git a/src/admin/router.rs b/src/admin/router.rs index 4ed085219..8506fcd3b 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -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 { ) .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)) diff --git a/src/admin/system/routes.rs b/src/admin/system/routes.rs index f3e88a5b8..7333dd260 100644 --- a/src/admin/system/routes.rs +++ b/src/admin/system/routes.rs @@ -1,16 +1,30 @@ //! 管理端系统版本与在线更新路由。 -use std::{env, time::Duration}; +use std::{ + convert::Infallible, + env, fs, io, + io::{Read, Write}, + path::{Component, Path, PathBuf}, + sync::OnceLock, + time::{Duration, Instant}, +}; use axum::{ - body::Bytes, extract::{Query, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, + }, }; +use chrono::Utc; +use flate2::read::GzDecoder; +use futures::Stream; use semver::Version; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; +use sha2::{Digest, Sha256}; +use tar::Archive; +use tokio::sync::{broadcast, Mutex}; use crate::{ admin::auth::session::require_admin_auth, @@ -18,10 +32,15 @@ use crate::{ runtime::state::AppState, }; -const DEFAULT_SERVICE_NAME: &str = "codex-proxy-rs"; +const APP_BINARY_NAME: &str = "codex-proxy-rs"; +const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist"; const GITHUB_API_BASE: &str = "https://api.github.com/repos"; +const UPDATE_CACHE_TTL: Duration = Duration::from_secs(20 * 60); +const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); +static RELEASE_CACHE: Mutex> = Mutex::const_new(None); +static UPDATE_EVENT_SENDER: OnceLock> = OnceLock::new(); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -29,13 +48,6 @@ pub(crate) struct CheckUpdateQuery { force: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateRequest { - target_version: Option, - confirm_backup: Option, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct VersionData { @@ -43,24 +55,25 @@ struct VersionData { git_sha: String, build_time: String, deployment_mode: String, - image: Option, + deployment_mode_label: String, update_channel: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct UpdateInfoData { current_version: String, latest_version: String, has_update: bool, deployment_mode: String, + deployment_mode_label: String, + build_type: String, + build_type_label: String, release_url: Option, notes: Option, cached: bool, update_supported: bool, unsupported_reason: Option, - target_image: Option, - requires_backup: bool, warning: Option, } @@ -70,35 +83,124 @@ struct UpdateStartedData { operation_id: String, deployment_mode: String, message: String, - need_reconnect: bool, + need_restart: bool, target_version: String, - target_image: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone)] +struct CachedUpdateInfo { + key: String, + info: UpdateInfoData, + cached_at: Instant, +} + +#[derive(Debug, Clone, Deserialize)] struct GitHubRelease { tag_name: String, name: Option, body: Option, html_url: Option, prerelease: bool, + #[serde(default)] + assets: Vec, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdaterUpdateRequest { - service: String, - image: String, - compose_project: Option, - target_version: String, - confirm_backup: bool, +#[derive(Debug, Clone, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, + size: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum UpdateLogLevel { + Info, + Success, + Warning, + Error, +} + +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct UpdaterRollbackRequest { - service: String, - compose_project: Option, +struct SystemUpdateEvent { + id: String, + operation_id: Option, + level: UpdateLogLevel, + step: Option, + message: String, + at: String, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct SystemUpdateStatusData { + previous_version: Option, + current_version: Option, + #[serde(default)] + operation: SystemOperationState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum SystemOperationKind { + Update, + Rollback, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +enum SystemOperationStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct SystemOperationState { + operation_id: Option, + kind: Option, + status: SystemOperationStatus, + target_version: Option, + message: Option, + error: Option, + started_at: Option, + finished_at: Option, +} + +impl Default for SystemOperationState { + fn default() -> Self { + Self { + operation_id: None, + kind: None, + status: SystemOperationStatus::Idle, + target_version: None, + message: None, + error: None, + started_at: None, + finished_at: None, + } + } +} + +#[derive(Debug)] +struct OperationLock { + path: PathBuf, +} + +#[derive(Debug)] +struct ExtractedRelease { + binary_path: PathBuf, + web_dist_dir: Option, +} + +#[derive(Debug, Clone, Copy)] +struct DownloadProgress<'a> { + operation_id: &'a str, + total_size: u64, } #[derive(Debug, Clone)] @@ -107,14 +209,14 @@ struct SystemUpdateConfig { git_sha: String, build_time: String, deployment_mode: String, + build_type: String, update_channel: String, update_repository: Option, - image_repository: Option, - image_tag: Option, - updater_url: Option, - updater_token: Option, - compose_project: Option, - compose_service: String, + github_api_base: String, + executable_path: Option, + web_dist_dir: PathBuf, + update_state_file: PathBuf, + update_lock_file: PathBuf, } /// `GET /api/admin/system/version` @@ -138,73 +240,152 @@ pub(crate) async fn check_updates( ) -> Result { require_admin_auth(&state, &headers).await?; let config = SystemUpdateConfig::from_env(); - let _force = query.force.unwrap_or(false); - let info = check_latest_release(&config).await; + let info = check_latest_release(&config, query.force.unwrap_or(false)).await; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info))) } +/// `GET /api/admin/system/update-events` +pub(crate) async fn update_event_stream( + State(state): State, + headers: HeaderMap, +) -> Result>>, AdminError> { + require_admin_auth(&state, &headers).await?; + + let receiver = update_event_sender().subscribe(); + let stream = futures::stream::unfold(receiver, |mut receiver| async move { + loop { + match receiver.recv().await { + Ok(message) => { + let id = message.id.clone(); + let data = serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string()); + let event = Event::default().event("update").id(id).data(data); + return Some((Ok(event), receiver)); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + /// `POST /api/admin/system/update` pub(crate) async fn perform_update( State(state): State, headers: HeaderMap, - body: Bytes, ) -> Result { require_admin_auth(&state, &headers).await?; let _guard = SYSTEM_OPERATION_LOCK .try_lock() .map_err(|_| AdminError::conflict("System update already running"))?; - let payload = parse_update_request(&body)?; let config = SystemUpdateConfig::from_env(); - let info = check_latest_release(&config).await; - let reason = info.unsupported_reason.clone(); - if !info.update_supported { - return Err(AdminError::conflict(reason.unwrap_or_else(|| { - "System update is not supported in this deployment".into() - }))); + if let Some(reason) = config.update_support_error() { + emit_update_event( + UpdateLogLevel::Error, + None, + Some("preflight"), + reason.clone(), + ); + return Err(AdminError::conflict(reason)); } - if !info.has_update && payload.target_version.is_none() { + + emit_update_event( + UpdateLogLevel::Info, + None, + Some("release"), + "正在获取最新 Release 信息", + ); + let release = fetch_latest_release( + &config.github_api_base, + config + .update_repository + .as_deref() + .ok_or_else(|| AdminError::conflict("Update checks require CPR_UPDATE_REPOSITORY"))?, + ) + .await + .map_err(|error| { + emit_update_event(UpdateLogLevel::Error, None, Some("release"), error.clone()); + AdminError::bad_gateway(error) + })?; + let info = update_info_from_release(&config, release.clone()); + if !info.has_update { + emit_update_event( + UpdateLogLevel::Warning, + None, + Some("release"), + "当前版本已是最新", + ); return Err(AdminError::conflict("Already up to date")); } - if info.requires_backup && payload.confirm_backup != Some(true) { - return Err(AdminError::conflict( - "This update requires backup confirmation before continuing", - )); + let target_version = info.latest_version.clone(); + let operation_id = operation_id("update"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + Some(&target_version), + )?; + + emit_update_event( + UpdateLogLevel::Info, + Some(&operation_id), + Some("prepare"), + format!("准备更新到 v{target_version}"), + ); + let result = perform_release_update(&config, &release, &target_version, &operation_id).await; + if let Err(error) = &result { + emit_update_event( + UpdateLogLevel::Error, + Some(&operation_id), + Some("failed"), + error.to_string(), + ); + } else { + emit_update_event( + UpdateLogLevel::Success, + Some(&operation_id), + Some("done"), + "更新文件已替换,等待服务重启生效", + ); } - - let target_version = payload - .target_version - .as_deref() - .map(normalize_version_tag) - .unwrap_or_else(|| normalize_version_tag(&info.latest_version)); - let target_image = config - .target_image_for_version(&target_version) - .ok_or_else(|| AdminError::conflict("Docker image repository is not configured"))?; - - call_updater_update( - &config, - UpdaterUpdateRequest { - service: config.compose_service.clone(), - image: target_image.clone(), - compose_project: config.compose_project.clone(), - target_version: target_version.clone(), - confirm_backup: payload.confirm_backup.unwrap_or(false), - }, - ) - .await?; + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + result.as_ref().ok().map(|_| target_version.clone()), + result.as_ref().err().map(ToString::to_string), + ); + drop(lock); + result?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(UpdateStartedData { - operation_id: format!("sysop-update-{}", chrono::Utc::now().timestamp_millis()), + operation_id, deployment_mode: config.deployment_mode, - message: "Update started".to_string(), - need_reconnect: true, + message: "更新完成,请重启服务。".to_string(), + need_restart: true, target_version, - target_image: Some(target_image), }), )) } +/// `GET /api/admin/system/update-status` +pub(crate) async fn update_status( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + let config = SystemUpdateConfig::from_env(); + let status = read_state(&config.update_state_file)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(status), + )) +} + /// `POST /api/admin/system/rollback` pub(crate) async fn rollback( State(state): State, @@ -213,22 +394,37 @@ pub(crate) async fn rollback( require_admin_auth(&state, &headers).await?; let _guard = SYSTEM_OPERATION_LOCK .try_lock() - .map_err(|_| AdminError::conflict("System operation already running"))?; + .map_err(|_| AdminError::conflict("系统操作正在执行中"))?; let config = SystemUpdateConfig::from_env(); - call_updater_rollback( - &config, - UpdaterRollbackRequest { - service: config.compose_service.clone(), - compose_project: config.compose_project.clone(), - }, - ) - .await?; + if let Some(reason) = config.update_support_error() { + return Err(AdminError::conflict(reason)); + } + + let operation_id = operation_id("rollback"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + )?; + let result = rollback_release_update(&config).await; + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + result.as_ref().err().map(ToString::to_string), + ); + drop(lock); + result?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(serde_json::json!({ - "message": "Rollback started", - "needReconnect": true + "message": "回滚完成,请重启服务。", + "needRestart": true, + "operationId": operation_id })), )) } @@ -241,7 +437,7 @@ pub(crate) async fn restart( require_admin_auth(&state, &headers).await?; if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") { return Err(AdminError::conflict( - "Self restart is disabled; set CPR_ENABLE_SELF_RESTART=true to enable it", + "自重启未启用,请设置 CPR_ENABLE_SELF_RESTART=true", )); } @@ -253,46 +449,39 @@ pub(crate) async fn restart( Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(serde_json::json!({ - "message": "Restart scheduled", - "needReconnect": true + "message": "已安排重启", + "operationId": operation_id("restart") })), )) } impl SystemUpdateConfig { fn from_env() -> Self { - let image_repository = env_string("CPR_IMAGE_REPOSITORY"); - let update_repository = env_string("CPR_UPDATE_REPOSITORY").or_else(|| { - image_repository - .as_deref() - .and_then(github_repo_from_image) - .map(str::to_string) - }); + let update_repository = env_string("CPR_UPDATE_REPOSITORY"); + let state_file = env_string("CPR_UPDATE_STATE_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/app/data/update-state.json")); + let update_lock_file = env_string("CPR_UPDATE_LOCK_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| state_file.with_extension("lock")); Self { - version: env_string("CPR_VERSION").unwrap_or_else(|| { - option_env!("CPR_VERSION") - .unwrap_or(env!("CARGO_PKG_VERSION")) - .to_string() - }), - git_sha: env_string("CPR_GIT_SHA") - .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()), - build_time: env_string("CPR_BUILD_TIME").unwrap_or_else(|| { - option_env!("CPR_BUILD_TIME") - .unwrap_or("unknown") - .to_string() - }), + version: build_version(), + git_sha: build_git_sha(), + build_time: build_time(), deployment_mode: env_string("CPR_DEPLOYMENT_MODE") .unwrap_or_else(|| "source".to_string()), + build_type: build_type(), update_channel: env_string("CPR_UPDATE_CHANNEL") .unwrap_or_else(|| "stable".to_string()), update_repository, - image_repository, - image_tag: env_string("CPR_IMAGE_TAG"), - updater_url: env_string("CPR_UPDATER_URL"), - updater_token: env_string("CPR_UPDATER_TOKEN"), - compose_project: env_string("CPR_COMPOSE_PROJECT"), - compose_service: env_string("CPR_COMPOSE_SERVICE") - .unwrap_or_else(|| DEFAULT_SERVICE_NAME.to_string()), + github_api_base: env_string("CPR_GITHUB_API_BASE") + .unwrap_or_else(|| GITHUB_API_BASE.to_string()), + executable_path: env_string("CPR_UPDATE_EXE_PATH").map(PathBuf::from), + web_dist_dir: env_string("CPR_WEB_DIST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)), + update_state_file: state_file, + update_lock_file, } } @@ -302,82 +491,119 @@ impl SystemUpdateConfig { git_sha: self.git_sha.clone(), build_time: self.build_time.clone(), deployment_mode: self.deployment_mode.clone(), - image: self.current_image(), + deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(), update_channel: self.update_channel.clone(), } } - fn current_image(&self) -> Option { - let repository = self.image_repository.as_ref()?; - let tag = self.image_tag.as_deref().unwrap_or(self.version.as_str()); - Some(format!("{repository}:{tag}")) - } - - fn target_image_for_version(&self, version: &str) -> Option { - self.image_repository - .as_ref() - .map(|repository| format!("{repository}:{}", normalize_version_tag(version))) - } - fn update_support_error(&self) -> Option { - if self.deployment_mode != "docker" { - return Some("One-click update requires CPR_DEPLOYMENT_MODE=docker".to_string()); + if self.build_type != "release" { + return Some("一键更新需要正式构建包".to_string()); } - if self.image_repository.is_none() { - return Some("Docker one-click update requires CPR_IMAGE_REPOSITORY".to_string()); - } - if self.updater_url.is_none() || self.updater_token.is_none() { - return Some( - "Docker one-click update requires CPR_UPDATER_URL and CPR_UPDATER_TOKEN" - .to_string(), - ); + if self.update_repository.is_none() { + return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()); } None } + + fn release_cache_key(&self) -> String { + format!( + "{}|{}|{}|{}|{}", + self.update_repository.as_deref().unwrap_or_default(), + self.version, + self.deployment_mode, + self.build_type, + self.update_channel, + ) + } + + fn executable_path(&self) -> Result { + if let Some(path) = &self.executable_path { + return Ok(path.clone()); + } + env::current_exe() + .and_then(fs::canonicalize) + .map_err(|error| AdminError::internal(format!("Failed to resolve executable: {error}"))) + } } -async fn check_latest_release(config: &SystemUpdateConfig) -> UpdateInfoData { +async fn check_latest_release(config: &SystemUpdateConfig, force: bool) -> UpdateInfoData { let Some(repository) = config.update_repository.as_deref() else { return UpdateInfoData { current_version: config.version.clone(), latest_version: config.version.clone(), has_update: false, deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), release_url: None, notes: None, cached: false, update_supported: false, - unsupported_reason: Some( - "Update checks require CPR_UPDATE_REPOSITORY or a GHCR image repository" - .to_string(), - ), - target_image: None, - requires_backup: false, + unsupported_reason: Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()), warning: None, }; }; + let cache_key = config.release_cache_key(); - match fetch_latest_release(repository).await { - Ok(release) => update_info_from_release(config, release), - Err(error) => UpdateInfoData { - current_version: config.version.clone(), - latest_version: config.version.clone(), - has_update: false, - deployment_mode: config.deployment_mode.clone(), - release_url: None, - notes: None, - cached: false, - update_supported: false, - unsupported_reason: config.update_support_error(), - target_image: None, - requires_backup: false, - warning: Some(error), - }, + if !force { + if let Some(info) = cached_release_info(&cache_key).await { + return info; + } + } + + match fetch_latest_release(&config.github_api_base, repository).await { + Ok(release) => { + let info = update_info_from_release(config, release); + cache_release_info(cache_key, &info).await; + info + } + Err(error) => cached_release_info(&cache_key) + .await + .unwrap_or_else(|| UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: config.update_support_error(), + warning: Some(error), + }), } } -async fn fetch_latest_release(repository: &str) -> Result { - let url = format!("{GITHUB_API_BASE}/{repository}/releases/latest"); +async fn cached_release_info(cache_key: &str) -> Option { + let cache = RELEASE_CACHE.lock().await; + let cached = cache.as_ref()?; + if cached.key != cache_key || cached.cached_at.elapsed() > UPDATE_CACHE_TTL { + return None; + } + let mut info = cached.info.clone(); + info.cached = true; + Some(info) +} + +async fn cache_release_info(cache_key: String, info: &UpdateInfoData) { + let mut cache = RELEASE_CACHE.lock().await; + *cache = Some(CachedUpdateInfo { + key: cache_key, + info: info.clone(), + cached_at: Instant::now(), + }); +} + +async fn fetch_latest_release(api_base: &str, repository: &str) -> Result { + let url = format!( + "{}/{repository}/releases/latest", + api_base.trim_end_matches('/') + ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -385,7 +611,7 @@ async fn fetch_latest_release(repository: &str) -> Result let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .header(reqwest::header::USER_AGENT, "codex-proxy-rs-updater") + .header(reqwest::header::USER_AGENT, APP_BINARY_NAME) .send() .await .map_err(|error| error.to_string())?; @@ -405,26 +631,20 @@ fn update_info_from_release(config: &SystemUpdateConfig, release: GitHubRelease) && version_is_newer(&config.version, &latest_version).unwrap_or(false); let unsupported_reason = config.update_support_error(); let update_supported = unsupported_reason.is_none(); - let target_image = update_supported - .then(|| config.target_image_for_version(&latest_version)) - .flatten(); let notes = release.body.or(release.name); - let requires_backup = notes - .as_deref() - .is_some_and(|notes| notes.to_ascii_lowercase().contains("backup required: yes")); - UpdateInfoData { current_version: config.version.clone(), latest_version, has_update, deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), release_url: release.html_url, notes, cached: false, update_supported, unsupported_reason, - target_image, - requires_backup, warning: None, } } @@ -439,64 +659,603 @@ fn version_is_newer(current: &str, latest: &str) -> Option { Some(latest > current) } -async fn call_updater_update( +async fn perform_release_update( config: &SystemUpdateConfig, - payload: UpdaterUpdateRequest, + release: &GitHubRelease, + version: &str, + operation_id: &str, ) -> Result<(), AdminError> { - call_updater(config, "/update", &payload).await + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + "正在选择匹配当前平台的更新包", + ); + let archive = select_release_archive(release, version)?; + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + format!( + "已选择更新包 {} ({})", + archive.name, + format_bytes(archive.size) + ), + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("verify"), + "正在校验下载地址", + ); + validate_download_url(&archive.browser_download_url)?; + if archive.size > MAX_DOWNLOAD_SIZE { + return Err(AdminError::bad_request("Release archive is too large")); + } + let checksum = release + .assets + .iter() + .find(|asset| asset.name == "checksums.txt"); + if let Some(asset) = checksum { + validate_download_url(&asset.browser_download_url)?; + } + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("prepare"), + "正在创建临时更新目录", + ); + let exe_path = config.executable_path()?; + let exe_dir = exe_path + .parent() + .ok_or_else(|| AdminError::internal("Executable path has no parent"))?; + let temp_dir = fs::canonicalize(exe_dir) + .and_then(|dir| fs::create_dir_all(&dir).map(|()| dir)) + .and_then(|dir| tempfile_dir_in(&dir, ".codex-proxy-rs-update-")) + .map_err(|error| { + AdminError::internal(format!("Failed to create update temp dir: {error}")) + })?; + let archive_path = temp_dir.join(&archive.name); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("download"), + "开始下载更新包", + ); + download_file( + &archive.browser_download_url, + &archive_path, + MAX_DOWNLOAD_SIZE, + Some(DownloadProgress { + operation_id, + total_size: archive.size, + }), + ) + .await?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("download"), + "更新包下载完成", + ); + + if let Some(checksum) = checksum { + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("checksum"), + "正在校验 checksum", + ); + verify_checksum(&archive_path, &archive.name, &checksum.browser_download_url).await?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("checksum"), + "checksum 校验通过", + ); + } + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("extract"), + "正在解压更新包", + ); + let extracted = extract_release_archive(&archive_path, &temp_dir)?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("extract"), + "更新包解压完成", + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("replace"), + "正在替换应用文件", + ); + replace_release_files(&exe_path, &config.web_dist_dir, extracted)?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("replace"), + "应用文件替换完成", + ); + let _ = fs::remove_dir_all(temp_dir); + Ok(()) } -async fn call_updater_rollback( - config: &SystemUpdateConfig, - payload: UpdaterRollbackRequest, -) -> Result<(), AdminError> { - call_updater(config, "/rollback", &payload).await +async fn rollback_release_update(config: &SystemUpdateConfig) -> Result<(), AdminError> { + let exe_path = config.executable_path()?; + let backup_path = backup_path_for(&exe_path); + if !backup_path.exists() { + return Err(AdminError::conflict("No binary backup found for rollback")); + } + fs::rename(&backup_path, &exe_path) + .map_err(|error| AdminError::internal(format!("Binary rollback failed: {error}")))?; + + let web_backup = backup_path_for(&config.web_dist_dir); + if web_backup.exists() { + if config.web_dist_dir.exists() { + fs::remove_dir_all(&config.web_dist_dir).map_err(|error| { + AdminError::internal(format!("Web rollback cleanup failed: {error}")) + })?; + } + fs::rename(&web_backup, &config.web_dist_dir) + .map_err(|error| AdminError::internal(format!("Web rollback failed: {error}")))?; + } + Ok(()) } -async fn call_updater( - config: &SystemUpdateConfig, - path: &str, - payload: &T, +fn select_release_archive<'a>( + release: &'a GitHubRelease, + version: &str, +) -> Result<&'a GitHubAsset, AdminError> { + let os = env::consts::OS; + let arch = env::consts::ARCH; + let normalized = normalize_version_tag(version); + release + .assets + .iter() + .find(|asset| { + let name = asset.name.as_str(); + name.contains(APP_BINARY_NAME) + && name.contains(&normalized) + && name.contains(os) + && name.contains(arch) + && !name.ends_with(".txt") + }) + .or_else(|| { + release.assets.iter().find(|asset| { + let name = asset.name.as_str(); + name.contains(os) && name.contains(arch) && !name.ends_with(".txt") + }) + }) + .ok_or_else(|| { + AdminError::conflict(format!( + "No compatible release archive found for {os}/{arch}" + )) + }) +} + +async fn download_file( + url: &str, + dest: &Path, + max_size: u64, + progress: Option>, ) -> Result<(), AdminError> { - let updater_url = config - .updater_url - .as_deref() - .ok_or_else(|| AdminError::conflict("CPR_UPDATER_URL is not configured"))?; - let token = config - .updater_token - .as_deref() - .ok_or_else(|| AdminError::conflict("CPR_UPDATER_TOKEN is not configured"))?; - let url = format!("{}{}", updater_url.trim_end_matches('/'), path); let client = reqwest::Client::builder() .timeout(Duration::from_secs(120)) .build() - .map_err(|_| AdminError::internal("Failed to create updater client"))?; - let response = client - .post(url) - .bearer_auth(token) - .json(payload) + .map_err(|error| AdminError::internal(format!("Failed to create HTTP client: {error}")))?; + let mut response = client + .get(url) .send() .await - .map_err(|error| AdminError::bad_gateway(format!("Updater request failed: {error}")))?; + .map_err(|error| AdminError::bad_gateway(format!("Download failed: {error}")))?; if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); return Err(AdminError::bad_gateway(format!( - "Updater returned {status}: {}", - body.trim() + "Download failed with {}", + response.status() + ))); + } + let mut file = fs::File::create(dest).map_err(|error| { + AdminError::internal(format!("Failed to create download file: {error}")) + })?; + let mut downloaded = 0_u64; + let mut next_progress = 10_u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| AdminError::bad_gateway(format!("Download stream failed: {error}")))? + { + downloaded += chunk.len() as u64; + if downloaded > max_size { + return Err(AdminError::bad_request("Download exceeds max allowed size")); + } + file.write_all(&chunk) + .map_err(|error| AdminError::internal(format!("Failed to write download: {error}")))?; + if let Some(progress) = progress { + let percent = download_percent(downloaded, progress.total_size); + if progress.total_size > 0 + && (percent >= next_progress || downloaded >= progress.total_size) + { + emit_update_event( + UpdateLogLevel::Info, + Some(progress.operation_id), + Some("download"), + format!( + "已下载 {} / {} ({percent}%)", + format_bytes(downloaded), + format_bytes(progress.total_size) + ), + ); + next_progress = percent.saturating_add(10); + } + } + } + Ok(()) +} + +async fn verify_checksum( + file_path: &Path, + file_name: &str, + checksum_url: &str, +) -> Result<(), AdminError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| AdminError::internal(format!("Failed to create HTTP client: {error}")))?; + let body = client + .get(checksum_url) + .send() + .await + .map_err(|error| AdminError::bad_gateway(format!("Checksum download failed: {error}")))? + .text() + .await + .map_err(|error| AdminError::bad_gateway(format!("Checksum read failed: {error}")))?; + + let expected = body.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let hash = parts.next()?; + let name = parts.next()?; + (Path::new(name).file_name()?.to_string_lossy() == file_name).then(|| hash.to_string()) + }); + let expected = expected.ok_or_else(|| AdminError::bad_gateway("Checksum not found"))?; + let actual = sha256_file(file_path)?; + if expected.eq_ignore_ascii_case(&actual) { + Ok(()) + } else { + Err(AdminError::bad_gateway("Checksum mismatch")) + } +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path) + .map_err(|error| AdminError::internal(format!("Failed to open checksum file: {error}")))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer).map_err(|error| { + AdminError::internal(format!("Failed to read checksum file: {error}")) + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn extract_release_archive( + archive_path: &Path, + temp_dir: &Path, +) -> Result { + let file = fs::File::open(archive_path) + .map_err(|error| AdminError::internal(format!("Failed to open archive: {error}")))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let extract_dir = temp_dir.join("extracted"); + fs::create_dir_all(&extract_dir) + .map_err(|error| AdminError::internal(format!("Failed to create extract dir: {error}")))?; + let binary_path = temp_dir.join(APP_BINARY_NAME); + let web_dist_dir = temp_dir.join("web-dist"); + let mut found_binary = false; + let mut found_web = false; + + for entry in archive + .entries() + .map_err(|error| AdminError::internal(format!("Failed to read archive: {error}")))? + { + let mut entry = entry + .map_err(|error| AdminError::internal(format!("Invalid archive entry: {error}")))?; + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry + .path() + .map_err(|error| AdminError::internal(format!("Invalid archive path: {error}")))? + .to_path_buf(); + if unsafe_archive_path(&path) { + return Err(AdminError::bad_request("Unsafe archive path")); + } + + if path.file_name().is_some_and(|name| name == APP_BINARY_NAME) { + entry.unpack(&binary_path).map_err(|error| { + AdminError::internal(format!("Failed to extract binary: {error}")) + })?; + found_binary = true; + continue; + } + + if let Some(relative) = web_dist_relative_path(&path) { + let target = web_dist_dir.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + AdminError::internal(format!("Failed to create web asset dir: {error}")) + })?; + } + entry.unpack(&target).map_err(|error| { + AdminError::internal(format!("Failed to extract web asset: {error}")) + })?; + found_web = true; + } + } + + if !found_binary { + return Err(AdminError::bad_request( + "Release archive does not contain codex-proxy-rs", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)) + .map_err(|error| AdminError::internal(format!("Failed to chmod binary: {error}")))?; + } + + Ok(ExtractedRelease { + binary_path, + web_dist_dir: found_web.then_some(web_dist_dir), + }) +} + +fn replace_release_files( + exe_path: &Path, + web_dist_dir: &Path, + extracted: ExtractedRelease, +) -> Result<(), AdminError> { + let web_backup = backup_path_for(web_dist_dir); + let web_replaced = if let Some(new_web) = extracted.web_dist_dir { + replace_dir(web_dist_dir, &web_backup, &new_web)?; + true + } else { + false + }; + + let binary_backup = backup_path_for(exe_path); + if binary_backup.exists() { + fs::remove_file(&binary_backup).map_err(|error| { + AdminError::internal(format!("Failed to remove old binary backup: {error}")) + })?; + } + if let Err(error) = fs::rename(exe_path, &binary_backup) { + if web_replaced { + let _ = restore_dir(web_dist_dir, &web_backup); + } + return Err(AdminError::internal(format!( + "Binary backup failed: {error}" + ))); + } + if let Err(error) = fs::rename(&extracted.binary_path, exe_path) { + let _ = fs::rename(&binary_backup, exe_path); + if web_replaced { + let _ = restore_dir(web_dist_dir, &web_backup); + } + return Err(AdminError::internal(format!( + "Binary replace failed: {error}" ))); } Ok(()) } -fn parse_update_request(body: &[u8]) -> Result { - if body.trim_ascii().is_empty() { - return Ok(UpdateRequest { - target_version: None, - confirm_backup: None, - }); +fn replace_dir(current: &Path, backup: &Path, replacement: &Path) -> Result<(), AdminError> { + if backup.exists() { + fs::remove_dir_all(backup).map_err(|error| { + AdminError::internal(format!("Failed to remove old web backup: {error}")) + })?; + } + if current.exists() { + fs::rename(current, backup).map_err(|error| { + AdminError::internal(format!("Failed to backup web assets: {error}")) + })?; + } + fs::rename(replacement, current) + .map_err(|error| AdminError::internal(format!("Failed to replace web assets: {error}"))) +} + +fn restore_dir(current: &Path, backup: &Path) -> io::Result<()> { + if current.exists() { + fs::remove_dir_all(current)?; + } + if backup.exists() { + fs::rename(backup, current)?; + } + Ok(()) +} + +fn backup_path_for(path: &Path) -> PathBuf { + let mut backup = path.as_os_str().to_os_string(); + backup.push(".backup"); + PathBuf::from(backup) +} + +fn web_dist_relative_path(path: &Path) -> Option { + let components = path + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + .collect::>(); + for index in 0..components.len().saturating_sub(1) { + if components[index] == "web" + && components + .get(index + 1) + .is_some_and(|value| value == "dist") + { + return Some(components[index + 2..].iter().collect()); + } + if components[index] == "dist" { + return Some(components[index + 1..].iter().collect()); + } + } + None +} + +fn unsafe_archive_path(path: &Path) -> bool { + path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) +} + +fn validate_download_url(raw_url: &str) -> Result<(), AdminError> { + let url = reqwest::Url::parse(raw_url) + .map_err(|error| AdminError::bad_request(format!("Invalid download URL: {error}")))?; + if url.scheme() == "http" + && env_string("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS").as_deref() == Some("true") + { + return Ok(()); + } + if url.scheme() != "https" { + return Err(AdminError::bad_request( + "Only HTTPS release downloads are allowed", + )); + } + let host = url + .host_str() + .ok_or_else(|| AdminError::bad_request("Download URL is missing host"))?; + let allowed = host == "github.com" + || host.ends_with(".github.com") + || host == "objects.githubusercontent.com" + || host.ends_with(".objects.githubusercontent.com"); + if allowed { + Ok(()) + } else { + Err(AdminError::bad_request(format!( + "Download host is not allowed: {host}" + ))) + } +} + +fn tempfile_dir_in(parent: &Path, prefix: &str) -> io::Result { + for attempt in 0..100 { + let path = parent.join(format!( + "{prefix}{}-{attempt}", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create unique temp dir", + )) +} + +fn read_state(path: &Path) -> Result { + if !path.exists() { + return Ok(SystemUpdateStatusData::default()); + } + let data = fs::read_to_string(path) + .map_err(|error| AdminError::internal(format!("Failed to read update state: {error}")))?; + serde_json::from_str(&data) + .map_err(|error| AdminError::internal(format!("Invalid update state: {error}"))) +} + +fn write_state(path: &Path, state: &SystemUpdateStatusData) -> Result<(), AdminError> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|error| { + AdminError::internal(format!("Failed to create update state directory: {error}")) + })?; + } + let data = serde_json::to_string_pretty(state) + .map_err(|error| AdminError::internal(format!("Failed to encode update state: {error}")))?; + fs::write(path, data) + .map_err(|error| AdminError::internal(format!("Failed to write update state: {error}"))) +} + +fn set_operation_running( + path: &Path, + operation_id: &str, + kind: SystemOperationKind, + version: Option<&str>, +) -> Result<(), AdminError> { + let mut state = read_state(path)?; + state.operation = SystemOperationState { + operation_id: Some(operation_id.to_string()), + kind: Some(kind), + status: SystemOperationStatus::Running, + target_version: version.map(ToString::to_string), + message: Some("operation running".to_string()), + error: None, + started_at: Some(Utc::now().to_rfc3339()), + finished_at: None, + }; + write_state(path, &state) +} + +fn finish_operation( + path: &Path, + operation_id: &str, + kind: SystemOperationKind, + version: Option, + error: Option, +) { + let mut state = match read_state(path) { + Ok(state) => state, + Err(error) => { + eprintln!("failed to read update state: {error}"); + return; + } + }; + if state.operation.operation_id.as_deref() != Some(operation_id) { + return; + } + + if let Some(error) = error { + state.operation.status = SystemOperationStatus::Failed; + state.operation.message = Some("operation failed".to_string()); + state.operation.error = Some(error); + } else { + state.operation.status = SystemOperationStatus::Succeeded; + state.operation.message = Some("operation succeeded".to_string()); + state.operation.error = None; + if kind == SystemOperationKind::Update { + state.previous_version = state.current_version.take(); + state.current_version = version.clone(); + } + state.operation.target_version = version; + } + state.operation.finished_at = Some(Utc::now().to_rfc3339()); + + if let Err(error) = write_state(path, &state) { + eprintln!("failed to write update operation state: {error}"); } - serde_json::from_slice(body).map_err(|_| AdminError::malformed_json("Malformed JSON body")) } fn env_string(key: &str) -> Option { @@ -506,10 +1265,189 @@ fn env_string(key: &str) -> Option { .filter(|value| !value.is_empty()) } +fn runtime_build_info_override(key: &str) -> Option { + if cfg!(debug_assertions) { + env_string(key) + } else { + None + } +} + +fn build_version() -> String { + runtime_build_info_override("CPR_VERSION").unwrap_or_else(|| { + option_env!("CPR_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string() + }) +} + +fn build_git_sha() -> String { + runtime_build_info_override("CPR_GIT_SHA") + .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()) +} + +fn build_time() -> String { + runtime_build_info_override("CPR_BUILD_TIME").unwrap_or_else(|| { + option_env!("CPR_BUILD_TIME") + .unwrap_or("unknown") + .to_string() + }) +} + +fn build_type() -> String { + runtime_build_info_override("CPR_BUILD_TYPE").unwrap_or_else(|| { + option_env!("CPR_BUILD_TYPE") + .unwrap_or("source") + .to_string() + }) +} + +fn update_event_sender() -> &'static broadcast::Sender { + UPDATE_EVENT_SENDER.get_or_init(|| { + let (sender, _receiver) = broadcast::channel(256); + sender + }) +} + +fn emit_update_event( + level: UpdateLogLevel, + operation_id: Option<&str>, + step: Option<&str>, + message: impl Into, +) { + let now = Utc::now(); + let event = SystemUpdateEvent { + id: format!( + "update-log-{}", + now.timestamp_nanos_opt() + .unwrap_or_else(|| now.timestamp_millis()) + ), + operation_id: operation_id.map(ToString::to_string), + level, + step: step.map(ToString::to_string), + message: message.into(), + at: now.to_rfc3339(), + }; + let _ = update_event_sender().send(event); +} + +fn download_percent(downloaded: u64, total_size: u64) -> u64 { + if total_size == 0 { + return 0; + } + downloaded + .saturating_mul(100) + .saturating_div(total_size) + .min(100) +} + +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + if bytes >= MIB { + return format!("{:.1} MiB", bytes as f64 / MIB as f64); + } + if bytes >= KIB { + return format!("{:.1} KiB", bytes as f64 / KIB as f64); + } + format!("{bytes} B") +} + +fn deployment_mode_label(value: &str) -> &str { + match value { + "docker" => "Docker", + "source" => "源码部署", + "binary" => "二进制部署", + _ => value, + } +} + +fn build_type_label(value: &str) -> &str { + match value { + "release" => "正式构建", + "source" => "源码构建", + "dev" => "开发构建", + _ => value, + } +} + fn normalize_version_tag(version: &str) -> String { version.trim().trim_start_matches('v').to_string() } -fn github_repo_from_image(image_repository: &str) -> Option<&str> { - image_repository.strip_prefix("ghcr.io/") +fn operation_id(kind: &str) -> String { + format!("sysop-{kind}-{}", Utc::now().timestamp_millis()) +} + +impl OperationLock { + fn acquire(path: &Path) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|error| { + AdminError::internal(format!("Failed to create update lock directory: {error}")) + })?; + } + + match Self::try_create(path) { + Ok(()) => Ok(Self { + path: path.to_path_buf(), + }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if stale_lock(path)? { + fs::remove_file(path).map_err(|error| { + AdminError::internal(format!("Failed to remove stale update lock: {error}")) + })?; + Self::try_create(path).map_err(|error| { + AdminError::internal(format!("Failed to create update lock: {error}")) + })?; + return Ok(Self { + path: path.to_path_buf(), + }); + } + Err(AdminError::conflict("System update already running")) + } + Err(error) => Err(AdminError::internal(format!( + "Failed to create update lock: {error}" + ))), + } + } + + fn try_create(path: &Path) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + writeln!( + file, + "pid={}\ncreated_at={}", + std::process::id(), + Utc::now().to_rfc3339() + ) + } +} + +impl Drop for OperationLock { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + if error.kind() != io::ErrorKind::NotFound { + eprintln!("failed to remove update lock: {error}"); + } + } + } +} + +fn stale_lock(path: &Path) -> Result { + let metadata = fs::metadata(path) + .map_err(|error| AdminError::internal(format!("Failed to read update lock: {error}")))?; + let modified = metadata.modified().map_err(|error| { + AdminError::internal(format!("Failed to read update lock timestamp: {error}")) + })?; + modified + .elapsed() + .map(|age| age > Duration::from_secs(30 * 60)) + .map_err(|error| { + AdminError::internal(format!("Failed to calculate update lock age: {error}")) + }) } diff --git a/src/bin/codex-proxy-rs-updater.rs b/src/bin/codex-proxy-rs-updater.rs deleted file mode 100644 index fd8d9e87e..000000000 --- a/src/bin/codex-proxy-rs-updater.rs +++ /dev/null @@ -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> { - 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, - compose_file: PathBuf, - compose_project: Option, - compose_env_file: Option, - compose_image_env: String, - state_file: PathBuf, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateRequest { - service: String, - image: String, - compose_project: Option, - target_version: Option, - confirm_backup: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RollbackRequest { - service: String, - compose_project: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct UpdaterState { - previous_image: Option, - current_image: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdaterResponse { - message: String, - service: String, - image: Option, -} - -#[derive(Debug)] -struct UpdaterError { - status: StatusCode, - message: String, -} - -impl UpdaterConfig { - fn from_env() -> Result> { - let bind = env_string("CPR_UPDATER_BIND") - .unwrap_or_else(|| "0.0.0.0:8090".to_string()) - .parse::()?; - 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 { - requested - .and_then(non_empty) - .map(ToString::to_string) - .or_else(|| self.compose_project.clone()) - } -} - -async fn update( - State(config): State, - headers: HeaderMap, - Json(payload): Json, -) -> Result { - 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, - headers: HeaderMap, - Json(payload): Json, -) -> Result { - 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(&self, args: I) -> Command - where - I: IntoIterator, - S: AsRef, - { - let mut command = Command::new(&self.docker_bin); - for arg in args { - command.arg(arg.as_ref()); - } - command - } - - fn compose_command(&self, args: I) -> Command - where - I: IntoIterator, - S: AsRef, - { - 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, 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 { - 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::(); - fs::write(path, data) - .map_err(|error| UpdaterError::internal(format!("Failed to write env file: {error}"))) -} - -fn read_env_file(path: &Path) -> Result, 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 { - 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 { - 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 { - env::var(key) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn required_env(key: &str) -> Result { - 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) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: message.into(), - } - } - - fn unauthorized(message: impl Into) -> Self { - Self { - status: StatusCode::UNAUTHORIZED, - message: message.into(), - } - } - - fn conflict(message: impl Into) -> Self { - Self { - status: StatusCode::CONFLICT, - message: message.into(), - } - } - - fn internal(message: impl Into) -> 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() - } -} diff --git a/tests/admin/mod.rs b/tests/admin/mod.rs index 4972c9edb..44419e4d3 100644 --- a/tests/admin/mod.rs +++ b/tests/admin/mod.rs @@ -4,3 +4,4 @@ pub(crate) mod keys; pub(crate) mod monitoring; pub(crate) mod response; pub(crate) mod settings; +pub(crate) mod system; diff --git a/tests/admin/system/mod.rs b/tests/admin/system/mod.rs new file mode 100644 index 000000000..ce8fdc15d --- /dev/null +++ b/tests/admin/system/mod.rs @@ -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 { + 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(builder: &mut Builder, 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), + } +} diff --git a/web/src/api/modules/system.ts b/web/src/api/modules/system.ts index 7216a1c6e..c365912f1 100644 --- a/web/src/api/modules/system.ts +++ b/web/src/api/modules/system.ts @@ -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({ 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({ url: '/api/admin/system/restart', method: 'POST', }) diff --git a/web/src/composables/useSystemUpdate.ts b/web/src/composables/useSystemUpdate.ts new file mode 100644 index 000000000..18d51b43b --- /dev/null +++ b/web/src/composables/useSystemUpdate.ts @@ -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(null) +const updateInfo = shallowRef(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([]) +const updateStreaming = shallowRef(false) +const updateStreamError = shallowRef('') +const updateEventSource = shallowRef(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).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, + } +} diff --git a/web/src/layout/components/AppSidebar.vue b/web/src/layout/components/AppSidebar.vue index 781788379..e7475ee72 100644 --- a/web/src/layout/components/AppSidebar.vue +++ b/web/src/layout/components/AppSidebar.vue @@ -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'" >
@@ -235,10 +242,28 @@ watch( ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden" > - Codex - Proxy RS · v0.1.0 + + Codex Proxy + + + + Rust build + + +
@@ -284,13 +309,14 @@ watch(
- + -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({ default: false }) -const version = shallowRef(null) -const updateInfo = shallowRef(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() }) @@ -180,124 +231,151 @@ onUnmounted(() => { -
-
+
+
-
-

运行版本

-

- {{ loading ? '加载中...' : displayValue(version?.version) }} +

+

+ Codex Proxy RS +

+

+ 应用包更新

- {{ loading ? '加载中...' : displayValue(version?.deploymentMode) }} + + {{ statusView.label }}
-
-
-

镜像

-

- {{ displayValue(version?.image) }} +

+
+

+ {{ item.label }}

-
-
-

Git SHA

- {{ displayValue(version?.gitSha) }} + {{ item.value }}

-
-
- - {{ updateStatus }} - - - {{ displayValue(updateInfo?.currentVersion) }} -> - {{ displayValue(updateInfo?.latestVersion) }} - +
+
+
+

状态

+

+ {{ statusMessage }} +

+
- Release + 发布页
- {{ updateInfo.unsupportedReason }} + + +

+ {{ statusMessage }} +

+
-
- {{ updateInfo.warning }} -
+
+
+
+ +

更新进度

+
+ + + {{ streamStatusLabel }} + +
-
- {{ reconnectMessage }} -
- -
-
-

目标镜像

-

+

+
- {{ displayValue(updateInfo?.targetImage) }} -

+ {{ log.time }} + +

+ [{{ log.step }}] + {{ log.message }} +

+
-
-

备份确认

-

- {{ updateInfo?.requiresBackup ? '需要' : '不需要' }} -

-
-
+

+ 暂无进度 +

+
-

发布说明

- +
+

发布说明

+ + {{ displayValue(updateInfo?.latestVersion) }} + +
+
{{ updateInfo.notes }}
@@ -308,36 +386,37 @@ onUnmounted(() => { - 检查 + 检查更新 - - - 回滚 - - + - 重启 + {{ restarting ? `重启中 ${restartCountdown}s` : '立即重启' }} - 一键更新 + 立即更新