#!/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..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 是否漏配)。