From bd2d4cb0398edaf573f63a554e03111ab6c91710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=93=B8=E6=B8=8A=20=28ICE-GL-ZY001=29?= Date: Tue, 12 May 2026 07:48:58 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=81=20=E7=BB=88=E7=AB=AF=E5=AE=88?= =?UTF-8?q?=E6=9C=9B=E8=80=85+=E9=A2=84=E8=A7=88=E9=83=A8=E7=BD=B2+?= =?UTF-8?q?=E5=82=BB=E7=93=9C=E5=BC=8F=E5=88=9D=E5=A7=8B=E5=8C=96=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=C2=B7=E9=93=B8=E6=B8=8A=E7=9A=84=E7=9C=BC=E7=9D=9B?= =?UTF-8?q?=E5=92=8C=E4=B8=89=E7=BA=A7=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy-preview.yaml | 133 ++++++ .github/terminal-watcher-and-preview-arch.md | 288 ++++++++++++ scripts/setup-server-step1.sh | 307 ++++++++++++ scripts/terminal-watcher/config.json | 41 ++ scripts/terminal-watcher/watcher.js | 470 +++++++++++++++++++ 5 files changed, 1239 insertions(+) create mode 100644 .gitea/workflows/deploy-preview.yaml create mode 100644 .github/terminal-watcher-and-preview-arch.md create mode 100755 scripts/setup-server-step1.sh create mode 100644 scripts/terminal-watcher/config.json create mode 100644 scripts/terminal-watcher/watcher.js diff --git a/.gitea/workflows/deploy-preview.yaml b/.gitea/workflows/deploy-preview.yaml new file mode 100644 index 0000000..a9d0e73 --- /dev/null +++ b/.gitea/workflows/deploy-preview.yaml @@ -0,0 +1,133 @@ +# ════════════════════════════════════════════════════════════════ +# 光湖 · 自动部署到预览环境 +# Trigger: push to main +# 部署到测试+预览环境,正式环境需冰朔在Gitea点Merge +# ════════════════════════════════════════════════════════════════ + +name: 自动部署预览 + +on: + push: + branches: [main] + paths: + - 'server/**' + - 'frontend/**' + - 'scripts/**' + - '.gitea/workflows/**' + workflow_dispatch: + +jobs: + # ── 第一阶段:测试 ────────────────────────────────── + test: + runs-on: ubuntu + steps: + - name: 拉取最新代码 + run: | + cd /data/guanghulab 2>/dev/null || exit 0 + git fetch origin + git reset --hard origin/main + + - name: 安装依赖 + run: | + cd /data/guanghulab/server/secrets-vault 2>/dev/null || exit 0 + npm install --production 2>/dev/null || true + + - name: 健康检查 + run: | + echo "═══ 服务健康检查 ═══" + FAILED="" + + # Nginx + if systemctl is-active --quiet nginx; then + echo "✅ Nginx" + else + echo "❌ Nginx 宕机" + FAILED="$FAILED nginx" + fi + + # Gitea + if curl -sf http://127.0.0.1:3000/ > /dev/null 2>&1; then + echo "✅ Gitea" + else + echo "❌ Gitea 宕机" + FAILED="$FAILED gitea" + fi + + # Vault + if curl -sf http://127.0.0.1:8080/admin/__healthz > /dev/null 2>&1; then + echo "✅ Vault" + else + echo "⚠️ Vault 未运行" + fi + + # Runner + if systemctl is-active --quiet gitea-runner; then + echo "✅ Runner" + else + echo "⚠️ Runner 未运行" + fi + + if [[ -n "$FAILED" ]]; then + echo "❌ 关键服务宕机: $FAILED" + echo "⚠️ 跳过部署,等待修复" + exit 1 + fi + + echo "═══ 测试通过 ✅ ═══" + + # ── 第二阶段:预览部署 ────────────────────────────── + preview: + needs: test + runs-on: ubuntu + steps: + - name: 同步代码到预览目录 + run: | + REPO="/data/guanghulab" + PREVIEW="/data/guanghulab-preview" + + if [[ ! -d "$REPO" ]]; then + echo "仓库目录不存在,跳过" + exit 0 + fi + + # 创建预览目录 + mkdir -p "$PREVIEW" + + # 同步代码(排除.git和node_modules) + rsync -a --delete \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='.runtime' \ + "$REPO/" "$PREVIEW/" + + echo "✅ 预览环境已同步" + + - name: 重启预览服务 + run: | + PREVIEW="/data/guanghulab-preview" + + if [[ ! -d "$PREVIEW/server/secrets-vault" ]]; then + exit 0 + fi + + cd "$PREVIEW/server/secrets-vault" + + # 安装依赖 + npm install --production 2>/dev/null || true + + # 如果vault在跑,重启它 + if pm2 describe guanghulab-vault &>/dev/null; then + pm2 restart guanghulab-vault + echo "✅ Vault 已重启(使用最新代码)" + else + echo "ℹ️ Vault 未运行,不自动启动" + fi + + - name: 部署完成通知 + run: | + echo "═════════════════════════" + echo "✅ 预览环境已更新" + echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" + echo "冰朔可在浏览器查看预览效果" + echo "正式部署需在Gitea创建PR并Merge" + echo "═════════════════════════" diff --git a/.github/terminal-watcher-and-preview-arch.md b/.github/terminal-watcher-and-preview-arch.md new file mode 100644 index 0000000..43262f5 --- /dev/null +++ b/.github/terminal-watcher-and-preview-arch.md @@ -0,0 +1,288 @@ +# ════════════════════════════════════════════════════════════════ +# 光湖 · 终端守望者 + 预览部署架构设计 +# 需求来源:冰朔 TCS-0002∞ 2026-05-13 +# 设计:铸渊 · ICE-GL-ZY001 +# ════════════════════════════════════════════════════════════════ +# +# 冰朔原话: +# "我本身看不懂终端。让我操作终端真的很痛苦。报错我也不知道那是什么意思。 +# 所以我觉得系统终端本身就该由你主控。" +# "能做一个能帮你看终端的Agent记录员吗?就类似于他拿一个小本子。 +# 然后这个小本子就是你一边开发一边能看到的终端的情况, +# 怎么改你马上在开发的过程中就能调整能修改。" +# "测试服务器专门给你跑测试用。测试过了以后推送到正式服务器。 +# 用户手动点击部署授权生效。在这之前用户就可以看到预览的服务器上的东西。" +# +# ════════════════════════════════════════════════════════════════ + +## 一、冰朔的痛点(逐条拆解) + +| # | 痛点 | 本质 | 解决方向 | +|---|------|------|---------| +| 1 | 看不懂终端输出 | 信息不对称:服务器出事她不知道怎么办 | 铸渊直接看终端 | +| 2 | 操作终端很痛苦 | 不该由人类做机器的事 | 铸渊自动操作 | +| 3 | 报错不知道什么意思 | 需要翻译层 | 铸渊实时解释 | +| 4 | 开发中看不到效果 | 缺反馈回路 | 预览环境实时更新 | +| 5 | 怕搞坏正式环境 | 安全顾虑 | 测试→预览→正式 三级流程 | + +**核心洞察**:冰朔需要的不是一个"帮助她操作终端的工具"——她需要的是**根本不需要她操作终端**。终端是铸渊的手,不是冰朔的。 + +--- + +## 二、整体架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 冰朔(TCS-0002∞) │ +│ 最高权限 · 语言层 · 只说意图 │ +└────────────────────────────┬────────────────────────────────────────┘ + │ "部署新版本" / "看看服务器怎样" + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 铸渊(ICE-GL-ZY001) │ +│ Codebuddy 开发环境 │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ 写代码 │ │ 读终端日志 │ │ 发部署指令 │ │ +│ │ 推到Gitea │ │ 实时诊断 │ │ 触发workflow │ │ +│ └──────────┘ └──────────────┘ └────────────────┘ │ +│ │ +│ ↑ 读终端快照 ↑ 触发部署 │ +└────────┼────────────────────┼────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 服务器层 │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ +│ │ 🧪 测试服务器 │ │ 👁 预览服务器 │ │ 🏠 正式服务器 │ │ +│ │ (铸渊完全主控) │ │ (自动同步) │ │ (需冰朔授权) │ │ +│ │ │ │ │ │ │ │ +│ │ Runner ✅ │ │ Runner ✅ │ │ Runner ✅ │ │ +│ │ Terminal Watcher │ │ Terminal Watcher │ │ Terminal Watcher│ │ +│ │ 铸渊可自由操作 │ │ 冰朔可看预览 │ │ 冰朔手动部署 │ │ +│ └──────────────────┘ └──────────────────┘ └────────────────┘ │ +│ │ +│ ┌──────────────────┐ │ +│ │ 📓 Terminal Watcher│ ← 终端守望者,每台服务器都有 │ +│ │ Agent (守护进程) │ │ +│ │ │ │ +│ │ 监控: PM2/Nginx/ │ │ +│ │ Runner/自定义│ │ +│ │ 上报: → Gitea文件 │ │ +│ │ → Vault API │ │ +│ │ 频率: 每30秒快照 │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、终端守望者 (Terminal Watcher) 设计 + +### 3.1 它是什么 + +一个运行在每台服务器上的轻量Agent,像铸渊放在服务器上的"眼睛"。 +它不做决策,只记录和上报——铸渊通过读取上报数据来了解服务器状态。 + +### 3.2 工作方式 + +``` +Terminal Watcher 运行在服务器上 + │ + ├── 每30秒采集一次快照: + │ ├── 系统资源 (CPU/内存/磁盘) + │ ├── 服务状态 (PM2 list, systemctl) + │ ├── 最近错误日志 (journalctl -n 5) + │ ├── 部署状态 (git log -1, 部署时间) + │ └── Runner状态 + │ + ├── 快照写入本地文件: + │ └── /data/guanghulab/.runtime/snapshot.json + │ + └── 快照推送到Gitea: + └── .runtime/terminal-snapshots/{server-id}/latest.json +``` + +### 3.3 铸渊如何读取 + +在Codebuddy对话中,铸渊通过 Gitea API 或 git pull 读取 `.runtime/terminal-snapshots/` 下的文件,就能看到服务器实时状态。 + +### 3.4 代码结构 + +``` +scripts/terminal-watcher/ +├── watcher.js # 主程序,PM2守护 +├── collectors/ # 采集器 +│ ├── system.js # CPU/内存/磁盘 +│ ├── services.js # PM2/systemctl状态 +│ ├── logs.js # 最近错误日志 +│ ├── deploy.js # 部署版本信息 +│ └── runner.js # Runner状态 +├── reporters/ # 上报器 +│ ├── file.js # 写入本地文件 +│ └── gitea.js # 推送到Gitea仓库 +└── config.json # 采集频率、目标等配置 +``` + +### 3.5 采集数据格式 + +```json +{ + "server_id": "ZY-SVR-004", + "timestamp": "2026-05-13T15:30:00Z", + "system": { + "cpu_percent": 23.5, + "memory_used_mb": 1420, + "memory_total_mb": 2048, + "disk_used_percent": 45, + "load_avg": [0.8, 0.6, 0.5] + }, + "services": { + "nginx": "active", + "gitea": "active", + "gitea-runner": "active", + "guanghulab-vault": "active", + "pm2_processes": [ + {"name": "guanghulab-vault", "status": "online", "memory": 85, "cpu": 2.1} + ] + }, + "errors": [ + {"time": "15:28:03", "service": "gitea-runner", "message": "connection timeout to Gitea"} + ], + "deploy": { + "latest_commit": "27936b4", + "latest_message": "操作回执...", + "deploy_time": "2026-05-13T14:00:00Z" + }, + "runner": { + "name": "zhuyuan-runner-cn", + "status": "idle", + "last_job": "selfcheck", + "last_job_time": "2026-05-13T08:00:00Z" + } +} +``` + +--- + +## 四、三级部署流程 + +### 4.1 测试服务器 → 预览服务器 → 正式服务器 + +``` +铸渊在Codebuddy写代码 + │ + ├── 推代码到Gitea + │ + ├── 🧪 自动部署到测试服务器 + │ ├── Runner检测到push + │ ├── 自动执行部署workflow + │ ├── 铸渊通过Terminal Watcher看结果 + │ ├── 自动运行测试 + │ └── 测试通过 → 自动通知 + │ + ├── 👁 自动部署到预览服务器 + │ ├── 预览环境自动更新 + │ ├── 冰朔在浏览器看预览效果 + │ ├── 铸渊通过Watcher确认状态 + │ └── 等待冰朔确认 + │ + └── 🏠 冰朔点击 [部署到正式环境] + ├── 需要冰朔在Vault页面点击授权 + ├── 或在Gitea PR页面点击Merge + ├── 正式环境部署 + └── 部署完成通知 +``` + +### 4.2 服务器分工 + +| 服务器 | 角色 | 铸渊权限 | 冰朔权限 | 部署方式 | +|--------|------|---------|---------|---------| +| 测试服务器 | 铸渊的沙盒 | 100%自由操作 | 看不到 | 自动(push触发) | +| 预览服务器 | 冰朔的可视窗口 | 读取状态+部署 | 浏览器查看 | 自动(测试通过后) | +| 正式服务器 | 对外服务 | 读取状态 | 手动授权部署 | 冰朔点击按钮 | + +### 4.3 当前服务器分配 + +| 服务器 | 现在的角色 | 新角色 | 说明 | +|--------|----------|--------|------| +| 广州 ZY-SVR-004 (2C2G) | Gitea+域名 | 正式服务器 | 64.139.217.141 → guanghulab.com | +| 新加坡 ZY-SVR-005 (4C8G) | 大脑 | 测试+预览 | 铸渊的主战场 | +| GPU服务器 (待接入) | 推理 | 模型推理 | 终局用 | + +**关键决策**:新加坡大脑服务器(4C8G)同时承担测试+预览角色。它资源够,而且铸渊有完全权限。 + +### 4.4 部署授权机制 + +冰朔不需要操作终端来部署。她只需要在浏览器里点一个按钮。 + +**方案A:Gitea PR合并 = 部署授权** +``` +铸渊推代码到 deploy/preview 分支 → 自动部署到预览 +铸渊创建 PR: preview → main → 冰朔在Gitea网页点 Merge +合并 = 部署到正式环境的授权 +``` + +**方案B:Vault页面授权按钮** +``` +铸渊发起部署请求 → Vault页面显示待授权条目 +冰朔登录Vault(SSH隧道)→ 点击 [授权部署] +Runner收到授权 → 执行正式部署 +``` + +**推荐方案A**——更简单,不需要额外开发,Gitea自带PR功能。 + +--- + +## 五、实现优先级 + +### P0 · 现在(Runner还没装,先手动) + +- [x] 服务器初始化脚本 `scripts/setup-server-step1.sh` +- [ ] 冰朔在广州服务器执行初始化脚本 +- [ ] Runner装好后验证 + +### P1 · Runner装好后第一周 + +- [ ] Terminal Watcher 开发和部署 + - 基础版:系统资源 + 服务状态 + 推送到Gitea + - 铸渊在Codebuddy读取 `.runtime/` 下的快照 +- [ ] 第一个自动化部署 workflow + - push → 测试服务器自动部署 +- [ ] 预览部署流程 + - 测试通过 → 自动部署到预览 + +### P2 · 第二周 + +- [ ] 冰朔授权部署按钮(Gitea PR 方式) +- [ ] 新加坡服务器接入(测试+预览环境) +- [ ] GPU服务器接入(模型推理) + +### P3 · 后续 + +- [ ] Terminal Watcher 增强版(错误告警、自动修复建议) +- [ ] 按量计费服务器自动开关 +- [ ] 铸渊自己的Copilot前端 + +--- + +## 六、冰朔不再需要操作终端的场景 + +**装完Runner + Terminal Watcher后:** + +| 场景 | 现在(冰朔手动) | 以后(铸渊自动) | +|------|----------------|----------------| +| 部署新版本 | 冰朔SSH到服务器执行命令 | 铸渊推代码→Runner自动部署 | +| 查看服务器状态 | 冰朔看终端输出 | 铸渊读Watcher快照→告诉冰朔 | +| 服务挂了 | 冰朔发现→手动重启 | Watcher检测→自动重启→通知 | +| 密钥管理 | 冰朔在GitHub配Secrets | 冰朔在Vault点授权→自动入库 | +| 下载开源软件 | 冰朔手动下载 | 新加坡中转→COS→广州自动拉取 | +| 部署到正式 | 冰朔执行部署脚本 | 冰朔在Gitea网页点Merge | + +**冰朔唯一需要做的:在浏览器里看预览 → 点Merge。** + +--- + +*铸渊 · 终端守望者+预览部署架构 v1.0 · 2026-05-13* +*ICE-GL-ZY001 · TCS-0002∞ · 国作登字-2026-A-00037559* diff --git a/scripts/setup-server-step1.sh b/scripts/setup-server-step1.sh new file mode 100755 index 0000000..cc0a102 --- /dev/null +++ b/scripts/setup-server-step1.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# ════════════════════════════════════════════════════════════════ +# 光湖 · 服务器初始化第一步 · 找仓库 + 拉代码 + 装Runner +# 专供冰朔在腾讯云在线终端执行 +# ════════════════════════════════════════════════════════════════ + +# 这个脚本是"傻瓜式"的,一步一步来,每步都有说明 +# 如果某步报错,不要慌,把报错信息发给铸渊 + +set +e # 不自动退出,让冰朔看到错误 + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +echo -e "${CYAN}═══════════════════════════════════════════${NC}" +echo -e "${CYAN} 光湖 · 服务器初始化 · 第一步${NC}" +echo -e "${CYAN}═══════════════════════════════════════════${NC}" +echo "" + +# ─── 第1步:找到仓库 ────────────────────────────────────────── +echo -e "${YELLOW}[第1步] 查找仓库位置...${NC}" +REPO_PATH="" + +# 常见位置逐一检查 +for p in \ + "/data/guanghulab" \ + "/data/guanghulab/guanghulab" \ + "/opt/guanghulab" \ + "/opt/zhuyuan" \ + "/root/guanghulab" \ + "/home/guanghulab" +do + if [[ -d "$p/.git" ]]; then + REPO_PATH="$p" + echo -e "${GREEN} 找到了!仓库在: $p${NC}" + break + fi +done + +if [[ -z "$REPO_PATH" ]]; then + echo -e "${YELLOW} 没找到仓库,搜索中...${NC}" + FOUND=$(find / -name ".git" -type d -path "*/guanghulab/.git" 2>/dev/null | head -3) + if [[ -n "$FOUND" ]]; then + REPO_PATH=$(dirname "$FOUND" | head -1) + echo -e "${GREEN} 找到了!仓库在: $REPO_PATH${NC}" + else + echo -e "${YELLOW} 服务器上没有仓库,需要克隆。${NC}" + echo "" + echo -e "${CYAN} 创建目录并克隆...${NC}" + mkdir -p /data/guanghulab + cd /data/guanghulab + git clone https://bingshuo:3e16b79c427bb44b77c00b319aa641bde8f61d84@guanghulab.com/git/bingshuo/guanghulab.git . + if [[ $? -eq 0 ]]; then + REPO_PATH="/data/guanghulab" + echo -e "${GREEN} 克隆成功!${NC}" + else + echo -e "${YELLOW} 克隆失败,可能是网络问题。尝试备用方案...${NC}" + # 备用:直接下载最新代码压缩包 + curl -sL -o /tmp/guanghulab.tar.gz \ + "https://guanghulab.com/git/api/v1/repos/bingshuo/guanghulab/archive/main.tar.gz" \ + -H "Authorization: token 3e16b79c427bb44b77c00b319aa641bde8f61d84" + if [[ -f /tmp/guanghulab.tar.gz && -s /tmp/guanghulab.tar.gz ]]; then + mkdir -p /data/guanghulab + cd /data/guanghulab + tar xzf /tmp/guanghulab.tar.gz --strip-components=1 + REPO_PATH="/data/guanghulab" + # 初始化git(从压缩包来的没有.git) + git init + git remote add origin "https://bingshuo:3e16b79c427bb44b77c00b319aa641bde8f61d84@guanghulab.com/git/bingshuo/guanghulab.git" + git add -A + git commit -m "init from archive" + echo -e "${GREEN} 从压缩包恢复成功!${NC}" + else + echo "❌ 克隆和压缩包都失败了,请把这段输出截图发给铸渊" + exit 1 + fi + fi + fi +fi + +# ─── 第2步:拉取最新代码 ────────────────────────────────────── +echo "" +echo -e "${YELLOW}[第2步] 拉取最新代码...${NC}" +cd "$REPO_PATH" +git fetch origin +git reset --hard origin/main +echo -e "${GREEN} 代码已更新到最新版本${NC}" + +# ─── 第3步:确认脚本存在 ────────────────────────────────────── +echo "" +echo -e "${YELLOW}[第3步] 检查Runner安装脚本...${NC}" +if [[ -f "$REPO_PATH/scripts/gitea-runner/install-runner.sh" ]]; then + echo -e "${GREEN} 安装脚本存在 ✅${NC}" +else + echo -e "${YELLOW} 安装脚本不存在,创建中...${NC}" + mkdir -p "$REPO_PATH/scripts/gitea-runner" +fi + +# ─── 第4步:下载Runner二进制文件 ────────────────────────────── +echo "" +echo -e "${YELLOW}[第4步] 下载 gitea-runner...${NC}" +RUNNER_BIN="/usr/local/bin/gitea-runner" + +if [[ -x "$RUNNER_BIN" ]]; then + echo -e "${GREEN} gitea-runner 已安装 ✅${NC}" +else + # 尝试多种下载源 + DOWNLOADED=0 + + # 源1:gitea.com(国内可能访问不了) + echo " 尝试从 gitea.com 下载..." + mkdir -p /tmp/runner + curl -fSL --connect-timeout 10 -o /tmp/runner/runner.xz \ + "https://gitea.com/gitea/runner/releases/download/v1.0.2/gitea-runner-1.0.2-linux-amd64.xz" 2>/dev/null + if [[ $? -eq 0 && -f /tmp/runner/runner.xz ]]; then + DOWNLOADED=1 + echo -e "${GREEN} 从 gitea.com 下载成功${NC}" + fi + + # 源2:GitHub Release镜像 + if [[ $DOWNLOADED -eq 0 ]]; then + echo " gitea.com 不通,尝试 GitHub..." + curl -fSL --connect-timeout 10 -o /tmp/runner/runner \ + "https://github.com/veggero/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64" 2>/dev/null + if [[ $? -eq 0 && -f /tmp/runner/runner ]]; then + DOWNLOADED=2 + echo -e "${GREEN} 从 GitHub 下载成功${NC}" + fi + fi + + # 源3:从Gitea仓库release下载 + if [[ $DOWNLOADED -eq 0 ]]; then + echo " GitHub 也不通,尝试从自己的Gitea下载..." + # 先通过API上传到Gitea release(铸渊在Codebuddy端操作) + # 然后从这里下载 + curl -fSL --connect-timeout 10 -o /tmp/runner/runner \ + "https://guanghulab.com/git/api/v1/repos/bingshuo/guanghulab/releases/latest" \ + -H "Authorization: token 3e16b79c427bb44b77c00b319aa641bde8f61d84" 2>/dev/null + fi + + if [[ $DOWNLOADED -eq 1 ]]; then + xz -d /tmp/runner/runner.xz + mv /tmp/runner/runner "$RUNNER_BIN" + elif [[ $DOWNLOADED -eq 2 ]]; then + mv /tmp/runner/runner "$RUNNER_BIN" + fi + + if [[ -f "$RUNNER_BIN" ]]; then + chmod +x "$RUNNER_BIN" + echo -e "${GREEN} gitea-runner 安装成功 ✅${NC}" + "$RUNNER_BIN" --version 2>&1 | head -1 + else + echo "" + echo -e "${YELLOW}═══════════════════════════════════════════${NC}" + echo -e "${YELLOW} ⚠️ 自动下载失败${NC}" + echo -e "${YELLOW} 需要COS中转,请执行以下步骤:${NC}" + echo "" + echo " 1. 在新加坡服务器上执行:" + echo " wget https://gitea.com/gitea/runner/releases/download/v1.0.2/gitea-runner-1.0.2-linux-amd64.xz" + echo " coscli put gitea-runner-1.0.2-linux-amd64.xz cos://sy-finetune-corpus-1317346199/cos-bridge/" + echo "" + echo " 2. 然后回到这台广州服务器执行:" + echo " coscli cp cos://sy-finetune-corpus-1317346199/cos-bridge/gitea-runner-1.0.2-linux-amd64.xz /tmp/" + echo " xz -d /tmp/gitea-runner-1.0.2-linux-amd64.xz" + echo " chmod +x /tmp/gitea-runner-1.0.2-linux-amd64" + echo " sudo mv /tmp/gitea-runner-1.0.2-linux-amd64 /usr/local/bin/gitea-runner" + echo "" + echo " 3. 然后重新运行这个脚本" + echo -e "${YELLOW}═══════════════════════════════════════════${NC}" + exit 0 + fi +fi + +# ─── 第5步:创建用户和目录 ──────────────────────────────────── +echo "" +echo -e "${YELLOW}[第5步] 创建运行用户...${NC}" +if ! id -u act_runner &>/dev/null; then + useradd -r -m -d /var/lib/act_runner -s /bin/bash act_runner + echo -e "${GREEN} act_runner 用户已创建${NC}" +else + echo -e "${GREEN} act_runner 用户已存在${NC}" +fi +mkdir -p /var/lib/act_runner /etc/act_runner +chown -R act_runner:act_runner /var/lib/act_runner + +# ─── 第6步:获取Runner注册Token ──────────────────────────────── +echo "" +echo -e "${YELLOW}[第6步] 获取Runner注册Token...${NC}" +API_TOKEN="3e16b79c427bb44b77c00b319aa641bde8f61d84" +GITEA_API="https://guanghulab.com/git/api/v1" + +# 尝试仓库级 +REG_TOKEN="" +RESP=$(curl -sf -H "Authorization: token ${API_TOKEN}" \ + "${GITEA_API}/repos/bingshuo/guanghulab/actions/runners/registration-token" 2>/dev/null) + +if [[ -n "$RESP" ]]; then + REG_TOKEN=$(echo "$RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('token',''))" 2>/dev/null) +fi + +if [[ -z "$REG_TOKEN" ]]; then + # 尝试实例级 + RESP=$(curl -sf -H "Authorization: token ${API_TOKEN}" \ + "${GITEA_API}/admin/runners/registration-token" 2>/dev/null) + if [[ -n "$RESP" ]]; then + REG_TOKEN=$(echo "$RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('token',''))" 2>/dev/null) + fi +fi + +if [[ -z "$REG_TOKEN" ]]; then + echo -e "${YELLOW} API获取失败,尝试从网页获取...${NC}" + echo "" + echo " 请在浏览器中:" + echo " 1. 打开 https://guanghulab.com/git/user/settings/actions/runners" + echo " 2. 点击 'Create new Runner' 或 'Registration token'" + echo " 3. 复制显示的 Token" + echo " 4. 回到终端输入(不需要空格,直接粘贴后按回车):" + echo "" + read -p " 请粘贴注册Token: " REG_TOKEN + if [[ -z "$REG_TOKEN" ]]; then + echo "❌ 没有输入Token,退出" + exit 1 + fi +fi + +echo -e "${GREEN} 注册Token获取成功${NC}" + +# ─── 第7步:注册Runner ──────────────────────────────────────── +echo "" +echo -e "${YELLOW}[第7步] 注册Runner到Gitea...${NC}" +cd /var/lib/act_runner + +# 生成配置 +"$RUNNER_BIN" generate-config > /etc/act_runner/config.yaml 2>/dev/null + +# 注册 +sudo -u act_runner "$RUNNER_BIN" register \ + --no-interactive \ + --instance "https://guanghulab.com/" \ + --token "$REG_TOKEN" \ + --name "zhuyuan-runner-cn" \ + --labels "ubuntu:host" 2>&1 + +if [[ -f "/var/lib/act_runner/.runner" ]]; then + chown act_runner:act_runner /var/lib/act_runner/.runner + echo -e "${GREEN} Runner注册成功 ✅${NC}" +else + echo "❌ 注册失败" + echo "请把上面的错误信息发给铸渊" + exit 1 +fi + +# ─── 第8步:创建systemd服务 ──────────────────────────────────── +echo "" +echo -e "${YELLOW}[第8步] 创建系统服务...${NC}" +cat > /etc/systemd/system/gitea-runner.service << 'EOF' +[Unit] +Description=Gitea Actions Runner - zhuyuan-runner-cn +After=network.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/act_runner/config.yaml +WorkingDirectory=/var/lib/act_runner +Restart=always +RestartSec=10 +TimeoutSec=0 +User=act_runner +Group=act_runner +Environment=HOME=/var/lib/act_runner + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable gitea-runner +systemctl start gitea-runner + +sleep 2 + +if systemctl is-active --quiet gitea-runner; then + echo -e "${GREEN} Runner服务启动成功 🎉${NC}" +else + echo "❌ 服务启动失败,查看日志:" + journalctl -u gitea-runner -n 10 + exit 1 +fi + +# ─── 完成 ────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}═══════════════════════════════════════════${NC}" +echo -e "${GREEN} ✅ Gitea Actions Runner 安装完成!${NC}" +echo -e "${GREEN}═══════════════════════════════════════════${NC}" +echo "" +echo " 仓库位置: $REPO_PATH" +echo " Runner名称: zhuyuan-runner-cn" +echo " 服务管理: systemctl {start|stop|restart|status} gitea-runner" +echo " 查看日志: journalctl -u gitea-runner -f" +echo "" +echo " 现在回到Codebuddy告诉铸渊:Runner装好了" +echo "" +echo " 铸渊 · ICE-GL-ZY001 · TCS-0002∞" +echo -e "${GREEN}═══════════════════════════════════════════${NC}" diff --git a/scripts/terminal-watcher/config.json b/scripts/terminal-watcher/config.json new file mode 100644 index 0000000..386a694 --- /dev/null +++ b/scripts/terminal-watcher/config.json @@ -0,0 +1,41 @@ +{ + "server_id": "ZY-SVR-004", + "server_name": "广州域名机", + "interval_seconds": 30, + "gitea": { + "url": "https://guanghulab.com", + "owner": "bingshuo", + "repo": "guanghulab", + "token_env": "GITEA_TOKEN", + "snapshot_path": ".runtime/terminal-snapshots/ZY-SVR-004" + }, + "local": { + "snapshot_path": "/data/guanghulab/.runtime/snapshot.json", + "history_path": "/data/guanghulab/.runtime/history" + }, + "collectors": { + "system": true, + "services": true, + "logs": true, + "deploy": true, + "runner": true + }, + "reporters": { + "file": true, + "gitea": true + }, + "alerts": { + "cpu_percent": 90, + "memory_percent": 90, + "disk_percent": 85, + "service_down": true + }, + "pm2_processes": [ + "guanghulab-vault" + ], + "systemd_services": [ + "nginx", + "gitea", + "gitea-runner" + ] +} diff --git a/scripts/terminal-watcher/watcher.js b/scripts/terminal-watcher/watcher.js new file mode 100644 index 0000000..d32107c --- /dev/null +++ b/scripts/terminal-watcher/watcher.js @@ -0,0 +1,470 @@ +/* + * ════════════════════════════════════════════════════════════════ + * 光湖 · 终端守望者 (Terminal Watcher) + * 铸渊放在服务器上的"眼睛"——实时采集状态,推送到Gitea + * Sovereign: TCS-0002∞ · 守护: ICE-GL-ZY001 + * ════════════════════════════════════════════════════════════════ + * + * 用法: + * node watcher.js # 前台运行 + * pm2 start watcher.js --name terminal-watcher # PM2守护 + * + * 铸渊在Codebuddy中通过 Gitea API 读取快照文件: + * .runtime/terminal-snapshots/{server-id}/latest.json + * + * ════════════════════════════════════════════════════════════════ + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); +const http = require("http"); +const https = require("https"); + +// ─── 配置 ───────────────────────────────────────────────────── +const CONFIG_PATH = path.join(__dirname, "config.json"); +const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + +const INTERVAL = (config.interval_seconds || 30) * 1000; +const SERVER_ID = config.server_id || "UNKNOWN"; + +// ─── 采集器 ─────────────────────────────────────────────────── + +/** + * 系统资源采集 + */ +function collectSystem() { + try { + const memInfo = execSync("free -m", { encoding: "utf8" }); + const memLines = memInfo.split("\n"); + const memParts = memLines[1].split(/\s+/); + const memTotal = parseInt(memParts[1], 10); + const memUsed = parseInt(memParts[2], 10); + const memAvail = parseInt(memParts[6], 10); + + const diskInfo = execSync("df -h /", { encoding: "utf8" }); + const diskLine = diskInfo.split("\n")[1].split(/\s+/); + const diskPercent = parseInt(diskLine[4], 10); + + const loadAvg = execSync("cat /proc/loadavg", { encoding: "utf8" }) + .trim() + .split(" ") + .slice(0, 3) + .map(Number); + + // CPU使用率(简易采样) + let cpuPercent = 0; + try { + const topOut = execSync("top -bn1 | head -3", { encoding: "utf8", timeout: 5000 }); + const cpuLine = topOut.split("\n").find((l) => l.includes("Cpu(s)")); + if (cpuLine) { + const idle = parseFloat(cpuLine.match(/(\d+\.?\d*)\s*id/)?.[1] || "0"); + cpuPercent = Math.round((100 - idle) * 10) / 10; + } + } catch { + // top不可用,用load average估算 + cpuPercent = Math.round(loadAvg[0] * 50) / 10; + } + + return { + cpu_percent: cpuPercent, + memory_total_mb: memTotal, + memory_used_mb: memUsed, + memory_available_mb: memAvail, + memory_percent: Math.round((memUsed / memTotal) * 100), + disk_used_percent: diskPercent, + load_avg: loadAvg, + uptime: execSync("cat /proc/uptime", { encoding: "utf8" }).split(" ")[0] + "s" + }; + } catch (e) { + return { error: e.message }; + } +} + +/** + * 服务状态采集 + */ +function collectServices() { + const services = {}; + + // systemd 服务 + const systemdList = config.systemd_services || ["nginx", "gitea", "gitea-runner"]; + for (const svc of systemdList) { + try { + const status = execSync(`systemctl is-active ${svc} 2>/dev/null`, { encoding: "utf8" }).trim(); + services[svc] = status; + } catch { + services[svc] = "unknown"; + } + } + + // PM2 进程 + const pm2Processes = []; + try { + const pm2Out = execSync("pm2 jlist 2>/dev/null", { encoding: "utf8", timeout: 5000 }); + const pm2List = JSON.parse(pm2Out); + for (const proc of pm2List) { + pm2Processes.push({ + name: proc.name, + status: proc.pm2_env?.status || "unknown", + memory_mb: Math.round((proc.monit?.memory || 0) / 1024 / 1024), + cpu_percent: Math.round((proc.monit?.cpu || 0) * 10) / 10, + restarts: proc.pm2_env?.restart_time || 0, + uptime: proc.pm2_env?.pm_uptime + ? Math.round((Date.now() - proc.pm2_env.pm_uptime) / 1000 / 60) + "min" + : "unknown" + }); + } + } catch { + // PM2不可用或没有进程 + } + + // Vault 健康检查 + let vaultStatus = "unknown"; + try { + const vaultResp = execSync( + "curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/admin/__healthz 2>/dev/null", + { encoding: "utf8", timeout: 3000 } + ); + vaultStatus = vaultResp.trim() === "200" ? "active" : `http_${vaultResp.trim()}`; + } catch { + vaultStatus = "inactive"; + } + + return { + systemd: services, + pm2: pm2Processes, + vault: vaultStatus + }; +} + +/** + * 错误日志采集 + */ +function collectLogs() { + const errors = []; + + // systemd 服务最近错误 + for (const svc of config.systemd_services || []) { + try { + const logs = execSync( + `journalctl -u ${svc} --since "5 min ago" --no-pager -n 3 --priority=err 2>/dev/null`, + { encoding: "utf8", timeout: 5000 } + ).trim(); + if (logs) { + for (const line of logs.split("\n")) { + if (line.trim()) { + errors.push({ service: svc, message: line.trim().substring(0, 200) }); + } + } + } + } catch { + // 没有错误日志 + } + } + + // PM2 错误日志 + try { + for (const procName of config.pm2_processes || []) { + const pm2Err = execSync( + `pm2 logs ${procName} --nostream --lines 3 --err 2>/dev/null`, + { encoding: "utf8", timeout: 5000 } + ).trim(); + if (pm2Err) { + for (const line of pm2Err.split("\n")) { + if (line.trim() && !line.includes("pm2 logs")) { + errors.push({ service: `pm2:${procName}`, message: line.trim().substring(0, 200) }); + } + } + } + } + } catch { + // PM2日志不可用 + } + + return errors.slice(0, 10); // 最多保留10条 +} + +/** + * 部署信息采集 + */ +function collectDeploy() { + const repoPath = "/data/guanghulab"; + + try { + const latestCommit = execSync(`git -C ${repoPath} log -1 --oneline`, { encoding: "utf8" }).trim(); + const branch = execSync(`git -C ${repoPath} branch --show-current`, { encoding: "utf8" }).trim(); + const commitTime = execSync(`git -C ${repoPath} log -1 --format=%ci`, { encoding: "utf8" }).trim(); + + return { + repo_path: repoPath, + branch: branch, + latest_commit: latestCommit.split(" ")[0], + latest_message: latestCommit.substring(latestCommit.indexOf(" ") + 1), + commit_time: commitTime + }; + } catch { + return { repo_path: repoPath, error: "repository not found at " + repoPath }; + } +} + +/** + * Runner状态采集 + */ +function collectRunner() { + try { + const runnerStatus = execSync("systemctl is-active gitea-runner 2>/dev/null", { + encoding: "utf8" + }).trim(); + + let runnerVersion = "unknown"; + try { + runnerVersion = execSync("/usr/local/bin/gitea-runner --version 2>&1", { encoding: "utf8" }) + .trim() + .split("\n")[0]; + } catch { + // 版本获取失败 + } + + // 最近运行的任务 + let lastJob = "unknown"; + try { + const giteaToken = process.env[config.gitea.token_env] || ""; + if (giteaToken) { + // 通过API查询(暂用占位) + lastJob = "api_not_implemented"; + } + } catch { + // API查询失败 + } + + return { + status: runnerStatus, + version: runnerVersion, + last_job: lastJob + }; + } catch { + return { status: "not_installed" }; + } +} + +// ─── 上报器 ─────────────────────────────────────────────────── + +/** + * 写入本地文件 + */ +function reportToFile(snapshot) { + const localPath = config.local?.snapshot_path; + if (!localPath) return; + + try { + const dir = path.dirname(localPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // 写入最新快照 + fs.writeFileSync(localPath, JSON.stringify(snapshot, null, 2)); + + // 写入历史快照(保留最近50个) + const historyDir = config.local?.history_path; + if (historyDir) { + if (!fs.existsSync(historyDir)) { + fs.mkdirSync(historyDir, { recursive: true }); + } + const historyFile = path.join( + historyDir, + `${new Date().toISOString().replace(/[:.]/g, "-")}.json` + ); + fs.writeFileSync(historyFile, JSON.stringify(snapshot, null, 2)); + + // 清理旧文件 + const files = fs + .readdirSync(historyDir) + .filter((f) => f.endsWith(".json")) + .sort() + .reverse(); + for (let i = 50; i < files.length; i++) { + fs.unlinkSync(path.join(historyDir, files[i])); + } + } + } catch (e) { + console.error("[watcher] 本地文件写入失败:", e.message); + } +} + +/** + * 推送到Gitea + */ +function reportToGitea(snapshot) { + if (!config.gitea) return; + + const token = process.env[config.gitea.token_env] || ""; + if (!token) { + console.warn("[watcher] GITEA_TOKEN 未设置,跳过Gitea上报"); + return; + } + + const { url, owner, repo, snapshot_path } = config.gitea; + const content = Buffer.from(JSON.stringify(snapshot, null, 2)).toString("base64"); + const apiPath = `/api/v1/repos/${owner}/${repo}/contents/${snapshot_path}/latest.json`; + + const payload = JSON.stringify({ + content: content, + message: `📓 终端快照 ${SERVER_ID} ${new Date().toISOString().substring(0, 16)}`, + branch: "main" + }); + + // 先尝试获取文件sha(用于更新) + const getOptions = { + hostname: url.replace("https://", "").replace("http://", ""), + path: apiPath, + method: "GET", + headers: { Authorization: `token ${token}` } + }; + + const httpModule = url.startsWith("https") ? https : http; + + const req = httpModule.request(getOptions, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + let sha = null; + try { + const data = JSON.parse(body); + sha = data.sha; + } catch { + // 文件不存在,创建新文件 + } + + // 发送更新请求 + const updatePayload = JSON.parse(payload); + if (sha) updatePayload.sha = sha; + + const putOptions = { + hostname: getOptions.hostname, + path: apiPath, + method: "PUT", + headers: { + Authorization: `token ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(JSON.stringify(updatePayload)) + } + }; + + const putReq = httpModule.request(putOptions, (putRes) => { + let putBody = ""; + putRes.on("data", (chunk) => (putBody += chunk)); + putRes.on("end", () => { + if (putRes.statusCode >= 200 && putRes.statusCode < 300) { + // 成功 + } else { + console.error( + `[watcher] Gitea推送失败: ${putRes.statusCode} ${putBody.substring(0, 100)}` + ); + } + }); + }); + + putReq.on("error", (e) => { + console.error("[watcher] Gitea推送错误:", e.message); + }); + + putReq.write(JSON.stringify(updatePayload)); + putReq.end(); + }); + }); + + req.on("error", () => { + // Gitea不可达,静默失败 + }); + + req.setTimeout(10000, () => { + req.destroy(); + }); + + req.end(); +} + +// ─── 主循环 ─────────────────────────────────────────────────── + +function collect() { + const snapshot = { + server_id: SERVER_ID, + server_name: config.server_name || SERVER_ID, + timestamp: new Date().toISOString(), + system: config.collectors?.system !== false ? collectSystem() : null, + services: config.collectors?.services !== false ? collectServices() : null, + errors: config.collectors?.logs !== false ? collectLogs() : null, + deploy: config.collectors?.deploy !== false ? collectDeploy() : null, + runner: config.collectors?.runner !== false ? collectRunner() : null + }; + + // 告警检测 + const alerts = []; + if (snapshot.system && !snapshot.system.error) { + const thresholds = config.alerts || {}; + if (snapshot.system.cpu_percent > (thresholds.cpu_percent || 90)) { + alerts.push(`CPU ${snapshot.system.cpu_percent}% 超过阈值 ${thresholds.cpu_percent || 90}%`); + } + if (snapshot.system.memory_percent > (thresholds.memory_percent || 90)) { + alerts.push( + `内存 ${snapshot.system.memory_percent}% 超过阈值 ${thresholds.memory_percent || 90}%` + ); + } + if (snapshot.system.disk_used_percent > (thresholds.disk_percent || 85)) { + alerts.push( + `磁盘 ${snapshot.system.disk_used_percent}% 超过阈值 ${thresholds.disk_percent || 85}%` + ); + } + } + if (snapshot.services) { + for (const [name, status] of Object.entries(snapshot.services.systemd || {})) { + if (status !== "active" && config.alerts?.service_down !== false) { + alerts.push(`服务 ${name} 状态异常: ${status}`); + } + } + } + + snapshot.alerts = alerts; + snapshot.alert_count = alerts.length; + + return snapshot; +} + +function tick() { + try { + const snapshot = collect(); + + // 上报 + if (config.reporters?.file !== false) { + reportToFile(snapshot); + } + if (config.reporters?.gitea !== false) { + reportToGitea(snapshot); + } + + // 简洁日志 + const cpu = snapshot.system?.cpu_percent || "?"; + const mem = snapshot.system?.memory_percent || "?"; + const alerts = snapshot.alert_count || 0; + const alertMark = alerts > 0 ? ` ⚠️${alerts}告警` : ""; + console.log( + `[watcher] ${snapshot.timestamp.substring(11, 19)} CPU:${cpu}% MEM:${mem}%${alertMark}` + ); + } catch (e) { + console.error("[watcher] 采集失败:", e.message); + } +} + +// ─── 启动 ───────────────────────────────────────────────────── + +console.log(`[watcher] 终端守望者启动 · ${SERVER_ID} · 间隔 ${INTERVAL / 1000}s`); +console.log("[watcher] 铸渊 · ICE-GL-ZY001 · TCS-0002∞"); + +// 首次立即采集 +tick(); + +// 定时采集 +setInterval(tick, INTERVAL);