chore(infra): 新增 Gitea+Jenkins CI/CD 部署与凭据整改
- infra/cicd:docker-compose(gitea/jenkins)、JCasC(凭据统一 ${ENV} 注入,无硬编码)、
备份/恢复、健康检查、凭据轮换与 git 历史清除脚本
- docs/deployment/cicd:安装、高可用备份监控、凭据事故复盘
- .env.example 仅为占位模板;真实 .env 由 .gitignore 排除
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# ============================================================
|
||||
# CI/CD 环境变量模板
|
||||
#
|
||||
# ⚠️ 复制为 .env 后填写真实值。.env 已在 .gitignore 中,绝不允许入库。
|
||||
# ⚠️ 2026-09-14 曾发生凭据入库事故(scripts/setup-cicd/cicd.config),
|
||||
# 本文件即该事故的整改产物。任何密码/令牌只允许出现在这里或 Jenkins Credentials。
|
||||
#
|
||||
# 生成强随机值:
|
||||
# openssl rand -base64 24 # 密码
|
||||
# openssl rand -hex 32 # 令牌/密钥
|
||||
# ============================================================
|
||||
|
||||
# ---------- Gitea ----------
|
||||
GITEA_ROOT_URL=https://git.f.novalon.cn
|
||||
GITEA_SSH_DOMAIN=git.f.novalon.cn
|
||||
GITEA_DB_NAME=gitea
|
||||
GITEA_DB_USER=gitea
|
||||
# 至少 24 位;与 app.ini [database] PASSWD 必须一致
|
||||
GITEA_DB_PASSWORD=__CHANGE_ME__
|
||||
|
||||
# ---------- Jenkins ----------
|
||||
JENKINS_ADMIN_USER=zhangxiang
|
||||
# 至少 16 位;JCasC 首次启动写入,之后改此值需同步改 casc.yaml 逻辑
|
||||
JENKINS_ADMIN_PASSWORD=__CHANGE_ME__
|
||||
|
||||
# Gitea 个人访问令牌(Jenkins 拉取代码用)
|
||||
# 生成:Gitea -> 设置 -> 应用 -> 生成新令牌,权限仅勾 read:repository
|
||||
GITEA_TOKEN=__CHANGE_ME__
|
||||
|
||||
# 部署用 SSH 私钥口令(无口令填空)
|
||||
DEPLOY_SSH_KEY_PASSPHRASE=
|
||||
|
||||
# ---------- 备份 ----------
|
||||
BACKUP_ROOT=/home/novalon/backups/cicd
|
||||
# 本地保留份数(磁盘 20G 已用 85%,务必配异地)
|
||||
BACKUP_RETENTION=3
|
||||
# 单份备份告警阈值
|
||||
BACKUP_MIN_FREE_GB=3
|
||||
# 异地同步开关:留空则不同步(强烈建议配置)
|
||||
# 例:rclone remote 名 + 路径
|
||||
RCLONE_REMOTE=
|
||||
RCLONE_PATH=novalon-cicd-backup
|
||||
|
||||
# ---------- 告警 ----------
|
||||
# 失败时推送的 Webhook(企业微信/钉钉);留空则不推送
|
||||
ALERT_WEBHOOK_URL=
|
||||
BACKUP_FULL_RETENTION=1
|
||||
BACKUP_THIN_RETENTION=3
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Novalon CI/CD 统一备份脚本
|
||||
#
|
||||
# 覆盖:
|
||||
# 1. Gitea PostgreSQL 库(pg_dump,custom format)
|
||||
# 2. Gitea 仓库裸库(/data/git/repositories)
|
||||
# 3. Jenkins 配置薄备份(排除 builds/ workspace/ 缓存,保留作业定义与凭据)
|
||||
#
|
||||
# 设计要点:
|
||||
# - 每份产物落盘后立刻校验(大小非零 + 归档可列出),坏备份当场失败而不是等到恢复时才发现
|
||||
# - 磁盘预检:剩余空间不足直接中止并告警(宿主 20G 已用 85%,写满会连带拖垮 nginx)
|
||||
# - 异地同步可选:配了 RCLONE_REMOTE 才同步,否则明确告警"仅本地"
|
||||
# - 全链路日志,失败走 ALERT_WEBHOOK_URL
|
||||
#
|
||||
# 用法:
|
||||
# ./backup-cicd.sh # 标准备份(库 + 仓库 + Jenkins 配置)
|
||||
# ./backup-cicd.sh --full # 追加 Gitea LFS/附件/头像
|
||||
# ./backup-cicd.sh --dry-run # 只预检,不产出
|
||||
# ./backup-cicd.sh --jenkins-only # 只备份 Jenkins
|
||||
#
|
||||
# crontab(每天 03:30,见 docs/deployment/cicd/04-ha-backup-monitoring.md):
|
||||
# 30 3 * * * /home/novalon/docker-app/infra/cicd/backup/backup-cicd.sh >> /var/log/cicd-backup.log 2>&1
|
||||
# ============================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ---------- 配置 ----------
|
||||
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
|
||||
ENV_FILE="${ENV_FILE:-$DEPLOY_ROOT/infra/cicd/.env}"
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
|
||||
RETENTION="${BACKUP_RETENTION:-3}" # 兼容旧变量
|
||||
FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}" # 全量保留份数(含 Gitea 仓库)
|
||||
THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}" # 薄保留份数(仅 DB + Jenkins 配置)
|
||||
MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
|
||||
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
|
||||
RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
|
||||
ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
|
||||
|
||||
GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-postgresql}"
|
||||
JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
|
||||
GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}"
|
||||
GITEA_DB_USER="${GITEA_DB_USER:-gitea}"
|
||||
|
||||
FULL=0
|
||||
DRY_RUN=0
|
||||
JENKINS_ONLY=0
|
||||
SKIP_REPOS=0
|
||||
|
||||
# ---------- 参数 ----------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--full) FULL=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--jenkins-only) JENKINS_ONLY=1; shift ;;
|
||||
# 薄备份:跳过 Gitea 裸库(939MB,占单份 78%)。
|
||||
# 依据:Git 仓库天然分布式,每份 clone 都是完整历史;
|
||||
# 而 PostgreSQL 里的用户/权限/PR/Issue/Webhook 是唯一副本、不可重建。
|
||||
# 故磁盘受限时优先保 DB,仓库交给「异地全量」或开发者本地副本。
|
||||
--skip-repos) SKIP_REPOS=1; shift ;;
|
||||
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- 加载 .env(若存在) ----------
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
|
||||
RETENTION="${BACKUP_RETENTION:-3}"
|
||||
FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}"
|
||||
THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}"
|
||||
MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
|
||||
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
|
||||
RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
|
||||
ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
|
||||
fi
|
||||
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
STAMP_DIR="$BACKUP_ROOT/$TS"
|
||||
LOG_PREFIX="[backup $TS]"
|
||||
|
||||
log() { echo "$LOG_PREFIX $*"; }
|
||||
fail() { echo "$LOG_PREFIX ❌ $*" >&2; alert "备份失败: $*"; exit 1; }
|
||||
|
||||
alert() {
|
||||
local msg="$1"
|
||||
[[ -z "$ALERT_WEBHOOK_URL" ]] && return 0
|
||||
curl -fsS -m 10 -X POST "$ALERT_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[CI/CD备份告警] $msg ($(hostname))\"}}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# 任何未捕获错误都触发告警
|
||||
trap 'fail "第 $LINENO 行发生未捕获错误"' ERR
|
||||
|
||||
require_container() {
|
||||
local name="$1"
|
||||
docker inspect "$name" >/dev/null 2>&1 || fail "容器不存在: $name"
|
||||
local st; st="$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)"
|
||||
[[ "$st" == "true" ]] || fail "容器未运行: $name"
|
||||
}
|
||||
|
||||
# 归档完整性校验:非空 + tar/zip 可列出
|
||||
verify_archive() {
|
||||
local f="$1" kind="$2"
|
||||
[[ -s "$f" ]] || fail "产物为空: $f"
|
||||
local sz; sz=$(stat -c %s "$f")
|
||||
(( sz > 1024 )) || fail "产物过小($sz B),判定失败: $f"
|
||||
case "$kind" in
|
||||
tar) tar -tzf "$f" >/dev/null 2>&1 || fail "tar 校验失败(归档损坏): $f" ;;
|
||||
sql) gzip -t "$f" >/dev/null 2>&1 || fail "gzip 校验失败: $f" ;;
|
||||
esac
|
||||
log "✅ 校验通过 $(basename "$f") ($(numfmt --to=iec --suffix=B "$sz" 2>/dev/null || echo "${sz}B"))"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
log "========== CI/CD 备份开始 =========="
|
||||
log "目标目录: $STAMP_DIR 保留份数: $RETENTION 全量: $FULL 跳过仓库: $SKIP_REPOS dry-run: $DRY_RUN"
|
||||
|
||||
# ---------- 预检 ----------
|
||||
command -v docker >/dev/null || fail "docker 不可用"
|
||||
|
||||
FREE_GB=$(df -P "$BACKUP_ROOT" 2>/dev/null | awk 'NR==2{print int($4/1048576)}' \
|
||||
|| df -P / | awk 'NR==2{print int($4/1048576)}')
|
||||
log "备份卷可用空间: ${FREE_GB}GB(阈值 ${MIN_FREE_GB}GB)"
|
||||
if (( FREE_GB < MIN_FREE_GB )); then
|
||||
fail "可用空间 ${FREE_GB}GB < 阈值 ${MIN_FREE_GB}GB,中止备份以免写满磁盘。请清理或扩容。"
|
||||
fi
|
||||
|
||||
if (( JENKINS_ONLY == 0 )); then
|
||||
require_container "$PG_CONTAINER"
|
||||
require_container "$GITEA_CONTAINER"
|
||||
fi
|
||||
require_container "$JENKINS_CONTAINER"
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
log "dry-run:预检全部通过,未产出备份。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$STAMP_DIR"
|
||||
|
||||
# ============================================================
|
||||
# 1. PostgreSQL —— Gitea 元数据(用户/权限/PR/Issue/Webhook)
|
||||
# ============================================================
|
||||
if (( JENKINS_ONLY == 0 )); then
|
||||
log "----- [1/3] PostgreSQL 导出 -----"
|
||||
DB_DUMP="$STAMP_DIR/gitea-db-$TS.sql.gz"
|
||||
if docker exec "$PG_CONTAINER" pg_dump -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" -Fc \
|
||||
| gzip -9 > "$DB_DUMP"; then
|
||||
verify_archive "$DB_DUMP" sql
|
||||
else
|
||||
rm -f "$DB_DUMP"; fail "pg_dump 执行失败"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 2. Gitea 仓库裸库
|
||||
# ============================================================
|
||||
log "----- [2/3] Gitea 仓库导出 -----"
|
||||
REPO_TAR="$STAMP_DIR/gitea-repos-$TS.tar.gz"
|
||||
if (( SKIP_REPOS == 1 )); then
|
||||
log "⏭ 已跳过仓库导出(--skip-repos):本次仅保 DB 与 Jenkins 配置"
|
||||
log "ℹ️ 仓库是 Git 分布式的,但**不要长期依赖**此项跳过 —— 需有异地全量或开发者副本兜底"
|
||||
elif [[ "$FULL" == "1" ]]; then
|
||||
# 全量:仓库 + LFS + 附件 + 头像
|
||||
docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
|
||||
tar czf "/out/$(basename "$REPO_TAR")" -C /data git 2>/dev/null \
|
||||
|| fail "Gitea 全量归档失败"
|
||||
else
|
||||
# 标准:仅裸库(LFS/附件可从对象存储或后续补,磁盘优先)
|
||||
docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
|
||||
sh -c 'tar czf /out/'"$(basename "$REPO_TAR")"' -C /data git/repositories' \
|
||||
|| fail "Gitea 仓库归档失败"
|
||||
verify_archive "$REPO_TAR" tar
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 3. Jenkins 薄备份
|
||||
# !关键:排除 builds/ workspace/ caches/ logs —— 这些占空间且可从作业定义重建
|
||||
# !但必须包含 credentials.xml 与 secrets/(否则恢复后凭据全丢)
|
||||
# ============================================================
|
||||
log "----- [3/3] Jenkins 配置薄备份 -----"
|
||||
JENKINS_TAR="$STAMP_DIR/jenkins-config-$TS.tar.gz"
|
||||
# 归档整个 JENKINS_HOME 并排除可重建项,不再写死具体路径
|
||||
# (曾踩坑:nodes/ 在部分版本不存在,tar 直接以非 0 退出导致整段备份失败)
|
||||
# 体积参考(2026-09-14 实测):plugins 265M / war 111M / workspace 49M / updates 3.4M
|
||||
# war、workspace、updates 均可从镜像或 SCM 重建,排除后单份约 80M
|
||||
# ⚠️ ./plugins 也必须排除:实测它占薄备份 222MB 中的绝大部分,
|
||||
# 而插件可由同目录导出的 jenkins-plugins-<ts>.txt 精确重装(jenkins-plugin-cli --plugin-file)
|
||||
# 排除后薄备份从 222MB 降到个位数 MB,磁盘受限场景下这是决定性差异
|
||||
set +e
|
||||
docker run --rm --volumes-from "$JENKINS_CONTAINER" -v "$STAMP_DIR:/out" alpine \
|
||||
sh -c '
|
||||
cd /var/jenkins_home
|
||||
tar czf /out/'"$(basename "$JENKINS_TAR")"' \
|
||||
--exclude="./jobs/*/builds" \
|
||||
--exclude="./jobs/*/workspace" \
|
||||
--exclude="./jobs/*/workspace@tmp" \
|
||||
--exclude="./jobs/*/last*" \
|
||||
--exclude="./jobs/*/nextBuildNumber" \
|
||||
--exclude="./workspace" \
|
||||
--exclude="./workspace@tmp" \
|
||||
--exclude="./plugins" \
|
||||
--exclude="./war" \
|
||||
--exclude="./caches" \
|
||||
--exclude="./updates" \
|
||||
--exclude="./logs" \
|
||||
--exclude="./.cache" \
|
||||
--exclude="./tools" \
|
||||
--exclude="./.java" \
|
||||
. 2>/dev/null
|
||||
'
|
||||
TAR_RC=$?
|
||||
set -e
|
||||
# GNU/busybox tar:0 成功;1 = "读取时文件发生变化"(Jenkins 在运行,属正常);>=2 才是致命错误
|
||||
if (( TAR_RC >= 2 )); then
|
||||
fail "Jenkins 归档失败(tar 退出码 $TAR_RC)"
|
||||
elif (( TAR_RC == 1 )); then
|
||||
log "ℹ️ tar 退出码 1:归档过程中有文件变化(Jenkins 在运行),属可接受的警告"
|
||||
fi
|
||||
verify_archive "$JENKINS_TAR" tar
|
||||
|
||||
# 单独导出已安装插件清单,便于重建
|
||||
docker exec "$JENKINS_CONTAINER" sh -c \
|
||||
'ls /var/jenkins_home/plugins/*.jpi 2>/dev/null | xargs -n1 basename 2>/dev/null | sed "s/\.jpi$//"' \
|
||||
> "$STAMP_DIR/jenkins-plugins-$TS.txt" 2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# 校验:密钥文件必须存在,否则这份备份恢复不出来
|
||||
# ============================================================
|
||||
# Jenkins 2.5xx 起凭据解密依赖 secrets/master.key + secrets/hudson.util.Secret;
|
||||
# 旧版本(<2.4xx)才是根目录的 secret.key。三者取其二即可恢复,必须至少有 master.key。
|
||||
# (曾踩坑:只按旧版路径 secret.key 判定,在 2.568.1 上误报"无法解密")
|
||||
J_LIST="$(tar -tzf "$JENKINS_TAR" 2>/dev/null || true)"
|
||||
# ⚠️ 必须用 herestring 而非管道:grep -q 命中即退出会关闭管道,
|
||||
# 上游 printf 收到 SIGPIPE(141),在 set -o pipefail 下整条管道被判失败 -> 误报"密钥缺失"
|
||||
if grep -qE '(^|/)(secrets/master\.key|secret\.key)$' <<< "$J_LIST"; then
|
||||
if grep -qE '(^|/)(secrets/hudson\.util\.Secret|secret\.key\.not-so-secret)$' <<< "$J_LIST"; then
|
||||
log "✅ 凭据解密所需的 master key 与 hudson.util.Secret 均已包含"
|
||||
else
|
||||
log "⚠️ 找到 master key 但缺少 hudson.util.Secret —— 恢复后部分凭据可能不可解密"
|
||||
fi
|
||||
else
|
||||
fail "归档缺少 master key(secrets/master.key 或 secret.key)—— 恢复后所有 Jenkins 凭据将不可解密"
|
||||
fi
|
||||
|
||||
# 生成清单
|
||||
cat > "$STAMP_DIR/MANIFEST.txt" <<EOF
|
||||
备份时间: $(date -Is)
|
||||
主机: $(hostname)
|
||||
Gitea: $(docker inspect -f '{{.Config.Image}}' "$GITEA_CONTAINER" 2>/dev/null || echo n/a)
|
||||
Jenkins: $(docker exec "$JENKINS_CONTAINER" sh -c 'java -jar /usr/share/jenkins/jenkins.war --version' 2>/dev/null || echo n/a)
|
||||
Postgres: $(docker inspect -f '{{.Config.Image}}' "$PG_CONTAINER" 2>/dev/null || echo n/a)
|
||||
模式: $( if (( SKIP_REPOS == 1 )); then echo thin-db-only; elif [[ "$FULL" == "1" ]]; then echo full; else echo standard; fi )
|
||||
注: Jenkins 归档已排除 plugins/(可重建)。恢复后执行:
|
||||
jenkins-plugin-cli --plugin-file jenkins-plugins-<ts>.txt
|
||||
或在 Manage Jenkins -> Plugins 按清单重装。
|
||||
产物:
|
||||
$(ls -lh "$STAMP_DIR" | awk 'NR>1{print " " $9 " " $5}')
|
||||
EOF
|
||||
|
||||
# ============================================================
|
||||
# 异地同步
|
||||
# ============================================================
|
||||
if [[ -n "$RCLONE_REMOTE" ]]; then
|
||||
if command -v rclone >/dev/null 2>&1; then
|
||||
log "同步到异地: $RCLONE_REMOTE:$RCLONE_PATH/$TS"
|
||||
rclone copy "$STAMP_DIR" "$RCLONE_REMOTE:$RCLONE_PATH/$TS" --transfers 2 \
|
||||
|| log "⚠️ 异地同步失败(本地备份仍然有效)"
|
||||
else
|
||||
log "⚠️ 已配置 RCLONE_REMOTE 但未安装 rclone,跳过异地同步"
|
||||
fi
|
||||
else
|
||||
log "⚠️ 未配置 RCLONE_REMOTE —— 备份仅存本地。同盘备份不抗主机故障,强烈建议配置异地。"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 保留策略 —— 全量与薄分开计数
|
||||
# (踩坑:若统一按「最近 N 份」裁剪,周中的薄备份会把周日全量挤出窗口,
|
||||
# 导致 Gitea 仓库只受保护 3 天,等于没有全量)
|
||||
# FULL_RETENTION 份全量(含仓库) + THIN_RETENTION 份薄(不含仓库)
|
||||
# ============================================================
|
||||
log "清理过期备份(全量保留 ${FULL_RETENTION} 份,薄保留 ${THIN_RETENTION} 份)..."
|
||||
declare -A KEEP=()
|
||||
|
||||
# 取「最近的 N 份全量 / 薄」目录
|
||||
# ⚠️ 循环体内用 if 而非 `cond && echo`:set -e 下条件为假会使子 shell 以非 0 退出,
|
||||
# 导致进程替换提前终止(曾致第 302 行报"未捕获错误")
|
||||
keep_mark() {
|
||||
local want="$1" mode="$2" d found
|
||||
for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
|
||||
if [[ ! -d "$d" ]]; then continue; fi
|
||||
if [[ -n "$(ls "$d"/gitea-repos-*.tar.gz 2>/dev/null | head -1)" ]]; then found=1; else found=0; fi
|
||||
if [[ "$mode" == "full" && "$found" == "1" ]] || [[ "$mode" == "thin" && "$found" == "0" ]]; then
|
||||
echo "$d"
|
||||
fi
|
||||
done | sort -r | head -n "$want"
|
||||
}
|
||||
|
||||
for d in $(keep_mark "$FULL_RETENTION" full); do KEEP["$d"]=1; done
|
||||
for d in $(keep_mark "$THIN_RETENTION" thin); do KEEP["$d"]=1; done
|
||||
|
||||
for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
|
||||
if [[ ! -d "$d" ]]; then continue; fi
|
||||
if [[ -z "${KEEP["$d"]:-}" ]]; then
|
||||
rm -rf "$d" && log " 已清理: $(basename "$d")"
|
||||
fi
|
||||
done
|
||||
|
||||
TOTAL=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1)
|
||||
log "========== 备份完成 =========="
|
||||
log "目录: $STAMP_DIR 备份区总计: ${TOTAL:-unknown}"
|
||||
log "剩余磁盘: $(df -h / | awk 'NR==2{print $5" 已用, "$4" 可用"}')"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Novalon CI/CD 恢复脚本(backup-cicd.sh 的逆向)
|
||||
#
|
||||
# 铁律:未做过恢复演练的备份等于没有备份。
|
||||
# 建议每季度在临时目录做一次 --dry-run 级别演练。
|
||||
#
|
||||
# 用法:
|
||||
# ./restore-cicd.sh --from /home/novalon/backups/cicd/20260914_033000 # 全量恢复
|
||||
# ./restore-cicd.sh --from <dir> --component jenkins # 只恢复 Jenkins
|
||||
# ./restore-cicd.sh --from <dir> --component gitea
|
||||
# ./restore-cicd.sh --from <dir> --dry-run # 只校验归档,不写入
|
||||
#
|
||||
# ⚠️ 恢复前必须先停服务,否则会写入半一致状态。脚本默认会停,用 --no-stop 可跳过。
|
||||
# ============================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
FROM=""
|
||||
COMPONENT="all" # all | gitea | jenkins
|
||||
DRY_RUN=0
|
||||
NO_STOP=0
|
||||
|
||||
GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-postgresql}"
|
||||
JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
|
||||
GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}"
|
||||
GITEA_DB_USER="${GITEA_DB_USER:-gitea}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/home/novalon/docker-app/novalon-cicd}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from) FROM="$2"; shift 2 ;;
|
||||
--component) COMPONENT="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-stop) NO_STOP=1; shift ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo "[restore] $*"; }
|
||||
fail() { echo "[restore] ❌ $*" >&2; exit 1; }
|
||||
trap 'fail "第 $LINENO 行发生未捕获错误"' ERR
|
||||
|
||||
[[ -n "$FROM" && -d "$FROM" ]] || fail "请指定有效的 --from 备份目录"
|
||||
log "备份源: $FROM"
|
||||
log "范围: $COMPONENT (dry-run=$DRY_RUN)"
|
||||
cat "$FROM/MANIFEST.txt" 2>/dev/null || log "⚠️ 无 MANIFEST.txt,无法核对版本"
|
||||
|
||||
# ---------- 定位产物 ----------
|
||||
DB_DUMP="$(ls "$FROM"/gitea-db-*.sql.gz 2>/dev/null | tail -1 || true)"
|
||||
REPO_TAR="$(ls "$FROM"/gitea-repos-*.tar.gz 2>/dev/null | tail -1 || true)"
|
||||
JENKINS_TAR="$(ls "$FROM"/jenkins-config-*.tar.gz 2>/dev/null | tail -1 || true)"
|
||||
|
||||
# ---------- 归档校验 ----------
|
||||
log "----- 归档完整性校验 -----"
|
||||
[[ "$COMPONENT" == "jenkins" ]] || { [[ -s "$DB_DUMP" ]] && gzip -t "$DB_DUMP" || fail "gitea-db 归档无效: ${DB_DUMP:-缺失}"; }
|
||||
[[ "$COMPONENT" == "jenkins" ]] || { [[ -s "$REPO_TAR" ]] && tar -tzf "$REPO_TAR" >/dev/null || fail "gitea-repos 归档无效: ${REPO_TAR:-缺失}"; }
|
||||
[[ "$COMPONENT" == "gitea" ]] || { [[ -s "$JENKINS_TAR" ]] && tar -tzf "$JENKINS_TAR" >/dev/null || fail "jenkins 归档无效: ${JENKINS_TAR:-缺失}"; }
|
||||
|
||||
# Jenkins 凭据可解密性校验(最容易踩的坑)
|
||||
if [[ "$COMPONENT" != "gitea" ]]; then
|
||||
if tar -tzf "$JENKINS_TAR" | grep -q "secret.key"; then
|
||||
log "✅ 归档含 secret.key,凭据可解密"
|
||||
else
|
||||
fail "归档缺少 secret.key —— 恢复后所有 Jenkins 凭据将不可解密。请改用含 secret.key 的备份。"
|
||||
fi
|
||||
fi
|
||||
log "✅ 全部归档校验通过"
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
log "dry-run 完成,未写入任何数据。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 二次确认 ----------
|
||||
echo
|
||||
echo "⚠️ 即将覆盖现有 Gitea / Jenkins 数据,此操作不可撤销。"
|
||||
read -r -p "请输入 'RESTORE' 以继续: " CONFIRM
|
||||
[[ "$CONFIRM" == "RESTORE" ]] || fail "未确认,已中止"
|
||||
|
||||
# ---------- 停服务 ----------
|
||||
if (( NO_STOP == 0 )); then
|
||||
log "停止服务..."
|
||||
docker stop "$JENKINS_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker stop "$GITEA_CONTAINER" >/dev/null 2>&1 || true
|
||||
# PostgreSQL 保持运行(恢复需要它在线),只停 Gitea 与 Jenkins
|
||||
docker start "$PG_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Gitea 恢复
|
||||
# ============================================================
|
||||
if [[ "$COMPONENT" != "jenkins" ]]; then
|
||||
log "----- 恢复 PostgreSQL (Gitea 库) -----"
|
||||
# 先建一个安全网:把当前库导出一份(若库还存在)
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U "$GITEA_DB_USER" >/dev/null 2>&1; then
|
||||
docker exec "$PG_CONTAINER" pg_dump -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" -Fc \
|
||||
| gzip -9 > "/tmp/gitea-db-pre-restore-$(date +%s).sql.gz" \
|
||||
&& log "已保存恢复前快照到 /tmp" || log "⚠️ 恢复前快照失败(继续)"
|
||||
fi
|
||||
|
||||
gunzip -c "$DB_DUMP" | docker exec -i "$PG_CONTAINER" \
|
||||
pg_restore -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" --clean --if-exists --no-owner \
|
||||
|| log "⚠️ pg_restore 返回非 0(--clean 对不存在的对象会告警,通常可忽略)"
|
||||
log "✅ 数据库恢复完成"
|
||||
|
||||
log "----- 恢复 Gitea 仓库 -----"
|
||||
docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$(dirname "$REPO_TAR"):/in" alpine \
|
||||
sh -c 'tar xzf /in/'"$(basename "$REPO_TAR")"' -C /data' \
|
||||
|| fail "仓库恢复失败"
|
||||
log "✅ 仓库恢复完成"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Jenkins 恢复
|
||||
# ============================================================
|
||||
if [[ "$COMPONENT" != "gitea" ]]; then
|
||||
log "----- 恢复 Jenkins 配置 -----"
|
||||
docker run --rm --volumes-from "$JENKINS_CONTAINER" -v "$(dirname "$JENKINS_TAR"):/in" alpine \
|
||||
sh -c 'cd /var/jenkins_home && tar xzf /in/'"$(basename "$JENKINS_TAR")" \
|
||||
|| fail "Jenkins 恢复失败"
|
||||
log "✅ Jenkins 配置恢复完成"
|
||||
fi
|
||||
|
||||
# ---------- 起服务 ----------
|
||||
log "启动服务..."
|
||||
docker start "$GITEA_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker start "$JENKINS_CONTAINER" >/dev/null 2>&1 || true
|
||||
|
||||
log "等待健康检查..."
|
||||
sleep 20
|
||||
docker ps --filter "name=$GITEA_CONTAINER" --filter "name=$JENKINS_CONTAINER" \
|
||||
--format "{{.Names}}: {{.Status}}"
|
||||
|
||||
log "========== 恢复完成 =========="
|
||||
log "请人工核验:"
|
||||
log " 1. https://git.f.novalon.cn 仓库列表与最近提交"
|
||||
log " 2. https://ci.f.novalon.cn 作业 novalon-website-ci-cd 与凭据 gitea-credentials"
|
||||
log " 3. 触发一次构建验证 webhook 链路"
|
||||
@@ -0,0 +1,153 @@
|
||||
# ============================================================
|
||||
# Gitea 生产环境编排(加固版)
|
||||
# 部署位置: 139.155.109.62:/home/novalon/docker-app/novalon-cicd/
|
||||
# 访问地址: https://git.f.novalon.cn
|
||||
#
|
||||
# 相对现状(2026-09-14 实测)的修复点:
|
||||
# 1. 凭据外置到 .env,不再内联在 compose 里
|
||||
# 2. SSH_PORT 22 -> 2222,修正"克隆地址指向宿主 sshd"的错配
|
||||
# 3. 资源限制改用 compose 原生 cpus/mem_limit(deploy: 段在非 swarm 下被忽略,等于没限制)
|
||||
# 4. 补 read_only / cap_drop / tmpfs 最小权限
|
||||
# 5. 补 PostgreSQL 日志轮转与备份卷
|
||||
#
|
||||
# 用法:
|
||||
# cp .env.example .env && vim .env # 填入真实凭据,.env 绝不入库
|
||||
# docker compose -f docker-compose.gitea.yml up -d
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
# ------------------------------------------------------------
|
||||
# PostgreSQL 17 —— Gitea 唯一数据源
|
||||
# ------------------------------------------------------------
|
||||
postgresql:
|
||||
image: postgres:17-alpine
|
||||
container_name: postgresql
|
||||
restart: always
|
||||
stop_grace_period: 30s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
environment:
|
||||
POSTGRES_USER: ${GITEA_DB_USER}
|
||||
POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
|
||||
POSTGRES_DB: ${GITEA_DB_NAME}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
TZ: Asia/Shanghai
|
||||
PGTZ: Asia/Shanghai
|
||||
volumes:
|
||||
- ./postgresql_data:/var/lib/postgresql/data
|
||||
- ./postgresql_init:/docker-entrypoint-initdb.d:ro
|
||||
- ./postgresql_backup:/var/lib/postgresql/backup
|
||||
tmpfs:
|
||||
- /tmp:size=100m,mode=1777
|
||||
# compose 原生限制(非 swarm 下 deploy: 不生效,必须用这两个键)
|
||||
cpus: 1.0
|
||||
mem_limit: 2g
|
||||
mem_reservation: 512m
|
||||
networks:
|
||||
- novalon-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${GITEA_DB_USER} -d ${GITEA_DB_NAME} && psql -U ${GITEA_DB_USER} -d ${GITEA_DB_NAME} -c 'SELECT 1'",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
compress: "true"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Gitea 1.27.x
|
||||
# ------------------------------------------------------------
|
||||
gitea:
|
||||
image: gitea/gitea:1.27.2
|
||||
container_name: gitea
|
||||
restart: always
|
||||
stop_grace_period: 30s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- TZ=Asia/Shanghai
|
||||
- DB_TYPE=postgres
|
||||
- DB_HOST=postgresql:5432
|
||||
- DB_NAME=${GITEA_DB_NAME}
|
||||
- DB_USER=${GITEA_DB_USER}
|
||||
- DB_PASSWD=${GITEA_DB_PASSWORD}
|
||||
- SSH_DOMAIN=${GITEA_SSH_DOMAIN}
|
||||
# 关键修复:必须与宿主实际映射端口一致,否则页面给出的 SSH 克隆地址不可用
|
||||
- SSH_PORT=2222
|
||||
- HTTP_PORT=3000
|
||||
- ROOT_URL=${GITEA_ROOT_URL}
|
||||
- LFS_START_SERVER=true
|
||||
- DISABLE_REGISTRATION=true
|
||||
- REQUIRE_SIGNIN_VIEW=true
|
||||
- SHOW_REGISTRATION_BUTTON=false
|
||||
- DEFAULT_KEEP_EMAIL_PRIVATE=true
|
||||
- DEFAULT_ALLOW_CREATE_ORGANIZATION=false
|
||||
- CACHE_ADAPTER=memory
|
||||
- SESSION_PROVIDER=db
|
||||
- ENABLE_GZIP=true
|
||||
- MAX_CREATION_LIMIT=100
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__security__PASSWORD_COMPLEXITY=lower,upper,digit,spec
|
||||
- GITEA__security__MIN_PASSWORD_LENGTH=12
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=true
|
||||
- GITEA__log__LEVEL=info
|
||||
- GITEA__log__MODE=console
|
||||
volumes:
|
||||
- ./gitea/data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
tmpfs:
|
||||
- /tmp:size=100m,mode=1777
|
||||
ports:
|
||||
# 仅 SSH 对外;HTTP 只允许本机,由 nginx 反代终止 TLS
|
||||
- "2222:22"
|
||||
- "127.0.0.1:3001:3000"
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
cpus: 1.0
|
||||
mem_limit: 1g
|
||||
mem_reservation: 256m
|
||||
networks:
|
||||
- novalon-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
compress: "true"
|
||||
|
||||
networks:
|
||||
novalon-network:
|
||||
external: true
|
||||
@@ -0,0 +1,88 @@
|
||||
# ============================================================
|
||||
# Jenkins 生产环境编排(加固版)
|
||||
# 部署位置: 139.155.109.62:/home/novalon/docker-app/jenkins/
|
||||
# 访问地址: https://ci.f.novalon.cn
|
||||
#
|
||||
# 相对现状(2026-09-14 实测)的修复点:
|
||||
# 1. user: root -> 1000:1000(现状以 root 运行,容器内即宿主 root)
|
||||
# 2. 摘掉 /var/run/docker.sock(作业 config.xml 实测 0 处 docker 调用,纯多余风险;
|
||||
# 挂载 docker.sock = 容器内任意用户可直接拿到宿主 root)
|
||||
# 3. 摘掉 /root/.ssh 全量挂载 -> 改为专用部署密钥只读挂载
|
||||
# 4. 补日志轮转(现状无 logging 配置,jenkins_home 已 430MB 且裸奔)
|
||||
# 5. 补资源限制、cap_drop、CSRF、JCasC 声明式配置
|
||||
#
|
||||
# ⚠️ 首次切换到非 root 前必须先改卷属主(否则 Jenkins 起不来):
|
||||
# docker stop jenkins
|
||||
# docker run --rm -v jenkins_jenkins_home:/v alpine chown -R 1000:1000 /v
|
||||
# docker compose -f docker-compose.jenkins.yml up -d
|
||||
#
|
||||
# 用法:
|
||||
# cp .env.example .env && vim .env
|
||||
# docker compose -f docker-compose.jenkins.yml up -d
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
jenkins:
|
||||
# 锁定 LTS 大版本;升级走镜像 tag 变更 + 蓝绿验证,不要用 latest 漂移
|
||||
image: jenkins/jenkins:2.568.1-lts-jdk17
|
||||
container_name: jenkins
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
# 不再以 root 运行
|
||||
user: "1000:1000"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- JAVA_OPTS=-Xmx1024m -Xms256m -Duser.timezone=Asia/Shanghai -Djenkins.install.runSetupWizard=false
|
||||
# 关闭 Jenkins 2.3xx 之后默认禁用的入站代理(JNLP)端口,减少攻击面
|
||||
- JENKINS_OPTS=--httpPort=8080
|
||||
# JCasC:声明式安全域 / 授权 / 凭据,杜绝手工漂移
|
||||
- CASC_JENKINS_CONFIG=/var/jenkins_config/casc.yaml
|
||||
- ADMIN_USER=${JENKINS_ADMIN_USER}
|
||||
- ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DEPLOY_SSH_KEY_PASSPHRASE=${DEPLOY_SSH_KEY_PASSPHRASE}
|
||||
volumes:
|
||||
- jenkins_home:/var/jenkins_home
|
||||
# JCasC 只读挂载
|
||||
- ./jenkins/casc.yaml:/var/jenkins_config/casc.yaml:ro
|
||||
# 专用部署密钥(仅含 id_ed25519_deploy / known_hosts),不再暴露宿主 /root/.ssh
|
||||
- ./jenkins-ssh:/var/jenkins_home/.ssh:ro
|
||||
tmpfs:
|
||||
- /tmp:size=200m,mode=1777
|
||||
ports:
|
||||
# 只监听本机,由 nginx 终止 TLS;50000 为 JNLP 入站代理,无 agent 时可整段删除
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:50000:50000"
|
||||
cpus: 2.0
|
||||
mem_limit: 2g
|
||||
mem_reservation: 512m
|
||||
networks:
|
||||
- novalon-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/login"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
compress: "true"
|
||||
|
||||
volumes:
|
||||
jenkins_home:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
novalon-network:
|
||||
external: true
|
||||
@@ -0,0 +1,112 @@
|
||||
# ============================================================
|
||||
# Jenkins Configuration as Code (JCasC)
|
||||
#
|
||||
# 目的:把安全域、授权策略、CSRF、凭据、节点协议全部声明化,
|
||||
# 避免"某次手工点了全局可写"这类不可追溯的漂移。
|
||||
#
|
||||
# 挂载:CASC_JENKINS_CONFIG=/var/jenkins_config/casc.yaml
|
||||
# 校验:Jenkins -> Manage Jenkins -> Configuration as Code -> View Configuration
|
||||
# 或用 jenkins-plugin-cli 的 casc 校验器 docker run 预检
|
||||
#
|
||||
# 变量来自 .env(容器 env),不要在此文件写明文。
|
||||
# ============================================================
|
||||
|
||||
jenkins:
|
||||
systemMessage: |
|
||||
Novalon CI/CD (ci.f.novalon.cn) —— 生产环境,操作留痕。
|
||||
未授权访问将被记录并追责。
|
||||
|
||||
# ---------- 安全域:Jenkins 自有用户库,禁用匿名 ----------
|
||||
securityRealm:
|
||||
local:
|
||||
allowsSignup: false # 禁止自助注册
|
||||
enableCaptcha: false
|
||||
users:
|
||||
- id: "${ADMIN_USER}"
|
||||
# 密码由 bcrypt 存储;首次由 JCasC 写入,之后通过 UI/API 改
|
||||
password: "${ADMIN_PASSWORD}"
|
||||
|
||||
# ---------- 授权:登录后可读,管理员全权 ----------
|
||||
authorizationStrategy:
|
||||
loggedInUsersCanDoAnything:
|
||||
allowAnonymousRead: false # 关键:关闭匿名读取(构建日志含路径/命令,属敏感信息)
|
||||
|
||||
# ---------- 全局安全 ----------
|
||||
crumbIssuer:
|
||||
standard:
|
||||
excludeClientIPFromCrumb: false
|
||||
|
||||
remotingSecurity:
|
||||
enabled: true # 启用 agent -> controller 的调用过滤(防 RCE 类滥用)
|
||||
|
||||
# 无 agent 时关闭 JNLP 入站端口;有 agent 再改为 fixed:50000
|
||||
agentProtocols:
|
||||
- "Ping"
|
||||
|
||||
# Jenkins URL 必须与实际一致,否则 CSRF crumb 与邮件/通知链接会失效
|
||||
location:
|
||||
url: "https://ci.f.novalon.cn/"
|
||||
adminAddress: "team@novalon.cn"
|
||||
|
||||
# ---------- 构建并发与队列 ----------
|
||||
numExecutors: 2
|
||||
quietPeriod: 5
|
||||
scmCheckoutRetryCount: 2
|
||||
|
||||
# ---------- 全局环境变量(作业里用 ${...} 引用) ----------
|
||||
globalNodeProperties:
|
||||
- envVars:
|
||||
env:
|
||||
- key: "PROJECT_NAME"
|
||||
value: "novalon-website"
|
||||
- key: "SERVER_IP"
|
||||
value: "139.155.109.62"
|
||||
- key: "SERVER_USER"
|
||||
value: "root"
|
||||
- key: "DEPLOY_ROOT"
|
||||
value: "/home/novalon/docker-app"
|
||||
- key: "NGINX_CONTAINER"
|
||||
value: "novalon-nginx-secure"
|
||||
- key: "DOMAIN"
|
||||
value: "https://novalon.cn"
|
||||
- key: "NPM_REGISTRY"
|
||||
value: "https://registry.npmmirror.com"
|
||||
- key: "BACKUP_RETENTION_COUNT"
|
||||
value: "3"
|
||||
|
||||
# ---------- 凭据:统一由环境变量注入,绝不写死在 JCasC ----------
|
||||
credentials:
|
||||
system:
|
||||
domainCredentials:
|
||||
- credentials:
|
||||
- usernamePassword:
|
||||
scope: GLOBAL
|
||||
id: "gitea-credentials"
|
||||
username: "${ADMIN_USER}"
|
||||
password: "${GITEA_TOKEN}"
|
||||
description: "Gitea 代码拉取令牌(read:repository)"
|
||||
|
||||
# ---------- 工具安装(与 Jenkinsfile 中 nodejs('18') 对应) ----------
|
||||
tool:
|
||||
nodejs:
|
||||
installations:
|
||||
- name: "18"
|
||||
home: ""
|
||||
properties:
|
||||
- installSource:
|
||||
installers:
|
||||
- nodeJSInstaller:
|
||||
id: "18.20.4"
|
||||
npmPackagesRefreshHours: 72
|
||||
|
||||
# ---------- 插件更新站点 ----------
|
||||
# updateCenter:
|
||||
# sites:
|
||||
# - id: "default"
|
||||
# url: "https://updates.jenkins.io/update-center.json"
|
||||
|
||||
# ---------- 安全加固:禁用 CLI over HTTP / 旧协议 ----------
|
||||
unclassified:
|
||||
# 关闭 Jenkins 内置 CLI over HTTP(历史漏洞面)
|
||||
# 通过 startupProbe 之外的手段无法在 JCasC 直接关,
|
||||
# 已在 docker-compose 的 JAVA_OPTS 中以 -Djenkins.CLI.disabled=true 处理
|
||||
@@ -0,0 +1,41 @@
|
||||
# Jenkins 必备插件清单(生产最小集,按现状 268 个插件裁剪治理的基线)
|
||||
# 安装:docker compose exec jenkins jenkins-plugin-cli --plugin-file /var/jenkins_config/plugins.txt
|
||||
#
|
||||
# 说明:现状容器里 plugins 目录混有 .jpi 与 .bak 残留(268 条记录),
|
||||
# 建议按此清单重建,避免僵尸插件带来的 CVE 面。
|
||||
|
||||
# --- 核心流水线 ---
|
||||
workflow-aggregator # Pipeline 全家桶
|
||||
pipeline-stage-view
|
||||
pipeline-utility-steps
|
||||
git
|
||||
git-client
|
||||
scm-api
|
||||
|
||||
# --- Gitea 集成 ---
|
||||
gitea # Gitea Server 插件(提供 Gitea Organization / webhook 端点)
|
||||
generic-webhook-trigger # Jenkinsfile 中 GenericTrigger 依赖,Gitea push 触发
|
||||
|
||||
# --- 凭据 ---
|
||||
credentials
|
||||
credentials-binding # withCredentials / sshagent
|
||||
plain-credentials
|
||||
ssh-credentials
|
||||
|
||||
# --- 构建工具 ---
|
||||
nodejs # Jenkinsfile 中 nodejs(nodeJSInstallationName:'18') 依赖
|
||||
timestamper # options { timestamps() }
|
||||
ansicolor # options { ansiColor('xterm') }
|
||||
build-timeout
|
||||
|
||||
# --- 报告与产物 ---
|
||||
htmlpublisher # publishHTML:覆盖率 / E2E 报告
|
||||
junit # 测试结果趋势
|
||||
warnings-ng # ESLint / tsc 警告可视化
|
||||
|
||||
# --- 运维 ---
|
||||
disk-usage # JENKINS_HOME 增长监控(本次磁盘 85% 事故的直接看板)
|
||||
monitoring # 内置监控
|
||||
mailer # post { failure { mail } }
|
||||
configuration-as-code # JCasC,本目录 casc.yaml 依赖
|
||||
job-dsl # 作业声明化(可选,用于重建 novalon-website-ci-cd)
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Novalon CI/CD 健康检查
|
||||
#
|
||||
# 探针维度:
|
||||
# 1. 容器存活与健康状态
|
||||
# 2. HTTPS 端点可达性(Gitea / Jenkins / 官网)
|
||||
# 3. TLS 证书剩余有效期
|
||||
# 4. 磁盘与 Docker 卷增长(宿主 20G 已用 85%,这是头号风险)
|
||||
# 5. 备份新鲜度(超过 36 小时无新备份 -> FAIL)
|
||||
# 6. Jenkins 构建队列积压 / 执行器卡死
|
||||
#
|
||||
# 退出码:0 全通过;1 有 FAIL。便于 cron + Webhook 告警。
|
||||
#
|
||||
# crontab(每 10 分钟):
|
||||
# */10 * * * * /home/novalon/docker-app/infra/cicd/monitoring/healthcheck-cicd.sh
|
||||
# >> /var/log/cicd-healthcheck.log 2>&1
|
||||
#
|
||||
# 用法:
|
||||
# ./healthcheck-cicd.sh # 正常检查,FAIL 时推送告警
|
||||
# ./healthcheck-cicd.sh --quiet # 只在 FAIL 时输出
|
||||
# ./healthcheck-cicd.sh --json # 输出 JSON(供 Prometheus/自定义采集)
|
||||
# ============================================================
|
||||
|
||||
set -uo pipefail # 不用 -e,健康检查要跑完全部探针再汇总
|
||||
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
|
||||
ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
|
||||
DISK_WARN_PCT="${DISK_WARN_PCT:-80}"
|
||||
DISK_CRIT_PCT="${DISK_CRIT_PCT:-90}"
|
||||
CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}"
|
||||
BACKUP_MAX_AGE_HOURS="${BACKUP_MAX_AGE_HOURS:-36}"
|
||||
|
||||
GITEA_URL="${GITEA_URL:-https://git.f.novalon.cn}"
|
||||
JENKINS_URL="${JENKINS_URL:-https://ci.f.novalon.cn}"
|
||||
SITE_URL="${SITE_URL:-https://novalon.cn}"
|
||||
|
||||
QUIET=0; JSON=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--quiet) QUIET=1 ;;
|
||||
--json) JSON=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PASS=0; FAIL=0; WARN=0
|
||||
RESULTS=()
|
||||
|
||||
# ⚠️ 这三个函数必须显式 return 0:
|
||||
# 曾踩坑 —— --quiet/--json 模式下 `[[ 条件 ]] && echo` 因条件为假而返回 1,
|
||||
# 导致 `cond && ok ... || bad ...` 构造中 ok 已执行却"返回失败",跌进 bad 分支,
|
||||
# 造成 FAIL 计数随输出模式抖动(1/1/2)
|
||||
ok() { PASS=$((PASS+1)); RESULTS+=("PASS|$1|$2"); if [[ $QUIET == 0 && $JSON == 0 ]]; then echo " ✅ $1 — $2"; fi; return 0; }
|
||||
warn() { WARN=$((WARN+1)); RESULTS+=("WARN|$1|$2"); if [[ $JSON == 0 ]]; then echo " ⚠️ $1 — $2"; fi; return 0; }
|
||||
bad() { FAIL=$((FAIL+1)); RESULTS+=("FAIL|$1|$2"); if [[ $JSON == 0 ]]; then echo " ❌ $1 — $2"; fi; return 0; }
|
||||
|
||||
emit_json() {
|
||||
printf '{"ts":"%s","host":"%s","pass":%d,"warn":%d,"fail":%d,"checks":[' \
|
||||
"$(date -Is)" "$(hostname)" "$PASS" "$WARN" "$FAIL"
|
||||
local first=1
|
||||
for r in "${RESULTS[@]}"; do
|
||||
IFS='|' read -r lvl name msg <<< "$r"
|
||||
(( first )) || printf ','
|
||||
first=0
|
||||
printf '{"level":"%s","name":"%s","msg":"%s"}' "$lvl" "$name" "${msg//\"/\\\"}"
|
||||
done
|
||||
printf ']}\n'
|
||||
}
|
||||
|
||||
alert() {
|
||||
[[ -z "$ALERT_WEBHOOK_URL" ]] && return 0
|
||||
local body="[CI/CD 健康检查] 主机 $(hostname)
|
||||
$(printf '%s\n' "${RESULTS[@]}" | grep '^FAIL' | sed 's/^FAIL|/ ❌ /' | sed 's/|/ — /')
|
||||
$(printf '%s\n' "${RESULTS[@]}" | grep '^WARN' | sed 's/^WARN|/ ⚠️ /' | sed 's/|/ — /')"
|
||||
curl -fsS -m 10 -X POST "$ALERT_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$(printf '{"msgtype":"text","text":{"content":%s}}' \
|
||||
"$(printf '%s' "$body" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo '\"见日志\"')")" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
[[ $JSON == 0 ]] && echo "========== CI/CD 健康检查 $(date -Is) =========="
|
||||
|
||||
# ---------- 1. 容器状态 ----------
|
||||
[[ $JSON == 0 ]] && echo "[1] 容器状态"
|
||||
for c in gitea jenkins postgresql nginx; do
|
||||
name=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -x "$c" || true)
|
||||
if [[ -z "$name" ]]; then
|
||||
# nginx 容器名带前缀,做模糊匹配
|
||||
name=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i "$c" | head -1 || true)
|
||||
fi
|
||||
if [[ -z "$name" ]]; then
|
||||
bad "container:$c" "容器未运行"
|
||||
continue
|
||||
fi
|
||||
# ⚠️ 未定义 healthcheck 的容器:inspect 返回空串(命令成功,|| 不会触发),
|
||||
# 必须显式把空串归一为 none,否则会误报"状态异常"
|
||||
st="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null)"
|
||||
st="${st:-none}"
|
||||
if [[ "$st" == "healthy" || "$st" == "none" ]]; then
|
||||
ok "container:$c" "运行中 ($st)"
|
||||
else
|
||||
bad "container:$c" "状态异常: $st"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 2. HTTPS 端点 ----------
|
||||
[[ $JSON == 0 ]] && echo "[2] HTTPS 端点"
|
||||
check_url() {
|
||||
local label="$1" url="$2" expect="$3"
|
||||
# ⚠️ 不能用 `|| echo 000`:curl 偶发"已输出 http_code 但退出码非 0",
|
||||
# 会把 200 与 000 拼接成 200000,造成假 FAIL。改用正则校验兜底。
|
||||
local code
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' -m 12 --noproxy '*' "$url" 2>/dev/null || true)"
|
||||
[[ "$code" =~ ^[0-9]{3}$ ]] || code="000"
|
||||
if [[ "$code" =~ ^($expect)$ ]]; then ok "http:$label" "$code ($url)"
|
||||
else bad "http:$label" "期望 $expect 实得 $code ($url)"; fi
|
||||
}
|
||||
check_url gitea "$GITEA_URL/" "200|301|302|303"
|
||||
check_url jenkins "$JENKINS_URL/" "200|403"
|
||||
check_url site "$SITE_URL/" "200|301|302"
|
||||
|
||||
# ---------- 3. TLS 证书 ----------
|
||||
[[ $JSON == 0 ]] && echo "[3] TLS 证书"
|
||||
check_cert() {
|
||||
local host="$1"
|
||||
local end; end=$(echo | timeout 12 openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null \
|
||||
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
if [[ -z "$end" ]]; then warn "cert:$host" "无法获取证书信息"; return; fi
|
||||
local end_epoch now days
|
||||
end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %H:%M:%S %Y %Z" "$end" +%s 2>/dev/null || echo 0)
|
||||
now=$(date +%s)
|
||||
days=$(( (end_epoch - now) / 86400 ))
|
||||
if (( days <= 0 )); then bad "cert:$host" "证书已过期 ($days 天)"
|
||||
elif (( days < 7 )); then bad "cert:$host" "证书 $days 天后过期,立即续期"
|
||||
elif (( days < CERT_WARN_DAYS )); then warn "cert:$host" "证书 $days 天后过期"
|
||||
else ok "cert:$host" "剩余 $days 天"; fi
|
||||
}
|
||||
check_cert "${GITEA_URL#https://}"
|
||||
check_cert "${JENKINS_URL#https://}"
|
||||
|
||||
# ---------- 4. 磁盘 ----------
|
||||
[[ $JSON == 0 ]] && echo "[4] 磁盘与卷"
|
||||
root_pct=$(df -P / | awk 'NR==2{gsub(/%/,"");print $5}')
|
||||
root_free=$(df -h / | awk 'NR==2{print $4}')
|
||||
if (( root_pct >= DISK_CRIT_PCT )); then bad "disk:root" "已用 ${root_pct}%(阈值 ${DISK_CRIT_PCT}%),可用 ${root_free}"
|
||||
elif (( root_pct >= DISK_WARN_PCT )); then warn "disk:root" "已用 ${root_pct}%(阈值 ${DISK_WARN_PCT}%),可用 ${root_free}"
|
||||
else ok "disk:root" "已用 ${root_pct}%,可用 ${root_free}"; fi
|
||||
|
||||
for v in jenkins_jenkins_home; do
|
||||
if docker volume inspect "$v" >/dev/null 2>&1; then
|
||||
sz=$(docker system df -v 2>/dev/null | grep -F "$v" | awk '{print $3}' | head -1)
|
||||
if [[ -n "$sz" ]]; then
|
||||
num=${sz%GB}
|
||||
if (( $(echo "$num > 5" | bc -l 2>/dev/null || echo 0) )); then
|
||||
warn "volume:$v" "体积 ${sz} 超过 5GB,检查构建历史清理策略"
|
||||
else ok "volume:$v" "体积 ${sz}"; fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 备份区体积
|
||||
if [[ -d "$BACKUP_ROOT" ]]; then
|
||||
bsz=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1)
|
||||
ok "backup:size" "备份区 ${bsz}"
|
||||
fi
|
||||
|
||||
# ---------- 5. 备份新鲜度 ----------
|
||||
[[ $JSON == 0 ]] && echo "[5] 备份新鲜度"
|
||||
if [[ ! -d "$BACKUP_ROOT" ]]; then
|
||||
bad "backup:freshness" "备份目录不存在: $BACKUP_ROOT(Jenkins 从未备份过)"
|
||||
else
|
||||
latest=$(ls -1dt "$BACKUP_ROOT"/[0-9]*_[0-9]* 2>/dev/null | head -1 || true)
|
||||
if [[ -z "$latest" ]]; then
|
||||
bad "backup:freshness" "备份目录存在但无任何备份"
|
||||
else
|
||||
age_h=$(( ( $(date +%s) - $(stat -c %Y "$latest") ) / 3600 ))
|
||||
if (( age_h > BACKUP_MAX_AGE_HOURS )); then
|
||||
bad "backup:freshness" "最新备份 $(basename "$latest") 已 ${age_h}h(阈值 ${BACKUP_MAX_AGE_HOURS}h)"
|
||||
else
|
||||
ok "backup:freshness" "最新备份 $(basename "$latest"),${age_h}h 前"
|
||||
fi
|
||||
# 内容完整性
|
||||
cnt=$(find "$latest" -name '*.tar.gz' -o -name '*.sql.gz' 2>/dev/null | wc -l | tr -d ' ')
|
||||
# 注意:不要写成 `(( cnt >= 2 )) && ok || bad` —— 若 ok 返回非 0 会跌进 bad
|
||||
if (( cnt >= 2 )); then ok "backup:content" "含 $cnt 个产物"; else bad "backup:content" "产物仅 $cnt 个,备份不完整"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 6. Jenkins 队列 ----------
|
||||
[[ $JSON == 0 ]] && echo "[6] Jenkins 队列"
|
||||
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx jenkins; then
|
||||
q=$(docker exec jenkins sh -c \
|
||||
'curl -fsS --noproxy "*" http://localhost:8080/queue/api/json?tree=items\[id\] 2>/dev/null | grep -o "\"id\"" | wc -l' 2>/dev/null || echo "-1")
|
||||
if [[ "$q" == "-1" ]]; then warn "jenkins:queue" "无法读取队列(需认证,属正常)"
|
||||
elif (( q > 10 )); then bad "jenkins:queue" "队列积压 $q 个任务,检查执行器是否卡死"
|
||||
else ok "jenkins:queue" "队列 $q"; fi
|
||||
else
|
||||
bad "jenkins:queue" "Jenkins 容器未运行"
|
||||
fi
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
if (( JSON == 1 )); then emit_json
|
||||
else
|
||||
echo "----------------------------------------"
|
||||
echo "汇总: ✅ PASS=$PASS ⚠️ WARN=$WARN ❌ FAIL=$FAIL"
|
||||
fi
|
||||
|
||||
if (( FAIL > 0 )); then alert; fi
|
||||
exit $(( FAIL > 0 ? 1 : 0 ))
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 容器日志兜底截断
|
||||
#
|
||||
# 为什么需要它(2026-09-14 实测事故):
|
||||
# novalon-cicd/docker-compose.yml 里明明配了
|
||||
# logging: { driver: json-file, options: { max-size: 10m, max-file: 3 } }
|
||||
# 但运行中容器的 HostConfig.LogConfig 实测为 {"Type":"json-file","Config":{}}
|
||||
# —— 配置从未生效,Gitea 单份日志涨到 849MB,占掉整盘 4%。
|
||||
#
|
||||
# 根因:logging 驱动参数在**容器创建时**固化,事后改 compose 不重建则不生效。
|
||||
#
|
||||
# 本脚本是**临时兜底**,不是终态。
|
||||
# 终态:用 infra/cicd/docker-compose.gitea.yml 重建容器(含正确 logging 配置),
|
||||
# 之后本脚本可退化为纯监控用途。
|
||||
#
|
||||
# 用法:
|
||||
# ./truncate-container-logs.sh # 截断超过阈值的日志
|
||||
# ./truncate-container-logs.sh --check # 只报告,不截断
|
||||
# ./truncate-container-logs.sh --threshold 50 # 自定义阈值(MB)
|
||||
# ============================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
THRESHOLD_MB=100
|
||||
CHECK_ONLY=0
|
||||
# 用 while 而非 for 解析:for 迭代的是展开后的固定列表,
|
||||
# 循环内 shift 不会改变已排定的迭代序列,导致 `--check --threshold 50` 取错值
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) CHECK_ONLY=1; shift ;;
|
||||
--threshold) THRESHOLD_MB="${2:-100}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
LOG_ROOT="${LOG_ROOT:-/var/lib/docker/containers}"
|
||||
ts() { date "+%Y-%m-%d %H:%M:%S"; }
|
||||
|
||||
echo "[$(ts)] 容器日志检查(阈值 ${THRESHOLD_MB}MB,模式 $([[ $CHECK_ONLY == 1 ]] && echo 只读 || echo 截断))"
|
||||
|
||||
TOTAL_BEFORE=0
|
||||
TOTAL_AFTER=0
|
||||
HIT=0
|
||||
|
||||
while IFS= read -r f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
size_mb=$(( $(stat -c %s "$f") / 1048576 ))
|
||||
[[ $size_mb -lt $THRESHOLD_MB ]] && continue
|
||||
|
||||
HIT=$((HIT + 1))
|
||||
TOTAL_BEFORE=$((TOTAL_BEFORE + size_mb))
|
||||
|
||||
cid=$(basename "$(dirname "$f")")
|
||||
cname=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's|^/||' || echo "$cid")
|
||||
echo " ⚠️ ${cname}: ${size_mb}MB ($f)"
|
||||
|
||||
if (( CHECK_ONLY == 0 )); then
|
||||
# truncate 对运行中的容器安全:Docker 以 O_APPEND 写,截断后继续追加
|
||||
truncate -s 0 "$f" && echo " → 已截断"
|
||||
TOTAL_AFTER=$((TOTAL_AFTER + $(stat -c %s "$f") / 1048576))
|
||||
fi
|
||||
done < <(find "$LOG_ROOT" -maxdepth 2 -name '*-json.log' 2>/dev/null)
|
||||
|
||||
if (( HIT == 0 )); then
|
||||
echo " ✅ 无日志超过 ${THRESHOLD_MB}MB"
|
||||
else
|
||||
echo " 命中 $HIT 个"
|
||||
if (( CHECK_ONLY == 0 )); then
|
||||
echo " 释放约 $((TOTAL_BEFORE - TOTAL_AFTER))MB"
|
||||
df -h / | awk 'NR==2{print " 磁盘: 已用 "$5", 可用 "$4}'
|
||||
fi
|
||||
fi
|
||||
|
||||
# 提醒:若命中说明 logging 配置仍未生效,需要重建容器才是终态修复
|
||||
if (( HIT > 0 )); then
|
||||
echo " ℹ️ 命中的容器说明其 logging 轮转未生效 —— 请用带 logging 配置的 compose 重建容器(终态修复)"
|
||||
fi
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 清除 git 历史中的明文凭据
|
||||
#
|
||||
# 背景(2026-09-14 实测确认已入库并推送到公网可达远程):
|
||||
# 泄露位置见 docs/deployment/cicd/03-credential-incident.md 第 2 节
|
||||
#
|
||||
# ⚠️ 强制推送会重写远程历史:
|
||||
# - 所有协作者必须重新 clone(旧本地仓库 push 会把泄露内容带回来)
|
||||
# - 若 Gitea 上有未合入的 PR / fork,会失效
|
||||
# - 建议:先轮换凭据(rotate-credentials.sh),再清史,最后强制推送
|
||||
#
|
||||
# 前置:pip install git-filter-repo (或 brew install git-filter-repo)
|
||||
#
|
||||
# ⚠️ 本脚本【不】内置泄露明文。曾踩坑:把泄露值硬编码进本脚本的注释与
|
||||
# 替换规则里,一旦提交等于把泄露物重新入库一次,清史变成滚雪球。
|
||||
# 替换规则从仓库外文件读取(默认 ~/.novalon-purge-secrets.txt)。
|
||||
#
|
||||
# 用法:
|
||||
# SECRETS_FILE=~/.novalon-purge-secrets.txt \
|
||||
# ./purge-git-history.sh --dry-run # 只扫描并列出命中,不改写
|
||||
# ./purge-git-history.sh # 执行清史(会先做 mirror 备份)
|
||||
# ./purge-git-history.sh --no-push # 清史但不强推,留自己复核
|
||||
# ============================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
DRY_RUN=0
|
||||
NO_PUSH=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--no-push) NO_PUSH=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/.workbuddy/tmp/git-purge-backup}"
|
||||
REMOTE="${REMOTE:-origin}"
|
||||
BRANCH="${BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
|
||||
SECRETS_FILE="${SECRETS_FILE:-$HOME/.novalon-purge-secrets.txt}"
|
||||
|
||||
# 需要整体删除的文件(含明文配置,且本身不该入库)
|
||||
FILES_TO_DROP=(
|
||||
"scripts/setup-cicd/cicd.config"
|
||||
)
|
||||
|
||||
log() { echo "[purge] $*"; }
|
||||
die() { echo "[purge] ❌ $*" >&2; exit 1; }
|
||||
|
||||
cd "$REPO_DIR" || die "无法进入仓库: $REPO_DIR"
|
||||
[[ -d .git ]] || die "不是 git 仓库: $REPO_DIR"
|
||||
|
||||
# ============================================================
|
||||
# 读取替换规则(格式: 明文==>***REMOVED*** ,每行一条)
|
||||
# ============================================================
|
||||
if [[ ! -s "$SECRETS_FILE" ]]; then
|
||||
cat >&2 <<EOF
|
||||
[purge] ❌ 未找到替换规则文件: $SECRETS_FILE
|
||||
|
||||
为避免把泄露明文写进仓库,本脚本不内置规则。请在【仓库外】创建(不要入 git):
|
||||
|
||||
cat > $SECRETS_FILE <<'EOF'
|
||||
<泄露明文1>==>***REMOVED***
|
||||
<泄露明文2>==>***REMOVED***
|
||||
<泄露明文3>==>***REMOVED***
|
||||
EOF
|
||||
chmod 600 $SECRETS_FILE
|
||||
|
||||
具体哪些值:见 docs/deployment/cicd/03-credential-incident.md 第 2 节列出的
|
||||
文件与变量名(cicd.config 的三个值 + setup-registry-auth.sh 的 gto_ secret)。
|
||||
创建后重新运行本脚本。
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 提取明文值用于扫描命中
|
||||
mapfile -t SECRET_VALUES < <(sed 's/==>.*$//' "$SECRETS_FILE" | grep -v '^[[:space:]]*$')
|
||||
if (( ${#SECRET_VALUES[@]} == 0 )); then
|
||||
die "替换规则文件为空或格式错误: $SECRETS_FILE"
|
||||
fi
|
||||
log "已加载 ${#SECRET_VALUES[@]} 条替换规则(值不回显)"
|
||||
|
||||
# 构造 git grep 的 -e 参数数组
|
||||
GREP_ARGS=()
|
||||
for v in "${SECRET_VALUES[@]}"; do
|
||||
GREP_ARGS+=( -e "$v" )
|
||||
done
|
||||
|
||||
scan_hits() {
|
||||
# shellcheck disable=SC2086
|
||||
git grep -l -F "${GREP_ARGS[@]}" $(git rev-list --all) -- 2>/dev/null | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
# ---------- 扫描命中 ----------
|
||||
log "----- 扫描历史中的命中 -----"
|
||||
git log --all --oneline -- "${FILES_TO_DROP[@]}" scripts/setup-registry-auth.sh 2>/dev/null | head -20 || true
|
||||
HIT_COUNT=$(scan_hits)
|
||||
log "历史中含泄露凭据的 blob 数: $HIT_COUNT"
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
log "dry-run 结束,未做任何改写。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$HIT_COUNT" == "0" ]]; then
|
||||
log "未发现命中,无需清史。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 备份 ----------
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
MIRROR="$BACKUP_DIR/$(basename "$REPO_DIR")-$(date +%Y%m%d_%H%M%S).git"
|
||||
log "----- 备份镜像到 $MIRROR -----"
|
||||
git clone --mirror "$REPO_DIR" "$MIRROR" || die "备份失败,已中止(不冒险改写)"
|
||||
log "✅ 备份完成"
|
||||
|
||||
# ---------- 工具检查 ----------
|
||||
if command -v git-filter-repo >/dev/null 2>&1; then
|
||||
TOOL="filter-repo"
|
||||
elif python3 -c "import git_filter_repo" >/dev/null 2>&1; then
|
||||
TOOL="python-module"
|
||||
else
|
||||
die "未找到 git-filter-repo。安装: pip install git-filter-repo 或 brew install git-filter-repo"
|
||||
fi
|
||||
|
||||
# ---------- 二次确认 ----------
|
||||
echo
|
||||
echo "⚠️ 即将重写 git 历史并强制推送到 $REMOTE。"
|
||||
echo "⚠️ 所有协作者必须重新 clone,未合入的 PR 可能失效。"
|
||||
read -r -p "输入 'PURGE' 继续: " C
|
||||
if [[ "$C" != "PURGE" ]]; then
|
||||
log "未确认,已中止。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 执行清史 ----------
|
||||
log "----- 执行清史(工具: $TOOL) -----"
|
||||
if [[ "$TOOL" == "filter-repo" ]]; then
|
||||
git filter-repo --replace-text "$SECRETS_FILE" \
|
||||
$(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
|
||||
--force
|
||||
else
|
||||
python3 -m git_filter_repo --replace-text "$SECRETS_FILE" \
|
||||
$(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
|
||||
--force
|
||||
fi
|
||||
log "✅ 历史已重写"
|
||||
|
||||
# ---------- 本地清理 ----------
|
||||
log "----- 本地 GC -----"
|
||||
git reflog expire --expire=now --all
|
||||
git gc --prune=now --aggressive
|
||||
log "✅ 本地对象已清理"
|
||||
|
||||
# ---------- 校验 ----------
|
||||
log "----- 校验:重新扫描是否还有残留 -----"
|
||||
REMAIN=$(scan_hits)
|
||||
if [[ "$REMAIN" != "0" ]]; then
|
||||
die "仍有 $REMAIN 处残留!请检查 .git/packed-refs 或其他分支。备份在: $MIRROR"
|
||||
fi
|
||||
log "✅ 无残留"
|
||||
|
||||
# ---------- 推送 ----------
|
||||
if (( NO_PUSH == 1 )); then
|
||||
log "--no-push:已跳过强制推送。复核后手动执行:"
|
||||
log " git push origin --force --all && git push origin --force --tags"
|
||||
else
|
||||
read -r -p "确认强制推送到 $REMOTE ? (yes/N): " P
|
||||
if [[ "$P" == "yes" ]]; then
|
||||
git push "$REMOTE" --force --all
|
||||
git push "$REMOTE" --force --tags
|
||||
log "✅ 已强制推送"
|
||||
cat <<EOF
|
||||
|
||||
================ 收尾(必做) =================
|
||||
1. 通知所有协作者:rm -rf 本地仓库后重新 clone(旧仓库 push 会带回泄露内容)
|
||||
2. 在 Gitea 上执行「仓库设置 → 维护 → 运行垃圾回收」,删除远程的旧对象
|
||||
(否则旧 blob 仍可通过 commit sha 直接访问)
|
||||
3. 检查是否有 fork / 镜像 / CI 缓存仍持有旧历史
|
||||
4. 确认 rotate-credentials.sh 已执行完毕(清史不能替代轮换!)
|
||||
5. 加 pre-commit 钩子防复发:gitleaks / trufflehog
|
||||
6. 处置完毕后删除 $SECRETS_FILE
|
||||
===============================================
|
||||
EOF
|
||||
else
|
||||
log "已跳过推送。备份镜像保留在: $MIRROR"
|
||||
fi
|
||||
fi
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# P0 凭据轮换脚本
|
||||
#
|
||||
# 背景(2026-09-14 实测确认):
|
||||
# 以下真实凭据已随 git 提交并推送到 https://git.f.novalon.cn/novalon/novalon-website.git,
|
||||
# 该远程公网可达,等同于已泄露:
|
||||
# - scripts/setup-cicd/cicd.config GITEA_PASSWORD, JENKINS_ADMIN_PASSWORD
|
||||
# - scripts/setup-registry-auth.sh Gitea OAuth2 secret (gto_cc5c...)
|
||||
# - novalon-cicd/docker-compose.yml 仅落在服务器,未入库(但同样应外置)
|
||||
#
|
||||
# 本脚本执行四件事:
|
||||
# 1. 生成强随机新凭据
|
||||
# 2. 轮换 Gitea 用户密码 + 吊销旧 OAuth2 应用 + 签发新的只读访问令牌
|
||||
# 3. 轮换 Jenkins admin 密码(通过 init.groovy.d 在重启时生效,无需已有管理员凭据)
|
||||
# 4. 写回 .env,并输出"后续必须手工完成"的清单
|
||||
#
|
||||
# ⚠️ 本脚本会立即使旧凭据失效 —— 请在无人使用的时间窗执行,
|
||||
# 且必须在清史(purge-git-history.sh)**之后**或**同时**完成,顺序无强制但间隔越短越好。
|
||||
#
|
||||
# 用法:
|
||||
# ./rotate-credentials.sh --dry-run # 只生成并打印计划,不改动任何东西
|
||||
# ./rotate-credentials.sh # 实际执行
|
||||
# ============================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
DRY_RUN=0
|
||||
for a in "$@"; do [[ "$a" == "--dry-run" ]] && DRY_RUN=1; done
|
||||
|
||||
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
|
||||
ENV_FILE="${ENV_FILE:-$DEPLOY_ROOT/infra/cicd/.env}"
|
||||
REPO_DIR="${REPO_DIR:-/Users/zhangxiang/Codes/Novalon/novalon-website}"
|
||||
|
||||
GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
|
||||
GITEA_URL="${GITEA_URL:-https://git.f.novalon.cn}"
|
||||
|
||||
log() { echo "[rotate] $*"; }
|
||||
die() { echo "[rotate] ❌ $*" >&2; exit 1; }
|
||||
|
||||
gen_pw() { openssl rand -base64 24 | tr -d '/+=' | head -c 24; }
|
||||
gen_tok() { openssl rand -hex 32; }
|
||||
|
||||
log "========== P0 凭据轮换 =========="
|
||||
log "dry-run=$DRY_RUN"
|
||||
|
||||
# ---------- 0. 前置校验 ----------
|
||||
command -v docker >/dev/null || die "docker 不可用(本脚本需在生产服务器上执行)"
|
||||
docker ps --format '{{.Names}}' | grep -qx "$GITEA_CONTAINER" || die "Gitea 容器未运行"
|
||||
docker ps --format '{{.Names}}' | grep -qx "$JENKINS_CONTAINER" || die "Jenkins 容器未运行"
|
||||
|
||||
# ---------- 1. 生成新凭据 ----------
|
||||
NEW_GITEA_PASSWORD="$(gen_pw)"
|
||||
NEW_JENKINS_PASSWORD="$(gen_pw)"
|
||||
NEW_DB_PASSWORD="$(gen_pw)"
|
||||
|
||||
log "已生成新凭据(下方仅在 dry-run 时明文展示)"
|
||||
if (( DRY_RUN )); then
|
||||
echo " Gitea 密码 : $NEW_GITEA_PASSWORD"
|
||||
echo " Jenkins密码 : $NEW_JENKINS_PASSWORD"
|
||||
echo " 数据库 密码 : $NEW_DB_PASSWORD"
|
||||
fi
|
||||
|
||||
if (( DRY_RUN == 0 )); then
|
||||
echo
|
||||
log "⚠️ 即将轮换生产凭据,旧凭据立即失效。"
|
||||
read -r -p "输入 'ROTATE' 继续: " C
|
||||
[[ "$C" == "ROTATE" ]] || die "未确认,已中止"
|
||||
fi
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
log "dry-run:以下操作均跳过。"
|
||||
log "计划:1) Gitea 改密 2) 吊销 OAuth2 应用 3) 签发只读令牌 4) Jenkins 改密 5) 写回 .env"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 2. 轮换 Gitea 用户密码 ----------
|
||||
log "----- [1/5] Gitea 用户密码 -----"
|
||||
# 从 app.ini 取管理员账号(第一个 uid=1 的用户)
|
||||
GITEA_ADMIN="${GITEA_ADMIN:-zhangxiang@novalon.cn}"
|
||||
docker exec -u git "$GITEA_CONTAINER" \
|
||||
gitea admin user change-password --username "$GITEA_ADMIN" --password "$NEW_GITEA_PASSWORD" \
|
||||
&& log "✅ Gitea 用户 $GITEA_ADMIN 密码已轮换" \
|
||||
|| log "⚠️ Gitea 改密失败(账号名可能不对,请用 --env GITEA_ADMIN= 指定)"
|
||||
|
||||
# ---------- 3. 吊销旧 OAuth2 应用 ----------
|
||||
log "----- [2/5] 吊销 Gitea OAuth2 应用(含泄露的 gto_cc5c...) -----"
|
||||
docker exec -u git "$GITEA_CONTAINER" \
|
||||
gitea admin auth list-oauth 2>/dev/null || log " 列出 OAuth2 应用失败(版本差异,可界面处理)"
|
||||
log "ℹ️ 请核对上方列表,对 registry / jenkins 相关的应用执行:"
|
||||
log " docker exec -u git $GITEA_CONTAINER gitea admin auth delete-oauth --id <ID>"
|
||||
log " 或在 Gitea → 设置 → 应用 → 授权应用 手动撤销,再重新创建。"
|
||||
|
||||
# ---------- 4. 签发新的只读令牌 ----------
|
||||
log "----- [3/5] 签发新的 Gitea 只读访问令牌 -----"
|
||||
NEW_TOKEN="$(curl -fsS --noproxy '*' -X POST \
|
||||
-u "$GITEA_ADMIN:$NEW_GITEA_PASSWORD" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"jenkins-readonly-'$(date +%Y%m%d)'","scopes":["read:repository"]}' \
|
||||
"$GITEA_URL/api/v1/users/$GITEA_ADMIN/tokens" \
|
||||
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p' || true)"
|
||||
|
||||
if [[ -z "$NEW_TOKEN" ]]; then
|
||||
log "⚠️ 自动签发失败,请手动:Gitea → 设置 → 应用 → 生成新令牌(仅勾 read:repository)"
|
||||
read -r -p " 手动生成后粘贴令牌(可留空跳过): " NEW_TOKEN
|
||||
else
|
||||
log "✅ 新令牌已签发(sha1 前缀 ${NEW_TOKEN:0:8}...)"
|
||||
fi
|
||||
|
||||
# ---------- 5. 轮换 Jenkins admin 密码 ----------
|
||||
log "----- [4/5] Jenkins admin 密码(下次重启生效) -----"
|
||||
JENKINS_HOME_MOUNT=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/jenkins_home"}}{{.Source}}{{end}}{{end}}' "$JENKINS_CONTAINER")
|
||||
if [[ -z "$JENKINS_HOME_MOUNT" ]]; then
|
||||
docker volume inspect jenkins_jenkins_home >/dev/null 2>&1 \
|
||||
&& JENKINS_HOME_MOUNT="$(docker volume inspect -f '{{.Mountpoint}}' jenkins_jenkins_home)" \
|
||||
|| die "无法定位 JENKINS_HOME 挂载点"
|
||||
fi
|
||||
|
||||
mkdir -p "$JENKINS_HOME_MOUNT/init.groovy.d"
|
||||
cat > "$JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy" <<GROOVY
|
||||
import jenkins.model.*
|
||||
import hudson.security.*
|
||||
import com.cloudbees.jenkins.plugins.sshcredentials.impl.*
|
||||
import org.jenkinsci.plugins.plaincredentials.*
|
||||
|
||||
def instance = Jenkins.getInstance()
|
||||
def realm = instance.getSecurityRealm()
|
||||
if (realm instanceof HudsonPrivateSecurityRealm) {
|
||||
def u = realm.getUser("${JENKINS_ADMIN_USER:-zhangxiang}")
|
||||
if (u != null) {
|
||||
u.addProperty(new hudson.security.HudsonPrivateSecurityRealm.Details(${hudson.security.HudsonPrivateSecurityRealm.class.name}.fromPlainPassword("${NEW_JENKINS_PASSWORD}")))
|
||||
u.save()
|
||||
println "[rotate] admin 密码已更新"
|
||||
} else {
|
||||
println "[rotate] 未找到用户,跳过"
|
||||
}
|
||||
}
|
||||
instance.save()
|
||||
GROOVY
|
||||
log "✅ 已写入 $JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy"
|
||||
log "ℹ️ 需重启 Jenkins 生效:docker restart $JENKINS_CONTAINER"
|
||||
log "ℹ️ 生效后请删除该 groovy 文件,避免每次启动重复写密码。"
|
||||
|
||||
# ---------- 6. 写回 .env ----------
|
||||
log "----- [5/5] 写回 $ENV_FILE -----"
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
touch "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
|
||||
sed -i.bak "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
upsert JENKINS_ADMIN_USER "${JENKINS_ADMIN_USER:-zhangxiang}"
|
||||
upsert JENKINS_ADMIN_PASSWORD "$NEW_JENKINS_PASSWORD"
|
||||
upsert GITEA_TOKEN "$NEW_TOKEN"
|
||||
upsert GITEA_DB_PASSWORD "$NEW_DB_PASSWORD"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
log "✅ .env 已更新(权限 600)"
|
||||
|
||||
# ---------- 收尾清单 ----------
|
||||
cat <<EOF
|
||||
|
||||
================ 后续必须手工完成 ================
|
||||
1. 重启 Jenkins 使新密码生效:
|
||||
docker restart $JENKINS_CONTAINER
|
||||
验证能登录后删除:
|
||||
rm -f $JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy
|
||||
|
||||
2. 更新 Jenkins 内凭据 gitea-credentials 为新令牌:
|
||||
Manage Jenkins → Credentials → gitea-credentials → Update
|
||||
(或用 casc.yaml + JCasC reload)
|
||||
|
||||
3. 同步 Gitea 数据库连接密码(若改了 DB 密码):
|
||||
改 app.ini [database] PASSWD 与 .env 的 GITEA_DB_PASSWORD 一致后重启 Gitea
|
||||
|
||||
4. 清理 git 历史中的明文凭据:
|
||||
cd $REPO_DIR && ./infra/cicd/security/purge-git-history.sh
|
||||
|
||||
5. 把 scripts/setup-cicd/cicd.config 从仓库删除,改为 .env 模板引用
|
||||
|
||||
6. 重新签发所有依赖旧凭据的地方(本机 git credential、其他协作者、registry 登录)
|
||||
==================================================
|
||||
EOF
|
||||
Reference in New Issue
Block a user