Files

505 lines
27 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.
#!/bin/sh
set -eu
umask 077
# Standalone public customer bootstrap. It never checks out application source
# and it never pulls an Auth image.
REGISTRY=${OVERCOOKED_REGISTRY:-git.leee.xin/overcooked}
VERSION=${OVERCOOKED_VERSION:-latest}
ROOT=${OVERCOOKED_ROOT:-/opt/overcooked}
SERVER_IP=${OVERCOOKED_SERVER_IP:-}
API_PORT=${OVERCOOKED_API_PORT:-8100}
WEB_PORT=${OVERCOOKED_WEB_PORT:-8111}
E_PORT=${OVERCOOKED_E_PORT:-8096}
ACTIVATION_CODE=${OVERCOOKED_ACTIVATION_CODE:-}
AUTH_URL=${OVERCOOKED_AUTH_URL:-http://148.135.97.10:18091}
AUTH_PUBLIC_KEYS=${OVERCOOKED_AUTH_PUBLIC_KEYS:-}
INSTALLER_BASE_URL=${OVERCOOKED_INSTALLER_BASE_URL:-https://git.leee.xin/OverCooked/OverCooked-Installer/raw/branch/main}
S_COMPOSE=$ROOT/compose/s.yml
E_COMPOSE=$ROOT/compose/e.yml
ENV_FILE=$ROOT/production.env
S_PROJECT=overcooked-s
E_PROJECT=overcooked-e
FAILURE_REPORTED=0
S_POSTGRES_PASSWORD=not-generated
S_VALKEY_PASSWORD=not-generated
S_INTERNAL_SHARED_SECRET=not-generated
S_ACCESS_TOKEN_SECRET=not-generated
S_SETUP_TOKEN=not-generated
E_POSTGRES_PASSWORD=not-generated
E_PLAYBACK_TICKET_SECRET=not-generated
E_PLAYBACK_LEASE_KEY=not-generated
E_STORAGE_CREDENTIAL_KEY=not-generated
E_PLUGIN_SECRET_KEY=not-generated
RESET_FAILED_INSTALL=${OVERCOOKED_RESET_FAILED_INSTALL:-0}
CONFIRM_NO_BUSINESS_DATA=${OVERCOOKED_CONFIRM_NO_BUSINESS_DATA:-}
die() { echo "overcooked-install: $*" >&2; exit 1; }
is_ipv4() {
old_ifs=$IFS; IFS=.; set -- $1; IFS=$old_ifs
[ "$#" = 4 ] || return 1
for octet in "$@"; do
case "$octet" in ''|*[!0-9]*) return 1 ;; esac
[ "$octet" -le 255 ] 2>/dev/null || return 1
done
}
is_private_ipv4() {
second=$(printf '%s\n' "$1" | cut -d. -f2)
case "$1" in
0.*|10.*|127.*|169.254.*|192.168.*|100.64.*|100.65.*|100.66.*|100.67.*|100.68.*|100.69.*|100.70.*|100.71.*|100.72.*|100.73.*|100.74.*|100.75.*|100.76.*|100.77.*|100.78.*|100.79.*|100.8*.*|100.9*.*) return 0 ;;
172.*) [ "$second" -ge 16 ] 2>/dev/null && [ "$second" -le 31 ] 2>/dev/null; return ;;
esac
return 1
}
check_port() {
port=$1
command -v ss >/dev/null 2>&1 || die "未找到 ss,无法安全检测端口 $port;请安装 iproute2 后重试"
if ss -H -ltn 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" || $4 ~ p "\\]$" { found=1 } END { exit !found }'; then
die "端口 $port 已被占用,请停止占用进程或调整部署端口"
fi
}
redact_logs() {
sed -e "s|$S_POSTGRES_PASSWORD|[REDACTED]|g" \
-e "s|$S_VALKEY_PASSWORD|[REDACTED]|g" \
-e "s|$S_INTERNAL_SHARED_SECRET|[REDACTED]|g" \
-e "s|$S_ACCESS_TOKEN_SECRET|[REDACTED]|g" \
-e "s|$S_SETUP_TOKEN|[REDACTED]|g" \
-e "s|$E_POSTGRES_PASSWORD|[REDACTED]|g" \
-e "s|$E_PLAYBACK_TICKET_SECRET|[REDACTED]|g" \
-e "s|$E_PLAYBACK_LEASE_KEY|[REDACTED]|g" \
-e "s|$E_STORAGE_CREDENTIAL_KEY|[REDACTED]|g" \
-e "s|$E_PLUGIN_SECRET_KEY|[REDACTED]|g"
}
show_failure() {
[ "$FAILURE_REPORTED" = 1 ] && return 0
FAILURE_REPORTED=1
[ -f "$ENV_FILE" ] || return 0
echo "安装失败,以下是脱敏后的容器状态和最近日志:" >&2
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" ps >&2 || true
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" ps >&2 || true
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true
}
on_exit() { rc=$?; [ "$rc" -eq 0 ] || show_failure; exit "$rc"; }
trap on_exit EXIT
[ "$(id -u)" = 0 ] || die "请使用 sudo/root 执行"
command -v docker >/dev/null 2>&1 || die "未找到 Docker Engine"
docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2"
command -v curl >/dev/null 2>&1 || die "未找到 curl"
command -v openssl >/dev/null 2>&1 || die "未找到 openssl"
command -v tar >/dev/null 2>&1 || die "未找到 tar"
command -v mktemp >/dev/null 2>&1 || die "未找到 mktemp"
for pair in "OVERCOOKED_REGISTRY:$REGISTRY" "OVERCOOKED_VERSION:$VERSION" "OVERCOOKED_ROOT:$ROOT" "OVERCOOKED_AUTH_URL:$AUTH_URL" "OVERCOOKED_INSTALLER_BASE_URL:$INSTALLER_BASE_URL"; do
name=${pair%%:*}; value=${pair#*:}; [ -n "$value" ] || die "$name 不能为空"
case "$value" in *' '*|*' '*|*'\r'*|*'\n'*) die "$name 包含非法空白字符" ;; esac
done
case "$REGISTRY" in *://*|*/|*//*|*=*) die "OVERCOOKED_REGISTRY 必须是镜像 Registry 路径" ;; esac
case "$VERSION" in */*|*:*|*[!A-Za-z0-9._-]*) die "OVERCOOKED_VERSION 格式无效" ;; esac
case "$ROOT" in /*) ;; *) die "OVERCOOKED_ROOT 必须是绝对路径" ;; esac
case "$INSTALLER_BASE_URL" in *://*) ;; *) die "Installer 地址必须是 HTTP(S) URL" ;; esac
if [ -z "$SERVER_IP" ]; then
command -v ip >/dev/null 2>&1 || die "未找到 ip,请设置 OVERCOOKED_SERVER_IP"
SERVER_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
[ -n "$SERVER_IP" ] || die "无法识别服务器公网 IPv4,请设置 OVERCOOKED_SERVER_IP"
EXPLICIT_SERVER_IP=0
else
EXPLICIT_SERVER_IP=1
fi
is_ipv4 "$SERVER_IP" || die "仅支持有效 IPv4 地址:$SERVER_IP"
if [ "$EXPLICIT_SERVER_IP" = 0 ] && is_private_ipv4 "$SERVER_IP"; then
die "检测到私有地址,请设置 OVERCOOKED_SERVER_IP=公网IPv4"
fi
[ -n "$AUTH_PUBLIC_KEYS" ] || die "请设置 OVERCOOKED_AUTH_PUBLIC_KEYS(Auth 公钥 JSON)"
case "$AUTH_PUBLIC_KEYS" in \{*\}) ;; *) die "OVERCOOKED_AUTH_PUBLIC_KEYS 必须是 JSON 对象" ;; esac
case "$AUTH_PUBLIC_KEYS" in *'\n'*|*'\r'*) die "OVERCOOKED_AUTH_PUBLIC_KEYS 不得包含换行" ;; esac
if [ -z "$ACTIVATION_CODE" ]; then
[ -t 0 ] || die "授权码不能为空,请设置 OVERCOOKED_ACTIVATION_CODE"
printf '请输入授权码: ' >&2; IFS= read -r ACTIVATION_CODE
fi
[ -n "$ACTIVATION_CODE" ] || die "授权码不能为空"
check_port "$API_PORT"; check_port "$WEB_PORT"; check_port "$E_PORT"
install -d -m 700 "$ROOT" "$ROOT/config" "$ROOT/compose" "$ROOT/migrations" "$ROOT/node-images" "$ROOT/control-plane"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
fetch_asset() {
relative=$1; destination=$2; source="$SCRIPT_DIR/$relative"
if [ -f "$source" ]; then install -m 600 "$source" "$destination"; return; fi
temporary=$(mktemp "$destination.download.XXXXXX")
curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 "$INSTALLER_BASE_URL/$relative" -o "$temporary" || die "下载公开 Installer 资产失败:$relative"
[ -s "$temporary" ] || die "公开 Installer 资产为空:$relative"
install -m 600 "$temporary" "$destination"; rm -f "$temporary"
}
fetch_asset compose/s.yml "$S_COMPOSE"
fetch_asset compose/e.yml "$E_COMPOSE"
fetch_asset s-migrate.sh "$ROOT/s-migrate.sh"
chmod 644 "$ROOT/s-migrate.sh"
fetch_asset config/e-server.production.yaml "$ROOT/config/e-server.production.yaml"
chmod 644 "$ROOT/config/e-server.production.yaml"
fetch_asset s-migrations.tar.gz "$ROOT/s-migrations.tar.gz"
tar -xzf "$ROOT/s-migrations.tar.gz" -C "$ROOT/migrations"; rm -f "$ROOT/s-migrations.tar.gz"
find "$ROOT/migrations" -type f ! -name '*.up.sql' -delete
find "$ROOT/migrations" -type f -name '*.up.sql' -exec chmod 644 {} \;
chmod 755 "$ROOT/migrations" "$ROOT/node-images"
# E requires an HTTPS control-plane origin in production. Generate a local
# CA and service/client certificates for the restricted proxy sidecar. The
# proxy is generated locally and runs from the already-approved S WebUI image;
# no additional image or source checkout is needed.
CONTROL_PLANE_DIR="$ROOT/control-plane"
cat > "$CONTROL_PLANE_DIR/control-proxy.mjs" <<'NODE_PROXY'
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
const listenPort = 8443;
const options = {
key: fs.readFileSync("/run/overcooked-control-plane/server.key"),
cert: fs.readFileSync("/run/overcooked-control-plane/server.crt"),
ca: fs.readFileSync("/run/overcooked-control-plane/ca.crt"),
requestCert: true,
rejectUnauthorized: true,
minVersion: "TLSv1.2",
};
function reject(response, status, message) {
response.writeHead(status, {"content-type": "text/plain; charset=utf-8"});
response.end(message);
}
const server = https.createServer(options, (request, response) => {
let target;
let decodedPath;
try {
target = new URL(request.url ?? "/", "https://control-proxy");
decodedPath = decodeURIComponent(target.pathname);
} catch {
reject(response, 400, "invalid request target");
return;
}
if (target.origin !== "https://control-proxy" || target.username || target.password ||
decodedPath.includes("..") || decodedPath.includes("\u0000") ||
decodedPath !== "/health/live" && !decodedPath.startsWith("/internal/v1/")) {
reject(response, 404, "not found");
return;
}
if (decodedPath === "/health/live") {
response.writeHead(200, {"content-type": "application/json"});
response.end('{"status":"live"}');
return;
}
const headers = {...request.headers};
delete headers.host;
delete headers.connection;
delete headers["x-forwarded-for"];
delete headers["x-forwarded-host"];
delete headers["x-forwarded-proto"];
headers.host = "s-control:8080";
const upstream = http.request({hostname: "s-control", port: 8080, method: request.method, path: `${target.pathname}${target.search}`, headers, timeout: 10000}, (upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
});
upstream.on("timeout", () => upstream.destroy(new Error("upstream timeout")));
upstream.on("error", () => {
if (!response.headersSent) reject(response, 502, "control plane unavailable");
else response.destroy();
});
request.pipe(upstream);
});
server.listen(listenPort, "0.0.0.0");
NODE_PROXY
chmod 644 "$CONTROL_PLANE_DIR/control-proxy.mjs"
chown 1000:1000 "$CONTROL_PLANE_DIR/control-proxy.mjs"
openssl genrsa -out "$CONTROL_PLANE_DIR/ca.key" 3072
openssl req -x509 -new -nodes -key "$CONTROL_PLANE_DIR/ca.key" -sha256 -days 3650 -subj "/CN=OverCooked customer control plane CA" -out "$CONTROL_PLANE_DIR/ca.crt"
openssl genrsa -out "$CONTROL_PLANE_DIR/server.key" 2048
openssl req -new -key "$CONTROL_PLANE_DIR/server.key" -subj "/CN=control-proxy" -out "$CONTROL_PLANE_DIR/server.csr"
printf '%s\n' 'subjectAltName=DNS:control-proxy,DNS:product-e' 'extendedKeyUsage=serverAuth' > "$CONTROL_PLANE_DIR/server.ext"
openssl x509 -req -in "$CONTROL_PLANE_DIR/server.csr" -CA "$CONTROL_PLANE_DIR/ca.crt" -CAkey "$CONTROL_PLANE_DIR/ca.key" -CAcreateserial -out "$CONTROL_PLANE_DIR/server.crt" -days 825 -sha256 -extfile "$CONTROL_PLANE_DIR/server.ext"
openssl genrsa -out "$CONTROL_PLANE_DIR/client.key" 2048
openssl req -new -key "$CONTROL_PLANE_DIR/client.key" -subj "/CN=overcooked-e" -out "$CONTROL_PLANE_DIR/client.csr"
printf '%s\n' 'extendedKeyUsage=clientAuth' > "$CONTROL_PLANE_DIR/client.ext"
openssl x509 -req -in "$CONTROL_PLANE_DIR/client.csr" -CA "$CONTROL_PLANE_DIR/ca.crt" -CAkey "$CONTROL_PLANE_DIR/ca.key" -CAcreateserial -out "$CONTROL_PLANE_DIR/client.crt" -days 825 -sha256 -extfile "$CONTROL_PLANE_DIR/client.ext"
rm -f "$CONTROL_PLANE_DIR/ca.key" "$CONTROL_PLANE_DIR/server.csr" "$CONTROL_PLANE_DIR/server.ext" "$CONTROL_PLANE_DIR/client.csr" "$CONTROL_PLANE_DIR/client.ext" "$CONTROL_PLANE_DIR/ca.srl"
chmod 644 "$CONTROL_PLANE_DIR/ca.crt" "$CONTROL_PLANE_DIR/server.crt" "$CONTROL_PLANE_DIR/client.crt"
chmod 640 "$CONTROL_PLANE_DIR/server.key" "$CONTROL_PLANE_DIR/client.key"
chown 1000:1000 "$CONTROL_PLANE_DIR/server.key"
# The proxy healthcheck (uid 1000) and E (uid 10001) both need the client key;
# a shared group keeps it unreadable to other container users.
chown 1000:10001 "$CONTROL_PLANE_DIR/client.key"
chmod 755 "$CONTROL_PLANE_DIR"
secret() { openssl rand -hex 32; }
read_existing_secret() {
secret_name=$1
secret_count=$(awk -v key="$secret_name" 'index($0, key "=") == 1 { count++ } END { print count + 0 }' "$ENV_FILE")
[ "$secret_count" = 1 ] || { echo "production.env 中缺少或重复的 $secret_name" >&2; return 1; }
secret_value=$(awk -v key="$secret_name" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$ENV_FILE")
case "$secret_value" in ''|*[!0-9A-Fa-f]*) echo "production.env 中的 $secret_name 不是安全的 hex 值" >&2; return 1 ;; esac
[ "${#secret_value}" = 64 ] || { echo "production.env 中的 $secret_name 长度无效" >&2; return 1; }
printf '%s\n' "$secret_value"
}
read_existing_secret_alias() {
primary_name=$1; legacy_name=$2
primary_count=$(awk -v key="$primary_name" 'index($0, key "=") == 1 { count++ } END { print count + 0 }' "$ENV_FILE")
legacy_count=$(awk -v key="$legacy_name" 'index($0, key "=") == 1 { count++ } END { print count + 0 }' "$ENV_FILE")
if [ "$primary_count" = 1 ] && [ "$legacy_count" = 0 ]; then
read_existing_secret "$primary_name"
elif [ "$primary_count" = 0 ] && [ "$legacy_count" = 1 ]; then
read_existing_secret "$legacy_name"
else
echo "production.env 中的 $primary_name/$legacy_name 缺少、重复或同时存在" >&2
return 1
fi
}
if [ -f "$ENV_FILE" ]; then
# Re-runs must reuse every generated credential that initialized existing
# volumes. Do not execute arbitrary production.env content.
S_POSTGRES_PASSWORD=$(read_existing_secret S_POSTGRES_PASSWORD) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 S_POSTGRES_PASSWORD"
S_VALKEY_PASSWORD=$(read_existing_secret S_VALKEY_PASSWORD) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 S_VALKEY_PASSWORD"
S_INTERNAL_SHARED_SECRET=$(read_existing_secret S_INTERNAL_SHARED_SECRET) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 S_INTERNAL_SHARED_SECRET"
S_ACCESS_TOKEN_SECRET=$(read_existing_secret S_ACCESS_TOKEN_SECRET) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 S_ACCESS_TOKEN_SECRET"
S_SETUP_TOKEN=$(read_existing_secret S_SETUP_TOKEN) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 S_SETUP_TOKEN"
E_POSTGRES_PASSWORD=$(read_existing_secret_alias PRODUCT_E_POSTGRES_PASSWORD E_POSTGRES_PASSWORD) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 Product E 数据库密码"
E_PLAYBACK_TICKET_SECRET=$(read_existing_secret_alias PRODUCT_E_PLAYBACK_TICKET_SECRET E_PLAYBACK_TICKET_SECRET) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 Product E 播放票据密钥"
E_PLAYBACK_LEASE_KEY=$(read_existing_secret_alias PRODUCT_E_PLAYBACK_LEASE_KEY E_PLAYBACK_LEASE_KEY) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 Product E 租约密钥"
E_STORAGE_CREDENTIAL_KEY=$(read_existing_secret_alias PRODUCT_E_STORAGE_CREDENTIAL_KEY E_STORAGE_CREDENTIAL_KEY) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 Product E 存储凭据密钥"
E_PLUGIN_SECRET_KEY=$(read_existing_secret_alias PRODUCT_E_PLUGIN_SECRET_KEY E_PLUGIN_SECRET_KEY) || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法安全读取 Product E 插件密钥"
else
S_POSTGRES_PASSWORD=$(secret); S_VALKEY_PASSWORD=$(secret)
S_INTERNAL_SHARED_SECRET=$(secret); S_ACCESS_TOKEN_SECRET=$(secret); S_SETUP_TOKEN=$(secret)
E_POSTGRES_PASSWORD=$(secret); E_PLAYBACK_TICKET_SECRET=$(secret); E_PLAYBACK_LEASE_KEY=$(secret)
E_STORAGE_CREDENTIAL_KEY=$(secret); E_PLUGIN_SECRET_KEY=$(secret)
fi
INSTANCE_SUFFIX=$(hostname 2>/dev/null | tr -cd '[:alnum:]' | cut -c1-24); [ -n "$INSTANCE_SUFFIX" ] || INSTANCE_SUFFIX=customer
cat > "$ENV_FILE" <<EOF
OVERCOOKED_ROOT=$ROOT
S_CONTROL_IMAGE=$REGISTRY/overcooked-s-control:$VERSION
S_WEBUI_IMAGE=$REGISTRY/overcooked-s-webui:$VERSION
S_POSTGRES_PASSWORD=$S_POSTGRES_PASSWORD
S_VALKEY_PASSWORD=$S_VALKEY_PASSWORD
S_INTERNAL_KEY_ID=customer-s
S_INTERNAL_SHARED_SECRET=$S_INTERNAL_SHARED_SECRET
S_ACCESS_TOKEN_SECRET=$S_ACCESS_TOKEN_SECRET
S_SETUP_TOKEN=$S_SETUP_TOKEN
S_E_BASE_URL=http://e-server:8096
S_E_PUBLIC_URL=http://$SERVER_IP:$E_PORT
# Product E's internal API is protected by the same control-plane verifier
# configured as PRODUCT_E_CONTROL_PLANE_KEY_ID. S must therefore sign its
# S->E requests with that verifier key id (the shared secret is also shared).
# Keep this aligned so first-run license activation does not receive 401.
S_E_KEY_ID=customer-s
S_E_INTERNAL_SECRET=$S_INTERNAL_SHARED_SECRET
# S's private backend client talks to E on the internal Docker network. This
# does not weaken E's production mTLS control-plane connection below.
S_E_ALLOW_INSECURE_HTTP=true
S_AUTH_BASE_URL=$AUTH_URL
S_AUTH_ALLOW_INSECURE_HTTP=$(case "$AUTH_URL" in http://*) echo true ;; *) echo false ;; esac)
S_AUTH_PUBLIC_KEYS=$AUTH_PUBLIC_KEYS
S_INSTANCE_ID=s-$INSTANCE_SUFFIX
S_PLATFORM=linux-amd64
S_VERSION=$VERSION
S_PUBLIC_HOST=$SERVER_IP
S_PORTAL_SERVER_URL=http://$SERVER_IP:$API_PORT
S_PORTAL_WEB_CLIENT_URL=http://$SERVER_IP:$WEB_PORT/login
S_API_PORT_MAPPING=0.0.0.0:$API_PORT:8080
S_WEB_PORT_MAPPING=0.0.0.0:$WEB_PORT:3000
S_TURNSTILE_SITE_KEY=${OVERCOOKED_TURNSTILE_SITE_KEY:-}
S_TURNSTILE_SECRET_KEY=${OVERCOOKED_TURNSTILE_SECRET_KEY:-}
S_NODE_IMAGE_NAME=$REGISTRY/overcooked-media-node:$VERSION
PRODUCT_E_IMAGE=$REGISTRY/overcooked-e:$VERSION
PRODUCT_E_HTTP_PORT_MAPPING=0.0.0.0:$E_PORT:$E_PORT
PRODUCT_E_INSTANCE_ID=e-$INSTANCE_SUFFIX
PRODUCT_E_DATABASE_URL=postgres://product_e:$E_POSTGRES_PASSWORD@postgres:5432/product_e?sslmode=disable
PRODUCT_E_POSTGRES_PASSWORD=$E_POSTGRES_PASSWORD
PRODUCT_E_VALKEY_PASSWORD=$S_VALKEY_PASSWORD
PRODUCT_E_CONTROL_PLANE_URL=https://control-proxy:8443
PRODUCT_E_CONTROL_PLANE_KEY_ID=customer-s
PRODUCT_E_CONTROL_PLANE_SHARED_SECRET=$S_INTERNAL_SHARED_SECRET
PRODUCT_E_CONTROL_PLANE_TLS_CERT_FILE=/run/overcooked-control-plane/client.crt
PRODUCT_E_CONTROL_PLANE_TLS_KEY_FILE=/run/overcooked-control-plane/client.key
PRODUCT_E_CONTROL_PLANE_TLS_CA_FILE=/run/overcooked-control-plane/ca.crt
PRODUCT_E_LICENSE_ENABLED=true
PRODUCT_E_AUTH_URL=$AUTH_URL
PRODUCT_E_AUTH_ALLOW_INSECURE_HTTP=$(case "$AUTH_URL" in http://*) echo true ;; *) echo false ;; esac)
PRODUCT_E_AUTH_ACTIVATION_CODE=$ACTIVATION_CODE
PRODUCT_E_AUTH_PUBLIC_KEYS=$AUTH_PUBLIC_KEYS
PRODUCT_E_PLAYBACK_TICKET_KEY_ID=customer-playback
PRODUCT_E_PLAYBACK_TICKET_SECRET=$E_PLAYBACK_TICKET_SECRET
PRODUCT_E_PLAYBACK_LEASE_KEY=$E_PLAYBACK_LEASE_KEY
PRODUCT_E_STORAGE_CREDENTIAL_KEY_ID=customer-storage
PRODUCT_E_STORAGE_CREDENTIAL_KEY=$E_STORAGE_CREDENTIAL_KEY
PRODUCT_E_PLUGIN_SECRET_KEY_ID=customer-plugin
PRODUCT_E_PLUGIN_SECRET_KEY=$E_PLUGIN_SECRET_KEY
PRODUCT_E_PLUGIN_TRUSTED_PUBLISHERS={}
EOF
chmod 600 "$ENV_FILE"
cat > "$ROOT/config/e-server.production.yaml" <<EOF
service:
name: overcooked-e
instance_id: e-$INSTANCE_SUFFIX
environment: production
emby:
server_name: OverCooked
server_id: 10000000-0000-4000-8000-000000000001
compatibility_version: 4.8.0.80
control_plane:
base_url: https://s-control.invalid
key_id: customer-s
shared_secret: replace-by-environment
timeout: 10s
server:
address: 0.0.0.0:8096
database:
url: postgres://product_e:replace-by-environment@postgres:5432/product_e?sslmode=disable
valkey:
address: valkey:6379
observability:
log_level: info
trace_sample_ratio: 0.05
playback:
node_id: 10000000-0000-4000-8000-000000000001
plugins:
enabled: true
root: /data/plugins
sdk_socket: unix:///data/plugins/runtime/core.sock
core_version: 0.1.0
unsigned_policy: require-signature
trusted_publishers: {}
secret_key_id: customer-plugin
previous_secret_keys: {}
network_allowed_domains: []
network_allowed_cidrs: []
EOF
chmod 644 "$ROOT/config/e-server.production.yaml"
docker network inspect overcooked-control >/dev/null 2>&1 || docker network create overcooked-control >/dev/null
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" config >/dev/null
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" config >/dev/null
MEDIA_NODE_IMAGE="$REGISTRY/overcooked-media-node:$VERSION"
for image in "$REGISTRY/overcooked-e:$VERSION" "$REGISTRY/overcooked-s-control:$VERSION" "$REGISTRY/overcooked-s-webui:$VERSION" "$MEDIA_NODE_IMAGE"; do
docker pull "$image"
docker image inspect "$image" >/dev/null 2>&1 || die "镜像校验失败:$image"
done
image_tar=$(mktemp "$ROOT/node-images/media-node.tar.XXXXXX")
image_archive=$(mktemp "$ROOT/node-images/media-node.tar.gz.XXXXXX")
docker save "$MEDIA_NODE_IMAGE" -o "$image_tar" || die "无法导出 Media Node 镜像归档"
gzip -c "$image_tar" > "$image_archive" || die "无法压缩 Media Node 镜像归档"
rm -f "$image_tar"
mv "$image_archive" "$ROOT/node-images/media-node.tar.gz"
chmod 644 "$ROOT/node-images/media-node.tar.gz"
wait_for_compose_health() {
compose_file=$1; project=$2; service=$3; attempt=1
while [ "$attempt" -le 60 ]; do
if ! container=$(docker compose --env-file "$ENV_FILE" -f "$compose_file" -p "$project" ps -q "$service" 2>/dev/null); then
die "$service 容器状态查询失败"
fi
status=''
if [ -n "$container" ]; then
if ! status=$(docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null); then
die "$service 容器健康状态查询失败"
fi
fi
[ "$status" = healthy ] && return 0
[ "$status" = unhealthy ] && die "$service 容器健康检查失败"
sleep 2
attempt=$((attempt + 1))
done
die "$service 容器健康检查超时"
}
reset_e_postgres_volume() {
compose_file=$1; project=$2
database_label=E; expected_volume=e-postgres
[ "$RESET_FAILED_INSTALL" = 1 ] && [ "$CONFIRM_NO_BUSINESS_DATA" = YES ] || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: $database_label 数据库密码不匹配;如确认卷中无业务数据,请同时设置 OVERCOOKED_RESET_FAILED_INSTALL=1 OVERCOOKED_CONFIRM_NO_BUSINESS_DATA=YES"
container=$(docker compose --env-file "$ENV_FILE" -f "$compose_file" -p "$project" ps -q postgres 2>/dev/null || true)
[ -n "$container" ] || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 找不到 $database_label postgres 容器"
volume=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' "$container" 2>/dev/null || true)
case "$volume" in ''|*[!A-Za-z0-9_.-]*) die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法确认 $database_label 数据卷" ;; esac
volume_project=$(docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$volume" 2>/dev/null || true)
volume_service=$(docker volume inspect -f '{{index .Labels "com.docker.compose.volume"}}' "$volume" 2>/dev/null || true)
[ "$volume_project" = "$project" ] && [ "$volume_service" = "$expected_volume" ] || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: $database_label 数据卷标签不匹配,拒绝删除"
echo "已收到显式无业务数据确认,将删除失败安装的 $database_label PostgreSQL 卷:$volume" >&2
docker compose --env-file "$ENV_FILE" -f "$compose_file" -p "$project" rm -sf postgres
docker volume rm "$volume" >/dev/null || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 无法删除 $database_label 数据卷 $volume"
}
check_e_database_role() {
database_output=''
if database_output=$(printf 'postgres:5432:product_e:product_e:%s\n' "$E_POSTGRES_PASSWORD" | docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" exec -T postgres sh -ceu 'umask 077; cat > /tmp/.overcooked-pgpass; PGPASSFILE=/tmp/.overcooked-pgpass psql -U product_e -d product_e -v ON_ERROR_STOP=1 -Atqc "SELECT current_user"; status=$?; rm -f /tmp/.overcooked-pgpass; exit "$status"' 2>&1); then
[ "$database_output" = product_e ] || { echo "$database_output" | redact_logs >&2; return 1; }
return 0
fi
echo "$database_output" | redact_logs >&2
return 1
}
check_s_database_role() {
database_output=''
if database_output=$(printf 'postgres:5432:product_s:product_s:%s\n' "$S_POSTGRES_PASSWORD" | docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" exec -T postgres sh -ceu 'umask 077; cat > /tmp/.overcooked-pgpass; PGPASSFILE=/tmp/.overcooked-pgpass psql -U product_s -d product_s -v ON_ERROR_STOP=1 -Atqc "SELECT current_user"; status=$?; rm -f /tmp/.overcooked-pgpass; exit "$status"' 2>&1); then
[ "$database_output" = product_s ] || { echo "$database_output" | redact_logs >&2; return 1; }
return 0
fi
echo "$database_output" | redact_logs >&2
return 1
}
prepare_e_database() {
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d postgres valkey
wait_for_compose_health "$E_COMPOSE" "$E_PROJECT" postgres
if check_e_database_role; then return 0; fi
if [ "$RESET_FAILED_INSTALL" = 1 ] && [ "$CONFIRM_NO_BUSINESS_DATA" = YES ]; then
reset_e_postgres_volume "$E_COMPOSE" "$E_PROJECT"
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d postgres
wait_for_compose_health "$E_COMPOSE" "$E_PROJECT" postgres
check_e_database_role || die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: 重置后仍无法使用固定 product_e 角色认证"
return 0
fi
die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: product_e 角色密码不匹配;未删除数据库卷"
}
prepare_s_database() {
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d postgres valkey
wait_for_compose_health "$S_COMPOSE" "$S_PROJECT" postgres
if check_s_database_role; then return 0; fi
die "DATABASE_PASSWORD_MISMATCH_NEEDS_MANUAL_RECOVERY: product_s 角色密码不匹配;未删除数据库卷"
}
prepare_e_database
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" run --rm --no-deps e-server migrate -config /etc/overcooked-e/config.yaml
prepare_s_database
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" run --rm --no-deps s-migrate
docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d s-control control-proxy webui
wait_for_compose_health "$S_COMPOSE" "$S_PROJECT" control-proxy
docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d e-server
wait_for() {
label=$1; url=$2; attempt=1
while [ "$attempt" -le 60 ]; do
curl -fsS --max-time 5 "$url" >/dev/null 2>&1 && return 0
sleep 2; attempt=$((attempt + 1))
done
die "$label 健康检查失败:$url"
}
wait_for "S live" http://127.0.0.1:$API_PORT/health/live
wait_for "S ready" http://127.0.0.1:$API_PORT/health/ready
wait_for "E live" http://127.0.0.1:$E_PORT/health/live
wait_for "WebUI" http://127.0.0.1:$WEB_PORT/
wait_for "Setup" http://127.0.0.1:$WEB_PORT/setup
echo "OverCooked 客户部署已通过容器健康检查。"
echo "WebUI: http://$SERVER_IP:$WEB_PORT/"
echo "API: http://$SERVER_IP:$API_PORT/"
echo "E: http://$SERVER_IP:$E_PORT/"
echo "首次初始化: http://$SERVER_IP:$WEB_PORT/setup"
echo "Setup Token 已保存于 $ENV_FILE(不会在终端显示,请限制文件访问权限)。"
echo "镜像来源:$REGISTRY(仅公开 E、S Control、S WebUI、Media Node;不包含 Auth 镜像)"