mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: harden deployment and CI pipeline
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# Frontend dependencies and build output.
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
.pnpm-store
|
||||
|
||||
# Application runtime state.
|
||||
*.runtime
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
|
||||
@@ -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,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
@@ -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 证明。
|
||||
|
||||
Reference in New Issue
Block a user