Files
codex-proxy-rs/deploy/install.sh
T
2026-11-05 00:48:19 +08:00

370 lines
11 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
# codex-proxy-rs 一键安装脚本。
# 新安装使用同一个 Release 的 compose.yaml 与 config.example.yaml;
# 已存在 deploy/config.yaml 时保留整套部署文件,不执行版本升级。
REPO="zyycn/codex-proxy-rs"
CPR_RELEASE_TAG="${CPR_RELEASE_TAG:-}"
INSTALL_DIR="${INSTALL_DIR:-$PWD/codex-proxy-rs}"
DEPLOY_DIR="${INSTALL_DIR}/deploy"
RUNTIME_DIR="${INSTALL_DIR}/.runtime"
CONFIG_EXAMPLE="${DEPLOY_DIR}/config.example.yaml"
CONFIG_FILE="${DEPLOY_DIR}/config.yaml"
COMPOSE_FILE="${DEPLOY_DIR}/compose.yaml"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
GREEN='\033[1;32m'
YELLOW='\033[1;33m'
RED='\033[1;31m'
CYAN='\033[1;36m'
NC='\033[0m'
log() {
printf '%b[+]%b %s\n' "$GREEN" "$NC" "$*"
}
info() {
printf '%b[*]%b %s\n' "$CYAN" "$NC" "$*"
}
warn() {
printf '%b[!]%b %s\n' "$YELLOW" "$NC" "$*" >&2
}
die() {
printf '%b[x]%b %s\n' "$RED" "$NC" "$*" >&2
exit 1
}
on_error() {
local code=$?
printf '\n' >&2
warn "部署失败,退出码:${code}"
warn "安装目录已保留:${INSTALL_DIR}"
if [[ -f "$COMPOSE_FILE" ]]; then
warn "可执行以下命令查看状态:"
printf ' cd %q && docker compose -f deploy/compose.yaml ps\n' "$INSTALL_DIR" >&2
printf ' cd %q && docker compose -f deploy/compose.yaml logs --tail=200\n' "$INSTALL_DIR" >&2
fi
exit "$code"
}
trap on_error ERR
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "缺少命令:$1"
}
run_root() {
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
die "该步骤需要 root 权限,但系统没有 sudo;请使用 root 用户运行。"
fi
}
prepare_install_dir() {
if ! mkdir -p -- "$INSTALL_DIR" 2>/dev/null; then
run_root install -d -m 0750 -o "$(id -u)" -g "$(id -g)" -- "$INSTALL_DIR"
fi
mkdir -p -- "$DEPLOY_DIR"
}
resolve_release_tag() {
local release_url
if [[ -z "$CPR_RELEASE_TAG" ]]; then
release_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' \
"https://github.com/${REPO}/releases/latest")"
CPR_RELEASE_TAG="${release_url%/}"
CPR_RELEASE_TAG="${CPR_RELEASE_TAG##*/}"
fi
[[ "$CPR_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] \
|| die "无法确定有效的发布版本:${CPR_RELEASE_TAG:-<空>}"
}
download() {
local url="$1"
local output="$2"
curl \
--fail \
--location \
--silent \
--show-error \
--retry 3 \
--connect-timeout 15 \
"$url" \
--output "$output"
}
download_release_files() {
local base_url
resolve_release_tag
base_url="https://github.com/${REPO}/releases/download/${CPR_RELEASE_TAG}"
log "下载 ${CPR_RELEASE_TAG} compose.yaml"
download "${base_url}/compose.yaml" "$COMPOSE_FILE"
log "下载 ${CPR_RELEASE_TAG} config.example.yaml"
download "${base_url}/config.example.yaml" "$CONFIG_EXAMPLE"
grep -q '^name: codex-proxy-rs$' "$COMPOSE_FILE" \
|| die "下载到的 compose.yaml 内容异常。"
grep -q '^schema_version:' "$CONFIG_EXAMPLE" \
|| die "下载到的 config.example.yaml 内容异常。"
}
random_password() {
openssl rand -hex 24
}
validate_admin_password() {
local password="$1"
local normalized
if (( ${#password} < 12 )); then
die "ADMIN_PASSWORD 至少需要 12 个字符。"
fi
if [[ "$password" == *'$'* ]]; then
die 'ADMIN_PASSWORD 不能包含 $ 字符。'
fi
if [[ "$password" =~ [[:cntrl:]] ]]; then
die "ADMIN_PASSWORD 不能包含换行或其他控制字符。"
fi
normalized="$(printf '%s' "$password" | tr '[:upper:]' '[:lower:]')"
if [[ "$normalized" == "codex-proxy-rs" ]]; then
die "ADMIN_PASSWORD 不能使用常见弱口令。"
fi
}
yaml_single_quote() {
local value="${1//\'/\'\'}"
printf "'%s'" "$value"
}
patch_config() {
local postgres_password redis_password admin_password
local postgres_count=0 redis_count=0 admin_count=0
local line rendered=''
local postgres_pattern="^([[:blank:]]*password:[[:blank:]]*&postgres_password[[:blank:]]*)''[[:blank:]]*$"
local redis_pattern="^([[:blank:]]*password:[[:blank:]]*&redis_password[[:blank:]]*)''[[:blank:]]*$"
local admin_pattern="^([[:blank:]]*default_password:[[:blank:]]*)''[[:blank:]]*$"
postgres_password="$(yaml_single_quote "$1")"
redis_password="$(yaml_single_quote "$2")"
admin_password="$(yaml_single_quote "$3")"
# 按模板中的空密码占位替换;先完整检查,再写文件,避免留下半份配置。
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ $postgres_pattern ]]; then
line="${BASH_REMATCH[1]}${postgres_password}"
postgres_count=$((postgres_count + 1))
elif [[ "$line" =~ $redis_pattern ]]; then
line="${BASH_REMATCH[1]}${redis_password}"
redis_count=$((redis_count + 1))
elif [[ "$line" =~ $admin_pattern ]]; then
line="${BASH_REMATCH[1]}${admin_password}"
admin_count=$((admin_count + 1))
fi
rendered+="$line"$'\n'
done < "$CONFIG_EXAMPLE"
if (( postgres_count != 1 || redis_count != 1 || admin_count != 1 )); then
die "config.example.yaml 结构与安装脚本预期不一致,已停止以免生成错误配置。"
fi
printf '%s' "$rendered" > "$CONFIG_FILE"
}
service_user_ids() {
local service="$1"
local user="$2"
local uid
local gid
uid="$(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml run --rm --no-deps --entrypoint id "$service" -u "$user"
)"
gid="$(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml run --rm --no-deps --entrypoint id "$service" -g "$user"
)"
[[ "$uid" =~ ^[0-9]+$ ]] || die "无法读取 ${service} 容器用户 ${user} 的 UID。"
[[ "$gid" =~ ^[0-9]+$ ]] || die "无法读取 ${service} 容器用户 ${user} 的 GID。"
printf '%s:%s\n' "$uid" "$gid"
}
fix_runtime_permissions() {
local postgres_ids
local redis_ids
local app_ids
local app_gid
log "读取容器用户 UID/GID"
postgres_ids="$(service_user_ids postgres postgres)"
redis_ids="$(service_user_ids redis redis)"
app_ids="$(service_user_ids codex-proxy-rs cpr)"
app_gid="${app_ids#*:}"
info "PostgreSQL postgres = ${postgres_ids}"
info "Redis redis = ${redis_ids}"
info "codex-proxy-rs cpr = ${app_ids}"
log "设置运行目录权限"
run_root chown -R "$postgres_ids" -- "${RUNTIME_DIR}/postgres"
run_root chmod 0750 -- "${RUNTIME_DIR}/postgres"
run_root chown -R "$redis_ids" -- "${RUNTIME_DIR}/redis"
run_root chmod 0750 -- "${RUNTIME_DIR}/redis"
# 只设置顶层目录,保留应用创建的私有目录与文件的所有权,确保重复安装后仍可读写
run_root chown "$(id -u):${app_gid}" -- "${RUNTIME_DIR}/data" "${RUNTIME_DIR}/logs"
run_root chmod 0770 -- "${RUNTIME_DIR}/data" "${RUNTIME_DIR}/logs"
run_root chown "$(id -u):${app_gid}" -- "$CONFIG_FILE"
run_root chmod 0640 -- "$CONFIG_FILE"
}
check_health() {
local status
status="$(curl \
--silent \
--output /dev/null \
--write-out '%{http_code}' \
--max-time 10 \
http://127.0.0.1:8080/healthz || true)"
[[ "$status" == "204" ]]
}
main() {
local existing_config=0
local postgres_password
local redis_password
printf '\n'
printf '%s\n' '============================================='
printf '%s\n' ' codex-proxy-rs 一键部署'
printf '%s\n' ' 新安装使用同一 Release 的官方部署文件'
printf '%s\n' '============================================='
printf '\n'
[[ "$(uname -s)" == "Linux" ]] || die "此脚本仅支持 Linux。"
need_cmd curl
need_cmd docker
need_cmd install
need_cmd openssl
docker info >/dev/null 2>&1 \
|| die "Docker daemon 不可用;请先安装、启动 Docker,并确认当前用户可以访问。"
docker compose version >/dev/null 2>&1 \
|| die "未检测到 Docker Compose v2;请确保 'docker compose' 可用。"
prepare_install_dir
if [[ -e "$CONFIG_FILE" ]]; then
existing_config=1
[[ -f "$COMPOSE_FILE" && -f "$CONFIG_EXAMPLE" ]] \
|| die "已有 config.yaml,但 compose.yaml 或 config.example.yaml 缺失。"
warn "发现已有配置:${CONFIG_FILE}"
info "将保留现有 config.yaml、compose.yaml 和 config.example.yaml,不执行版本升级。"
if [[ -n "$ADMIN_PASSWORD" ]]; then
warn "已有 config.yaml,因此忽略本次传入的 ADMIN_PASSWORD。"
fi
else
download_release_files
postgres_password="$(random_password)"
redis_password="$(random_password)"
if [[ -z "$ADMIN_PASSWORD" ]]; then
ADMIN_PASSWORD="$(openssl rand -hex 16)"
fi
validate_admin_password "$ADMIN_PASSWORD"
log "写入密码配置"
patch_config "$postgres_password" "$redis_password" "$ADMIN_PASSWORD"
fi
log "创建运行目录"
mkdir -p -- \
"${RUNTIME_DIR}/postgres" \
"${RUNTIME_DIR}/redis" \
"${RUNTIME_DIR}/data" \
"${RUNTIME_DIR}/logs"
log "验证 Docker Compose 配置"
(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml config --quiet
)
log "拉取容器镜像"
(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml pull
)
fix_runtime_permissions
log "启动服务"
(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml up -d --no-build --wait
)
log "检查容器状态"
(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml ps
)
info "验证健康检查接口"
if ! check_health; then
warn "Compose 已启动,但 http://127.0.0.1:8080/healthz 未返回 204。"
warn "最近日志:"
(
cd "$INSTALL_DIR"
docker compose -f deploy/compose.yaml logs --tail=100 codex-proxy-rs
) || true
return 1
fi
printf '\n'
printf '%b%s%b\n' "$GREEN" '=============================================' "$NC"
printf '%b%s%b\n' "$GREEN" ' 部署完成' "$NC"
printf '%b%s%b\n' "$GREEN" '=============================================' "$NC"
printf '\n'
printf '访问地址: http://127.0.0.1:8080\n'
printf '管理员用户名: admin@cpr.local\n'
if [[ "$existing_config" -eq 0 ]]; then
printf '管理员密码: %s\n' "$ADMIN_PASSWORD"
else
printf '管理员密码: 未更改\n'
fi
printf '安装目录: %s\n' "$INSTALL_DIR"
printf '配置文件: %s\n' "$CONFIG_FILE"
printf '\n'
printf '常用命令:\n'
printf ' cd %q\n' "$INSTALL_DIR"
printf ' docker compose -f deploy/compose.yaml ps\n'
printf ' docker compose -f deploy/compose.yaml logs -f codex-proxy-rs\n'
printf ' docker compose -f deploy/compose.yaml restart codex-proxy-rs\n'
printf ' docker compose -f deploy/compose.yaml down\n'
printf '\n'
if [[ "$existing_config" -eq 0 ]]; then
printf '%b请立即保存管理员密码。%b\n' "$YELLOW" "$NC"
fi
printf '%b%s%b\n' "$YELLOW" \
'Compose 默认只把 8080 绑定到 127.0.0.1;公网访问请配置 HTTPS 反向代理。' "$NC"
printf '\n'
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi