diff --git a/.dockerignore b/.dockerignore index 32c126b79..9e489904d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,13 @@ target web/node_modules web/dist +.runtime data logs local.yaml local.yml +.env +config.yaml .git .codegraph .worktrees diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..0f9407a99 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: zyycn/codex-proxy-rs + UPDATER_IMAGE_NAME: zyycn/codex-proxy-rs-updater + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive version + id: version + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "build_time=${BUILD_TIME}" >> "$GITHUB_OUTPUT" + echo "git_sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable pnpm + run: corepack enable + + - name: Install frontend dependencies + run: pnpm --dir web install --frozen-lockfile + + - name: Build frontend + run: pnpm --dir web build + + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.95 + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + + - name: Build release binaries + env: + CPR_VERSION: ${{ steps.version.outputs.version }} + CPR_GIT_SHA: ${{ steps.version.outputs.git_sha }} + CPR_BUILD_TIME: ${{ steps.version.outputs.build_time }} + run: cargo build --release --locked --bins + + - name: Package release assets + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + PACKAGE_DIR="dist-package/codex-proxy-rs" + mkdir -p "${PACKAGE_DIR}" + cp target/release/codex-proxy-rs "${PACKAGE_DIR}/" + cp target/release/codex-proxy-rs-updater "${PACKAGE_DIR}/" + cp -r web/dist "${PACKAGE_DIR}/web-dist" + tar -czf "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" -C dist-package codex-proxy-rs + sha256sum "codex-proxy-rs_${VERSION}_linux_x86_64.tar.gz" > checksums.txt + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push app image + uses: docker/build-push-action@v6 + with: + context: . + target: runtime + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + CPR_VERSION=${{ steps.version.outputs.version }} + CPR_GIT_SHA=${{ steps.version.outputs.git_sha }} + CPR_BUILD_TIME=${{ steps.version.outputs.build_time }} + labels: | + org.opencontainers.image.title=Codex Proxy RS + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ steps.version.outputs.git_sha }} + org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + + - name: Build and push updater image + uses: docker/build-push-action@v6 + with: + context: . + target: updater-runtime + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + CPR_VERSION=${{ steps.version.outputs.version }} + CPR_GIT_SHA=${{ steps.version.outputs.git_sha }} + CPR_BUILD_TIME=${{ steps.version.outputs.build_time }} + labels: | + org.opencontainers.image.title=Codex Proxy RS Updater + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ steps.version.outputs.git_sha }} + org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs + tags: | + ${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.UPDATER_IMAGE_NAME }}:sha-${{ github.sha }} + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + codex-proxy-rs_${{ steps.version.outputs.version }}_linux_x86_64.tar.gz + checksums.txt diff --git a/.gitignore b/.gitignore index 6c88a0a86..6865f3a79 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ # Local configuration. /local.yaml /local.yml +/.env # Local workspaces and tooling state. /.worktrees/ diff --git a/Cargo.lock b/Cargo.lock index 7efb0da04..e08dc7c43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,6 +335,7 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "secrecy", + "semver", "serde", "serde_json", "serde_yml", @@ -2193,6 +2194,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index c953c368e..6bd067833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ rustls = { version = "=0.23.36", default-features = false, features = ["ring", " rustls-native-certs = "0.8.4" rustls-pki-types = "1.14.1" secrecy = "0.10.3" +semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" @@ -67,4 +68,8 @@ needless_collect = { level = "deny", priority = 7 } debug = true [profile.release] -debug = true +debug = false +strip = "symbols" +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/Dockerfile b/Dockerfile index 1fc7b51f3..cf00cad36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,70 @@ -FROM rust:1.95-bookworm AS builder +# syntax=docker/dockerfile:1.7 + +FROM node:24-bookworm-slim AS web-builder + +WORKDIR /app/web +COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY web ./ +RUN pnpm build + +FROM rust:1.95-bookworm AS rust-builder WORKDIR /app +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown +ENV CPR_VERSION=${CPR_VERSION} +ENV CPR_GIT_SHA=${CPR_GIT_SHA} +ENV CPR_BUILD_TIME=${CPR_BUILD_TIME} COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ COPY src ./src -COPY web/dist ./web/dist -RUN cargo build --release --locked +COPY --from=web-builder /app/web/dist ./web/dist +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked --bins \ + && cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs \ + && cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater \ + && strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater -FROM debian:bookworm-slim +FROM debian:bookworm-slim AS updater-runtime + +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \ + CPR_VERSION=${CPR_VERSION} \ + CPR_GIT_SHA=${CPR_GIT_SHA} \ + CPR_BUILD_TIME=${CPR_BUILD_TIME} +COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater + +EXPOSE 8090 + +CMD ["codex-proxy-rs-updater"] + +FROM debian:bookworm-slim AS runtime + +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=builder /app/target/release/codex-proxy-rs /usr/local/bin/codex-proxy-rs -COPY config.yaml ./config.yaml +ENV CPR_DEPLOYMENT_MODE=docker \ + CPR_VERSION=${CPR_VERSION} \ + CPR_GIT_SHA=${CPR_GIT_SHA} \ + CPR_BUILD_TIME=${CPR_BUILD_TIME} +COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs EXPOSE 8080 diff --git a/docker-compose.yml b/docker-compose.yml index e218ff9de..b7a5f0c97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,39 @@ services: codex-proxy-rs: - build: . + image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest} + container_name: codex-proxy-rs + restart: unless-stopped ports: - "8080:8080" volumes: + - ./config.yaml:/app/config.yaml:ro - ./data:/app/data - ./logs:/app/logs + environment: + CPR_DEPLOYMENT_MODE: docker + CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs + CPR_UPDATE_CHANNEL: stable + CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs + CPR_IMAGE_TAG: latest + CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090 + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} + depends_on: + - codex-proxy-rs-updater + + codex-proxy-rs-updater: + image: ghcr.io/zyycn/codex-proxy-rs-updater:latest + container_name: codex-proxy-rs-updater + restart: unless-stopped + working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} + environment: + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} + CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs + CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml + CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env + CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE + CPR_COMPOSE_SERVICE: codex-proxy-rs + CPR_DOCKER_COMPOSE_BIN: docker-compose + CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json diff --git a/docs/online-update.md b/docs/online-update.md index 131bb7b5c..0377f4fcf 100644 --- a/docs/online-update.md +++ b/docs/online-update.md @@ -44,12 +44,12 @@ browser 主应用容器不应直接拥有 Docker daemon 权限。updater 可以是一个独立容器,也可以是宿主机 systemd 服务。updater 只暴露给同一 Docker network 或 `127.0.0.1`,并使用共享 token 鉴权。 -当前 `docker-compose.yml` 使用 `build: .`,这种模式无法可靠在线更新到远端版本。要支持 Docker 一键更新,compose 应改为使用远端镜像: +项目只保留生产用 `docker-compose.yml`,不维护本地 Docker 开发配置。compose 必须使用远端镜像,不能使用 `build: .`,否则管理端只能检查更新,无法安全地完成一键更新。 ```yaml services: codex-proxy-rs: - image: ghcr.io//codex-proxy-rs:latest + image: ghcr.io/zyycn/codex-proxy-rs:latest ports: - "8080:8080" volumes: @@ -61,11 +61,9 @@ services: CPR_VERSION: 0.1.0 CPR_UPDATE_CHANNEL: stable CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090 - CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN} + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} ``` -如果继续使用 `build: .`,管理端只能检查上游是否有新版本,不能安全地完成远端包更新。 - ### 二进制模式 二进制模式可以采用 Sub2API 类似方案: @@ -87,8 +85,8 @@ services: 发布时应同时产出 Docker 镜像和 release 元数据: - Git tag:`v0.1.0` -- Docker image:`ghcr.io//codex-proxy-rs:0.1.0` -- Docker image:`ghcr.io//codex-proxy-rs:latest` +- Docker image:`ghcr.io/zyycn/codex-proxy-rs:0.1.0` +- Docker image:`ghcr.io/zyycn/codex-proxy-rs:latest` - Release archive:`codex-proxy-rs_0.1.0_linux_x86_64.tar.gz` - Checksum:`checksums.txt` @@ -103,6 +101,477 @@ services: Docker 模式下,检查更新可以优先使用 GitHub Releases 的 tag 做语义版本比较;真正更新时由 updater 拉取指定镜像 tag 或 digest。 +## 版本发布流程 + +版本发布以 Git tag 为唯一入口,tag、Docker 镜像、GitHub Release 和后端版本信息必须保持一致。 + +### 版本号规则 + +使用语义化版本: + +```text +MAJOR.MINOR.PATCH +``` + +示例: + +```text +0.2.0 +0.2.1 +1.0.0 +``` + +Git tag 使用 `v` 前缀: + +```text +v0.2.0 +``` + +代码中的版本来源: + +- `Cargo.toml` 的 `[package].version` 是本地构建默认版本。 +- `web/package.json` 的 `version` 应与后端版本保持一致。 +- CI 发布时以 Git tag 为准,并通过 build args 注入 `CPR_VERSION`。 + +发布前必须同步: + +```text +Cargo.toml version = "0.2.0" +web/package.json "version": "0.2.0" +Git tag v0.2.0 +Docker image tag 0.2.0 +GitHub Release tag v0.2.0 +``` + +如果后续希望避免手动同步,可以增加 `scripts/release-version`,统一更新 `Cargo.toml`、`Cargo.lock` 和 `web/package.json`。 + +### 发布通道 + +推荐先只维护 `stable` 通道: + +```text +latest -> 最新 stable 版本 +0.2.0 -> 固定版本 +sha-* -> commit 固定构建 +``` + +后续需要预发布时再增加: + +```text +next +beta +rc +``` + +管理端检查更新默认只看 stable release。带 `prerelease: true` 的 GitHub Release 不应推送给普通更新检查,除非配置了 `CPR_UPDATE_CHANNEL=beta`。 + +### 发布前检查 + +发布前必须完成: + +```bash +pnpm --dir web build +cargo fmt --check +cargo clippy --all-targets --all-features --locked -- -D warnings +cargo test --test main +``` + +发布前还应确认: + +- `Cargo.toml` release profile 已关闭 debug symbols。 +- Dockerfile 不复制本地 `config.yaml`。 +- Dockerfile 在容器内构建 `web/dist`。 +- `.dockerignore` 排除了 `target`、`node_modules`、数据库和日志。 +- `docker-compose.yml` 使用远端 `image`,不是 `build: .`。 +- release note 写明数据库迁移风险。 + +### 发布步骤 + +标准发布步骤: + +```bash +VERSION=0.2.0 + +# 1. 更新版本号 +# Cargo.toml: version = "0.2.0" +# web/package.json: "version": "0.2.0" + +# 2. 更新 Cargo.lock 中的本包版本 +cargo check --locked + +# 3. 提交版本变更 +git add Cargo.toml Cargo.lock web/package.json +git commit -m "chore: release v${VERSION}" + +# 4. 创建 tag +git tag "v${VERSION}" +git push origin HEAD +git push origin "v${VERSION}" +``` + +tag 推送后由 `.github/workflows/release.yml` 自动构建并发布: + +1. 构建前端。 +2. 构建 Rust release。 +3. 生成二进制压缩包。 +4. 生成 `checksums.txt`。 +5. 构建并推送 Docker 多架构镜像。 +6. 构建并推送 updater 多架构镜像。 +7. 创建 GitHub Release。 +8. 上传 release assets。 + +### Release Note 格式 + +GitHub Release body 建议固定结构,方便管理端展示: + +```markdown +## Highlights + +- ... + +## Changes + +- ... + +## Upgrade Notes + +- ... + +## Database + +- Migration: none +- Backup required: no +``` + +如果需要用户备份,必须写明: + +```markdown +## Database + +- Migration: required +- Backup required: yes +``` + +后端检查更新时可以从 release body 或额外 manifest 中解析 `requiresBackup`,并在管理端一键更新前要求确认。 + +### 发布后验证 + +发布完成后验证: + +```bash +docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0 +docker run --rm ghcr.io/zyycn/codex-proxy-rs:0.2.0 --version +``` + +如果暂时没有 `--version` 命令,则使用管理端接口验证: + +```bash +curl http://127.0.0.1:8080/api/admin/system/version +``` + +还需要验证: + +- GitHub Release latest 指向新版本。 +- `latest` 镜像 tag 指向新版本。 +- 固定版本 tag 可以拉取。 +- 管理端检查更新能看到新版本。 +- Docker 一键更新能从旧版本升级到新版本。 + +## Docker 构建方案 + +Docker 镜像应由 CI 直接完成前端构建、后端构建和 runtime 镜像组装。发布镜像不能依赖本地已经存在的 `web/dist`,否则 CI、开发机和发布机之间容易产出不一致。 + +推荐 Dockerfile 结构: + +```dockerfile +# syntax=docker/dockerfile:1.7 + +FROM node:24-bookworm-slim AS web-builder +WORKDIR /app/web +COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY web . +RUN pnpm build + +FROM rust:1.95-bookworm AS rust-builder +WORKDIR /app +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown +ENV CPR_VERSION=${CPR_VERSION} +ENV CPR_GIT_SHA=${CPR_GIT_SHA} +ENV CPR_BUILD_TIME=${CPR_BUILD_TIME} +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY src ./src +COPY --from=web-builder /app/web/dist ./web/dist +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked --bins && \ + cp /app/target/release/codex-proxy-rs /tmp/codex-proxy-rs && \ + cp /app/target/release/codex-proxy-rs-updater /tmp/codex-proxy-rs-updater && \ + strip /tmp/codex-proxy-rs /tmp/codex-proxy-rs-updater + +FROM debian:bookworm-slim AS updater-runtime +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates docker.io docker-compose \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +ENV CPR_DOCKER_COMPOSE_BIN=docker-compose \ + CPR_VERSION=${CPR_VERSION} \ + CPR_GIT_SHA=${CPR_GIT_SHA} \ + CPR_BUILD_TIME=${CPR_BUILD_TIME} +COPY --from=rust-builder /tmp/codex-proxy-rs-updater /usr/local/bin/codex-proxy-rs-updater + +EXPOSE 8090 +CMD ["codex-proxy-rs-updater"] + +FROM debian:bookworm-slim AS runtime +ARG CPR_VERSION=dev +ARG CPR_GIT_SHA=unknown +ARG CPR_BUILD_TIME=unknown +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV CPR_DEPLOYMENT_MODE=docker \ + CPR_VERSION=${CPR_VERSION} \ + CPR_GIT_SHA=${CPR_GIT_SHA} \ + CPR_BUILD_TIME=${CPR_BUILD_TIME} +COPY --from=rust-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs + +EXPOSE 8080 +CMD ["codex-proxy-rs"] +``` + +构建上下文必须增加 `.dockerignore`,避免把本地编译产物、数据库、日志和依赖目录传入 Docker daemon: + +```gitignore +.git +target +node_modules +web/node_modules +.runtime +data +logs +config.yaml +.env +*.sqlite +*.sqlite-* +*.db +``` + +发布镜像不应复制本地 `config.yaml`。配置文件应由 compose volume 挂载,避免镜像更新时覆盖用户配置: + +```yaml +volumes: + - ./config.yaml:/app/config.yaml:ro + - ./data:/app/data + - ./logs:/app/logs +``` + +后端 release profile 应关闭 debug symbols,并 strip 符号,避免 Docker 应用层过大: + +```toml +[profile.release] +debug = false +strip = "symbols" +lto = "thin" +codegen-units = 1 +panic = "abort" +``` + +如果后续仍需要 debug symbols,应把 symbols 作为单独 CI artifact 上传,不放进 runtime 镜像。 + +## Docker 发布方案 + +发布以 Git tag 为入口,例如 `v0.2.0`。CI 应完成以下产物: + +1. 多架构 Docker 镜像。 +2. GitHub Release。 +3. 二进制 tar.gz 包。 +4. `checksums.txt`。 + +推荐镜像标签: + +```text +ghcr.io/zyycn/codex-proxy-rs:0.2.0 +ghcr.io/zyycn/codex-proxy-rs:latest +ghcr.io/zyycn/codex-proxy-rs:sha- +``` + +推荐多架构平台: + +```text +linux/amd64 +linux/arm64 +``` + +CI 发布命令形态: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg CPR_VERSION="${VERSION}" \ + --build-arg CPR_GIT_SHA="${GIT_SHA}" \ + --build-arg CPR_BUILD_TIME="${BUILD_TIME}" \ + --label org.opencontainers.image.title="Codex Proxy RS" \ + --label org.opencontainers.image.version="${VERSION}" \ + --label org.opencontainers.image.revision="${GIT_SHA}" \ + --label org.opencontainers.image.source="https://github.com/zyycn/codex-proxy-rs" \ + -t "ghcr.io/zyycn/codex-proxy-rs:${VERSION}" \ + -t "ghcr.io/zyycn/codex-proxy-rs:latest" \ + --push \ + . +``` + +updater 镜像使用同一个 Dockerfile 的 `updater-runtime` target: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target updater-runtime \ + --build-arg CPR_VERSION="${VERSION}" \ + --build-arg CPR_GIT_SHA="${GIT_SHA}" \ + --build-arg CPR_BUILD_TIME="${BUILD_TIME}" \ + -t "ghcr.io/zyycn/codex-proxy-rs-updater:${VERSION}" \ + -t "ghcr.io/zyycn/codex-proxy-rs-updater:latest" \ + --push \ + . +``` + +GitHub Release 的 tag 是检查更新的权威来源。Release body 用于管理端展示更新说明。Docker 更新实际拉取的镜像应来自 release tag 对应的版本,不从用户输入拼接任意镜像地址。 + +生产 compose 使用仓库根目录的 `docker-compose.yml`。主服务镜像由 `.env` 的 `CPR_IMAGE` 控制,updater 更新时会改写这个变量;`.env` 是本机部署状态文件,不提交到仓库。 + +```dotenv +CPR_UPDATER_TOKEN=change-me +CPR_DEPLOY_DIR=/opt/codex-proxy-rs +CPR_IMAGE=ghcr.io/zyycn/codex-proxy-rs:latest +``` + +```yaml +services: + codex-proxy-rs: + image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest} + container_name: codex-proxy-rs + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./config.yaml:/app/config.yaml:ro + - ./data:/app/data + - ./logs:/app/logs + environment: + CPR_DEPLOYMENT_MODE: docker + CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs + CPR_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs + CPR_IMAGE_TAG: latest + CPR_UPDATE_CHANNEL: stable + CPR_UPDATER_URL: http://codex-proxy-rs-updater:8090 + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} + + codex-proxy-rs-updater: + image: ghcr.io/zyycn/codex-proxy-rs-updater:latest + restart: unless-stopped + working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} + environment: + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} + CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs + CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml + CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env + CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE + CPR_COMPOSE_SERVICE: codex-proxy-rs + CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json +``` + +`CPR_DEPLOY_DIR` 必须是宿主机上的绝对路径,并且 updater 容器内也挂载到同一个绝对路径。这样 updater 通过 Docker socket 调用 compose 时,`./config.yaml`、`./data`、`./logs` 等 bind mount 路径会被 host Docker 正确解析。 + +## Docker 在线一键更新流程 + +Docker 一键更新分为检查、触发、执行、恢复四个阶段。 + +### 1. 检查更新 + +后端调用 GitHub Releases latest: + +```text +GET https://api.github.com/repos/zyycn/codex-proxy-rs/releases/latest +``` + +后端比较: + +- 当前版本:构建时注入的 `CPR_VERSION`。 +- 最新版本:GitHub Release tag。 +- 当前部署模式:`CPR_DEPLOYMENT_MODE=docker`。 +- 当前镜像仓库:`CPR_IMAGE_REPOSITORY`。 +- updater 可用性:`CPR_UPDATER_URL` 和 `CPR_UPDATER_TOKEN` 是否存在。 + +只有满足以下条件时,`updateSupported` 才返回 `true`: + +- 当前部署模式是 `docker`。 +- 当前 compose 使用远端 `image`,不是 `build: .`。 +- 已配置 updater URL 和 token。 +- release tag 能映射到允许的镜像仓库。 + +### 2. 触发更新 + +管理员点击一键更新后,主应用后端: + +1. 校验管理员会话或管理员 API Key。 +2. 获取全局系统更新锁。 +3. 重新检查 latest release,避免使用过期缓存。 +4. 生成目标镜像,例如 `ghcr.io/zyycn/codex-proxy-rs:0.2.0`。 +5. 调用 updater 的 `/update`。 +6. 立即返回 `operationId` 和 `needReconnect: true`。 + +主应用后端不能接收前端传入的任意镜像地址。前端最多传 `targetVersion`,后端根据 release 信息和仓库白名单推导目标镜像。 + +### 3. 执行更新 + +updater 收到请求后: + +1. 校验 bearer token。 +2. 校验目标镜像仓库必须等于 `CPR_ALLOWED_IMAGE_REPOSITORY`。 +3. 立即返回 `update started`,让主应用可以先响应前端。 +4. 后台执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。 +5. 将 `.env` 中的 `CPR_IMAGE` 改成目标镜像。 +6. 执行 `docker compose up -d codex-proxy-rs`。 +7. 记录当前和上一个镜像,供回滚使用。 + +updater 只允许执行固定动作,不提供通用 shell 执行能力。 + +### 4. 服务恢复 + +Docker 重建主应用容器时,前端连接会中断。管理端 UI 应: + +1. 显示更新中。 +2. 每 2-3 秒轮询 `/api/admin/system/version`。 +3. 服务恢复后比较返回版本。 +4. 版本等于目标版本时显示更新成功。 +5. 超时后提示用户查看容器日志。 + +推荐超时时间:120 秒。 + +## 更新失败与回滚 + +Docker 模式下的回滚以镜像标签或 digest 为单位: + +1. 更新前记录当前镜像 digest。 +2. 更新失败时 updater 可以重新部署旧 digest。 +3. 如果服务已启动但健康检查失败,也回滚到旧 digest。 +4. 回滚只替换容器镜像,不回滚 SQLite 数据。 + +因此涉及数据库结构变更的版本必须谨慎发布。若 release 声明需要备份,管理端一键更新前应显示确认状态,后端可要求请求带上 `confirmBackup: true`。 + ## 后端 API 建议新增管理端接口: @@ -125,7 +594,7 @@ POST /api/admin/system/restart "gitSha": "abc1234", "buildTime": "2026-07-01T00:00:00Z", "deploymentMode": "docker", - "image": "ghcr.io//codex-proxy-rs:0.1.0" + "image": "ghcr.io/zyycn/codex-proxy-rs:0.1.0" } ``` @@ -137,7 +606,7 @@ POST /api/admin/system/restart "latestVersion": "0.2.0", "hasUpdate": true, "deploymentMode": "docker", - "releaseUrl": "https://github.com//codex-proxy-rs/releases/tag/v0.2.0", + "releaseUrl": "https://github.com/zyycn/codex-proxy-rs/releases/tag/v0.2.0", "notes": "...", "cached": false, "updateSupported": true, @@ -180,7 +649,7 @@ Content-Type: application/json ```json { "service": "codex-proxy-rs", - "image": "ghcr.io//codex-proxy-rs:0.2.0", + "image": "ghcr.io/zyycn/codex-proxy-rs:0.2.0", "composeProject": "codex-proxy-rs" } ``` @@ -189,22 +658,28 @@ updater 行为: 1. 校验 token。 2. 校验 image 仓库白名单。 -3. 执行 `docker pull ghcr.io//codex-proxy-rs:0.2.0`。 -4. 执行 `docker compose up -d codex-proxy-rs` 或 Docker API 等价操作。 -5. 记录当前镜像 digest,支持失败诊断。 +3. 执行 `docker pull ghcr.io/zyycn/codex-proxy-rs:0.2.0`。 +4. 将 `CPR_COMPOSE_ENV_FILE` 中的 `CPR_COMPOSE_IMAGE_ENV` 更新为目标镜像。 +5. 执行 `docker compose up -d codex-proxy-rs` 或 `docker-compose up -d codex-proxy-rs`。 +6. 记录当前和上一个镜像,用于回滚。 updater 不应暴露到公网。若使用 sidecar 容器,建议挂载 Docker socket 到 updater,而不是主应用: ```yaml services: codex-proxy-rs-updater: - image: ghcr.io//codex-proxy-rs-updater:latest + image: ghcr.io/zyycn/codex-proxy-rs-updater:latest + working_dir: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./docker-compose.yml:/workspace/docker-compose.yml:ro + - ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}:${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required} environment: - CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN} - CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io//codex-proxy-rs + CPR_UPDATER_TOKEN: ${CPR_UPDATER_TOKEN:?CPR_UPDATER_TOKEN is required} + CPR_ALLOWED_IMAGE_REPOSITORY: ghcr.io/zyycn/codex-proxy-rs + CPR_COMPOSE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/docker-compose.yml + CPR_COMPOSE_ENV_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.env + CPR_COMPOSE_IMAGE_ENV: CPR_IMAGE + CPR_UPDATER_STATE_FILE: ${CPR_DEPLOY_DIR:?CPR_DEPLOY_DIR is required}/.runtime/updater-state.json networks: - default ``` @@ -266,4 +741,3 @@ create table if not exists schema_migrations ( 6. 接入一键更新按钮。 7. 补充数据库迁移机制。 8. 再考虑二进制模式的原子替换和 rollback。 - diff --git a/src/admin/accounts/service/lifecycle.rs b/src/admin/accounts/service/lifecycle.rs index ab16d0b7a..dba5cd761 100644 --- a/src/admin/accounts/service/lifecycle.rs +++ b/src/admin/accounts/service/lifecycle.rs @@ -436,9 +436,8 @@ impl AdminAccountService { ids.as_ref() .is_none_or(|ids| ids.contains(account.id.as_str())) }) - .map(|account| account.id) - .collect::>(); - let results = stream::iter(candidate_ids.into_iter().enumerate().map( + .map(|account| account.id); + let results = stream::iter(candidate_ids.enumerate().map( |(index, account_id)| async move { if index > 0 && stagger_ms > 0 { let base_delay = stagger_ms.saturating_mul(index.min(concurrency) as u64); diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 24b4b4300..91d74de4f 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -14,3 +14,5 @@ pub mod response; pub mod router; /// 设置处理器。 pub mod settings; +/// 系统版本与在线更新处理器。 +pub mod system; diff --git a/src/admin/router.rs b/src/admin/router.rs index 62dc8a4ad..4ed085219 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -27,6 +27,7 @@ use super::{ admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings, update_settings, }, + system::routes::{check_updates, perform_update, restart, rollback, version}, }; /// 构造管理端路由。 @@ -44,6 +45,11 @@ pub fn router() -> Router { "/api/admin/settings/admin-api-key/regenerate", post(regenerate_admin_api_key), ) + .route("/api/admin/system/version", get(version)) + .route("/api/admin/system/check-updates", get(check_updates)) + .route("/api/admin/system/update", post(perform_update)) + .route("/api/admin/system/rollback", post(rollback)) + .route("/api/admin/system/restart", post(restart)) .route("/api/admin/dashboard/summary", get(dashboard_summary)) .route("/api/admin/dashboard/trend", get(dashboard_trend)) .route("/api/admin/accounts", get(accounts).post(create_account)) diff --git a/src/admin/system/mod.rs b/src/admin/system/mod.rs new file mode 100644 index 000000000..b70252b4a --- /dev/null +++ b/src/admin/system/mod.rs @@ -0,0 +1,3 @@ +//! 系统版本与在线更新处理器。 + +pub mod routes; diff --git a/src/admin/system/routes.rs b/src/admin/system/routes.rs new file mode 100644 index 000000000..f3e88a5b8 --- /dev/null +++ b/src/admin/system/routes.rs @@ -0,0 +1,515 @@ +//! 管理端系统版本与在线更新路由。 + +use std::{env, time::Duration}; + +use axum::{ + body::Bytes, + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::{ + admin::auth::session::require_admin_auth, + admin::response::{AdminEnvelope, AdminError, AdminResponse}, + runtime::state::AppState, +}; + +const DEFAULT_SERVICE_NAME: &str = "codex-proxy-rs"; +const GITHUB_API_BASE: &str = "https://api.github.com/repos"; + +static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CheckUpdateQuery { + force: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateRequest { + target_version: Option, + confirm_backup: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VersionData { + version: String, + git_sha: String, + build_time: String, + deployment_mode: String, + image: Option, + update_channel: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateInfoData { + current_version: String, + latest_version: String, + has_update: bool, + deployment_mode: String, + release_url: Option, + notes: Option, + cached: bool, + update_supported: bool, + unsupported_reason: Option, + target_image: Option, + requires_backup: bool, + warning: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateStartedData { + operation_id: String, + deployment_mode: String, + message: String, + need_reconnect: bool, + target_version: String, + target_image: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + name: Option, + body: Option, + html_url: Option, + prerelease: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdaterUpdateRequest { + service: String, + image: String, + compose_project: Option, + target_version: String, + confirm_backup: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdaterRollbackRequest { + service: String, + compose_project: Option, +} + +#[derive(Debug, Clone)] +struct SystemUpdateConfig { + version: String, + git_sha: String, + build_time: String, + deployment_mode: 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, +} + +/// `GET /api/admin/system/version` +pub(crate) async fn version( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + let config = SystemUpdateConfig::from_env(); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(config.version_data()), + )) +} + +/// `GET /api/admin/system/check-updates` +pub(crate) async fn check_updates( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> 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; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info))) +} + +/// `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 !info.has_update && payload.target_version.is_none() { + return Err(AdminError::conflict("Already up to date")); + } + if info.requires_backup && payload.confirm_backup != Some(true) { + return Err(AdminError::conflict( + "This update requires backup confirmation before continuing", + )); + } + + let target_version = payload + .target_version + .as_deref() + .map(normalize_version_tag) + .unwrap_or_else(|| normalize_version_tag(&info.latest_version)); + let target_image = config + .target_image_for_version(&target_version) + .ok_or_else(|| AdminError::conflict("Docker image repository is not configured"))?; + + call_updater_update( + &config, + UpdaterUpdateRequest { + service: config.compose_service.clone(), + image: target_image.clone(), + compose_project: config.compose_project.clone(), + target_version: target_version.clone(), + confirm_backup: payload.confirm_backup.unwrap_or(false), + }, + ) + .await?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(UpdateStartedData { + operation_id: format!("sysop-update-{}", chrono::Utc::now().timestamp_millis()), + deployment_mode: config.deployment_mode, + message: "Update started".to_string(), + need_reconnect: true, + target_version, + target_image: Some(target_image), + }), + )) +} + +/// `POST /api/admin/system/rollback` +pub(crate) async fn rollback( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + let _guard = SYSTEM_OPERATION_LOCK + .try_lock() + .map_err(|_| AdminError::conflict("System operation already running"))?; + let config = SystemUpdateConfig::from_env(); + call_updater_rollback( + &config, + UpdaterRollbackRequest { + service: config.compose_service.clone(), + compose_project: config.compose_project.clone(), + }, + ) + .await?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ + "message": "Rollback started", + "needReconnect": true + })), + )) +} + +/// `POST /api/admin/system/restart` +pub(crate) async fn restart( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") { + return Err(AdminError::conflict( + "Self restart is disabled; set CPR_ENABLE_SELF_RESTART=true to enable it", + )); + } + + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(500)).await; + std::process::exit(0); + }); + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ + "message": "Restart scheduled", + "needReconnect": true + })), + )) +} + +impl SystemUpdateConfig { + fn from_env() -> Self { + let image_repository = env_string("CPR_IMAGE_REPOSITORY"); + let update_repository = env_string("CPR_UPDATE_REPOSITORY").or_else(|| { + image_repository + .as_deref() + .and_then(github_repo_from_image) + .map(str::to_string) + }); + Self { + version: env_string("CPR_VERSION").unwrap_or_else(|| { + option_env!("CPR_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string() + }), + git_sha: env_string("CPR_GIT_SHA") + .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()), + build_time: env_string("CPR_BUILD_TIME").unwrap_or_else(|| { + option_env!("CPR_BUILD_TIME") + .unwrap_or("unknown") + .to_string() + }), + deployment_mode: env_string("CPR_DEPLOYMENT_MODE") + .unwrap_or_else(|| "source".to_string()), + update_channel: env_string("CPR_UPDATE_CHANNEL") + .unwrap_or_else(|| "stable".to_string()), + update_repository, + image_repository, + image_tag: env_string("CPR_IMAGE_TAG"), + updater_url: env_string("CPR_UPDATER_URL"), + updater_token: env_string("CPR_UPDATER_TOKEN"), + compose_project: env_string("CPR_COMPOSE_PROJECT"), + compose_service: env_string("CPR_COMPOSE_SERVICE") + .unwrap_or_else(|| DEFAULT_SERVICE_NAME.to_string()), + } + } + + fn version_data(&self) -> VersionData { + VersionData { + version: self.version.clone(), + git_sha: self.git_sha.clone(), + build_time: self.build_time.clone(), + deployment_mode: self.deployment_mode.clone(), + image: self.current_image(), + update_channel: self.update_channel.clone(), + } + } + + fn current_image(&self) -> Option { + 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.image_repository.is_none() { + return Some("Docker one-click update requires CPR_IMAGE_REPOSITORY".to_string()); + } + if self.updater_url.is_none() || self.updater_token.is_none() { + return Some( + "Docker one-click update requires CPR_UPDATER_URL and CPR_UPDATER_TOKEN" + .to_string(), + ); + } + None + } +} + +async fn check_latest_release(config: &SystemUpdateConfig) -> UpdateInfoData { + let Some(repository) = config.update_repository.as_deref() else { + return UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: Some( + "Update checks require CPR_UPDATE_REPOSITORY or a GHCR image repository" + .to_string(), + ), + target_image: None, + requires_backup: false, + warning: None, + }; + }; + + match fetch_latest_release(repository).await { + Ok(release) => update_info_from_release(config, release), + Err(error) => UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: config.update_support_error(), + target_image: None, + requires_backup: false, + warning: Some(error), + }, + } +} + +async fn fetch_latest_release(repository: &str) -> Result { + let url = format!("{GITHUB_API_BASE}/{repository}/releases/latest"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, "codex-proxy-rs-updater") + .send() + .await + .map_err(|error| error.to_string())?; + let status = response.status(); + if !status.is_success() { + return Err(format!("GitHub release check failed with {status}")); + } + response + .json::() + .await + .map_err(|error| error.to_string()) +} + +fn update_info_from_release(config: &SystemUpdateConfig, release: GitHubRelease) -> UpdateInfoData { + let latest_version = normalize_version_tag(&release.tag_name); + let has_update = release_allowed(config, &release) + && version_is_newer(&config.version, &latest_version).unwrap_or(false); + let unsupported_reason = config.update_support_error(); + let update_supported = unsupported_reason.is_none(); + let target_image = update_supported + .then(|| config.target_image_for_version(&latest_version)) + .flatten(); + let notes = release.body.or(release.name); + let requires_backup = notes + .as_deref() + .is_some_and(|notes| notes.to_ascii_lowercase().contains("backup required: yes")); + + UpdateInfoData { + current_version: config.version.clone(), + latest_version, + has_update, + deployment_mode: config.deployment_mode.clone(), + release_url: release.html_url, + notes, + cached: false, + update_supported, + unsupported_reason, + target_image, + requires_backup, + warning: None, + } +} + +fn release_allowed(config: &SystemUpdateConfig, release: &GitHubRelease) -> bool { + config.update_channel != "stable" || !release.prerelease +} + +fn version_is_newer(current: &str, latest: &str) -> Option { + let current = Version::parse(&normalize_version_tag(current)).ok()?; + let latest = Version::parse(&normalize_version_tag(latest)).ok()?; + Some(latest > current) +} + +async fn call_updater_update( + config: &SystemUpdateConfig, + payload: UpdaterUpdateRequest, +) -> Result<(), AdminError> { + call_updater(config, "/update", &payload).await +} + +async fn call_updater_rollback( + config: &SystemUpdateConfig, + payload: UpdaterRollbackRequest, +) -> Result<(), AdminError> { + call_updater(config, "/rollback", &payload).await +} + +async fn call_updater( + config: &SystemUpdateConfig, + path: &str, + payload: &T, +) -> Result<(), AdminError> { + let updater_url = config + .updater_url + .as_deref() + .ok_or_else(|| AdminError::conflict("CPR_UPDATER_URL is not configured"))?; + let token = config + .updater_token + .as_deref() + .ok_or_else(|| AdminError::conflict("CPR_UPDATER_TOKEN is not configured"))?; + let url = format!("{}{}", updater_url.trim_end_matches('/'), path); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|_| AdminError::internal("Failed to create updater client"))?; + let response = client + .post(url) + .bearer_auth(token) + .json(payload) + .send() + .await + .map_err(|error| AdminError::bad_gateway(format!("Updater request failed: {error}")))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AdminError::bad_gateway(format!( + "Updater returned {status}: {}", + body.trim() + ))); + } + Ok(()) +} + +fn parse_update_request(body: &[u8]) -> Result { + if body.trim_ascii().is_empty() { + return Ok(UpdateRequest { + target_version: None, + confirm_backup: None, + }); + } + serde_json::from_slice(body).map_err(|_| AdminError::malformed_json("Malformed JSON body")) +} + +fn env_string(key: &str) -> Option { + env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_version_tag(version: &str) -> String { + version.trim().trim_start_matches('v').to_string() +} + +fn github_repo_from_image(image_repository: &str) -> Option<&str> { + image_repository.strip_prefix("ghcr.io/") +} diff --git a/src/bin/codex-proxy-rs-updater.rs b/src/bin/codex-proxy-rs-updater.rs new file mode 100644 index 000000000..fd8d9e87e --- /dev/null +++ b/src/bin/codex-proxy-rs-updater.rs @@ -0,0 +1,461 @@ +use std::{ + collections::BTreeMap, + env, fs, io, + net::SocketAddr, + path::{Path, PathBuf}, + process::Command, +}; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + 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/src/proxy/dispatch/responses.rs b/src/proxy/dispatch/responses.rs index 89c78b510..caa73c5f5 100644 --- a/src/proxy/dispatch/responses.rs +++ b/src/proxy/dispatch/responses.rs @@ -114,6 +114,17 @@ pub struct ResponseDispatchService { cloudflare: CloudflareRecovery, } +pub(crate) struct ResponseDispatchServiceParts { + pub account_pool: Arc, + pub models: Arc, + pub codex: Arc, + pub session_affinity: Arc, + pub usage_records: Arc, + pub token_refresh: Arc>, + pub installation_id: Option, + pub cloudflare: CloudflareRecovery, +} + /// 默认 reasoning replay TTL 秒数。 const DEFAULT_REASONING_REPLAY_TTL_SECS: i64 = 55 * 60; const IMPLICIT_RESUME_MAX_AGE_SECS: i64 = DEFAULT_REASONING_REPLAY_TTL_SECS; @@ -151,28 +162,19 @@ impl Stream for MpscResponseBodyStream { use axum::body::Bytes; impl ResponseDispatchService { - pub(crate) fn new( - account_pool: Arc, - models: Arc, - codex: Arc, - session_affinity: Arc, - usage_records: Arc, - token_refresh: Arc>, - installation_id: Option, - cloudflare: CloudflareRecovery, - ) -> Self { + pub(crate) fn new(parts: ResponseDispatchServiceParts) -> Self { Self { - account_pool, - models, - codex, - session_affinity, + account_pool: parts.account_pool, + models: parts.models, + codex: parts.codex, + session_affinity: parts.session_affinity, reasoning_replay: Arc::new(Mutex::new(ReasoningReplayCache::new(Duration::seconds( DEFAULT_REASONING_REPLAY_TTL_SECS, )))), - usage_records, - token_refresh, - installation_id, - cloudflare, + usage_records: parts.usage_records, + token_refresh: parts.token_refresh, + installation_id: parts.installation_id, + cloudflare: parts.cloudflare, } } diff --git a/src/runtime/services.rs b/src/runtime/services.rs index 9bd8db2f0..908c01099 100644 --- a/src/runtime/services.rs +++ b/src/runtime/services.rs @@ -25,7 +25,7 @@ use crate::{ }, proxy::dispatch::{ chat::ChatDispatchService, - responses::ResponseDispatchService, + responses::{ResponseDispatchService, ResponseDispatchServiceParts}, session_affinity::{RuntimeSessionAffinityService, SqliteSessionAffinityStore}, }, upstream::accounts::{ @@ -264,16 +264,16 @@ impl Services { installation_id.clone(), cloudflare_recovery.clone(), )); - let responses = StdArc::new(ResponseDispatchService::new( - account_pool.clone(), - models.clone(), - codex.clone(), - session_affinity.clone(), - usage_records.clone(), - token_refresh.clone(), - installation_id.clone(), - cloudflare_recovery, - )); + let responses = StdArc::new(ResponseDispatchService::new(ResponseDispatchServiceParts { + account_pool: account_pool.clone(), + models: models.clone(), + codex: codex.clone(), + session_affinity: session_affinity.clone(), + usage_records: usage_records.clone(), + token_refresh: token_refresh.clone(), + installation_id: installation_id.clone(), + cloudflare: cloudflare_recovery, + })); Ok(Self { models, diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2803f0d51..a3501bd88 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -16,5 +16,8 @@ export * from './modules/dashboard' // 设置管理 export * from './modules/settings' +// 系统更新 +export * from './modules/system' + // 请求工具 export { default as request } from './request' diff --git a/web/src/api/modules/system.ts b/web/src/api/modules/system.ts new file mode 100644 index 000000000..7216a1c6e --- /dev/null +++ b/web/src/api/modules/system.ts @@ -0,0 +1,71 @@ +import request, { type ApiPayload } from '../request' + +export interface SystemVersion { + version: string + gitSha: string + buildTime: string + deploymentMode: string + image?: string + updateChannel: string +} + +export interface SystemUpdateInfo { + currentVersion: string + latestVersion: string + hasUpdate: boolean + deploymentMode: string + releaseUrl?: string + notes?: string + cached: boolean + updateSupported: boolean + unsupportedReason?: string + targetImage?: string + requiresBackup: boolean + warning?: string +} + +export interface SystemUpdateStarted { + operationId: string + deploymentMode: string + message: string + needReconnect: boolean + targetVersion: string + targetImage?: string +} + +export function getSystemVersion() { + return request({ + url: '/api/admin/system/version', + method: 'GET', + }) +} + +export function checkSystemUpdates(force = false) { + return request({ + url: '/api/admin/system/check-updates', + method: 'GET', + params: { force }, + }) +} + +export function performSystemUpdate(data: ApiPayload = {}) { + 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({ + url: '/api/admin/system/restart', + method: 'POST', + }) +} diff --git a/web/src/layout/components/AppSidebar.vue b/web/src/layout/components/AppSidebar.vue index 63ba8d3a3..781788379 100644 --- a/web/src/layout/components/AppSidebar.vue +++ b/web/src/layout/components/AppSidebar.vue @@ -13,6 +13,7 @@ import { Moon, PanelLeftClose, PanelLeftOpen, + PackageCheck, Settings, Sun, Users, @@ -22,6 +23,8 @@ import BaseButton from '@/components/base/BaseButton.vue' import { useAuthStore } from '@/stores/modules/auth' import { useUiStore } from '@/stores/modules/ui' +import SystemUpdateModal from './SystemUpdateModal.vue' + const route = useRoute() const router = useRouter() const authStore = useAuthStore() @@ -68,6 +71,7 @@ defineEmits<{ const sidebarEl = ref(null) const brandLabelEl = ref(null) const brandLabelVisible = shallowRef(!props.collapsed) +const systemUpdateOpen = shallowRef(false) const themeToggleLabel = computed(() => effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式', ) @@ -279,6 +283,16 @@ watch(
+ + + +
+ + diff --git a/web/src/layout/components/SystemUpdateModal.vue b/web/src/layout/components/SystemUpdateModal.vue new file mode 100644 index 000000000..b501c9248 --- /dev/null +++ b/web/src/layout/components/SystemUpdateModal.vue @@ -0,0 +1,344 @@ + + +