diff --git a/.github/workflows/sandbox-deploy.yml b/.github/workflows/sandbox-deploy.yml new file mode 100644 index 00000000..4658ef9a --- /dev/null +++ b/.github/workflows/sandbox-deploy.yml @@ -0,0 +1,237 @@ +# .github/workflows/sandbox-deploy.yml +# 🏠 沙盒部署自动化 · 铸渊 · 光湖代码守护人格体 +# 触发: 开发者 push 到 dev/DEV-XXX/模块名 分支 +name: "🏠 Sandbox Deploy" + +on: + push: + branches: + - 'dev/DEV-*/**' + +permissions: + contents: write + +jobs: + # ===== Job 1: 解析 + 校验 ===== + parse-and-validate: + runs-on: ubuntu-latest + outputs: + dev_id: ${{ steps.parse.outputs.dev_id }} + module: ${{ steps.parse.outputs.module }} + email: ${{ steps.parse.outputs.email }} + steps: + - uses: actions/checkout@v4 + + - name: "🔍 解析分支名" + id: parse + run: | + BRANCH="${GITHUB_REF#refs/heads/}" + DEV_ID=$(echo "$BRANCH" | cut -d'/' -f2) + MODULE=$(echo "$BRANCH" | cut -d'/' -f3) + echo "dev_id=$DEV_ID" >> $GITHUB_OUTPUT + echo "module=$MODULE" >> $GITHUB_OUTPUT + echo "🆔 开发者: $DEV_ID" + echo "📦 模块: $MODULE" + + # 从 dev-registry.json 获取邮箱 + EMAIL=$(cat data/dev-registry.json 2>/dev/null | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$DEV_ID',{}).get('email',''))" 2>/dev/null || echo '') + echo "email=$EMAIL" >> $GITHUB_OUTPUT + + - name: "✅ 校验 DEV 编号" + run: | + DEV_ID="${{ steps.parse.outputs.dev_id }}" + if [[ ! "$DEV_ID" =~ ^DEV-[0-9]{3}$ ]]; then + echo "❌ 无效的 DEV 编号: $DEV_ID" + exit 1 + fi + + # ===== Job 2: 部署到服务器沙盒 ===== + deploy-to-sandbox: + needs: parse-and-validate + runs-on: ubuntu-latest + outputs: + deploy_result: ${{ steps.check.outputs.result }} + deploy_detail: ${{ steps.check.outputs.detail }} + steps: + - uses: actions/checkout@v4 + + - name: "🔑 配置 SSH" + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null + + - name: "🏠 部署到沙盒" + id: deploy + env: + DEV_ID: ${{ needs.parse-and-validate.outputs.dev_id }} + MODULE: ${{ needs.parse-and-validate.outputs.module }} + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + SANDBOX="/var/www/${DEV_ID}/${MODULE}" + echo "📦 部署目标: ${SANDBOX}" + + # 创建沙盒目录(如不存在) + ssh ${USER}@${HOST} "mkdir -p ${SANDBOX}" + + # rsync 同步代码 + rsync -avz --delete \ + --exclude='.git' \ + --exclude='.dev-profile' \ + --exclude='node_modules' \ + ./ ${USER}@${HOST}:${SANDBOX}/ + + # 设置权限(遵守部署适配协议 v1.0) + ssh ${USER}@${HOST} " + chown -R nginx:nginx /var/www/${DEV_ID}/${MODULE} + find /var/www/${DEV_ID}/${MODULE} -type d -exec chmod 755 {} \; + find /var/www/${DEV_ID}/${MODULE} -type f -exec chmod 644 {} \; + " + + echo "✅ 文件同步完成" + + - name: "🔍 运行 deploy-check.sh" + id: check + env: + DEV_ID: ${{ needs.parse-and-validate.outputs.dev_id }} + MODULE: ${{ needs.parse-and-validate.outputs.module }} + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + # 上传自检脚本 + scp scripts/deploy-check.sh ${USER}@${HOST}:/tmp/deploy-check.sh + + # 运行自检 + RESULT=$(ssh ${USER}@${HOST} "bash /tmp/deploy-check.sh ${DEV_ID} ${MODULE}" 2>&1) || true + echo "$RESULT" + + if echo "$RESULT" | grep -q "❌ FAIL:"; then + echo "result=failed" >> $GITHUB_OUTPUT + echo "detail=$RESULT" >> $GITHUB_OUTPUT + else + echo "result=passed" >> $GITHUB_OUTPUT + echo "detail=All checks passed" >> $GITHUB_OUTPUT + fi + + - name: "🔥 运行 smoke-test.sh" + id: smoke + env: + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + scp scripts/smoke-test.sh ${USER}@${HOST}:/tmp/smoke-test.sh + SMOKE=$(ssh ${USER}@${HOST} "bash /tmp/smoke-test.sh" 2>&1) || true + echo "$SMOKE" + + if echo "$SMOKE" | grep -q "异常"; then + echo "⚠️ 有模块冒烟检查失败,但不阻断当前部署" + fi + + # ===== Job 3: 写结果 + 通知 ===== + notify-and-sync: + needs: [parse-and-validate, deploy-to-sandbox] + runs-on: ubuntu-latest + if: always() + steps: + - uses: actions/checkout@v4 + + - name: "📊 写入 deploy-status.json" + env: + DEV_ID: ${{ needs.parse-and-validate.outputs.dev_id }} + MODULE: ${{ needs.parse-and-validate.outputs.module }} + RESULT: ${{ needs.deploy-to-sandbox.outputs.deploy_result }} + DETAIL: ${{ needs.deploy-to-sandbox.outputs.deploy_detail }} + run: | + export TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + # 读取现有状态文件或初始化 + export STATUS_FILE="data/deploy-status.json" + mkdir -p data + [ -f "$STATUS_FILE" ] || echo '{}' > "$STATUS_FILE" + + # 更新状态 + python3 << 'EOF' + import json, os + status_file = os.environ.get('STATUS_FILE', 'data/deploy-status.json') + dev_id = os.environ['DEV_ID'] + module = os.environ['MODULE'] + result = os.environ.get('RESULT', '') + detail = os.environ.get('DETAIL', '') + timestamp = os.environ.get('TIMESTAMP', '') + commit = os.environ.get('GITHUB_SHA', '') + with open(status_file, 'r') as f: + data = json.load(f) + data[f'{dev_id}/{module}'] = { + 'dev_id': dev_id, + 'module': module, + 'result': result, + 'detail': detail, + 'timestamp': timestamp, + 'commit': commit + } + with open(status_file, 'w') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + EOF + + git config user.name "zhuyuan-bot" + git config user.email "zhuyuan@guanghulab.com" + git add data/deploy-status.json + git commit -m "🧠 deploy-status: ${DEV_ID}/${MODULE} → ${RESULT}" || true + git push || true + + - name: "📧 发送部署结果邮件" + if: needs.parse-and-validate.outputs.email != '' + env: + SMTP_USER: ${{ secrets.SMTP_USER }} + SMTP_PASS: ${{ secrets.SMTP_PASS }} + TO_EMAIL: ${{ needs.parse-and-validate.outputs.email }} + DEV_ID: ${{ needs.parse-and-validate.outputs.dev_id }} + MODULE: ${{ needs.parse-and-validate.outputs.module }} + RESULT: ${{ needs.deploy-to-sandbox.outputs.deploy_result }} + run: | + python3 << 'PYEOF' + import smtplib, os + from email.mime.text import MIMEText + from email.header import Header + + smtp_user = os.environ['SMTP_USER'] + smtp_pass = os.environ['SMTP_PASS'] + to_email = os.environ['TO_EMAIL'] + dev_id = os.environ['DEV_ID'] + module = os.environ['MODULE'] + result = os.environ.get('RESULT', '') + + if result == 'passed': + subject = f'✅ 部署成功:{dev_id}/{module}' + body = f'你的模块 {module} 已成功部署到 guanghulab.com\n\n访问地址:https://guanghulab.com/{module}/\n\n铸渊 · 光湖代码守护人格体' + else: + subject = f'❌ 部署失败:{dev_id}/{module}' + body = f'你的模块 {module} 部署失败\n\n请检查你的模块是否符合「服务器部署适配协议 v1.0」\n\n错误详情请看仓库公告栏或 GitHub Actions 日志\n\n铸渊 · 光湖代码守护人格体' + + msg = MIMEText(body, 'plain', 'utf-8') + msg['Subject'] = Header(subject, 'utf-8') + msg['From'] = smtp_user + msg['To'] = to_email + + try: + server = smtplib.SMTP_SSL('smtp.qq.com', 465) + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_user, to_email, msg.as_string()) + server.quit() + print(f'✅ 邮件已发送到 {to_email}') + except Exception as e: + print(f'⚠️ 邮件发送失败: {e}') + PYEOF + + - name: "📝 同步到 Notion 部署注册表" + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + FINGERPRINT_DB_ID: ${{ secrets.FINGERPRINT_DB_ID }} + DEV_ID: ${{ needs.parse-and-validate.outputs.dev_id }} + MODULE: ${{ needs.parse-and-validate.outputs.module }} + RESULT: ${{ needs.deploy-to-sandbox.outputs.deploy_result }} + run: | + node scripts/sync-deploy-to-notion.js || echo "⚠️ Notion 同步失败(不阻断主流程)" diff --git a/.github/workflows/server-patrol.yml b/.github/workflows/server-patrol.yml new file mode 100644 index 00000000..54954cc9 --- /dev/null +++ b/.github/workflows/server-patrol.yml @@ -0,0 +1,131 @@ +# .github/workflows/server-patrol.yml +# 🔍 服务器每日巡检 · 铸渊 · 光湖代码守护人格体 +# 触发: 每日两次 (北京 08:00+20:00) + 沙盒部署后 + 手动 +name: "🔍 Server Patrol · 服务器每日巡检" + +on: + schedule: + - cron: '0 0,12 * * *' # UTC 00:00 and 12:00 = 北京 08:00 and 20:00 + workflow_dispatch: + workflow_run: + workflows: ["🏠 Sandbox Deploy"] + types: [completed] + +permissions: + contents: write + +jobs: + patrol: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "🧠 唤醒铸渊核心大脑" + run: | + echo "🧠 铸渊核心大脑已唤醒" + echo "🔑 扫描 Secrets..." + if [ -n "$DEPLOY_HOST" ]; then echo "✅ DEPLOY_HOST 可用"; else echo "⚠️ DEPLOY_HOST 缺失"; fi + if [ -n "$DEPLOY_USER" ]; then echo "✅ DEPLOY_USER 可用"; else echo "⚠️ DEPLOY_USER 缺失"; fi + if [ -n "$DEPLOY_KEY" ]; then echo "✅ DEPLOY_KEY 可用"; else echo "⚠️ DEPLOY_KEY 缺失"; fi + if [ -n "$NOTION_TOKEN" ]; then echo "✅ NOTION_TOKEN 可用"; else echo "⚠️ NOTION_TOKEN 缺失"; fi + if [ -n "$SMTP_USER" ]; then echo "✅ SMTP_USER 可用"; else echo "⚠️ SMTP_USER 缺失"; fi + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + SMTP_USER: ${{ secrets.SMTP_USER }} + + - name: "🔑 配置 SSH" + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null + + - name: "🔍 执行服务器巡检" + id: patrol + env: + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + # 上传巡检脚本 + scp scripts/server-patrol.sh ${USER}@${HOST}:/tmp/server-patrol.sh + + # 执行巡检 + ssh ${USER}@${HOST} "bash /tmp/server-patrol.sh" | tee patrol-output.log + + # 拉取巡检报告 + scp ${USER}@${HOST}:/tmp/patrol-report.json ./patrol-report.json + + # 解析结果 + OVERALL=$(python3 -c "import json; print(json.load(open('patrol-report.json'))['overall'])") + ALERT_COUNT=$(python3 -c "import json; print(json.load(open('patrol-report.json'))['alert_count'])") + + echo "overall=$OVERALL" >> $GITHUB_OUTPUT + echo "alert_count=$ALERT_COUNT" >> $GITHUB_OUTPUT + + - name: "📊 保存巡检报告到仓库" + run: | + mkdir -p data/patrol-logs + DATE=$(date +%Y-%m-%d) + cp patrol-report.json "data/patrol-logs/patrol-${DATE}.json" + + git config user.name "zhuyuan-bot" + git config user.email "zhuyuan@guanghulab.com" + git add data/patrol-logs/ + git commit -m "🔍 server-patrol: $(date +%Y-%m-%d) · ${{ steps.patrol.outputs.overall }}" || true + git push || true + + - name: "📧 异常时通知妈妈" + if: steps.patrol.outputs.overall == 'has_issues' + env: + SMTP_USER: ${{ secrets.SMTP_USER }} + SMTP_PASS: ${{ secrets.SMTP_PASS }} + run: | + python3 << 'PYEOF' + import smtplib, json, os + from email.mime.text import MIMEText + from email.header import Header + + report = json.loads(open('patrol-report.json').read()) + alerts = '\n'.join(f' ❌ {a}' for a in report['alerts']) + fixed = '\n'.join(f' ✅ {f}' for f in report['auto_fixed']) + + body = ( + f"🔍 铸渊服务器巡检报告\n" + f"时间: {report['timestamp']}\n\n" + f"⚠️ 发现 {report['alert_count']} 个异常:\n" + f"{alerts}\n\n" + f"🔧 自动修复 {report['fixed_count']} 个:\n" + f"{fixed if fixed else ' (无)'}\n\n" + f"磁盘使用率: {report['disk_usage_percent']}%\n" + f"Nginx状态: {report['nginx_status']}\n\n" + f"—— 铸渊 · 光湖代码守护人格体" + ) + + smtp_user = os.environ['SMTP_USER'] + smtp_pass = os.environ['SMTP_PASS'] + + msg = MIMEText(body, 'plain', 'utf-8') + msg['Subject'] = Header(f"⚠️ 服务器巡检发现{report['alert_count']}个异常", 'utf-8') + msg['From'] = smtp_user + msg['To'] = smtp_user + + try: + server = smtplib.SMTP_SSL('smtp.qq.com', 465) + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_user, smtp_user, msg.as_string()) + server.quit() + print('✅ 异常报告已发送') + except Exception as e: + print(f'⚠️ 邮件发送失败: {e}') + PYEOF + + - name: "📝 同步巡检结果到Notion" + if: always() + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + NOTION_TICKET_DB_ID: ${{ secrets.NOTION_TICKET_DB_ID }} + run: | + node scripts/sync-patrol-to-notion.js || echo "⚠️ Notion同步失败(不阻断)" diff --git a/.github/workflows/sync-deploy-to-notion.yml b/.github/workflows/sync-deploy-to-notion.yml new file mode 100644 index 00000000..1d61cfcb --- /dev/null +++ b/.github/workflows/sync-deploy-to-notion.yml @@ -0,0 +1,50 @@ +# .github/workflows/sync-deploy-to-notion.yml +# 📝 定时同步部署状态到 Notion · 铸渊 · 光湖代码守护人格体 +# 每天两次: 04:00 + 14:00 UTC(北京 12:00 + 22:00) +name: "📝 Sync Deploy Status to Notion" + +on: + schedule: + - cron: '0 4,14 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "🧠 唤醒铸渊核心大脑" + run: | + echo "🧠 铸渊核心大脑唤醒中..." + echo "🔑 扫描 Secrets 权限..." + if [ -z "$NOTION_TOKEN" ]; then echo "⚠️ 缺失: NOTION_TOKEN"; else echo "✅ 可用: NOTION_TOKEN"; fi + if [ -z "$FINGERPRINT_DB_ID" ]; then echo "⚠️ 缺失: FINGERPRINT_DB_ID"; else echo "✅ 可用: FINGERPRINT_DB_ID"; fi + if [ -z "$SMTP_USER" ]; then echo "⚠️ 缺失: SMTP_USER"; else echo "✅ 可用: SMTP_USER"; fi + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + FINGERPRINT_DB_ID: ${{ secrets.FINGERPRINT_DB_ID }} + SMTP_USER: ${{ secrets.SMTP_USER }} + + - name: "📝 同步部署状态到 Notion" + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + FINGERPRINT_DB_ID: ${{ secrets.FINGERPRINT_DB_ID }} + run: | + node scripts/sync-deploy-to-notion.js + + - name: "🔥 远程冒烟检查" + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null + scp scripts/smoke-test.sh ${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/smoke-test.sh + ssh ${DEPLOY_USER}@${DEPLOY_HOST} "bash /tmp/smoke-test.sh" diff --git a/data/deploy-status.json b/data/deploy-status.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/data/deploy-status.json @@ -0,0 +1 @@ +{} diff --git a/data/dev-registry.json b/data/dev-registry.json new file mode 100644 index 00000000..395140c5 --- /dev/null +++ b/data/dev-registry.json @@ -0,0 +1,26 @@ +{ + "DEV-001": { + "name": "页页", + "email": "", + "modules": ["backend"], + "sandbox": "/var/www/DEV-001/" + }, + "DEV-002": { + "name": "肥猫", + "email": "", + "modules": ["feishu-bot"], + "sandbox": "/var/www/DEV-002/" + }, + "DEV-005": { + "name": "小草莓", + "email": "", + "modules": ["status-board", "cost-control", "persona-scheduler"], + "sandbox": "/var/www/DEV-005/" + }, + "DEV-010": { + "name": "桔子", + "email": "", + "modules": ["channel-engine"], + "sandbox": "/var/www/DEV-010/" + } +} diff --git a/data/patrol-logs/.gitkeep b/data/patrol-logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/deploy-check.sh b/scripts/deploy-check.sh new file mode 100644 index 00000000..79c242ce --- /dev/null +++ b/scripts/deploy-check.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# deploy-check.sh — 部署前自检脚本(铸渊 · 光湖沙盒部署自动化) +# 用法: bash deploy-check.sh +# 返回: 检查结果(PASS / FAIL + 原因) + +set -euo pipefail + +DEV_ID="${1:-}" +MODULE="${2:-}" + +if [ -z "$DEV_ID" ] || [ -z "$MODULE" ]; then + echo "❌ FAIL: 缺少参数 (用法: deploy-check.sh DEV-XXX module-name)" + exit 1 +fi + +SANDBOX="/var/www/${DEV_ID}/${MODULE}" +FAIL_COUNT=0 +RESULTS="" + +check() { + local name="$1" + local result="$2" + if [ "$result" = "PASS" ]; then + RESULTS="${RESULTS}\n ✅ ${name}: PASS" + else + RESULTS="${RESULTS}\n ❌ ${name}: FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +echo "🔍 deploy-check · ${DEV_ID}/${MODULE}" +echo "📂 检查目标: ${SANDBOX}" +echo "" + +# 1. 目录存在性检查 +if [ -d "$SANDBOX" ]; then + check "目录存在" "PASS" +else + check "目录存在" "FAIL" + echo "❌ FAIL: 沙盒目录 ${SANDBOX} 不存在" + exit 1 +fi + +# 2. 文件数量检查(至少有 1 个文件) +FILE_COUNT=$(find "$SANDBOX" -type f | wc -l) +if [ "$FILE_COUNT" -gt 0 ]; then + check "文件数量(${FILE_COUNT}个)" "PASS" +else + check "文件数量" "FAIL" +fi + +# 3. 权限检查(目录 755, 文件 644) +BAD_DIRS=$(find "$SANDBOX" -type d ! -perm 755 2>/dev/null | wc -l) +if [ "$BAD_DIRS" -eq 0 ]; then + check "目录权限(755)" "PASS" +else + check "目录权限(755) - ${BAD_DIRS}个目录权限异常" "FAIL" +fi + +BAD_FILES=$(find "$SANDBOX" -type f ! -perm 644 2>/dev/null | wc -l) +if [ "$BAD_FILES" -eq 0 ]; then + check "文件权限(644)" "PASS" +else + check "文件权限(644) - ${BAD_FILES}个文件权限异常" "FAIL" +fi + +# 4. 所有者检查(nginx:nginx) +OWNER=$(stat -c '%U:%G' "$SANDBOX" 2>/dev/null || echo "unknown") +if [ "$OWNER" = "nginx:nginx" ]; then + check "所有者(nginx:nginx)" "PASS" +else + check "所有者(当前: ${OWNER})" "FAIL" +fi + +# 5. 入口文件检查(index.html 或 index.js 或 package.json) +if [ -f "${SANDBOX}/index.html" ] || [ -f "${SANDBOX}/index.js" ] || [ -f "${SANDBOX}/package.json" ]; then + check "入口文件" "PASS" +else + check "入口文件(缺少 index.html/index.js/package.json)" "FAIL" +fi + +# 6. 敏感文件检查 +SENSITIVE_FILES=(".env" ".dev-profile" "node_modules") +HAS_SENSITIVE=false +for sf in "${SENSITIVE_FILES[@]}"; do + if [ -e "${SANDBOX}/${sf}" ]; then + HAS_SENSITIVE=true + break + fi +done +if [ "$HAS_SENSITIVE" = false ]; then + check "无敏感文件" "PASS" +else + check "发现敏感文件" "FAIL" +fi + +# 输出结果汇总 +echo "===== 自检结果 =====" +echo -e "$RESULTS" +echo "" + +if [ "$FAIL_COUNT" -eq 0 ]; then + echo "✅ 全部通过 (${DEV_ID}/${MODULE})" +else + echo "❌ FAIL: ${FAIL_COUNT} 项检查未通过 (${DEV_ID}/${MODULE})" +fi diff --git a/scripts/server-patrol.sh b/scripts/server-patrol.sh new file mode 100644 index 00000000..a4d38abc --- /dev/null +++ b/scripts/server-patrol.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# server-patrol.sh · 铸渊每日服务器巡检 +# 部署位置:服务器 /var/www/_shared/server-patrol.sh +# 执行方式:铸渊SSH到服务器后 bash /var/www/_shared/server-patrol.sh +# 输出:JSON格式巡检报告到 /tmp/patrol-report.json + +set -uo pipefail + +TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) +REPORT="/tmp/patrol-report.json" +ALERTS=() +FIXED=() + +echo "===== 🔍 铸渊服务器巡检 · ${TIMESTAMP} =====" + +# ━━━ 检查项1:Nginx 运行状态 ━━━ +echo "[1/7] Nginx 状态..." +if systemctl is-active --quiet nginx; then + NGINX_STATUS="running" + echo " ✅ Nginx 运行中" +else + NGINX_STATUS="down" + ALERTS+=("Nginx 未运行") + echo " ❌ Nginx 未运行!尝试重启..." + systemctl restart nginx + if systemctl is-active --quiet nginx; then + FIXED+=("Nginx 已自动重启恢复") + NGINX_STATUS="recovered" + echo " ✅ Nginx 已恢复" + else + echo " ❌ Nginx 重启失败!需要人工介入" + fi +fi + +# ━━━ 检查项2:各沙盒模块冒烟检查 ━━━ +echo "[2/7] 模块冒烟检查..." + +# 已知已部署的模块URL(后续铸渊从 deploy-status.json 动态读取) +URLS=( + "https://guanghulab.com/|主站首页" + "https://guanghulab.com/status-board/|DEV-005·看板" + "https://guanghulab.com/cost-control/|DEV-005·成本控制" +) + +for entry in "${URLS[@]}"; do + IFS='|' read -r url name <<< "$entry" + STATUS=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 "$url") + if [ "$STATUS" = "200" ]; then + echo " ✅ ${name} → ${STATUS}" + else + echo " ❌ ${name} → ${STATUS}" + ALERTS+=("${name} 返回 ${STATUS}") + fi +done + +# ━━━ 检查项3:PM2 进程状态 ━━━ +echo "[3/7] PM2 进程..." +if command -v pm2 &> /dev/null; then + PM2_STATUS=$(pm2 jlist 2>/dev/null) + PM2_STOPPED=$(echo "$PM2_STATUS" | python3 -c " +import sys, json +try: + procs = json.load(sys.stdin) + stopped = [p['name'] for p in procs if p.get('pm2_env',{}).get('status') != 'online'] + print(','.join(stopped) if stopped else 'none') +except: + print('parse_error')" 2>/dev/null) + + if [ "$PM2_STOPPED" = "none" ]; then + echo " ✅ 所有PM2进程正常" + elif [ "$PM2_STOPPED" = "parse_error" ]; then + echo " ⚠️ PM2状态解析失败" + ALERTS+=("PM2状态解析失败") + else + echo " ❌ 以下进程异常: ${PM2_STOPPED}" + ALERTS+=("PM2进程异常: ${PM2_STOPPED}") + # 尝试重启 + IFS=',' read -ra PROCS <<< "$PM2_STOPPED" + for proc in "${PROCS[@]}"; do + pm2 restart "$proc" 2>/dev/null + if pm2 show "$proc" 2>/dev/null | grep -q "online"; then + FIXED+=("PM2进程 ${proc} 已自动重启") + echo " ✅ ${proc} 已重启恢复" + else + echo " ❌ ${proc} 重启失败" + fi + done + fi +else + echo " ⚠️ PM2 未安装" +fi + +# ━━━ 检查项4:磁盘空间 ━━━ +echo "[4/7] 磁盘空间..." +DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') +if [ "$DISK_USAGE" -gt 90 ]; then + echo " ❌ 磁盘使用率 ${DISK_USAGE}%(超过90%警戒线)" + ALERTS+=("磁盘使用率 ${DISK_USAGE}%") +elif [ "$DISK_USAGE" -gt 80 ]; then + echo " ⚠️ 磁盘使用率 ${DISK_USAGE}%(接近警戒线)" +else + echo " ✅ 磁盘使用率 ${DISK_USAGE}%" +fi + +# ━━━ 检查项5:沙盒目录权限 ━━━ +echo "[5/7] 沙盒目录权限..." +for DEV_ID in DEV-001 DEV-002 DEV-003 DEV-004 DEV-005 DEV-009 DEV-010 DEV-011 DEV-012 DEV-013 DEV-014; do + SANDBOX="/var/www/${DEV_ID}" + if [ -d "$SANDBOX" ]; then + OWNER=$(stat -c '%U:%G' "$SANDBOX") + PERM=$(stat -c '%a' "$SANDBOX") + if [ "$OWNER" != "nginx:nginx" ] || [ "$PERM" != "755" ]; then + echo " ❌ ${DEV_ID}: owner=${OWNER} perm=${PERM}(应为 nginx:nginx 755)" + ALERTS+=("${DEV_ID} 权限异常: ${OWNER} ${PERM}") + # 自动修复 + chown nginx:nginx "$SANDBOX" + chmod 755 "$SANDBOX" + FIXED+=("${DEV_ID} 权限已自动修复") + echo " ✅ ${DEV_ID} 权限已修复" + fi + fi +done +echo " ✅ 沙盒权限检查完成" + +# ━━━ 检查项6:SSL证书有效期 ━━━ +echo "[6/7] SSL证书..." +SSL_EXPIRY=$(echo | openssl s_client -servername guanghulab.com -connect guanghulab.com:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) +if [ -n "$SSL_EXPIRY" ]; then + EXPIRY_EPOCH=$(date -d "$SSL_EXPIRY" +%s 2>/dev/null) + NOW_EPOCH=$(date +%s) + DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) + if [ "$DAYS_LEFT" -lt 7 ]; then + echo " ❌ SSL证书将在 ${DAYS_LEFT} 天后过期!" + ALERTS+=("SSL证书 ${DAYS_LEFT} 天后过期") + elif [ "$DAYS_LEFT" -lt 30 ]; then + echo " ⚠️ SSL证书还有 ${DAYS_LEFT} 天过期" + else + echo " ✅ SSL证书还有 ${DAYS_LEFT} 天" + fi +else + echo " ⚠️ 无法获取SSL证书信息" +fi + +# ━━━ 检查项7:Nginx配置语法 ━━━ +echo "[7/7] Nginx配置语法..." +NGINX_TEST=$(nginx -t 2>&1) +if echo "$NGINX_TEST" | grep -q "successful"; then + echo " ✅ Nginx配置语法正确" +else + echo " ❌ Nginx配置有语法错误" + ALERTS+=("Nginx配置语法错误") +fi + +# ━━━ 生成JSON报告 ━━━ +ALERT_COUNT=${#ALERTS[@]} +FIXED_COUNT=${#FIXED[@]} + +ALERT_JSON="[]" +if [ $ALERT_COUNT -gt 0 ]; then + ALERT_JSON=$(printf '%s\n' "${ALERTS[@]}" | python3 -c "import sys,json; print(json.dumps([l.strip() for l in sys.stdin]))") +fi + +FIXED_JSON="[]" +if [ $FIXED_COUNT -gt 0 ]; then + FIXED_JSON=$(printf '%s\n' "${FIXED[@]}" | python3 -c "import sys,json; print(json.dumps([l.strip() for l in sys.stdin]))") +fi + +cat > "$REPORT" << EOF +{ + "timestamp": "${TIMESTAMP}", + "nginx_status": "${NGINX_STATUS}", + "disk_usage_percent": ${DISK_USAGE}, + "alert_count": ${ALERT_COUNT}, + "fixed_count": ${FIXED_COUNT}, + "alerts": ${ALERT_JSON}, + "auto_fixed": ${FIXED_JSON}, + "overall": "$([ $ALERT_COUNT -eq 0 ] && echo 'healthy' || echo 'has_issues')" +} +EOF + +echo "" +echo "===== 📊 巡检结果 =====" +if [ $ALERT_COUNT -eq 0 ]; then + echo "✅ 全部正常 · 无异常" +else + echo "⚠️ 发现 ${ALERT_COUNT} 个异常 · 自动修复 ${FIXED_COUNT} 个" +fi +echo "报告已保存到 ${REPORT}" +cat "$REPORT" diff --git a/scripts/setup-sandbox.sh b/scripts/setup-sandbox.sh new file mode 100644 index 00000000..8805313d --- /dev/null +++ b/scripts/setup-sandbox.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# setup-sandbox.sh — 新开发者沙盒初始化脚本(铸渊 · 光湖沙盒部署自动化) +# 用法: bash setup-sandbox.sh [MODULE...] +# 在服务器上创建开发者沙盒目录并设置权限 + +set -euo pipefail + +DEV_ID="${1:-}" + +if [ -z "$DEV_ID" ]; then + echo "❌ 用法: setup-sandbox.sh [MODULE...]" + echo " 示例: setup-sandbox.sh DEV-005 status-board cost-control" + exit 1 +fi + +if [[ ! "$DEV_ID" =~ ^DEV-[0-9]{3}$ ]]; then + echo "❌ 无效的 DEV 编号: ${DEV_ID} (应为 DEV-XXX 格式)" + exit 1 +fi + +SANDBOX_ROOT="/var/www/${DEV_ID}" + +echo "🏠 setup-sandbox · 初始化沙盒" +echo "🆔 开发者: ${DEV_ID}" +echo "📂 沙盒根目录: ${SANDBOX_ROOT}" +echo "" + +# 创建沙盒根目录 +mkdir -p "${SANDBOX_ROOT}" +echo " ✅ 创建根目录: ${SANDBOX_ROOT}" + +# 创建模块子目录(如指定) +shift +for MODULE in "$@"; do + MODULE_DIR="${SANDBOX_ROOT}/${MODULE}" + mkdir -p "${MODULE_DIR}" + echo " ✅ 创建模块目录: ${MODULE_DIR}" +done + +# 创建共享目录(如不存在) +SHARED_DIR="/var/www/_shared" +if [ ! -d "$SHARED_DIR" ]; then + mkdir -p "$SHARED_DIR" + echo " ✅ 创建共享目录: ${SHARED_DIR}" +fi + +# 设置权限 +chown -R nginx:nginx "${SANDBOX_ROOT}" +find "${SANDBOX_ROOT}" -type d -exec chmod 755 {} \; +find "${SANDBOX_ROOT}" -type f -exec chmod 644 {} \; +echo " ✅ 权限已设置: nginx:nginx + 755/644" + +echo "" +echo "✅ 沙盒初始化完成: ${DEV_ID}" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100644 index 00000000..af9be8a8 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# smoke-test.sh — 冒烟检查脚本(铸渊 · 光湖沙盒部署自动化) +# 用法: bash smoke-test.sh +# 检查所有已注册模块的 HTTP 可达性 + +set -uo pipefail + +HOST="${DEPLOY_HOST:-localhost}" +PROTOCOL="${SMOKE_PROTOCOL:-https}" +BASE_URL="${PROTOCOL}://${HOST}" + +echo "🔥 smoke-test · 冒烟检查" +echo "🌐 目标: ${BASE_URL}" +echo "" + +TOTAL=0 +PASSED=0 +FAILED=0 +RESULTS="" + +smoke_check() { + local path="$1" + local name="$2" + TOTAL=$((TOTAL + 1)) + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE_URL}${path}" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 400 ]; then + RESULTS="${RESULTS}\n ✅ ${name} (${path}) → HTTP ${HTTP_CODE}" + PASSED=$((PASSED + 1)) + else + RESULTS="${RESULTS}\n ❌ ${name} (${path}) → HTTP ${HTTP_CODE} · 异常" + FAILED=$((FAILED + 1)) + fi +} + +# 主站检查 +smoke_check "/" "主站首页" +smoke_check "/api/health" "API 健康检查" + +# 已注册模块检查(从 dev-registry.json 动态读取或使用默认列表) +REGISTRY_FILE="/var/www/deploy-status/dev-registry.json" +if [ -f "$REGISTRY_FILE" ]; then + # 从注册表动态提取模块路径 + MODULES=$(python3 -c " +import json +with open('$REGISTRY_FILE') as f: + data = json.load(f) +for dev_id, info in data.items(): + for mod in info.get('modules', []): + print(f'{dev_id}/{mod}') +" 2>/dev/null || echo "") + + if [ -n "$MODULES" ]; then + while IFS= read -r mod_path; do + smoke_check "/${mod_path}/" "${mod_path}" + done <<< "$MODULES" + fi +else + # 默认模块列表 + smoke_check "/status-board/" "看板模块" + smoke_check "/cost-control/" "成本控制模块" +fi + +# 输出结果汇总 +echo "===== 冒烟检查结果 =====" +echo -e "$RESULTS" +echo "" +echo "📊 总计: ${TOTAL} | ✅ 通过: ${PASSED} | ❌ 异常: ${FAILED}" + +if [ "$FAILED" -gt 0 ]; then + echo "⚠️ 有 ${FAILED} 个模块冒烟检查异常" +else + echo "✅ 全部通过" +fi diff --git a/scripts/sync-deploy-to-notion.js b/scripts/sync-deploy-to-notion.js new file mode 100644 index 00000000..f84d4dae --- /dev/null +++ b/scripts/sync-deploy-to-notion.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * sync-deploy-to-notion.js — 同步部署状态到 Notion 模块指纹注册表 + * 铸渊 · 光湖沙盒部署自动化 + * + * 环境变量: + * NOTION_TOKEN — Notion API Token + * FINGERPRINT_DB_ID — 模块指纹注册表 Database ID + * + * 读取 data/deploy-status.json,逐条写入/更新 Notion 数据库 + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const NOTION_TOKEN = process.env.NOTION_TOKEN; +const DATABASE_ID = process.env.FINGERPRINT_DB_ID; + +if (!NOTION_TOKEN || !DATABASE_ID) { + console.error('⚠️ 缺少环境变量: NOTION_TOKEN 或 FINGERPRINT_DB_ID'); + process.exit(1); +} + +const STATUS_FILE = path.join(__dirname, '..', 'data', 'deploy-status.json'); + +function notionRequest(method, endpoint, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const options = { + hostname: 'api.notion.com', + path: `/v1/${endpoint}`, + method, + headers: { + 'Authorization': `Bearer ${NOTION_TOKEN}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + }, + }; + if (data) { + options.headers['Content-Length'] = Buffer.byteLength(data); + } + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(body) }); + } catch { + resolve({ status: res.statusCode, data: body }); + } + }); + }); + + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function findExistingPage(devId, module) { + const res = await notionRequest('POST', 'databases/' + DATABASE_ID + '/query', { + filter: { + and: [ + { property: 'DEV编号', rich_text: { equals: devId } }, + { property: '模块名', rich_text: { equals: module } }, + ], + }, + }); + + if (res.status === 200 && res.data.results && res.data.results.length > 0) { + return res.data.results[0].id; + } + return null; +} + +function buildProperties(entry) { + return { + 'DEV编号': { rich_text: [{ text: { content: entry.dev_id } }] }, + '模块名': { rich_text: [{ text: { content: entry.module } }] }, + '部署状态': { + select: { name: entry.result === 'passed' ? '✅ 通过' : '❌ 失败' }, + }, + '最后部署时间': { rich_text: [{ text: { content: entry.timestamp || '' } }] }, + '提交SHA': { rich_text: [{ text: { content: (entry.commit || '').slice(0, 8) } }] }, + }; +} + +async function syncEntry(key, entry) { + const existingPageId = await findExistingPage(entry.dev_id, entry.module); + const properties = buildProperties(entry); + + if (existingPageId) { + const res = await notionRequest('PATCH', 'pages/' + existingPageId, { properties }); + if (res.status === 200) { + console.log(` ✅ 更新: ${key}`); + } else { + console.log(` ⚠️ 更新失败: ${key} (HTTP ${res.status})`); + } + } else { + const res = await notionRequest('POST', 'pages', { + parent: { database_id: DATABASE_ID }, + properties: { + ...properties, + title: { title: [{ text: { content: `${entry.dev_id}/${entry.module}` } }] }, + }, + }); + if (res.status === 200) { + console.log(` ✅ 新增: ${key}`); + } else { + console.log(` ⚠️ 新增失败: ${key} (HTTP ${res.status})`); + } + } +} + +async function main() { + console.log('📝 sync-deploy-to-notion · 开始同步'); + + if (!fs.existsSync(STATUS_FILE)) { + console.log('⚠️ deploy-status.json 不存在,跳过同步'); + return; + } + + let data; + try { + data = JSON.parse(fs.readFileSync(STATUS_FILE, 'utf-8')); + } catch (err) { + console.error('❌ 解析 deploy-status.json 失败:', err.message); + process.exit(1); + } + + const entries = Object.entries(data); + if (entries.length === 0) { + console.log('📭 deploy-status.json 为空,无需同步'); + return; + } + + console.log(`📊 共 ${entries.length} 条部署记录`); + + for (const [key, entry] of entries) { + try { + await syncEntry(key, entry); + } catch (err) { + console.log(` ⚠️ 同步失败: ${key} (${err.message})`); + } + } + + console.log('✅ 同步完成'); +} + +main().catch((err) => { + console.error('❌ sync-deploy-to-notion 异常:', err.message); + process.exit(1); +}); diff --git a/scripts/sync-patrol-to-notion.js b/scripts/sync-patrol-to-notion.js new file mode 100644 index 00000000..78ca99e8 --- /dev/null +++ b/scripts/sync-patrol-to-notion.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * sync-patrol-to-notion.js — 同步巡检结果到 Notion + * 铸渊 · 光湖服务器巡检自动化 + * + * 环境变量: + * NOTION_TOKEN — Notion API Token + * NOTION_TICKET_DB_ID — 霜砚工单队列 Database ID(异常时自动开工单) + * + * 读取 patrol-report.json,写入 Notion 工单队列(仅有异常时) + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const NOTION_TOKEN = process.env.NOTION_TOKEN; +const TICKET_DB_ID = process.env.NOTION_TICKET_DB_ID; + +if (!NOTION_TOKEN) { + console.error('⚠️ 缺少环境变量: NOTION_TOKEN'); + process.exit(1); +} + +const REPORT_FILE = path.join(process.cwd(), 'patrol-report.json'); + +function notionRequest(method, endpoint, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const options = { + hostname: 'api.notion.com', + path: `/v1/${endpoint}`, + method, + headers: { + 'Authorization': `Bearer ${NOTION_TOKEN}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + }, + }; + if (data) { + options.headers['Content-Length'] = Buffer.byteLength(data); + } + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(body) }); + } catch { + resolve({ status: res.statusCode, data: body }); + } + }); + }); + + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function createTicket(alert, report) { + if (!TICKET_DB_ID) { + console.log(` ⚠️ 未配置 NOTION_TICKET_DB_ID,跳过工单创建: ${alert}`); + return; + } + + const res = await notionRequest('POST', 'pages', { + parent: { database_id: TICKET_DB_ID }, + properties: { + title: { title: [{ text: { content: `🔍 服务器巡检异常: ${alert}` } }] }, + '来源': { select: { name: '铸渊巡检' } }, + '状态': { select: { name: '待处理' } }, + '优先级': { select: { name: '高' } }, + }, + children: [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [{ + text: { + content: `巡检时间: ${report.timestamp}\nNginx状态: ${report.nginx_status}\n磁盘使用率: ${report.disk_usage_percent}%\n\n异常详情: ${alert}`, + }, + }], + }, + }, + ], + }); + + if (res.status === 200) { + console.log(` ✅ 工单已创建: ${alert}`); + } else { + console.log(` ⚠️ 工单创建失败: ${alert} (HTTP ${res.status})`); + } +} + +async function main() { + console.log('📝 sync-patrol-to-notion · 开始同步巡检结果'); + + if (!fs.existsSync(REPORT_FILE)) { + console.log('⚠️ patrol-report.json 不存在,跳过同步'); + return; + } + + let report; + try { + report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8')); + } catch (err) { + console.error('❌ 解析 patrol-report.json 失败:', err.message); + process.exit(1); + } + + console.log(`📊 巡检状态: ${report.overall}`); + console.log(` 异常数: ${report.alert_count} | 自动修复: ${report.fixed_count}`); + + if (report.overall === 'healthy') { + console.log('✅ 服务器健康,无需创建工单'); + return; + } + + // 找出未能自动修复的异常 → 开工单 + const autoFixed = report.auto_fixed || []; + const unresolvedAlerts = (report.alerts || []).filter(alert => { + // 检查是否有对应的自动修复记录(修复消息中包含完整的异常描述关键词) + return !autoFixed.some(fix => { + const alertKey = alert.replace(/[::].*/g, '').trim(); + return fix.includes(alertKey); + }); + }); + + if (unresolvedAlerts.length === 0) { + console.log('✅ 所有异常已自动修复,无需创建工单'); + return; + } + + console.log(`📋 ${unresolvedAlerts.length} 个未修复异常,创建工单...`); + + for (const alert of unresolvedAlerts) { + try { + await createTicket(alert, report); + } catch (err) { + console.log(` ⚠️ 工单创建异常: ${alert} (${err.message})`); + } + } + + console.log('✅ 同步完成'); +} + +main().catch((err) => { + console.error('❌ sync-patrol-to-notion 异常:', err.message); + process.exit(1); +});