topfans/k8s/scripts/fill-values-prod.sh
2026-07-06 13:26:46 +08:00

512 lines
27 KiB
Bash
Executable File

#!/bin/bash
# ===================================================================
# 交互式生成 values-prod.yaml (K8s helm 真值文件)
# ===================================================================
# 目的:
# - 避免把真密码 / API Key 写进 example 文件 (且 AI 永远不该看到)
# - 引导式输入, **未填的字段占位为 __FILL_ME__**, 不阻断流程
# - 文件权限 600 (owner only), .gitignore 确认
# - 写完会列出仍待填的占位符, 用户 $EDITOR 补齐
# - 可选: sops 加密后写 .sops.yaml (加密的可入 git)
#
# 使用:
# ./k8s/scripts/fill-values-prod.sh # 交互 (推荐, 空字段占位)
# ./k8s/scripts/fill-values-prod.sh --strict # 严格模式: 必填字段空 → exit 1
# ./k8s/scripts/fill-values-prod.sh --non-interactive # 全用环境变量 (CI 用, 仍严格)
# ./k8s/scripts/fill-values-prod.sh --dry-run # 只看不写
# ./k8s/scripts/fill-values-prod.sh --sops # 写完后 sops 加密
#
# 占位符语义:
# __FILL_ME__ 必填, helm install 会失败, 部署前必须替换
# (留空) 可选, helm install 用 default 值
#
# 真值来源 (与生产 / 团队 1Password 对照):
# * RDS endpoint 阿里云 RDS 控制台 → 数据库连接 → 内网地址
# * ElastiCache 阿里云 Redis 控制台 → 连接地址
# * DB / Redis 密码 RDS / Redis 实例设置
# * JWT Secret 现有 docker/.env.prod 里的 JWT_SECRET
# * OSS keys 阿里云 RAM 控制台 (子账号 AccessKey)
# * DIFY_API_BASE/KEY Dify 工作室 → 工作室 API → API 密钥
# * OPENAI_API_KEY 微达API 控制台
# * SMS_ACCESS_KEY_ID 阿里云短信服务控制台
# ===================================================================
set -euo pipefail
# set 模式三件套:
# -e: 任何命令非 0 退出立刻 abort (避免脚本"半路成功"的诡异状态)
# -u: 引用未定义变量时报错 (避免 var=$VAR 在 VAR 没设时悄悄变成空)
# -o pipefail: 管道链上任一环节失败 → 整链失败 (否则 `cmd1 | cmd2`
# 只要 cmd2 成功就 exit 0, 隐藏 cmd1 的失败, 比如 `gitleaks | tail`)
# **坑**: set -u 时 `${!var}` 这种间接引用要写 `${!var:-}`,
# 否则 var 没定义时直接 abort (见 prompt_required 里的写法)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ${BASH_SOURCE[0]} 是当前脚本自身路径 (source 时为调用者),
# dirname 取目录, cd+ls 解析软链/相对路径 → 拿到**真实**绝对路径。
# 这样无论用户从哪个 cwd 跑 `bash /path/to/this.sh`, SCRIPT_DIR 都是脚本所在目录。
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# SCRIPT_DIR 是 k8s/scripts/, 往上一级是 k8s/, 再上一级是仓库根。
# 这个变量专门给 .gitignore 写入路径用, 不用 helm chart 路径因为可能跨项目。
CHART_DIR="$REPO_ROOT/k8s/helm/topfans"
EXAMPLE_FILE="$CHART_DIR/values-prod.example.yaml"
# 该文件作为**参考模板** (空占位结构), 真值由本脚本生成到 OUTPUT_FILE。
# 注意: 我**没有**让脚本自动 cp example, 因为 example 写假值容易误部署。
OUTPUT_FILE="$CHART_DIR/values-prod.yaml"
# 真值输出文件, .gitignore 已屏蔽, chmod 600 由本脚本设。
# ---------- CLI 参数 ----------
DRY_RUN=false # --dry-run: 只 echo 内容不写文件 (调试用)
NON_INTERACTIVE=false # --non-interactive: 全靠环境变量读, 不交互; 隐含 STRICT=true
STRICT=false # --strict: 必填字段空时 exit 1 (而不是写占位符)
USE_SOPS=false # --sops: 写完后用 SOPS 加密成 .sops.yaml (可入 git)
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--non-interactive|--ci) NON_INTERACTIVE=true; STRICT=true ;;
# CI 流水线里如果允许"留空写占位符"会导致 helm install 后
# 才报错, 太晚; 故 CI 必填字段缺失直接 abort。
--strict) STRICT=true ;;
# 交互模式手动开严格, 留空会 exit 1 而非占位。
--sops) USE_SOPS=true ;;
-h|--help)
# 用 sed 抽文件开头 30 行做 manpage, 任何改 header 都会自动反映
sed -n '2,30p' "$0" | sed 's/^# \?//'
exit 0 ;;
*)
echo "未知参数: $arg"; exit 1 ;;
esac
done
# ---------- 颜色 ----------
# ANSI 控制序列。 `-e` 让 echo 解释 `\033` 转义。
# 颜色在 macOS Terminal / iTerm / VS Code 终端都有效; 但在 dumb / redirected
# 输出时会显示成 [\033 之类的乱码 — 这种场景要靠 `tput` 检查 TTY 后再决定是否染色。
# 本脚本只在交互 + 日志输出里染色, 写到文件的内容不带颜色, 所以乱码不会进 values-prod.yaml。
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# NC (No Color) 必须每次 reset, 否则一个 print 染色后所有后续 echo 都染色。
print_step() { echo -e "\n${CYAN}━━ $1 ━━${NC}"; }
print_warn() { echo -e "${YELLOW}$1${NC}"; }
print_err() { echo -e "${RED}$1${NC}"; }
print_ok() { echo -e "${GREEN}$1${NC}"; }
# ---------- 占位符常量 ----------
PLACEHOLDER='__FILL_ME__'
# 唯一强占位符, helm template 时会原样输出, helm install 时 K8s Admission 会拒
# (因为敏感字段不能为空)。这种"自我校验"机制让运维**部署前**就能发现有没有漏填。
# 唯一**不**当作占位符的字符串: 空串 "" — 空串可能是合法的可选值。
PLACEHOLDER_OPT='(留空, 用 chart default)'
# 目前没用到 (prompt_optional 默认逻辑是不写占位符, 而是让 helm fallback), 留着给以后文档用
# ---------- pre-check: not already present ----------
# 在大量 prompt 之前问一声, 避免写到一半才发现要保留旧文件 → 用户已经覆盖了半天输入。
# CI / dry-run 跳过确认 (CI 没交互, dry-run 不写文件)
if [ -f "$OUTPUT_FILE" ] && [ "$DRY_RUN" = false ] && [ "$NON_INTERACTIVE" = false ]; then
print_warn "values-prod.yaml 已存在, 继续会覆盖!"
read -rp "继续? (yes/no): " confirm
# 用 yes/no 而非 y/n 是为了避免"敲错键"误覆盖 (验证过 yes 才继续)
[ "$confirm" = "yes" ] || { print_warn "已取消"; exit 0; }
fi
# ---------- 待填占位符记录 ----------
# **Bug 历史**: 之前用 bash 数组 PENDING+=(), 但 prompt_* 在 $(...) command
# substitution 里调用, subshell 改的全局变量不会传回父 shell, 导致 summary
# 永远显示"全部已填"。修复: 改用临时文件。
PENDING_FILE="$(mktemp -t fill-pending.XXXXXX)"
# mktemp -t name.XXXXXX → /tmp/name.XXXXXX, 随机后缀避免冲突。
# 在 macOS 默认 /tmp/, Linux 默认 $TMPDIR(/tmp/)。
trap 'rm -f "$PENDING_FILE"' EXIT
# trap ... EXIT 在脚本退出 (包括 abort/err) 时都执行, 保证不留垃圾。
# 关键: 不能用 trap ... INT, 这样 ctrl-c 才不会触发清理, 但脚本会卡死。
# EXIT 是兜底。
> "$PENDING_FILE" # 清空 (mktemp 创建的可能已有 stale 残留下文)
# 把待补字段记到文件, 行格式: section_key|comment|nature(nature=必填/可选)
# 用 | 分隔: 用户**写**的不是分隔符, 字段名是固定的点路径, 没有 |。
record_pending() {
local section_key="$1" comment="$2" nature="$3"
printf '%s|%s|%s\n' "$section_key" "$comment" "$nature" >> "$PENDING_FILE"
}
# ---------- helpers ----------
# 两个 prompt 函数的语义:
# prompt_required — 必填, 留空 → 写 __FILL_ME__ + 加入 PENDING_FILE 标 "必填"
# prompt_optional — 可选, 留空 → 用 chart default; 在 prompt 处明确告诉用户
# "留空用 default 还是留空跳过", 用户可控
#
# 共同的间接引用: ${!var} 是 bash 的 "间接变量" 语法。
# 写法: var="FOO"; FOO=bar; echo "${!var}" → bar
# 用 `${!var:-}` + `-n` 测试, 是 set -u 兼容: var 没定义时返回空字符串, 不报错。
prompt_required() {
# 参数:
# $1 var — 在 CI/--strict 模式下读的环境变量名 (例如 DB_PASSWORD)
# $2 desc — 交互模式下显示的提示语 (例: 'Postgres 密码')
# $3 section_key — YAML 路径 (例: secrets.db.password), 用于 PENDING_FILE 记录
# $4 secret — 是否隐藏输入 (true/false), 密码字段用 true 走 `read -s`
local var="$1" desc="$2" section_key="$3" secret="${4:-false}"
local val prompt_str="$desc: "
# prompt_str 默认用 ": ", 后续 secret=true 时 -s 模式不带 prefix prompt 也行, 但保留
if [ "$NON_INTERACTIVE" = true ] || [ "$STRICT" = true ]; then
# CI / strict 路径: 不交互, 直接尝试读环境变量
if [ -n "${!var:-}" ]; then
# 找到了 → 输出真值
printf '%s' "${!var}"
return
elif [ "$NON_INTERACTIVE" = true ]; then
# CI 缺 var → 不可恢复, abort
print_err "$section_key 必填 (CI 模式: env $var 未设)"
exit 1
fi
# 走到这里是 STRICT=true 且 interactive 且 env $var 没设:
# 不 abort, 走下面的 read, 但**最后**校验非空。
fi
# 交互模式 (默认, 或 --strict 但 env $var 未设)
# read -r: 不要 backslash escape (默认值), 不加 -r 的话 \n 会变成 \\
# read -sp: -s 隐藏输入字符 (密码不回显); 注意 -s 在 macOS 上 minor bug,
# 偶发会丢首字符, 用 `read -s`, 再 `echo`, 然后 trim 即可
if [ "$secret" = true ]; then
read -rsp "$prompt_str" val || val=""
# `|| val=""`: read 失败 (Ctrl-D EOF / Ctrl-C 中断) 时不让 set -e 炸掉
echo # 隐藏模式下 read 不自动换行, 我们手动 echo 一个
else
read -rp "$prompt_str" val || val=""
fi
if [ -z "$val" ]; then
# 用户敲了空回车 → 占位 + 记到 PENDING_FILE
record_pending "$section_key" "$desc" "必填"
printf '%s' "$PLACEHOLDER"
else
printf '%s' "$val"
fi
}
prompt_optional() {
# 参数:
# $1 var — CI/--strict 模式下的环境变量名
# $2 desc — 提示语
# $3 default — 内置 default 值 (从 chart 同步过来), 用户敲回车用这个
# $4 section_key — YAML 路径, 留空时记 PENDING_FILE (可选)
#
# 行为对比 prompt_required:
# - 不会写 PLACEHOLDER 占位符, 而是写空串或 default
# - 留空时 PENDING_FILE 标 "可选", summary 显示 🟡 而不是 ⛔
# - 这样 chart 默认值可以"自动"接管, 即使这个 prompt 永远不交互
local var="$1" desc="$2" default="${3:-}" section_key="${4:-}"
local val prompt_str
if [ "$NON_INTERACTIVE" = true ]; then
# CI: env $var 优先 → default → 完全空 (helm 会用 chart 内置 default)
if [ -n "${!var:-}" ]; then
printf '%s' "${!var}"
elif [ -n "$default" ]; then
printf '%s' "$default"
else
printf '%s' "" # 留空, helm 会用 chart 内的 default / required 拦截
fi
return
fi
# 交互模式: 显示提示包含 default + 留空说明
if [ -n "$default" ]; then
prompt_str="$desc [$default, 留空用 default]: "
else
prompt_str="$desc [留空跳过]: "
fi
read -rp "$prompt_str" val || val=""
if [ -z "$val" ]; then
if [ -n "$default" ]; then
# 用户敲回车, 用 default
printf '%s' "$default"
elif [ -n "$section_key" ]; then
# 给 section_key 的可选字段留空: helm 收到空串而非 default,
# 仍记录到 PENDING_FILE (summary 用), 但标 🟡
record_pending "$section_key" "$desc" "可选"
printf '%s' ""
else
printf '%s' "" # 真正无配置 (与 helm fallback 一致)
fi
else
printf '%s' "$val"
fi
}
# ---------- input collection ----------
# 共 4 个章节, 22 个字段, 按"依赖关系"先通用后专用:
# 1/4 全球通用 — DB / Redis 连接信息
# 2/4 Gateway / 应用 — JWT / image tag / 域名
# 3/4 OSS / 短信 — 阿里云资源访问凭据
# 4/4 AI (镭射卡 / 对话) — 外部 AI 服务凭据
# 这种分组让运维心里有数, 而不是随便顺序问 30 个问题。
print_step "1/4 全球通用"
DB_HOST_INTERNAL=$(prompt_required DB_HOST_INTERNAL 'Postgres RDS 内网地址 (例: rm-xxx.mysql.rds.aliyuncs.com)' 'global.dbExternalEndpoint')
# DB_HOST 在 K8s 内部固定是 `postgres` (ExternalName), 这里要填的是 RDS **外网**
# 内网地址: K8s Pod 用这个解析到 RDS (Pod 不出外网, 必须用阿里云 VPC 内网地址)。
# 阿里云控制台: RDS → 实例 → 数据库连接 → 内网地址 (以 .mysql.rds.aliyuncs.com 结尾)。
DB_PORT=$(prompt_optional DB_PORT 'Postgres 端口' '5432' 'global.dbPort')
DB_NAME=$(prompt_optional DB_NAME '数据库名' 'topfans' 'global.dbName')
DB_USER=$(prompt_optional DB_USER '用户名' 'postgres' 'global.dbUser')
DB_PASSWORD=$(prompt_required DB_PASSWORD 'Postgres 密码' 'secrets.db.password' true)
REDIS_HOST_INTERNAL=$(prompt_required REDIS_HOST_INTERNAL 'Redis ElastiCache 内网地址 (例: r-xxx.redis.rds.aliyuncs.com)' 'global.redisExternalEndpoint')
# 同上, K8s 内部 `redis` 短名对应 ExternalName, 实际值要填阿里云 Redis 内网地址。
# 在 Redis 控制台 → 实例 → 连接管理 → 内网地址。
REDIS_PORT=$(prompt_optional REDIS_PORT 'Redis 端口' '6379' 'global.redisPort')
REDIS_PASSWORD=$(prompt_required REDIS_PASSWORD 'Redis 密码' 'secrets.redis.password' true)
print_step "2/4 Gateway / 应用"
JWT_SECRET=$(prompt_required JWT_SECRET 'JWT_SECRET (32 字符以上, 与 docker/.env.prod 的值保持一致)' 'secrets.jwt.secret' true)
# ⚠️ 关键: JWT Secret 一旦改, 所有已签发的 token 全部失效, 用户会全部退出登录。
# **必须**与现有 docker/.env.prod 的 JWT_SECRET 保持一致 (除非主动全员踢出)。
# 在云上托管的密钥里取, 不要换新值 (会全站掉登录)。
IMAGE_TAG=$(prompt_optional IMAGE_TAG '所有服务 image tag (CI 决定后填, 例如 v1.0.0)' 'latest')
# 默认 latest 允许本地开发 (debian docker-compose), 生产必须填具体版本 (v1.0.0)。
# helm install 用 --set global.image.tag=$IMAGE_TAG 可以临时覆盖所有服务 (见 deploy.sh upgrade)。
INGRESS_HOST=$(prompt_optional INGRESS_HOST 'Ingress 域名' 'api.example.com' 'ingress.host')
# Ingress 域名, K8s nginx-ingress 会读这个生成 cert-manager Certificate CR (TLS 自动续期)。
# 部署前到阿里云 DNS 控制台把 A 记录指向 nginx-ingress-controller 的 EXTERNAL-IP。
print_step "3/4 OSS / 短信"
OSS_REGION=$(prompt_optional OSS_REGION 'OSS region' 'cn-shanghai' 'secrets.oss.region')
# OSS region (上海) 与 RDS region 不一定一致, 按实际 bucket 创建的 region 填。
OSS_BUCKET=$(prompt_required OSS_BUCKET 'OSS bucket 名 (例: top-fans-prod)' 'secrets.oss.bucket')
OSS_KEY_ID=$(prompt_required OSS_KEY_ID 'OSS AccessKey ID' 'secrets.oss.accessKeyId')
OSS_KEY_SECRET=$(prompt_required OSS_KEY_SECRET 'OSS AccessKey Secret' 'secrets.oss.accessKeySecret' true)
# ⚠️ 这些是 OSS 全权子账号 (有 RAM STS AssumeRole 拿临时 token 上传头像/资产生成)
# 强烈推荐**用子账号**而不是主账号 AccessKey, 子账号只授 OSS 写权限 + 受 IP 白名单。
# 取值: 阿里云 RAM 控制台 → 用户 → AccessKey。
OSS_STS_ROLE_ARN=$(prompt_optional OSS_STS_ROLE_ARN 'OSS STS Role ARN (留空跳过)' '' 'secrets.oss.stsRoleArn')
# ARN 格式: acs:ram::1387642798143585:role/top-fans-oss-user
# 没有可留空, 但前端的"STS 临时上传"会失败 (降级到 long-lived key, 不安全)。
SMS_KEY_ID=$(prompt_optional SMS_KEY_ID 'SMS AccessKey ID (阿里云短信, 留空跳过)' '' 'secrets.sms.accessKeyId')
SMS_KEY_SECRET=$(prompt_optional SMS_KEY_SECRET 'SMS AccessKey Secret (短信不用就留空)' '' 'secrets.sms.accessKeySecret' true)
SMS_SIGN_NAME=$(prompt_optional SMS_SIGN_NAME 'SMS 签名 (留空跳过)' '' 'secrets.sms.signName')
SMS_TEMPLATE=$(prompt_optional SMS_TEMPLATE 'SMS 模板代码 (留空跳过)' '' 'secrets.sms.templateCode')
# SMS 4 项是阿里云短信服务的凭据, 不开通短信服务 (初版用 uniCloud 推送替代) 可全部留空。
print_step "4/4 AI (镭射卡 / 对话)"
DIFY_API_BASE=$(prompt_optional DIFY_API_BASE 'DIFY_API_BASE' 'http://dify:5001/v1' 'secrets.ai.difyApiBase')
# Dify 地址, 默认是 K8s 内部的 `dify:5001` 短 DNS (Dify 也用 ExternalName 占位,
# 见模板 external-db/ 那一对), 实际生产可能是阿里云部署的 Dify 公网地址。
DIFY_API_KEY=$(prompt_required DIFY_API_KEY 'DIFY_API_KEY' 'secrets.ai.difyApiKey' true)
# 在 Dify 控制台: 工作室 → 角角(应用名) → 后端服务 API → API 密钥 (格式 app-xxxxx)。
OPENAI_BASE=$(prompt_optional OPENAI_BASE 'OpenAI 兼容 API base' 'https://api.weda.cc/v1' 'secrets.ai.openaiBaseUrl')
OPENAI_KEY=$(prompt_required OPENAI_KEY 'OpenAI 兼容 API Key' 'secrets.ai.openaiApiKey' true)
# 默认走微达API中转站 (OPENAI_BASE=weda.cc)。可在 dashboard 切换成其他兼容服务。
# 没有微达账号就手动填 OpenAI 直连地址 https://api.openai.com/v1。
OPENAI_MODEL=$(prompt_optional OPENAI_MODEL 'OpenAI model' 'gpt-image-2' 'secrets.ai.openaiModel')
# 镭射卡生成模型, 默认 gpt-image-2。生产可在 LASER_GEN_PROVIDER 切到 dify/minimax。
MINIMAX_KEY=$(prompt_optional MINIMAX_KEY 'MiniMax API Key (默认用 OpenAI, 不需要可留空)' '' 'secrets.ai.minimaxApiKey' true)
MINIMAX_URL=$(prompt_optional MINIMAX_URL 'MiniMax URL' 'https://api.minimaxi.com/v1/image_generation' 'secrets.ai.minimaxApiUrl')
# 当 LASER_GEN_PROVIDER=minimax 时启用, 默认 OpenAI, 这 2 项可留空。
# ---------- write file ----------
# 这里用 here-string (...="...") 直接构造 YAML 字符串, 而不是逐行 echo,
# 因为变量多且结构清晰, here-string 写出来最易读。
# 关键约定:
# 1. 所有"必填没填"的变量值都是 PLACEHOLDER='__FILL_ME__' (常量)
# → helm template 会原样输出 → K8s Admission Webhook 会拒
# → 部署前运维必须用 $EDITOR 替换这些字符串
# 2. 所有"可选没填"的变量值都是 "" (空字符串, 这是合法 YAML)
# → helm template 不会输出该字段 → chart 用内置 default
# 3. 每个 ⚠ 标记提醒: 这个字段一旦有真值就是凭据
#
# **不要改字段顺序**, 给运维/AI 看的约定俗成, 改字段顺序增大 PR review 难度。
OUTPUT_CONTENT="global:
dbHost: postgres # K8s 内部短名, 对应 ExternalName postgres → RDS endpoint
dbPort: \"$DB_PORT\" # 见 global.dbExternalEndpoint
dbUser: \"$DB_USER\"
dbName: \"$DB_NAME\"
dbExternalEndpoint: \"$DB_HOST_INTERNAL\" # ⚠ 真值: 阿里云 RDS 内网地址, 不入 git
redisHost: redis # 同上, ExternalName redis → ElastiCache endpoint
redisPort: \"$REDIS_PORT\"
redisExternalEndpoint: \"$REDIS_HOST_INTERNAL\" # ⚠ 真值
image:
registry: registry.cn-shanghai.aliyuncs.com # 阿里云 ACR (design doc §10.1 选项 A)
repositoryNamespace: topfans # 仓库 namespace
env: production
ginMode: release
logLevel: info
secrets:
db:
password: \"$DB_PASSWORD\" # ⚠ 真值
redis:
password: \"$REDIS_PASSWORD\" # ⚠ 真值
jwt:
secret: \"$JWT_SECRET\" # ⚠ 真值, 改了会让全站 token 失效
oss:
region: \"$OSS_REGION\"
bucket: \"$OSS_BUCKET\"
accessKeyId: \"$OSS_KEY_ID\" # ⚠ 真值: RAM 子账号 AccessKey
accessKeySecret: \"$OSS_KEY_SECRET\" # ⚠ 真值
stsRoleArn: \"$OSS_STS_ROLE_ARN\" # STS AssumeRole ARN (可选)
landingBaseUrl: \"https://$INGRESS_HOST\" # 分享服务生成的落地页链接前缀
ai:
difyApiBase: \"$DIFY_API_BASE\"
difyApiKey: \"$DIFY_API_KEY\" # ⚠ 真值
openaiApiKey: \"$OPENAI_KEY\" # ⚠ 真值
openaiBaseUrl: \"$OPENAI_BASE\"
openaiModel: \"$OPENAI_MODEL\"
minimaxApiKey: \"$MINIMAX_KEY\" # 可选
minimaxApiUrl: \"$MINIMAX_URL\" # 可选
sms:
accessKeyId: \"$SMS_KEY_ID\" # ⚠ 真值 (可选)
accessKeySecret: \"$SMS_KEY_SECRET\" # ⚠ 真值 (可选)
signName: \"$SMS_SIGN_NAME\"
templateCode: \"$SMS_TEMPLATE\"
# ---------------------------------------------------------------
# 所有服务的 image tag 用全局覆盖 (services.<x>.image.tag = global.image.tag);
# 单服务差异在 helm install 时
# 用 --set 临时覆盖 (例: --set services.gateway.replicas=5)
# ---------------------------------------------------------------
services: {}
"
if [ "$DRY_RUN" = true ]; then
# --dry-run 模式: 不写文件, 把内容 echo 给用户看, 让他"先看再决定"
# 经常用来调试: ./fill-values-prod.sh --dry-run | less
print_warn "DRY RUN: 不写文件, 上面是预览"
echo "$OUTPUT_CONTENT"
exit 0
fi
print_step "写文件"
echo -n "$OUTPUT_CONTENT" > "$OUTPUT_FILE"
# echo -n 不带末尾换行; 加上 here-string 末尾已有一个 \n, 总共一个换行, 是标准 YAML 风格。
# > 的本质是 truncate+write, 即使脚本中间 SIGINT 也能拿到部分文件 (避免破坏性 >|)
chmod 600 "$OUTPUT_FILE"
# 600 = -rw------- ; owner 可读写, group/other 无权限。
# 防止偶然的 `cat file` / `less file` / git status (本地) 暴漏真值。
# 注意: chmod 在 NTFS / FAT 文件系统上无效, 这是 Linux/Mac ext4/apfs 的特性。
print_ok "已写 $OUTPUT_FILE (权限 600, owner-only)"
# ---------- .gitignore 检查 ----------
# 即使剧本本身极小概率出错 (运维 cp 命令误填), 也要有兜底防止真值入 git。
# .gitignore 用 glob ** 匹配任何层级的 values-prod.yaml。
# 检测是否已存在该条目:
GITIGNORE="$REPO_ROOT/.gitignore"
if grep -qE '^k8s/helm/\*\*/values-prod\.yaml$' "$GITIGNORE" 2>/dev/null; then
print_ok ".gitignore 已排除 values-prod.yaml"
else
print_warn ".gitignore **似乎** 未排除 values-prod.yaml, 现在加"
# 非交互模式 (CI) 不自动改 .gitignore — 那是仓库层面的修改, 必须人工 review。
if [ "$NON_INTERACTIVE" = false ]; then
read -rp "(yes/no): " confirm
[ "$confirm" = "yes" ] && {
printf '\n# K8s 真值文件 (含 DB/AI 凭据, 不入 git)\nk8s/helm/**/values-prod.yaml\n' >> "$GITIGNORE"
print_ok "已加 .gitignore 条目"
}
fi
fi
# ---------- 泄漏扫描 ----------
# 兜底: 即使 chmod 600 / .gitignore 都对, 万一用户 vim 编辑时手滑推到 git
# 也是灾难。 这里调 gitleaks (首选) / detect-secrets 扫**刚生成**的 file
# 检查真值模式是否还像 placeholder (没填)。
# 注意: 第一次 gitleaks detect 是**整个仓库** scan, 不是单 file,
# 这通常 CI 跑; 本地想要只 scan 当前文件需要 detect-secrets。
if command -v gitleaks &>/dev/null; then
print_step "gitleaks 扫描"
# -v: 详细模式; --no-banner: 不要版本条; 2>&1: 把 stderr 合到 stdout
# `|| true`: 不让 `gitleaks 检测到 leak` (exit 1) 把整个脚本带 abort
# 因为这是 informational, 我们后面让运维自己处理
gitleaks detect --source . --no-banner -v 2>&1 | tail -10 || true
elif command -v detect-secrets &>/dev/null; then
print_step "detect-secrets 扫描"
detect-secrets scan "$OUTPUT_FILE" 2>&1 || true
else
print_warn "未装 gitleaks / detect-secrets, 手动核对:"
echo " git diff --no-color k8s/helm/topfans/values-prod.yaml | grep -E 'password|key|secret' | head"
# `head` 防泄漏太长; 仅看有没有"看起来是真值"的行
fi
# ---------- SOPS 加密 (可选) ----------
# 真值**明文**版的 values-prod.yaml 必须 600 权限 + 不入 git (前两步已做)。
# 但运维不方便于共享给队友时, 用 SOPS 加密成 .sops.yaml 可以入 git
# (加密内容读者无法解密, 但解密密钥在 KMS / PGP / age 中)。
# 加密 helm install 流程:
# sops --decrypt values-prod.sops.yaml > values-prod.yaml
# helm install topfans ./helm/topfans -f values-prod.yaml
if [ "$USE_SOPS" = true ]; then
if ! command -v sops &>/dev/null; then
print_err "sops 未装 (brew install sops)"
exit 1
fi
print_step "sops 加密"
SOPS_FILE="${OUTPUT_FILE%.yaml}.sops.yaml"
# 去掉末尾的 .yaml, 加 .sops.yaml 作后缀: values-prod.sops.yaml
sops --encrypt --in-place "$OUTPUT_FILE"
# --in-place 原地加密, 不保留明文
mv "$OUTPUT_FILE" "$SOPS_FILE"
chmod 600 "$SOPS_FILE"
print_ok "加密: $SOPS_FILE"
print_ok "解密: sops --decrypt $SOPS_FILE > $OUTPUT_FILE"
print_warn "生产部署时 SOPS_FILE 可以入 git (加密的)"
fi
print_step "✅ 完成"
# ---------- 待填占位符汇总 ----------
# 决策点: if [ -s FILE ] 检查文件 size > 0 (有内容 = 有 pending 项)。
# 不用 [ -f FILE ] 因为 mktemp 创建后已存在, -f 永远 true;
# 不用 ${#PENDING[@]} 因为可能有未处理的元素 (虽然我们用文件而非数组)。
if [ -s "$PENDING_FILE" ]; then
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ ⚠ 本次未填的字段, deploy 前必须补齐 ║"
echo "╚════════════════════════════════════════════════════════════════╝"
# 表格列宽: 字段名 40 字符 (足够 longest = secrets.ai.minimaxApiKey)
printf " %-40s %s\n" "字段" "说明"
printf " %-40s %s\n" "────" "────"
while IFS='|' read -r key desc nature; do
# IFS='|' 覆盖全局 IFS 为本条 read, 不会污染后续 read
if [ "$nature" = "必填" ]; then
marker="⛔ 必填"
else
marker="🟡 可选"
fi
printf " ${YELLOW}%-40s${NC} %s · %s\n" "$key" "$marker" "$desc"
# 重定向 < "$PENDING_FILE" 让 read 从文件读, 而不是 stdin
done < "$PENDING_FILE"
echo ""
print_warn "占位符 $PLACEHOLDER 已经写在文件里"
print_warn "补齐方式: \$EDITOR k8s/helm/topfans/values-prod.yaml"
print_warn "搜索: grep -n $PLACEHOLDER k8s/helm/topfans/values-prod.yaml"
print_warn "替换后通过 helm template dry-run 验证:"
print_warn " helm template topfans k8s/helm/topfans/ -f k8s/helm/topfans/values-prod.yaml --namespace topfans | grep $PLACEHOLDER"
print_warn " 没有输出 = 全部替换完"
# helm template + grep 是部署前的**硬性** sanity check:
# 1. 占位符没替换会原样输出, grep 应该能命中
# 2. 没命中 = 全部填好了, 可以 install
else
echo ""
print_ok "所有必填字段都已填, 可以直接 helm install"
fi
echo ""
echo "用法:"
echo " ./k8s/deploy.sh upgrade $IMAGE_TAG # 升级 image tag = $IMAGE_TAG"
echo " ./k8s/deploy.sh upgrade --reuse-values # 升级, 复用当前 values"
echo ""
echo "验证 (不进真值到 git):"
echo " cd $REPO_ROOT && git status k8s/helm/topfans/values-prod.yaml"
# `git status` 显示为 ignored 才算稳。若显示为 untracked 那就是 .gitignore 出问题了,
# 这次 helm install 千万**别**在生产路径上跑 (应立刻看 .gitignore 是否漏配)。