505 lines
27 KiB
Bash
Executable File
505 lines
27 KiB
Bash
Executable File
#!/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 镜像)"
|