From 276b8286d126ee0014f96b08d05feb3bf49d9af5 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Mon, 7 Sep 2026 17:35:47 +0800 Subject: [PATCH] checkpoint before checking out master --- .gitea/workflows/sync-consul.yml | 67 +++++++++++++ .gitignore | 5 + README.md | 92 ++++++++++++++++++ idwms/admin.json | 38 ++++++++ idwms/common.json | 30 ++++++ idwms/itfxapi.json | 20 ++++ idwms/trackapi.json | 20 ++++ scripts/export-from-consul.sh | 59 ++++++++++++ scripts/sync-to-consul.sh | 158 +++++++++++++++++++++++++++++++ 9 files changed, 489 insertions(+) create mode 100644 .gitea/workflows/sync-consul.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 idwms/admin.json create mode 100644 idwms/common.json create mode 100644 idwms/itfxapi.json create mode 100644 idwms/trackapi.json create mode 100644 scripts/export-from-consul.sh create mode 100644 scripts/sync-to-consul.sh diff --git a/.gitea/workflows/sync-consul.yml b/.gitea/workflows/sync-consul.yml new file mode 100644 index 0000000..c488f23 --- /dev/null +++ b/.gitea/workflows/sync-consul.yml @@ -0,0 +1,67 @@ +# push 到 main/master → 渲染密钥 → 写入 Consul → 重启相关 API +# runner: k3s-host(与 api 部署同一台,可访问 ClusterIP + kubectl) +name: Sync Consul Config + +on: + push: + branches: [master, main] + paths: + - "idwms/**" + - "scripts/**" + - ".gitea/workflows/sync-consul.yml" + workflow_dispatch: + inputs: + targets: + description: "同步目标(空=全部;或 admin / common / itfxapi / trackapi,空格分隔)" + required: false + default: "" + restart: + description: "同步后重启 API(1/0)" + required: false + default: "1" + +jobs: + sync: + runs-on: k3s-host + steps: + - name: Checkout and sync + run: | + set -euxo pipefail + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + export NAMESPACE=prod + export RESTART_APIS="${{ gitea.event.inputs.restart || '1' }}" + if [[ "${{ gitea.event_name }}" == "push" ]]; then + export RESTART_APIS=1 + fi + + REPO="${{ gitea.repository }}" + REPO="${REPO:-${{ github.repository }}}" + SHA="${{ gitea.sha }}" + SHA="${SHA:-${{ github.sha }}}" + test -n "$REPO" && [[ "$REPO" == */* ]] + test -n "$SHA" + + GITEA_HTTP="https://git.ideship.com" + WORK="/data/ci-build/config" + GITEA_USER="$(kubectl -n prod get secret kaniko-git -o jsonpath='{.data.GITEA_USER}' | base64 -d)" + GIT_TOKEN="$(kubectl -n prod get secret kaniko-git -o jsonpath='{.data.GIT_TOKEN}' | base64 -d)" + GITEA_USER="${GITEA_USER:-gitea}" + + rm -rf "$WORK" + mkdir -p "$WORK" + cd "$WORK" + git init -q + git remote add origin "${GITEA_HTTP}/${REPO}.git" + AUTH_B64="$(printf '%s:%s' "$GITEA_USER" "$GIT_TOKEN" | base64 | tr -d '\n\r ')" + git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" \ + fetch --depth 1 --quiet --no-tags origin "$SHA" + git checkout --force --quiet FETCH_HEAD + + chmod +x scripts/sync-to-consul.sh + TARGETS="${{ gitea.event.inputs.targets }}" + if [[ -n "${TARGETS// /}" ]]; then + # shellcheck disable=SC2086 + ./scripts/sync-to-consul.sh $TARGETS + else + ./scripts/sync-to-consul.sh + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dfa25f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.local.json +/tmp/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f1005e --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# config — Consul 配置 GitOps + +Gitea 仓保存 `idwms/*.json` → CI 用 K8s Secret 渲染密码 → `PUT` 到 Consul KV → 可选重启 API。 + +应用仍只读 Consul(`AddConsul("idwms/...")`),不读本仓库。 + +## 目录 + +| 文件 | Consul KV | +|------|-----------| +| `idwms/common.json` | `idwms/common` | +| `idwms/admin.json` | `idwms/admin` | +| `idwms/itfxapi.json` | `idwms/itfxapi` | +| `idwms/trackapi.json` | `idwms/trackapi` | + +JSON 里敏感字段用占位符,例如 `${MYSQL_ROOT_PASSWORD}`。同步时从 `prod-credentials` / `jwt-secret` 注入,**明文密码不进 git**。 + +## 一次性准备 + +### 1. JWT(业务 API 用,中间件 Secret 里没有) + +在 k3s 节点: + +```bash +kubectl -n prod create secret generic jwt-secret \ + --from-literal=JWT_SECRET_KEY='请换成足够长的随机串' \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +也可把 `JWT_SECRET_KEY` 加进现有 `manifests/secrets.yaml` 的 `prod-credentials`。 + +### 2. (可选)Consul ACL Token + +若已开 ACL: + +```bash +kubectl -n prod create secret generic consul-acl \ + --from-literal=CONSUL_HTTP_TOKEN='...' \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +### 3. 推到 Gitea + +```bash +cd d:/my/itfx/config +git remote add origin https://git.ideship.com//config.git +git add -A && git commit -m "chore: initial consul config repo" +git push -u origin master +``` + +在 Gitea 启用 Actions,确保 runner 标签含 `k3s-host`(与 API 部署相同)。 + +### 4. 与线上对账(推荐先做) + +在能 `kubectl` 的机器上: + +```bash +./scripts/export-from-consul.sh +# 把导出文件里的密码改回 ${MYSQL_ROOT_PASSWORD} 等占位符后再提交 +``` + +避免模板库名/字段和现网不一致,第一次同步把线上冲掉。 + +## 日常 + +改 JSON → push `main`/`master` → workflow `Sync Consul Config` 自动同步并 `rollout restart` 对应 API。 + +手动: + +```bash +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml +./scripts/sync-to-consul.sh # 全部 +RESTART_APIS=1 ./scripts/sync-to-consul.sh admin +``` + +## 密钥来源 + +| 占位符 | Secret | +|--------|--------| +| `${MYSQL_ROOT_PASSWORD}` | `prod-credentials` | +| `${REDIS_PASSWORD}` | `prod-credentials` | +| `${RABBITMQ_DEFAULT_PASS}` | `prod-credentials` | +| `${ELASTIC_PASSWORD}` | `prod-credentials` | +| `${JWT_SECRET_KEY}` | `jwt-secret` 或 `prod-credentials` | + +与 `k3s/manifests/secrets.yaml` 一致:中间件用 Secret;本仓只存带占位符的业务配置模板。 + +## 注意 + +- `App:CorsOrigins` 在启动时打进 CORS 策略,改完必须重启 API(CI 默认会 restart)。 +- 首次用仓库模板前务必 `export-from-consul` 对过现网,或确认 MySQL 库名(`oms` / `itfx` / `track`)正确。 +- Consul 保持 ClusterIP,CI 经节点访问 Service ClusterIP,不暴露公网。 diff --git a/idwms/admin.json b/idwms/admin.json new file mode 100644 index 0000000..ffdf853 --- /dev/null +++ b/idwms/admin.json @@ -0,0 +1,38 @@ +{ + "App": { + "Urls": ["http://+:5281"], + "CorsOrigins": [ + "https://ideship.com", + "http://localhost:5173" + ], + "SendStartupNotification": false + }, + "ConnectionStrings": { + "Default": "Server=mysql;Port=3306;Database=oms;Uid=root;Pwd=${MYSQL_ROOT_PASSWORD};CharSet=utf8mb4;" + }, + "Jwt": { + "SecretKey": "${JWT_SECRET_KEY}", + "Issuer": "SimpleAdmin.Api", + "Audience": "SimpleAdmin.Api", + "ExpiryInMinutes": "480", + "RefreshExpiryInDays": "7", + "ClockSkewMinutes": "5" + }, + "JwtSettings": { + "SecretKey": "${JWT_SECRET_KEY}", + "Issuer": "SimpleAdmin.Api", + "Audience": "SimpleAdmin.Api", + "ExpiryInMinutes": "480", + "RefreshExpiryInDays": "7", + "ClockSkewMinutes": "5" + }, + "FeiShuNotice": { + "Webhook": "", + "EnableFeiShuExceptionSubscribe": false + }, + "ElasticSearch": { + "Uri": "http://elasticsearch:9200", + "Username": "elastic", + "Password": "${ELASTIC_PASSWORD}" + } +} diff --git a/idwms/common.json b/idwms/common.json new file mode 100644 index 0000000..1d05e3f --- /dev/null +++ b/idwms/common.json @@ -0,0 +1,30 @@ +{ + "Redis": { + "Host": "redis:6379", + "Password": "${REDIS_PASSWORD}", + "Database": 0, + "MaxPoolSize": 50, + "MinPoolSize": 1, + "IdleTimeout": 20000, + "ConnectTimeout": 5000 + }, + "RabbitMQ": { + "Connections": { + "Default": { + "HostName": "rabbitmq", + "Port": 5672, + "UserName": "oms", + "Password": "${RABBITMQ_DEFAULT_PASS}" + } + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + } + } +} diff --git a/idwms/itfxapi.json b/idwms/itfxapi.json new file mode 100644 index 0000000..bc6e67c --- /dev/null +++ b/idwms/itfxapi.json @@ -0,0 +1,20 @@ +{ + "App": { + "Urls": ["http://+:8888"], + "CorsOrigins": [ + "https://ideship.com", + "http://localhost:5173" + ] + }, + "ConnectionStrings": { + "Default": "Server=mysql;Port=3306;Database=itfx;Uid=root;Pwd=${MYSQL_ROOT_PASSWORD};CharSet=utf8mb4;" + }, + "Jwt": { + "SecretKey": "${JWT_SECRET_KEY}", + "Issuer": "InterfaceForward.Api", + "Audience": "InterfaceForward.Api", + "ExpiryInMinutes": "480", + "RefreshExpiryInDays": "7", + "ClockSkewMinutes": "5" + } +} diff --git a/idwms/trackapi.json b/idwms/trackapi.json new file mode 100644 index 0000000..8aa6c17 --- /dev/null +++ b/idwms/trackapi.json @@ -0,0 +1,20 @@ +{ + "App": { + "Urls": ["http://+:8889"], + "CorsOrigins": [ + "https://ideship.com", + "http://localhost:5173" + ] + }, + "ConnectionStrings": { + "Default": "Server=mysql;Port=3306;Database=track;Uid=root;Pwd=${MYSQL_ROOT_PASSWORD};CharSet=utf8mb4;" + }, + "Jwt": { + "SecretKey": "${JWT_SECRET_KEY}", + "Issuer": "TrackCenter.Api", + "Audience": "TrackCenter.Api", + "ExpiryInMinutes": "480", + "RefreshExpiryInDays": "7", + "ClockSkewMinutes": "5" + } +} diff --git a/scripts/export-from-consul.sh b/scripts/export-from-consul.sh new file mode 100644 index 0000000..0ed348f --- /dev/null +++ b/scripts/export-from-consul.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# 从 Consul 导出 KV 到 idwms/*.json(首次建仓 / 对账用) +# 注意:导出会含明文密码,勿直接 commit;先人工脱敏或改用占位符 +# 用法: ./scripts/export-from-consul.sh [admin|common|itfxapi|trackapi ...] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +export KUBECONFIG="${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}" +NS="${NAMESPACE:-prod}" +IDWMS_DIR="${ROOT}/idwms" +mkdir -p "$IDWMS_DIR" + +need() { command -v "$1" >/dev/null || { echo "FAIL: 需要 $1"; exit 1; }; } +need kubectl +need curl +need jq + +if [[ -z "${CONSUL_HTTP_ADDR:-}" ]]; then + CIP="$(kubectl -n "$NS" get svc consul -o jsonpath='{.spec.clusterIP}')" + CONSUL_HTTP_ADDR="http://${CIP}:8500" +fi + +if [[ -z "${CONSUL_HTTP_TOKEN:-}" ]]; then + raw="$(kubectl -n "$NS" get secret consul-acl -o jsonpath='{.data.CONSUL_HTTP_TOKEN}' 2>/dev/null || true)" + if [[ -n "$raw" ]]; then + CONSUL_HTTP_TOKEN="$(printf '%s' "$raw" | base64 -d)" + fi +fi + +CURL_AUTH=() +if [[ -n "${CONSUL_HTTP_TOKEN:-}" ]]; then + CURL_AUTH=(-H "X-Consul-Token: ${CONSUL_HTTP_TOKEN}") +fi + +if [[ $# -eq 0 ]]; then + TARGETS=(common admin itfxapi trackapi) +else + TARGETS=("$@") +fi + +for name in "${TARGETS[@]}"; do + key="idwms/${name}" + out="${IDWMS_DIR}/${name}.json" + echo "==> GET ${key}" + raw="$(curl -sS "${CURL_AUTH[@]}" "${CONSUL_HTTP_ADDR}/v1/kv/${key}?raw" || true)" + if [[ -z "$raw" ]]; then + echo "WARN: ${key} 为空或不存在,跳过" + continue + fi + if ! jq empty <<<"$raw" 2>/dev/null; then + echo "FAIL: ${key} 不是 JSON" + exit 1 + fi + jq . <<<"$raw" >"$out" + echo "OK → ${out}" +done + +echo "导出完成。提交前请把密码改成 \${MYSQL_ROOT_PASSWORD} 等占位符。" diff --git a/scripts/sync-to-consul.sh b/scripts/sync-to-consul.sh new file mode 100644 index 0000000..9d306ee --- /dev/null +++ b/scripts/sync-to-consul.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# 将仓库 idwms/*.json 渲染密钥后写入 Consul KV +# 用法: +# ./scripts/sync-to-consul.sh # 同步全部 +# ./scripts/sync-to-consul.sh admin # 只同步 idwms/admin +# RESTART_APIS=1 ./scripts/sync-to-consul.sh +# +# 密钥来源(优先): +# 1) 环境变量 MYSQL_ROOT_PASSWORD / REDIS_PASSWORD / ... +# 2) K8s Secret prod-credentials(及可选 jwt-secret) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +export KUBECONFIG="${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}" +NS="${NAMESPACE:-prod}" +CONSUL_HTTP_ADDR="${CONSUL_HTTP_ADDR:-}" +RESTART_APIS="${RESTART_APIS:-0}" +IDWMS_DIR="${ROOT}/idwms" + +need() { command -v "$1" >/dev/null || { echo "FAIL: 需要 $1"; exit 1; }; } +need kubectl +need curl +need jq +command -v envsubst >/dev/null || { echo "FAIL: 需要 envsubst(gettext)"; exit 1; } + +secret_b64() { + local name="$1" key="$2" + kubectl -n "$NS" get secret "$name" -o "jsonpath={.data.${key}}" 2>/dev/null || true +} + +load_secret() { + local var="$1" secret="$2" key="$3" + if [[ -n "${!var:-}" ]]; then + return 0 + fi + local raw + raw="$(secret_b64 "$secret" "$key")" + if [[ -n "$raw" ]]; then + export "$var"="$(printf '%s' "$raw" | base64 -d)" + fi +} + +echo "==> 加载密钥(K8s Secret / env)" +load_secret MYSQL_ROOT_PASSWORD prod-credentials MYSQL_ROOT_PASSWORD +load_secret REDIS_PASSWORD prod-credentials REDIS_PASSWORD +load_secret RABBITMQ_DEFAULT_PASS prod-credentials RABBITMQ_DEFAULT_PASS +load_secret ELASTIC_PASSWORD prod-credentials ELASTIC_PASSWORD +load_secret JWT_SECRET_KEY jwt-secret JWT_SECRET_KEY +# 兼容:JWT 也放在 prod-credentials +load_secret JWT_SECRET_KEY prod-credentials JWT_SECRET_KEY + +: "${MYSQL_ROOT_PASSWORD:?缺少 MYSQL_ROOT_PASSWORD(env 或 secret/prod-credentials)}" +: "${REDIS_PASSWORD:?缺少 REDIS_PASSWORD}" +: "${RABBITMQ_DEFAULT_PASS:?缺少 RABBITMQ_DEFAULT_PASS}" +: "${ELASTIC_PASSWORD:?缺少 ELASTIC_PASSWORD}" +: "${JWT_SECRET_KEY:?缺少 JWT_SECRET_KEY(请创建 secret/jwt-secret 或写入 prod-credentials)}" + +if [[ -z "$CONSUL_HTTP_ADDR" ]]; then + CIP="$(kubectl -n "$NS" get svc consul -o jsonpath='{.spec.clusterIP}')" + CONSUL_HTTP_ADDR="http://${CIP}:8500" +fi +echo "==> Consul ${CONSUL_HTTP_ADDR}" + +# ACL(可选) +if [[ -z "${CONSUL_HTTP_TOKEN:-}" ]]; then + raw="$(secret_b64 consul-acl CONSUL_HTTP_TOKEN)" + if [[ -n "$raw" ]]; then + export CONSUL_HTTP_TOKEN="$(printf '%s' "$raw" | base64 -d)" + fi +fi + +CURL_AUTH=() +if [[ -n "${CONSUL_HTTP_TOKEN:-}" ]]; then + CURL_AUTH=(-H "X-Consul-Token: ${CONSUL_HTTP_TOKEN}") +fi + +TARGETS=() +if [[ $# -eq 0 ]]; then + for f in "${IDWMS_DIR}"/*.json; do + [[ -f "$f" ]] || continue + TARGETS+=("$(basename "$f" .json)") + done +else + TARGETS=("$@") +fi + +[[ ${#TARGETS[@]} -gt 0 ]] || { echo "FAIL: 无同步目标"; exit 1; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +put_kv() { + local key="$1" file="$2" + local code + code="$(curl -sS -o /tmp/consul-put.out -w '%{http_code}' \ + "${CURL_AUTH[@]}" \ + -X PUT --data-binary @"$file" \ + "${CONSUL_HTTP_ADDR}/v1/kv/${key}")" + if [[ "$code" != "200" ]]; then + echo "FAIL: PUT ${key} HTTP ${code}" + cat /tmp/consul-put.out 2>/dev/null || true + exit 1 + fi + echo "OK idwms/${key##*/} → ${key}" +} + +RESTART_SET=() +for name in "${TARGETS[@]}"; do + src="${IDWMS_DIR}/${name}.json" + [[ -f "$src" ]] || { echo "FAIL: 无 ${src}"; exit 1; } + + # 仅替换已知占位符,避免 JSON 里其它 $ 被误伤 + export MYSQL_ROOT_PASSWORD REDIS_PASSWORD RABBITMQ_DEFAULT_PASS ELASTIC_PASSWORD JWT_SECRET_KEY + envsubst '${MYSQL_ROOT_PASSWORD} ${REDIS_PASSWORD} ${RABBITMQ_DEFAULT_PASS} ${ELASTIC_PASSWORD} ${JWT_SECRET_KEY}' \ + <"$src" >"${TMP}/${name}.json" + + if ! jq empty "${TMP}/${name}.json" 2>/dev/null; then + echo "FAIL: ${name}.json 渲染后不是合法 JSON" + exit 1 + fi + + # 禁止未替换的占位符漏网 + if grep -qE '\$\{[A-Z0-9_]+\}' "${TMP}/${name}.json"; then + echo "FAIL: ${name}.json 仍有未替换占位符:" + grep -E '\$\{[A-Z0-9_]+\}' "${TMP}/${name}.json" || true + exit 1 + fi + + put_kv "idwms/${name}" "${TMP}/${name}.json" + + case "$name" in + admin) RESTART_SET+=(admin-api) ;; + itfxapi) RESTART_SET+=(interfaceforward-api) ;; + trackapi) RESTART_SET+=(track-api) ;; + common) + RESTART_SET+=(admin-api interfaceforward-api track-api) + ;; + esac +done + +if [[ "$RESTART_APIS" == "1" ]]; then + # 去重 + readarray -t RESTART_SET < <(printf '%s\n' "${RESTART_SET[@]}" | sort -u) + echo "==> rollout restart: ${RESTART_SET[*]}" + for d in "${RESTART_SET[@]}"; do + [[ -n "$d" ]] || continue + if kubectl -n "$NS" get "deploy/${d}" >/dev/null 2>&1; then + kubectl -n "$NS" rollout restart "deploy/${d}" + kubectl -n "$NS" rollout status "deploy/${d}" --timeout=180s || true + else + echo "SKIP deploy/${d}(不存在)" + fi + done +fi + +echo "DONE"