feat: harden deployment and CI pipeline

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-11 17:18:17 +08:00
co-authored by Codex
parent 49120d0efb
commit 4dcd9dc31d
19 changed files with 884 additions and 246 deletions
+54
View File
@@ -0,0 +1,54 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: '09:00'
timezone: Asia/Shanghai
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /backend
schedule:
interval: weekly
day: monday
time: '09:10'
timezone: Asia/Shanghai
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
day: monday
time: '09:20'
timezone: Asia/Shanghai
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: docker
directory: /deploy
schedule:
interval: weekly
day: monday
time: '09:30'
timezone: Asia/Shanghai
groups:
minor-and-patch:
update-types:
- minor
- patch
+237
View File
@@ -0,0 +1,237 @@
name: Reusable Container
on:
workflow_call:
inputs:
ref:
description: Exact commit SHA to build and validate
required: true
type: string
permissions:
contents: read
security-events: write
jobs:
container:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }}
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
CPR_ADMIN_DEFAULT_PASSWORD: 'admin @:/?#%2F ci password'
CPR_POSTGRES_PASSWORD: 'pg @:/?#%2F ci'
CPR_REDIS_PASSWORD: 'redis @:/?#%2F ci'
CPR_SMOKE_PORT: '18080'
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
- name: Define isolated runtime paths
shell: bash
run: |
{
echo "CPR_CONFIG_FILE=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/config.yaml"
echo "CPR_DATA_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/data"
echo "CPR_LOG_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/logs"
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
} >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: deploy/Dockerfile
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
- name: Generate Trivy SARIF
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.CPR_IMAGE }}
version: v0.72.0
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '0'
- name: Upload Trivy SARIF
if: >-
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: trivy-results.sarif
category: runtime-container
- name: Enforce Trivy vulnerability gate
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.CPR_IMAGE }}
version: v0.72.0
format: table
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '1'
- name: Prepare isolated runtime
shell: bash
run: |
set -euo pipefail
runtime_dir="$(dirname "$CPR_CONFIG_FILE")"
mkdir -p "$runtime_dir" "$DIAGNOSTICS_DIR"
sudo install -d -m 0770 -o "$(id -u)" -g 10001 "$CPR_DATA_DIR" "$CPR_LOG_DIR"
cp deploy/config.example.yaml "$CPR_CONFIG_FILE"
sudo chown "$(id -u):10001" "$CPR_CONFIG_FILE"
chmod 0640 "$CPR_CONFIG_FILE"
- name: Validate Docker Compose
shell: bash
run: |
set -euo pipefail
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml config --quiet
- name: Verify PostgreSQL and Redis volume persistence
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml)
"${compose[@]}" up -d --no-build --wait postgres redis
"${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \
redis-cli SET cpr:ci:persistence-probe persisted
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c \
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
"${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"DROP TABLE cpr_ci_persistence_probe;"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \
redis-cli DEL cpr:ci:persistence-probe
- name: Start and verify application
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml)
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:${CPR_SMOKE_PORT}/healthz")"
test "$status" = 204
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/data/.ci-write /app/logs/.ci-write && rm /app/data/.ci-write /app/logs/.ci-write'
- name: Verify clean logs and graceful shutdown
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml)
"${compose[@]}" stop --timeout 30 codex-proxy-rs
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
test -n "$container_id"
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
> "$DIAGNOSTICS_DIR/application.log"
"${compose[@]}" logs --no-color --timestamps postgres \
> "$DIAGNOSTICS_DIR/postgres.log"
"${compose[@]}" logs --no-color --timestamps redis \
> "$DIAGNOSTICS_DIR/redis.log"
if grep --extended-regexp --ignore-case \
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
"$DIAGNOSTICS_DIR/application.log" \
|| grep --extended-regexp --ignore-case \
'(^|[[:space:]])(warning|error|fatal|panic):' \
"$DIAGNOSTICS_DIR/postgres.log" \
|| grep --extended-regexp --ignore-case \
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
"$DIAGNOSTICS_DIR/redis.log"; then
echo "container logs contain a warning or error severity" >&2
exit 1
fi
- name: Collect container diagnostics
if: always()
shell: bash
run: |
set +e
mkdir -p "$DIAGNOSTICS_DIR"
raw_dir="$(mktemp -d)"
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml)
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
if [ "${#container_ids[@]}" -gt 0 ]; then
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
fi
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
raw_dir = ENV.fetch("RAW_DIR")
output_dir = ENV.fetch("OUTPUT_DIR")
secrets = [
ENV.fetch("CPR_ADMIN_DEFAULT_PASSWORD"),
ENV.fetch("CPR_POSTGRES_PASSWORD"),
ENV.fetch("CPR_REDIS_PASSWORD"),
]
encoded_secrets = secrets.map do |secret|
secret.encode("UTF-8").bytes.map do |byte|
character = byte.chr
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
end.join
end
Dir.children(raw_dir).each do |name|
content = File.binread(File.join(raw_dir, name))
(secrets + encoded_secrets).each do |secret|
content = content.gsub(secret, "<redacted>")
end
File.binwrite(File.join(output_dir, name), content)
end
RUBY
rm -rf "$raw_dir"
cp trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
- name: Upload container diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
if-no-files-found: warn
retention-days: 7
- name: Remove isolated runtime
if: always()
shell: bash
run: |
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml \
down --volumes --remove-orphans || true
sudo rm -rf "$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
+137
View File
@@ -0,0 +1,137 @@
name: Reusable Quality
on:
workflow_call:
inputs:
ref:
description: Exact commit SHA to validate
required: true
type: string
upload_frontend:
description: Upload the built frontend for release jobs
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
RUSTFLAGS: -D warnings
services:
postgres:
image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
env:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
POSTGRES_PASSWORD: codex_proxy
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U codex_proxy -d codex_proxy"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95
with:
toolchain: '1.95'
components: rustfmt,clippy
- name: Cache Rust
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: backend -> target
- name: Rust format
working-directory: backend
run: cargo fmt --check
- name: Rust clippy
working-directory: backend
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Rust tests
working-directory: backend
run: cargo test --test main --locked
frontend:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
run: pnpm --dir frontend format:check
- name: Frontend build
run: pnpm --dir frontend build
- name: Upload frontend artifact
if: inputs.upload_frontend
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
workflow-lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.ref }}
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
--volume "$PWD:/repo"
--workdir /repo
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
.github/workflows/*.yml
+33 -82
View File
@@ -10,96 +10,47 @@ permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
group: ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
backend:
changes:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
POSTGRES_PASSWORD: codex_proxy
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U codex_proxy -d codex_proxy"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
timeout-minutes: 5
outputs:
container: ${{ steps.filter.outputs.container }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.95
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
components: rustfmt,clippy
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Cache Rust
uses: Swatinem/rust-cache@v2.9.1
- name: Detect container changes
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
with:
workspaces: backend -> target
filters: |
container:
- 'backend/**'
- 'frontend/**'
- 'deploy/**'
- '.dockerignore'
- 'release/version.yaml'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- name: Rust format
working-directory: backend
run: cargo fmt --check
quality:
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Rust clippy
working-directory: backend
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Rust tests
working-directory: backend
run: cargo test --test main --locked
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
run: pnpm --dir frontend format:check
- name: Frontend build
run: pnpm --dir frontend build
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Validate Docker Compose
run: docker compose -f deploy/docker-compose.yml config
- name: Build runtime image
run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:ci .
container:
needs: [changes, quality]
if: needs.changes.outputs.container == 'true'
permissions:
contents: read
security-events: write
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
+92 -92
View File
@@ -25,6 +25,7 @@ env:
jobs:
release-info:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
@@ -35,10 +36,11 @@ jobs:
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
fetch-depth: 0
@@ -151,62 +153,33 @@ jobs:
puts "non_docker_matrix=#{non_docker_matrix}"
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
RUBY
build-frontend:
quality:
needs: [release-info]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ needs.release-info.outputs.tag }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.7.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Build frontend
run: pnpm build
working-directory: frontend
- name: Upload frontend artifact
uses: actions/upload-artifact@v7
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
needs: [release-info]
needs: [release-info, quality]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release-info.outputs.tag }}
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Cache Cargo
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
@@ -232,9 +205,8 @@ jobs:
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
rust:1.95-bookworm \
rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
env:
@@ -244,7 +216,7 @@ jobs:
CPR_BUILD_TYPE: release
- name: Upload binary artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
@@ -253,20 +225,27 @@ jobs:
build-binary:
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
needs: [release-info]
needs: [release-info, quality]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.non_docker_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release-info.outputs.tag }}
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95
with:
toolchain: '1.95'
targets: ${{ matrix.target }}
- name: Cache Cargo
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: |
~/.cargo/registry
@@ -276,10 +255,6 @@ jobs:
restore-keys: |
cargo-${{ runner.os }}-${{ matrix.target }}-
- name: Install Rust target
run: rustup target add ${{ matrix.target }}
working-directory: backend
- name: Build binary
shell: bash
run: |
@@ -294,7 +269,7 @@ jobs:
CPR_BUILD_TYPE: release
- name: Upload binary artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
@@ -302,8 +277,9 @@ jobs:
retention-days: 1
package-assets:
needs: [release-info, build-frontend, build-docker-binary, build-binary]
needs: [release-info, quality, build-docker-binary, build-binary]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
@@ -311,18 +287,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release-info.outputs.tag }}
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download frontend artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: frontend-dist
path: dist/frontend
- name: Download binary artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: codex-proxy-rs-*
path: dist/binaries
@@ -407,12 +383,12 @@ jobs:
PY
- name: Generate release asset provenance
uses: actions/attest@v4
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
with:
subject-path: dist/release/*
- name: Upload release assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-assets
path: dist/release
@@ -421,30 +397,34 @@ jobs:
build-docker-image:
name: build-docker-image (${{ matrix.dockerPlatform }})
needs: [release-info, build-frontend, build-docker-binary]
needs: [release-info, quality, build-docker-binary]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
packages: write
id-token: write
env:
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release-info.outputs.tag }}
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download frontend artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: frontend-dist
path: dist/docker/web/dist
- name: Download Docker binary artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
@@ -476,10 +456,10 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -487,7 +467,7 @@ jobs:
- name: Build and push app image
id: build-and-push
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: deploy/Dockerfile
@@ -505,13 +485,24 @@ jobs:
org.opencontainers.image.version=${{ needs.release-info.outputs.version }}
org.opencontainers.image.revision=${{ needs.release-info.outputs.git_sha }}
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}-${{ matrix.arch }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
- name: Scan exact app image digest
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
version: v0.72.0
format: table
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '1'
publish:
needs: [release-info, package-assets, build-docker-image]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
packages: write
@@ -520,24 +511,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release-info.outputs.tag }}
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download release assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-assets
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Install Cosign
uses: sigstore/cosign-installer@v4.1.2
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -550,6 +541,7 @@ jobs:
VERSION: ${{ needs.release-info.outputs.version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
@@ -560,7 +552,7 @@ jobs:
platform="${platform//[[:space:]]/}"
[ -n "${platform}" ] || continue
arch="${platform##*/}"
sources+=("${image}:${VERSION}-${arch}")
sources+=("${image}:sha-${GIT_SHA}-${arch}")
done
if [ "${#sources[@]}" -eq 0 ]; then
@@ -568,11 +560,14 @@ jobs:
exit 1
fi
docker buildx imagetools create \
-t "${image}:${VERSION}" \
-t "${image}:latest" \
-t "${image}:sha-${GIT_SHA}" \
"${sources[@]}"
target_args=(
-t "${image}:${VERSION}"
-t "${image}:sha-${GIT_SHA}"
)
if [ "$PRERELEASE" != true ]; then
target_args+=(-t "${image}:latest")
fi
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
inspect_output="$(docker buildx imagetools inspect "${image}:${VERSION}")"
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
@@ -592,7 +587,7 @@ jobs:
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
- name: Generate container provenance attestation
uses: actions/attest@v4
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
@@ -602,10 +597,9 @@ jobs:
shell: bash
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
IMAGE_TAGS: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}
VERSION: ${{ needs.release-info.outputs.version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
if [ -z "${DIGEST}" ]; then
@@ -613,20 +607,26 @@ jobs:
exit 1
fi
image="${REGISTRY}/${IMAGE_NAME}"
tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}")
if [ "$PRERELEASE" != true ]; then
tags+=("${image}:latest")
fi
images=()
while IFS= read -r tag; do
[ -n "${tag}" ] || continue
for tag in "${tags[@]}"; do
images+=("${tag}@${DIGEST}")
done <<< "${IMAGE_TAGS}"
done
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
## 📥 Installation
+22 -26
View File
@@ -12,7 +12,7 @@ permissions:
contents: read
concurrency:
group: security-scan-${{ github.workflow }}-${{ github.ref }}
group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -21,18 +21,22 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.95
uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95
with:
toolchain: '1.95'
- name: Cache Rust
uses: Swatinem/rust-cache@v2.9.1
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: backend -> target
- name: Install cargo-audit
run: cargo install cargo-audit --locked
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Run cargo audit
working-directory: backend
@@ -43,15 +47,17 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
@@ -63,21 +69,11 @@ jobs:
- name: Run pnpm audit
run: pnpm --dir frontend audit --prod --audit-level=high
docker-security:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Build runtime image
run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:security-scan .
- name: Scan runtime image
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: codex-proxy-rs:security-scan
severity: HIGH,CRITICAL
vuln-type: os,library
ignore-unfixed: true
exit-code: '1'
container-security:
if: github.event_name == 'schedule'
permissions:
contents: read
security-events: write
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.sha }}
+1
View File
@@ -10,6 +10,7 @@
# Frontend dependencies and build output.
/frontend/node_modules/
/frontend/dist/
.pnpm-store
# Application runtime state.
*.runtime
+15 -5
View File
@@ -87,9 +87,19 @@ PostgreSQL 与 Redis 分别使用 `postgres-data`、`redis-data` 命名卷。
```bash
mkdir -p .runtime/data .runtime/logs
cp deploy/config.example.yaml .runtime/config.yaml
# 编辑 .runtime/config.yaml,设置 admin.default_password
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
sudo chown "$(id -u):10001" .runtime/config.yaml
chmod 0640 .runtime/config.yaml
cp deploy/.env.example deploy/.env
chmod 0600 deploy/.env
# 编辑 deploy/.env,设置管理员、PostgreSQL 与 Redis 密码
```
`deploy/.env` 是 Docker 部署的密钥配置文件,已被 Git 忽略。Compose 要求其中三项密码均为非空;
应用会用管理员密码覆盖 `admin.default_password`,并安全覆盖数据库 URL 的密码部分,不需要手工编码或
同步连接串。编辑密码时保留示例中的单引号。
Docker 配置文件里路径保持容器路径:
```yaml
@@ -116,8 +126,8 @@ telemetry:
构建并启动:
```bash
docker compose -f deploy/docker-compose.yml build
docker compose -f deploy/docker-compose.yml up -d
docker compose --env-file deploy/.env -f deploy/docker-compose.yml build
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
```
默认只绑定宿主机 `127.0.0.1:8080`。如需覆盖路径:
@@ -126,7 +136,7 @@ docker compose -f deploy/docker-compose.yml up -d
CPR_CONFIG_FILE=/path/to/config.yaml \
CPR_DATA_DIR=/path/to/data \
CPR_LOG_DIR=/path/to/logs \
docker compose -f deploy/docker-compose.yml up -d
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
```
## API
@@ -228,7 +238,7 @@ cargo fmt --manifest-path backend/Cargo.toml --check
cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
cargo test --manifest-path backend/Cargo.toml --test main --locked
pnpm --dir frontend build
docker compose -f deploy/docker-compose.yml config
docker compose --env-file deploy/.env -f deploy/docker-compose.yml config
```
## License
+2
View File
@@ -365,6 +365,7 @@ dependencies = [
"hex",
"indexmap",
"ipnet",
"percent-encoding",
"rand 0.10.1",
"rand_core 0.6.4",
"redis",
@@ -391,6 +392,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"tungstenite",
"url",
"uuid",
"wiremock",
]
+2
View File
@@ -19,6 +19,7 @@ futures = "0.3.32"
hex = "0.4.3"
indexmap = { version = "2.0", features = ["serde"] }
ipnet = "2.12.0"
percent-encoding = "2.3.2"
rand = "0.10.1"
rand_core = { version = "0.6.4", features = ["getrandom"] }
redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
@@ -44,6 +45,7 @@ tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
[dev-dependencies]
+55 -4
View File
@@ -1,6 +1,8 @@
use std::{collections::BTreeMap, env, path::Path};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use url::Url;
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
@@ -404,9 +406,12 @@ impl Default for TelemetryConfig {
}
const CONFIG_FILE_ENV: &str = "CPR_CONFIG_FILE";
const ADMIN_DEFAULT_PASSWORD_ENV: &str = "CPR_ADMIN_DEFAULT_PASSWORD";
const POSTGRES_PASSWORD_ENV: &str = "CPR_POSTGRES_PASSWORD";
const REDIS_PASSWORD_ENV: &str = "CPR_REDIS_PASSWORD";
impl AppConfig {
/// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件。
/// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件,并应用运行时密码。
pub fn load() -> Result<Self, ::config::ConfigError> {
let config_file = env::var(CONFIG_FILE_ENV)
.ok()
@@ -417,15 +422,61 @@ impl AppConfig {
}
}
/// 从指定目录加载配置文件。
/// 从指定目录加载配置文件,并应用运行时密码。
pub fn load_from_dir(config_dir: impl AsRef<Path>) -> Result<Self, ::config::ConfigError> {
load_file(config_dir.as_ref().join("config.yaml"))
}
}
fn load_file(config_file: impl AsRef<Path>) -> Result<AppConfig, ::config::ConfigError> {
::config::Config::builder()
let mut app_config: AppConfig = ::config::Config::builder()
.add_source(::config::File::from(config_file.as_ref()).required(true))
.build()?
.try_deserialize()
.try_deserialize()?;
apply_password_override(
&mut app_config.database.url,
"database.url",
POSTGRES_PASSWORD_ENV,
)?;
apply_password_override(&mut app_config.redis.url, "redis.url", REDIS_PASSWORD_ENV)?;
if let Some(password) = password_override(ADMIN_DEFAULT_PASSWORD_ENV)? {
app_config.admin.default_password = password;
}
Ok(app_config)
}
fn apply_password_override(
raw_url: &mut String,
config_key: &str,
env_name: &str,
) -> Result<(), ::config::ConfigError> {
let Some(password) = password_override(env_name)? else {
return Ok(());
};
let mut url = Url::parse(raw_url).map_err(|_| {
::config::ConfigError::Message(format!(
"{config_key} must be a valid URL when {env_name} is set"
))
})?;
let encoded_password = utf8_percent_encode(&password, NON_ALPHANUMERIC).to_string();
url.set_password(Some(&encoded_password)).map_err(|()| {
::config::ConfigError::Message(format!(
"{config_key} does not support a password override from {env_name}"
))
})?;
*raw_url = url.into();
Ok(())
}
fn password_override(env_name: &str) -> Result<Option<String>, ::config::ConfigError> {
match env::var(env_name) {
Ok(password) if password.is_empty() => Err(::config::ConfigError::Message(format!(
"{env_name} must not be empty"
))),
Ok(password) => Ok(Some(password)),
Err(env::VarError::NotPresent) => Ok(None),
Err(env::VarError::NotUnicode(_)) => Err(::config::ConfigError::Message(format!(
"{env_name} must contain valid UTF-8"
))),
}
}
+157 -6
View File
@@ -2,7 +2,7 @@ use codex_proxy_rs::bootstrap::config::LoggingConfig;
use codex_proxy_rs::bootstrap::config::{AdminConfig, AdminConfigError, AppConfig};
use codex_proxy_rs::bootstrap::services::account_pool_options_from_config;
use serde::de::DeserializeOwned;
use std::fs;
use std::{fs, process::Command};
const DEFAULT_CONFIG_YAML: &str = r#"
server:
@@ -179,6 +179,14 @@ redis:
#[test]
fn config_loader_should_read_only_config_yaml() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_FILE_ONLY_CASE";
if std::env::var(CASE_ENV).as_deref() != Ok("child") {
run_isolated_config_test("config_loader_should_read_only_config_yaml", |command| {
command.env(CASE_ENV, "child");
});
return;
}
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("config.yaml"),
@@ -315,6 +323,14 @@ fn server_config_should_reject_unknown_fields() {
#[test]
fn config_loader_should_read_explicit_config_file_from_env() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_EXPLICIT_CONFIG_FILE_CASE";
if std::env::var(CASE_ENV).as_deref() == Ok("child") {
let cfg = AppConfig::load().unwrap();
assert_eq!(cfg.server.host, "127.0.0.2");
assert_eq!(cfg.logging.file.directory, ".runtime/env-logs");
return;
}
let dir = tempfile::tempdir().unwrap();
let config_file = dir.path().join("custom-config.yaml");
fs::write(
@@ -324,13 +340,120 @@ fn config_loader_should_read_explicit_config_file_from_env() {
.replace("directory: .runtime/logs", "directory: .runtime/env-logs"),
)
.unwrap();
std::env::set_var("CPR_CONFIG_FILE", &config_file);
run_isolated_config_test(
"config_loader_should_read_explicit_config_file_from_env",
|command| {
command
.env(CASE_ENV, "child")
.env("CPR_CONFIG_FILE", &config_file);
},
);
}
let cfg = AppConfig::load().unwrap();
#[test]
fn config_loader_should_override_runtime_passwords_from_env() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_PASSWORD_OVERRIDE_CASE";
if std::env::var(CASE_ENV).as_deref() != Ok("child") {
run_isolated_config_test(
"config_loader_should_override_runtime_passwords_from_env",
|command| {
command
.env(CASE_ENV, "child")
.env("CPR_ADMIN_DEFAULT_PASSWORD", "admin @:/?#% password")
.env("CPR_POSTGRES_PASSWORD", "pg @:/?#%")
.env("CPR_REDIS_PASSWORD", "redis @:/?#%");
},
);
return;
}
std::env::remove_var("CPR_CONFIG_FILE");
assert_eq!(cfg.server.host, "127.0.0.2");
assert_eq!(cfg.logging.file.directory, ".runtime/env-logs");
let dir = write_default_config();
let cfg = AppConfig::load_from_dir(dir.path()).unwrap();
assert_eq!(
cfg.database.url,
"postgres://codex_proxy:pg%20%40%3A%2F%3F%23%25@127.0.0.1:5432/codex_proxy"
);
assert_eq!(
cfg.redis.url,
"redis://:redis%20%40%3A%2F%3F%23%25@127.0.0.1:6379"
);
assert_eq!(cfg.admin.default_password, "admin @:/?#% password");
}
#[test]
fn config_loader_should_reject_empty_password_env() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_CONFIG_PASSWORD_CASE";
if std::env::var(CASE_ENV).as_deref() != Ok("child") {
run_isolated_config_test(
"config_loader_should_reject_empty_password_env",
|command| {
command
.env(CASE_ENV, "child")
.env("CPR_POSTGRES_PASSWORD", "");
},
);
return;
}
let dir = write_default_config();
let error = AppConfig::load_from_dir(dir.path()).unwrap_err();
assert_eq!(error.to_string(), "CPR_POSTGRES_PASSWORD must not be empty");
}
#[test]
fn config_loader_should_reject_empty_admin_password_env() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_ADMIN_PASSWORD_CASE";
if std::env::var(CASE_ENV).as_deref() != Ok("child") {
run_isolated_config_test(
"config_loader_should_reject_empty_admin_password_env",
|command| {
command
.env(CASE_ENV, "child")
.env("CPR_ADMIN_DEFAULT_PASSWORD", "");
},
);
return;
}
let dir = write_default_config();
let error = AppConfig::load_from_dir(dir.path()).unwrap_err();
assert_eq!(
error.to_string(),
"CPR_ADMIN_DEFAULT_PASSWORD must not be empty"
);
}
#[test]
fn config_loader_should_not_expose_password_when_url_is_invalid() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_INVALID_CONFIG_URL_CASE";
const SECRET: &str = "do-not-leak-this-password";
if std::env::var(CASE_ENV).as_deref() != Ok("child") {
run_isolated_config_test(
"config_loader_should_not_expose_password_when_url_is_invalid",
|command| {
command
.env(CASE_ENV, "child")
.env("CPR_REDIS_PASSWORD", SECRET);
},
);
return;
}
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("config.yaml"),
DEFAULT_CONFIG_YAML.replace("redis://127.0.0.1:6379", "not a URL"),
)
.unwrap();
let error = AppConfig::load_from_dir(dir.path()).unwrap_err();
let message = error.to_string();
assert!(message.contains("redis.url"));
assert!(message.contains("CPR_REDIS_PASSWORD"));
assert!(!message.contains(SECRET));
}
#[test]
@@ -362,3 +485,31 @@ fn parse_yaml_config<T: DeserializeOwned>(yaml: &str) -> Result<T, config::Confi
.build()?
.try_deserialize()
}
fn write_default_config() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("config.yaml"), DEFAULT_CONFIG_YAML).unwrap();
dir
}
fn run_isolated_config_test(test_name: &str, configure: impl FnOnce(&mut Command)) {
let current_exe = std::env::current_exe().expect("current test binary path");
let mut command = Command::new(current_exe);
command
.arg("--exact")
.arg(format!("bootstrap::config::{test_name}"))
.arg("--nocapture")
.env_remove("CPR_CONFIG_FILE")
.env_remove("CPR_ADMIN_DEFAULT_PASSWORD")
.env_remove("CPR_POSTGRES_PASSWORD")
.env_remove("CPR_REDIS_PASSWORD");
configure(&mut command);
let output = command.output().expect("run isolated config test case");
assert!(
output.status.success(),
"isolated config test {test_name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
+4
View File
@@ -0,0 +1,4 @@
# Docker deployment secrets. Copy this file to deploy/.env and set every value.
CPR_ADMIN_DEFAULT_PASSWORD=''
CPR_POSTGRES_PASSWORD=''
CPR_REDIS_PASSWORD=''
+8 -5
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM node:24-bookworm-slim AS frontend-builder
FROM node:24-bookworm-slim@sha256:cb4e8f7c443347358b7875e717c29e27bf9befc8f5a26cf18af3c3dec80e58c5 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -8,7 +8,7 @@ RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend ./
RUN pnpm build
FROM rust:1.95-bookworm AS backend-builder
FROM rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
@@ -38,7 +38,10 @@ ARG TARGETARCH
WORKDIR /out
COPY --from=frontend-builder /app/frontend/dist /tmp/release/codex-proxy-rs/web/dist
RUN cp /tmp/codex-proxy-rs /tmp/release/codex-proxy-rs/ \
RUN test -n "${CPR_VERSION}" \
&& test -n "${TARGETOS}" \
&& test -n "${TARGETARCH}" \
&& cp /tmp/codex-proxy-rs /tmp/release/codex-proxy-rs/ \
&& tar -czf "/out/codex-proxy-rs_${CPR_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz" \
-C /tmp/release codex-proxy-rs
@@ -46,7 +49,7 @@ FROM scratch AS release-asset
COPY --from=release-asset-builder /out /
FROM debian:bookworm-slim AS runtime-base
FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
+20 -10
View File
@@ -9,6 +9,12 @@
```bash
mkdir -p .runtime/data .runtime/logs
cp deploy/config.example.yaml .runtime/config.yaml
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
sudo chown "$(id -u):10001" .runtime/config.yaml
chmod 0640 .runtime/config.yaml
cp deploy/.env.example deploy/.env
chmod 0600 deploy/.env
```
`deploy/config.example.yaml` 是 Docker 部署模板。Compose 默认使用仓库根目录的 `.runtime/`:
@@ -20,20 +26,24 @@ cp deploy/config.example.yaml .runtime/config.yaml
应用日志同时写入 `docker logs` 与 `.runtime/logs`。Compose 对应用、PostgreSQL、Redis
统一启用 `json-file` 的 `10m × 5` 轮转;应用文件日志还受配置中的自然日、单文件大小和文件总数限制。
复制后必须设置 `admin.default_password`。空值、长度不足 12 位或常见弱口令会被后端拒绝。
`deploy/.env` 是 Docker 部署的密钥配置文件,已被 Git 忽略且必须保持 mode `0600`。其中
`CPR_ADMIN_DEFAULT_PASSWORD`、`CPR_POSTGRES_PASSWORD`、`CPR_REDIS_PASSWORD` 三项均为必填,
留空时 Compose 会在启动前失败。管理员密码必须至少 12 位且不能是常见弱口令。
Redis 默认密码为 `codex_proxy`,与 `deploy/config.example.yaml` 的连接串一致。生产部署通过
`CPR_REDIS_PASSWORD` 覆盖后,必须同步更新 `.runtime/config.yaml` 中 URL 的密码(特殊字符需做 URL 编码)。
本地 Rust 集成测试若复用该 Compose Redis,设置
`CPR_TEST_REDIS_URL=redis://:codex_proxy@127.0.0.1:6379/`。
Compose 会把 PostgreSQL、Redis 密码的同一个值同时传给服务端和应用;应用使用 URL API 覆盖连接串
密码,并用管理员密码覆盖 `admin.default_password`。因此无需修改 `.runtime/config.yaml` 中的密码,
特殊字符也无需手工编码。本地 Rust 集成测试若复用该 Compose 服务,应使用与 `deploy/.env` 一致的
测试连接串。
编辑密码时保留 `deploy/.env` 中的单引号,使 `#`、`$` 等 Compose 语法字符按字面值传递。
如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,确保反代正常传递 `CF-Connecting-IP`、`X-Real-IP` 或 `X-Forwarded-For`。后端会按这组头自动解析真实客户端 IP;没有这些头时回落到直连 peer IP。
构建并启动:
```bash
docker compose -f deploy/docker-compose.yml build
docker compose -f deploy/docker-compose.yml up -d
# 先编辑 deploy/.env,设置全部三项密码。
docker compose --env-file deploy/.env -f deploy/docker-compose.yml build
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
```
可以通过环境变量覆盖宿主机路径。容器内始终通过 `CPR_CONFIG_FILE=/app/config.yaml` 读取挂载后的配置:
@@ -42,7 +52,7 @@ docker compose -f deploy/docker-compose.yml up -d
CPR_CONFIG_FILE=/path/to/config.yaml \
CPR_DATA_DIR=/path/to/data \
CPR_LOG_DIR=/path/to/logs \
docker compose -f deploy/docker-compose.yml up -d
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d
```
## 构建
@@ -56,7 +66,7 @@ docker build -f deploy/Dockerfile -t codex-proxy-rs:latest .
也可以用 Compose 构建:
```bash
docker compose -f deploy/docker-compose.yml build
docker compose --env-file deploy/.env -f deploy/docker-compose.yml build
```
需要写入版本元数据时:
@@ -65,5 +75,5 @@ docker compose -f deploy/docker-compose.yml build
CPR_VERSION="$(ruby -ryaml -e 'puts YAML.load_file("release/version.yaml").fetch("version").delete_prefix("v")')" \
CPR_GIT_SHA="$(git rev-parse HEAD)" \
CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
docker compose -f deploy/docker-compose.yml build
docker compose --env-file deploy/.env -f deploy/docker-compose.yml build
```
+1 -1
View File
@@ -84,7 +84,7 @@ fingerprint:
admin:
# 管理端登录会话有效期,单位分钟。
session_ttl_minutes: 1440
# 首次启动创建的默认管理员账号。default_password 必须改成至少 12 位的非默认密码。
# 首次启动创建的默认管理员账号。Docker 部署通过 CPR_ADMIN_DEFAULT_PASSWORD 注入密码。
default_username: admin@cpr.local
default_password: ''
+11
View File
@@ -0,0 +1,11 @@
services:
postgres:
ports: !reset []
redis:
ports: !reset []
codex-proxy-rs:
container_name: ${COMPOSE_PROJECT_NAME}-app
ports: !override
- "127.0.0.1:${CPR_SMOKE_PORT:-18080}:8080"
+8 -5
View File
@@ -8,12 +8,12 @@ x-json-logging: &json-logging
services:
postgres:
image: postgres:18-alpine
image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
restart: unless-stopped
environment:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:-codex_proxy}
POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env}
volumes:
- postgres-data:/var/lib/postgresql
ports:
@@ -28,7 +28,7 @@ services:
logging: *json-logging
redis:
image: redis:8-alpine
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
restart: unless-stopped
command:
[
@@ -36,7 +36,7 @@ services:
"--appendonly",
"yes",
"--requirepass",
"${CPR_REDIS_PASSWORD:-codex_proxy}",
"${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}",
]
volumes:
- redis-data:/data
@@ -49,7 +49,7 @@ services:
"redis-cli",
"--no-auth-warning",
"-a",
"${CPR_REDIS_PASSWORD:-codex_proxy}",
"${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}",
"ping",
]
interval: 5s
@@ -105,6 +105,9 @@ services:
HOME: /app
XDG_DATA_HOME: /app/data
CPR_CONFIG_FILE: /app/config.yaml
CPR_ADMIN_DEFAULT_PASSWORD: ${CPR_ADMIN_DEFAULT_PASSWORD:?CPR_ADMIN_DEFAULT_PASSWORD must be set in deploy/.env}
CPR_POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env}
CPR_REDIS_PASSWORD: ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_UPDATE_CHANNEL: stable
+25 -10
View File
@@ -170,16 +170,17 @@ push 与 pull request 使用和 CI 相同的 head repository、branch name concu
### 5.1 单一配置来源
`AppConfig` 保持当前 YAML 结构,新增两个可选的运行时环境变量:
`AppConfig` 保持当前 YAML 结构,新增三个可选的运行时环境变量:
- `CPR_ADMIN_DEFAULT_PASSWORD`
- `CPR_POSTGRES_PASSWORD`
- `CPR_REDIS_PASSWORD`
加载顺序固定为:
1. 严格解析完整 YAML;
2. 读取两个可选环境变量;
3. 使用 `url::Url` 仅替换 `database.url`、`redis.url` 的 password 部分;
2. 读取三个可选环境变量;
3. 直接覆盖 `admin.default_password`,使用 `url::Url` 仅替换 `database.url`、`redis.url` 的 password 部分;
4. 返回最终 `AppConfig`。
禁止用字符串拼接或替换 URL。用户名、scheme、host、port、path、query 均保持 YAML 原值。密码按原始字节内容交给 URL API 编码,不 trim;空值视为无效配置。URL 解析失败、URL 不支持密码或环境变量为空时,返回明确但不包含密码内容的配置错误。
@@ -190,16 +191,17 @@ push 与 pull request 使用和 CI 相同的 head repository、branch name concu
Compose 对数据库服务与应用服务使用完全相同的展开值:
- PostgreSQL 的 `POSTGRES_PASSWORD` 与应用的 `CPR_POSTGRES_PASSWORD` 都来自 `${CPR_POSTGRES_PASSWORD:-codex_proxy}`。
- Redis 的 `--requirepass`、healthcheck 与应用的 `CPR_REDIS_PASSWORD` 都来自 `${CPR_REDIS_PASSWORD:-codex_proxy}`。
- 管理员初始密码来自必填的 `CPR_ADMIN_DEFAULT_PASSWORD`;
- PostgreSQL 的 `POSTGRES_PASSWORD` 与应用的 `CPR_POSTGRES_PASSWORD` 都来自必填的 `CPR_POSTGRES_PASSWORD`;
- Redis 的 `--requirepass`、healthcheck 与应用的 `CPR_REDIS_PASSWORD` 都来自必填的 `CPR_REDIS_PASSWORD`。
因此默认配置无需修改 URL;生产环境只设置两个 Compose 变量,不再人工编辑 YAML 密码。`deploy/config.example.yaml` 保留可独立运行的默认 URL,环境变量在加载后安全覆盖其中的 password。
`deploy/.env` 定义为 Docker 部署唯一的密钥配置文件,必须被 Git 忽略且 mode 为 `0600`。三项变量任一缺失或为空时,Compose 在创建容器前失败。仓库提供仅含空值的 `deploy/.env.example`,不再人工编辑 YAML 密码;环境变量在加载后覆盖管理员密码和两个 URL 的 password。
### 5.3 bind mount 权限
部署文档必须给出 Linux 首次初始化命令,并明确以下权限:
- `.runtime/data`、`.runtime/logs` 归 UID/GID `10001:10001` 所有且不可被其他用户写入;
- `.runtime/data`、`.runtime/logs` 归宿主部署用户所有,group 为容器 GID 10001,mode 为 `0770`;宿主文件共享进程可访问,容器用户可通过组权限写入,其他用户无权限;
- `.runtime/config.yaml` 对容器 GID 10001 可读,对其他用户不可读;
- 修改配置后保持原 owner、group 和 mode。
@@ -207,11 +209,11 @@ Compose 对数据库服务与应用服务使用完全相同的展开值:
## 6. Compose 烟测
烟测使用临时工作目录和专用 Compose project,不能复用开发机 `.runtime/` 数据。配置文件通过 Ruby YAML API 从模板生成,写入至少 32 个 URL-safe 随机字符的管理员密码;禁止用 `sed` 修改 YAML。
烟测使用临时工作目录、专用 Compose project 和 `deploy/docker-compose.smoke.yml` override,不能复用开发机 `.runtime/` 数据。override 移除 PostgreSQL、Redis 的宿主端口,按 project name 隔离应用容器名,并把应用映射到专用烟测端口。配置文件直接复制模板,管理员密码和数据库密码均通过专用的 CI 环境变量注入。
PostgreSQL 与 Redis 密码必须包含 `@`、`:`、`/`、`?`、`#`、`%` 中的多种字符,以证明 URL 覆盖正确编码。执行顺序如下:
1. 创建 owner 为 `10001:10001`、mode 为 `0750` 的数据和日志目录;配置文件 mode 为 `0640`,owner 为 runner UID,group 为 10001;
1. 创建 owner 为 runner UID、group 为 10001、mode 为 `0770` 的数据和日志目录;配置文件 mode 为 `0640`,owner 为 runner UID,group 为 10001;
2. 只启动 PostgreSQL、Redis 并等待健康;
3. 分别写入 PostgreSQL 表探针和 Redis key;
4. 强制重建 PostgreSQL、Redis 服务容器;
@@ -220,7 +222,7 @@ PostgreSQL 与 Redis 密码必须包含 `@`、`:`、`/`、`?`、`#`、`%` 中的
7. 验证 `/healthz` 返回 HTTP 204;
8. 验证容器配置和进程实际 UID/GID 均为 `10001:10001`;
9. 验证 `/app/data`、`/app/logs` 可写;
10. 按日志级别或独立严重度单词扫描三个容器完整日志,不允许出现 WARN、WARNING、ERROR、FATAL、PANIC;字段名中的 `ops_error_logs` 等普通文本不构成失败;
10. 按日志级别扫描三个容器完整日志,不允许出现 WARN、WARNING、ERROR、FATAL、PANIC;应用检查结构化 `level`,PostgreSQL 检查 severity 前缀,Redis 检查 `#` warning marker 与独立严重度前缀,字段名中的 `ops_error_logs`、`bf-error-rate` 等普通文本不构成失败;
11. 向应用发送 SIGTERM,验证 30 秒内退出且退出码为 0。
失败时必须上传 Compose config、`docker compose ps -a`、inspect 结果和三个容器日志。清理步骤使用 `if: always()`,不得因前序失败遗留容器或命名卷。
@@ -318,3 +320,16 @@ Cosign 只签名本次实际创建的 tag。预发布版本不得在 manifest、
- 应用以非 root 身份健康启动并优雅退出;
- Rust、前端、workflow、漏洞扫描和运行日志均达到本文定义的 0 错误、0 警告;
- 所有测试代码位于 `backend/tests`,没有兼容路径和重复实现。
## 11. 实施验证结果
2026-07-11 本地验证结果:
- `git diff --check`、Rust fmt、全目标全特性 clippy `-D warnings`、actionlint 与 YAML 解析通过;
- 后端使用真实 PostgreSQL、Redis 完成 `634 passed, 0 failed`;前端格式、类型检查、构建与生产依赖审计通过;Cargo audit 无漏洞;
- 空的 `deploy/.env.example` 会在 Compose 插值阶段失败,三项特殊字符密码能够完整传递;
- `runtime` 镜像构建成功,镜像 ID 为 `sha256:bc6c2ae76be06e2ac7b64b7629413c6f3c8a214100175d3fcc416ff578b7af24`,Trivy HIGH/CRITICAL 为 0;
- 独立 Compose 烟测得到 `health=204`、UID/GID `10001:10001`、PostgreSQL/Redis 持久化通过、退出码 0、日志无警告和错误;
- 本机部署已迁移到 mode `0600` 的 `deploy/.env`,YAML 管理员密码为空;只替换应用容器,PostgreSQL、Redis 容器和数据未变。
远端 reusable workflow 权限、SARIF、GHA cache、artifact 与 Release job 依赖仍须在本次提交推送后由 GitHub Actions 证明。