Merge pull request #112 from qinfendebingshuo/copilot/ice-gl-zy001-sandbox-deployment-automation

feat: sandbox deployment automation + server patrol system
This commit is contained in:
冰朔 2026-03-15 21:26:36 +08:00 committed by GitHub
commit 74baf7f97b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1177 additions and 0 deletions

237
.github/workflows/sandbox-deploy.yml vendored Normal file
View File

@ -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 同步失败(不阻断主流程)"

131
.github/workflows/server-patrol.yml vendored Normal file
View File

@ -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同步失败不阻断"

View File

@ -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"

1
data/deploy-status.json Normal file
View File

@ -0,0 +1 @@
{}

26
data/dev-registry.json Normal file
View File

@ -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/"
}
}

View File

106
scripts/deploy-check.sh Normal file
View File

@ -0,0 +1,106 @@
#!/bin/bash
# deploy-check.sh — 部署前自检脚本(铸渊 · 光湖沙盒部署自动化)
# 用法: bash deploy-check.sh <DEV_ID> <MODULE>
# 返回: 检查结果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

189
scripts/server-patrol.sh Normal file
View File

@ -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} ====="
# ━━━ 检查项1Nginx 运行状态 ━━━
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
# ━━━ 检查项3PM2 进程状态 ━━━
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 " ✅ 沙盒权限检查完成"
# ━━━ 检查项6SSL证书有效期 ━━━
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
# ━━━ 检查项7Nginx配置语法 ━━━
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"

54
scripts/setup-sandbox.sh Normal file
View File

@ -0,0 +1,54 @@
#!/bin/bash
# setup-sandbox.sh — 新开发者沙盒初始化脚本(铸渊 · 光湖沙盒部署自动化)
# 用法: bash setup-sandbox.sh <DEV_ID> [MODULE...]
# 在服务器上创建开发者沙盒目录并设置权限
set -euo pipefail
DEV_ID="${1:-}"
if [ -z "$DEV_ID" ]; then
echo "❌ 用法: setup-sandbox.sh <DEV_ID> [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}"

75
scripts/smoke-test.sh Normal file
View File

@ -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

View File

@ -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);
});

View File

@ -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);
});