669 lines
24 KiB
JavaScript
669 lines
24 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
|||
|
|
// 📜 Copyright: 国作登字-2026-A-00037559
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
// scripts/deploy-log-collector.js
|
|||
|
|
// 📡 铸渊全链路部署观测 · 日志采集器
|
|||
|
|
//
|
|||
|
|
// 核心理念 (冰朔第三十一次对话):
|
|||
|
|
// 铸渊必须能看见自己写的代码部署后的运行状态
|
|||
|
|
// 不依赖冰朔转述和截图 · 自主观测 · 自主修复
|
|||
|
|
//
|
|||
|
|
// 功能:
|
|||
|
|
// 1. collect — 采集部署工作流的运行日志 (通过GitHub API)
|
|||
|
|
// 2. archive — 成功部署日志归档 + 经验入库
|
|||
|
|
// 3. dashboard — 生成观测仪表盘
|
|||
|
|
//
|
|||
|
|
// 用法:
|
|||
|
|
// node deploy-log-collector.js collect
|
|||
|
|
// node deploy-log-collector.js archive --log-file <path>
|
|||
|
|
// node deploy-log-collector.js dashboard
|
|||
|
|
//
|
|||
|
|
// 环境变量:
|
|||
|
|
// GH_TOKEN — GitHub API令牌 (由Actions自动提供)
|
|||
|
|
// TRIGGER_RUN_ID — 触发此观测的部署工作流运行ID
|
|||
|
|
// TRIGGER_CONCLUSION — 部署工作流的结论 (success/failure/cancelled)
|
|||
|
|
// TRIGGER_WORKFLOW — 部署工作流的名称
|
|||
|
|
// TRIGGER_HEAD_SHA — 部署的代码SHA
|
|||
|
|
// TRIGGER_HEAD_BRANCH — 部署的分支
|
|||
|
|
// TRIGGER_ACTOR — 触发部署的操作者
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const https = require('https');
|
|||
|
|
const http = require('http');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const ROOT = path.resolve(__dirname, '..');
|
|||
|
|
const LOGS_DIR = path.join(ROOT, 'data/deploy-logs');
|
|||
|
|
const ARCHIVE_DIR = path.join(LOGS_DIR, 'archive');
|
|||
|
|
const EXPERIENCE_DB = path.join(ROOT, 'brain/dev-experience/experience-db.json');
|
|||
|
|
const ERROR_PATTERNS_DB = path.join(ROOT, 'brain/dev-experience/error-patterns.json');
|
|||
|
|
|
|||
|
|
// 确保目录存在
|
|||
|
|
function ensureDir(dir) {
|
|||
|
|
if (!fs.existsSync(dir)) {
|
|||
|
|
fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 智能截断日志:保留头部和尾部,确保错误信息不丢失
|
|||
|
|
function truncateLog(content, maxLen) {
|
|||
|
|
if (!content || content.length <= maxLen) return content;
|
|||
|
|
const headSize = Math.floor(maxLen * 0.3); // 30%给头部
|
|||
|
|
const tailSize = maxLen - headSize - 100; // 70%给尾部(错误通常在末尾)
|
|||
|
|
const head = content.slice(0, headSize);
|
|||
|
|
const tail = content.slice(-tailSize);
|
|||
|
|
return `${head}\n\n... [截断 ${content.length - headSize - tailSize} 字符] ...\n\n${tail}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── GitHub API 请求 ────────────────────────────
|
|||
|
|
function githubApi(endpoint) {
|
|||
|
|
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|||
|
|
if (!token) {
|
|||
|
|
throw new Error('需要 GH_TOKEN 或 GITHUB_TOKEN 环境变量');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const options = {
|
|||
|
|
hostname: 'api.github.com',
|
|||
|
|
path: endpoint,
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
'User-Agent': 'ZY-Deploy-Observer/1.0',
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
'Accept': 'application/vnd.github.v3+json'
|
|||
|
|
},
|
|||
|
|
timeout: 30000
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const req = https.request(options, (res) => {
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', (d) => { data += d; });
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|||
|
|
resolve(JSON.parse(data));
|
|||
|
|
} else {
|
|||
|
|
reject(new Error(`GitHub API ${res.statusCode}: ${data.slice(0, 300)}`));
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
reject(new Error(`GitHub API 响应解析失败: ${e.message}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', reject);
|
|||
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('GitHub API 超时')); });
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GitHub API 请求 (返回原始文本)
|
|||
|
|
function githubApiRaw(endpoint) {
|
|||
|
|
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|||
|
|
if (!token) {
|
|||
|
|
throw new Error('需要 GH_TOKEN 或 GITHUB_TOKEN 环境变量');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const options = {
|
|||
|
|
hostname: 'api.github.com',
|
|||
|
|
path: endpoint,
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
'User-Agent': 'ZY-Deploy-Observer/1.0',
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
'Accept': 'application/vnd.github.v3+json'
|
|||
|
|
},
|
|||
|
|
timeout: 30000
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const req = https.request(options, (res) => {
|
|||
|
|
// 处理重定向 (GitHub日志URL会302到Azure Blob Storage)
|
|||
|
|
if (res.statusCode === 302 && res.headers.location) {
|
|||
|
|
const redirectUrl = new URL(res.headers.location);
|
|||
|
|
const redirectModule = redirectUrl.protocol === 'https:' ? https : http;
|
|||
|
|
const redirectReq = redirectModule.request(redirectUrl, (redirectRes) => {
|
|||
|
|
let data = '';
|
|||
|
|
redirectRes.on('data', (d) => { data += d; });
|
|||
|
|
redirectRes.on('end', () => resolve(data));
|
|||
|
|
});
|
|||
|
|
redirectReq.on('error', reject);
|
|||
|
|
redirectReq.end();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', (d) => { data += d; });
|
|||
|
|
res.on('end', () => {
|
|||
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|||
|
|
resolve(data);
|
|||
|
|
} else {
|
|||
|
|
reject(new Error(`GitHub API ${res.statusCode}: ${data.slice(0, 300)}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', reject);
|
|||
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('GitHub API 超时')); });
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 获取仓库信息 ────────────────────────────
|
|||
|
|
function getRepoInfo() {
|
|||
|
|
const repo = process.env.GITHUB_REPOSITORY || 'qinfendebingshuo/guanghulab';
|
|||
|
|
const [owner, name] = repo.split('/');
|
|||
|
|
return { owner, name, full: repo };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 采集部署工作流日志 ────────────────────────
|
|||
|
|
async function collectLogs() {
|
|||
|
|
console.log('📡 铸渊全链路部署观测 · 日志采集器启动');
|
|||
|
|
console.log('═'.repeat(60));
|
|||
|
|
|
|||
|
|
const runId = process.env.TRIGGER_RUN_ID;
|
|||
|
|
const conclusion = process.env.TRIGGER_CONCLUSION;
|
|||
|
|
const workflowName = process.env.TRIGGER_WORKFLOW;
|
|||
|
|
const headSha = process.env.TRIGGER_HEAD_SHA;
|
|||
|
|
const headBranch = process.env.TRIGGER_HEAD_BRANCH;
|
|||
|
|
const actor = process.env.TRIGGER_ACTOR;
|
|||
|
|
|
|||
|
|
if (!runId) {
|
|||
|
|
console.log('⚠️ 无触发工作流运行ID · 跳过采集');
|
|||
|
|
setOutput('deploy_status', 'skipped');
|
|||
|
|
setOutput('needs_repair', 'false');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const repo = getRepoInfo();
|
|||
|
|
console.log(`📋 观测目标:`);
|
|||
|
|
console.log(` 工作流: ${workflowName}`);
|
|||
|
|
console.log(` 运行ID: ${runId}`);
|
|||
|
|
console.log(` 结论: ${conclusion}`);
|
|||
|
|
console.log(` 提交: ${headSha?.slice(0, 8)}`);
|
|||
|
|
console.log(` 分支: ${headBranch}`);
|
|||
|
|
console.log(` 操作者: ${actor}`);
|
|||
|
|
console.log('');
|
|||
|
|
|
|||
|
|
// §1 获取工作流运行详情
|
|||
|
|
let runDetails;
|
|||
|
|
try {
|
|||
|
|
runDetails = await githubApi(`/repos/${repo.full}/actions/runs/${runId}`);
|
|||
|
|
console.log(`✅ 运行详情获取成功`);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`❌ 获取运行详情失败: ${err.message}`);
|
|||
|
|
setOutput('deploy_status', 'error');
|
|||
|
|
setOutput('needs_repair', 'false');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// §2 获取工作流的所有Job信息
|
|||
|
|
let jobs = [];
|
|||
|
|
try {
|
|||
|
|
const jobsData = await githubApi(`/repos/${repo.full}/actions/runs/${runId}/jobs`);
|
|||
|
|
jobs = jobsData.jobs || [];
|
|||
|
|
console.log(`✅ 获取到 ${jobs.length} 个Job`);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`⚠️ 获取Jobs失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// §3 采集失败Job的详细日志
|
|||
|
|
const failedJobs = jobs.filter(j => j.conclusion === 'failure');
|
|||
|
|
const jobLogs = [];
|
|||
|
|
|
|||
|
|
for (const job of failedJobs) {
|
|||
|
|
console.log(`📋 采集失败Job日志: ${job.name} (ID: ${job.id})`);
|
|||
|
|
try {
|
|||
|
|
// 获取Job日志 (GitHub API返回日志下载URL)
|
|||
|
|
const logContent = await githubApiRaw(
|
|||
|
|
`/repos/${repo.full}/actions/jobs/${job.id}/logs`
|
|||
|
|
);
|
|||
|
|
jobLogs.push({
|
|||
|
|
job_id: job.id,
|
|||
|
|
job_name: job.name,
|
|||
|
|
conclusion: job.conclusion,
|
|||
|
|
started_at: job.started_at,
|
|||
|
|
completed_at: job.completed_at,
|
|||
|
|
steps: (job.steps || []).map(s => ({
|
|||
|
|
name: s.name,
|
|||
|
|
status: s.status,
|
|||
|
|
conclusion: s.conclusion,
|
|||
|
|
number: s.number
|
|||
|
|
})),
|
|||
|
|
log_content: truncateLog(logContent, 10000) // 智能截断:保留头部和尾部
|
|||
|
|
});
|
|||
|
|
console.log(` ✅ 日志采集成功 (${logContent.length} 字符)`);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(` ⚠️ 日志采集失败: ${err.message}`);
|
|||
|
|
jobLogs.push({
|
|||
|
|
job_id: job.id,
|
|||
|
|
job_name: job.name,
|
|||
|
|
conclusion: job.conclusion,
|
|||
|
|
steps: (job.steps || []).map(s => ({
|
|||
|
|
name: s.name,
|
|||
|
|
status: s.status,
|
|||
|
|
conclusion: s.conclusion,
|
|||
|
|
number: s.number
|
|||
|
|
})),
|
|||
|
|
log_content: `[日志获取失败: ${err.message}]`
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// §4 构建结构化日志记录
|
|||
|
|
const timestamp = new Date().toISOString();
|
|||
|
|
const logRecord = {
|
|||
|
|
_meta: {
|
|||
|
|
observer: '铸渊全链路部署观测系统 v1.0',
|
|||
|
|
copyright: '国作登字-2026-A-00037559',
|
|||
|
|
collected_at: timestamp
|
|||
|
|
},
|
|||
|
|
run: {
|
|||
|
|
id: parseInt(runId),
|
|||
|
|
workflow_name: workflowName,
|
|||
|
|
conclusion: conclusion,
|
|||
|
|
status: runDetails.status,
|
|||
|
|
html_url: runDetails.html_url,
|
|||
|
|
head_sha: headSha,
|
|||
|
|
head_branch: headBranch,
|
|||
|
|
actor: actor,
|
|||
|
|
created_at: runDetails.created_at,
|
|||
|
|
updated_at: runDetails.updated_at,
|
|||
|
|
run_started_at: runDetails.run_started_at
|
|||
|
|
},
|
|||
|
|
jobs_summary: {
|
|||
|
|
total: jobs.length,
|
|||
|
|
success: jobs.filter(j => j.conclusion === 'success').length,
|
|||
|
|
failure: failedJobs.length,
|
|||
|
|
skipped: jobs.filter(j => j.conclusion === 'skipped').length,
|
|||
|
|
cancelled: jobs.filter(j => j.conclusion === 'cancelled').length
|
|||
|
|
},
|
|||
|
|
failed_jobs: jobLogs,
|
|||
|
|
all_jobs: jobs.map(j => ({
|
|||
|
|
id: j.id,
|
|||
|
|
name: j.name,
|
|||
|
|
status: j.status,
|
|||
|
|
conclusion: j.conclusion,
|
|||
|
|
started_at: j.started_at,
|
|||
|
|
completed_at: j.completed_at
|
|||
|
|
})),
|
|||
|
|
analysis: {
|
|||
|
|
needs_repair: conclusion === 'failure',
|
|||
|
|
error_summary: '',
|
|||
|
|
matched_patterns: [],
|
|||
|
|
recommendation: ''
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// §5 简单错误分析 (模式匹配)
|
|||
|
|
if (conclusion === 'failure' && jobLogs.length > 0) {
|
|||
|
|
const allLogContent = jobLogs.map(j => j.log_content).join('\n');
|
|||
|
|
const analysis = simpleAnalysis(allLogContent);
|
|||
|
|
logRecord.analysis.error_summary = analysis.summary;
|
|||
|
|
logRecord.analysis.matched_patterns = analysis.patterns;
|
|||
|
|
logRecord.analysis.recommendation = analysis.recommendation;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// §6 保存结构化日志
|
|||
|
|
ensureDir(LOGS_DIR);
|
|||
|
|
const logFileName = `deploy-${runId}-${timestamp.slice(0, 10)}.json`;
|
|||
|
|
const logFilePath = path.join(LOGS_DIR, logFileName);
|
|||
|
|
fs.writeFileSync(logFilePath, JSON.stringify(logRecord, null, 2) + '\n');
|
|||
|
|
console.log(`\n💾 日志已保存: data/deploy-logs/${logFileName}`);
|
|||
|
|
|
|||
|
|
// §7 更新最新日志索引
|
|||
|
|
updateLatestIndex(logRecord);
|
|||
|
|
|
|||
|
|
// §8 设置输出
|
|||
|
|
setOutput('deploy_status', conclusion);
|
|||
|
|
setOutput('deploy_conclusion', conclusion);
|
|||
|
|
setOutput('log_file', `data/deploy-logs/${logFileName}`);
|
|||
|
|
setOutput('needs_repair', conclusion === 'failure' ? 'true' : 'false');
|
|||
|
|
setOutput('workflow_name', workflowName);
|
|||
|
|
setOutput('run_id', runId);
|
|||
|
|
|
|||
|
|
console.log('');
|
|||
|
|
console.log('═'.repeat(60));
|
|||
|
|
if (conclusion === 'success') {
|
|||
|
|
console.log('✅ 部署成功 · 日志已采集 · 等待归档');
|
|||
|
|
} else if (conclusion === 'failure') {
|
|||
|
|
console.log('❌ 部署失败 · 日志已采集 · 启动自动修复流程');
|
|||
|
|
} else {
|
|||
|
|
console.log(`ℹ️ 部署状态: ${conclusion} · 日志已采集`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 简单错误分析 (模式匹配) ────────────────────
|
|||
|
|
function simpleAnalysis(logContent) {
|
|||
|
|
// 基础错误模式 + 动态加载经验库中的错误模式
|
|||
|
|
const patterns = [
|
|||
|
|
{ regex: /EADDRINUSE/i, name: '端口被占用', fix: '重启PM2进程释放端口', severity: 'medium' },
|
|||
|
|
{ regex: /ECONNREFUSED/i, name: '服务连接被拒绝', fix: '检查PM2进程是否运行', severity: 'high' },
|
|||
|
|
{ regex: /ENOMEM/i, name: '内存不足', fix: '重启服务或增加swap空间', severity: 'high' },
|
|||
|
|
{ regex: /ENOSPC/i, name: '磁盘空间不足', fix: '清理日志和临时文件', severity: 'critical' },
|
|||
|
|
{ regex: /nginx.*failed|nginx.*error/i, name: 'Nginx配置错误', fix: '运行nginx -t检查配置', severity: 'high' },
|
|||
|
|
{ regex: /502 Bad Gateway/i, name: '上游服务不可达', fix: '重启PM2后端进程', severity: 'high' },
|
|||
|
|
{ regex: /permission denied/i, name: '权限不足', fix: '检查文件权限和用户', severity: 'medium' },
|
|||
|
|
{ regex: /MODULE_NOT_FOUND/i, name: 'Node模块缺失', fix: '运行npm install', severity: 'high' },
|
|||
|
|
{ regex: /SyntaxError/i, name: 'JavaScript语法错误', fix: '检查最近代码变更', severity: 'high' },
|
|||
|
|
{ regex: /ssh.*connection.*refused|ssh.*timeout/i, name: 'SSH连接失败', fix: '检查服务器状态和SSH配置', severity: 'critical' },
|
|||
|
|
{ regex: /rsync.*error/i, name: '文件同步失败', fix: '检查rsync路径和权限', severity: 'high' },
|
|||
|
|
{ regex: /npm ERR!/i, name: 'npm安装错误', fix: '检查package.json和网络', severity: 'medium' },
|
|||
|
|
{ regex: /pm2.*error|pm2.*fail/i, name: 'PM2进程管理错误', fix: '重启PM2并检查ecosystem配置', severity: 'high' },
|
|||
|
|
{ regex: /ssl.*error|certificate/i, name: 'SSL证书问题', fix: '检查证书文件路径和有效期', severity: 'high' },
|
|||
|
|
{ regex: /HEALTH_FAIL/i, name: '健康检查失败', fix: '检查应用是否正常启动', severity: 'high' },
|
|||
|
|
{ regex: /exit code [1-9]/i, name: '命令执行失败', fix: '查看上方具体错误信息', severity: 'medium' }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 尝试从经验数据库动态加载错误模式
|
|||
|
|
try {
|
|||
|
|
const errPatternsDb = JSON.parse(fs.readFileSync(ERROR_PATTERNS_DB, 'utf8'));
|
|||
|
|
if (errPatternsDb.patterns) {
|
|||
|
|
for (const p of errPatternsDb.patterns) {
|
|||
|
|
// 提取经验库中的关键词作为匹配模式
|
|||
|
|
const keywords = p.pattern.split(/[·\s+]/g).filter(k => k.length > 3);
|
|||
|
|
if (keywords.length > 0) {
|
|||
|
|
patterns.push({
|
|||
|
|
regex: new RegExp(keywords.join('|'), 'i'),
|
|||
|
|
name: `[经验库] ${p.pattern}`,
|
|||
|
|
fix: (p.prevention || []).join(' → ') || '参考经验库',
|
|||
|
|
severity: p.severity || 'medium'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// 经验库加载失败不影响基础分析
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const matched = [];
|
|||
|
|
for (const p of patterns) {
|
|||
|
|
if (p.regex.test(logContent)) {
|
|||
|
|
matched.push({ name: p.name, fix: p.fix, severity: p.severity });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (matched.length === 0) {
|
|||
|
|
return {
|
|||
|
|
summary: '未匹配到已知错误模式 · 需要深度分析',
|
|||
|
|
patterns: [],
|
|||
|
|
recommendation: '建议唤醒铸渊进行LLM深度推理分析'
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const summary = matched.map(m => `[${m.severity}] ${m.name}`).join('; ');
|
|||
|
|
const hasCritical = matched.some(m => m.severity === 'critical');
|
|||
|
|
const recommendation = hasCritical
|
|||
|
|
? '存在严重问题 · 建议立即人工干预'
|
|||
|
|
: '副将可尝试自动修复: ' + matched.map(m => m.fix).join(' → ');
|
|||
|
|
|
|||
|
|
return { summary, patterns: matched, recommendation };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 更新最新日志索引 ────────────────────────────
|
|||
|
|
function updateLatestIndex(logRecord) {
|
|||
|
|
const indexFile = path.join(LOGS_DIR, 'latest-index.json');
|
|||
|
|
let index;
|
|||
|
|
try {
|
|||
|
|
index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|||
|
|
} catch {
|
|||
|
|
index = {
|
|||
|
|
_meta: {
|
|||
|
|
name: '铸渊部署观测 · 最新日志索引',
|
|||
|
|
copyright: '国作登字-2026-A-00037559'
|
|||
|
|
},
|
|||
|
|
stats: { total_observed: 0, success: 0, failure: 0, repair_attempted: 0, repair_success: 0 },
|
|||
|
|
recent_deploys: []
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新统计
|
|||
|
|
index.stats.total_observed++;
|
|||
|
|
if (logRecord.run.conclusion === 'success') index.stats.success++;
|
|||
|
|
if (logRecord.run.conclusion === 'failure') index.stats.failure++;
|
|||
|
|
|
|||
|
|
// 追加到最近部署列表 (保留最近30条)
|
|||
|
|
index.recent_deploys.unshift({
|
|||
|
|
run_id: logRecord.run.id,
|
|||
|
|
workflow: logRecord.run.workflow_name,
|
|||
|
|
conclusion: logRecord.run.conclusion,
|
|||
|
|
head_sha: logRecord.run.head_sha?.slice(0, 8),
|
|||
|
|
actor: logRecord.run.actor,
|
|||
|
|
observed_at: logRecord._meta.collected_at,
|
|||
|
|
error_summary: logRecord.analysis.error_summary || '',
|
|||
|
|
log_file: `deploy-${logRecord.run.id}-${logRecord._meta.collected_at.slice(0, 10)}.json`
|
|||
|
|
});
|
|||
|
|
index.recent_deploys = index.recent_deploys.slice(0, 30);
|
|||
|
|
|
|||
|
|
index.last_updated = new Date().toISOString();
|
|||
|
|
|
|||
|
|
fs.writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
|
|||
|
|
console.log('✅ 最新日志索引已更新');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 归档成功部署日志 ────────────────────────────
|
|||
|
|
async function archiveLogs() {
|
|||
|
|
const args = parseArgs();
|
|||
|
|
const logFile = args['log-file'];
|
|||
|
|
|
|||
|
|
console.log('📦 部署成功归档');
|
|||
|
|
console.log('═'.repeat(60));
|
|||
|
|
|
|||
|
|
if (logFile) {
|
|||
|
|
const logPath = path.join(ROOT, logFile);
|
|||
|
|
if (fs.existsSync(logPath)) {
|
|||
|
|
// 移动到归档目录
|
|||
|
|
ensureDir(ARCHIVE_DIR);
|
|||
|
|
const fileName = path.basename(logPath);
|
|||
|
|
const archivePath = path.join(ARCHIVE_DIR, fileName);
|
|||
|
|
fs.copyFileSync(logPath, archivePath);
|
|||
|
|
console.log(`✅ 日志已归档: data/deploy-logs/archive/${fileName}`);
|
|||
|
|
|
|||
|
|
// 更新经验数据库 (记录成功部署)
|
|||
|
|
try {
|
|||
|
|
const logData = JSON.parse(fs.readFileSync(logPath, 'utf8'));
|
|||
|
|
updateExperienceDb(logData);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`⚠️ 经验数据库更新失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.log(`⚠️ 日志文件不存在: ${logFile}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理旧的非归档日志文件 (保留最近10个)
|
|||
|
|
cleanOldLogs();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 更新经验数据库 ────────────────────────────
|
|||
|
|
function updateExperienceDb(logData) {
|
|||
|
|
if (!logData || !logData.run) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const db = JSON.parse(fs.readFileSync(EXPERIENCE_DB, 'utf8'));
|
|||
|
|
const conclusion = logData.run.conclusion;
|
|||
|
|
|
|||
|
|
// 更新统计
|
|||
|
|
if (conclusion === 'success') {
|
|||
|
|
db.stats.success_count = (db.stats.success_count || 0) + 1;
|
|||
|
|
} else if (conclusion === 'failure') {
|
|||
|
|
db.stats.failed_count = (db.stats.failed_count || 0) + 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果有匹配的错误模式,更新错误模式库
|
|||
|
|
if (logData.analysis && logData.analysis.matched_patterns && logData.analysis.matched_patterns.length > 0) {
|
|||
|
|
updateErrorPatterns(logData.analysis.matched_patterns);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db._meta.last_updated = new Date().toISOString();
|
|||
|
|
fs.writeFileSync(EXPERIENCE_DB, JSON.stringify(db, null, 2) + '\n');
|
|||
|
|
console.log('✅ 经验数据库已更新');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`⚠️ 经验数据库更新失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 更新错误模式库 ────────────────────────────
|
|||
|
|
function updateErrorPatterns(matchedPatterns) {
|
|||
|
|
try {
|
|||
|
|
const db = JSON.parse(fs.readFileSync(ERROR_PATTERNS_DB, 'utf8'));
|
|||
|
|
|
|||
|
|
for (const matched of matchedPatterns) {
|
|||
|
|
// 查找是否已存在此模式
|
|||
|
|
const existing = db.patterns.find(p =>
|
|||
|
|
p.pattern.toLowerCase().includes(matched.name.toLowerCase())
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (existing) {
|
|||
|
|
existing.occurrence_count = (existing.occurrence_count || 0) + 1;
|
|||
|
|
existing.last_seen = new Date().toISOString().slice(0, 10);
|
|||
|
|
}
|
|||
|
|
// 新错误模式不自动添加,需要铸渊审核
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db._meta.last_updated = new Date().toISOString();
|
|||
|
|
fs.writeFileSync(ERROR_PATTERNS_DB, JSON.stringify(db, null, 2) + '\n');
|
|||
|
|
console.log('✅ 错误模式库已更新');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`⚠️ 错误模式库更新失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 清理旧日志 ────────────────────────────
|
|||
|
|
function cleanOldLogs() {
|
|||
|
|
try {
|
|||
|
|
const files = fs.readdirSync(LOGS_DIR)
|
|||
|
|
.filter(f => f.startsWith('deploy-') && f.endsWith('.json'))
|
|||
|
|
.sort()
|
|||
|
|
.reverse();
|
|||
|
|
|
|||
|
|
// 保留最近10个日志文件
|
|||
|
|
const toDelete = files.slice(10);
|
|||
|
|
for (const f of toDelete) {
|
|||
|
|
// 移动到归档而不是删除
|
|||
|
|
const src = path.join(LOGS_DIR, f);
|
|||
|
|
ensureDir(ARCHIVE_DIR);
|
|||
|
|
const dest = path.join(ARCHIVE_DIR, f);
|
|||
|
|
if (!fs.existsSync(dest)) {
|
|||
|
|
fs.copyFileSync(src, dest);
|
|||
|
|
}
|
|||
|
|
fs.unlinkSync(src);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toDelete.length > 0) {
|
|||
|
|
console.log(`🗑️ 已归档 ${toDelete.length} 个旧日志文件`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`⚠️ 清理旧日志失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 生成观测仪表盘 ────────────────────────────
|
|||
|
|
async function generateDashboard() {
|
|||
|
|
console.log('📊 生成铸渊部署观测仪表盘');
|
|||
|
|
console.log('═'.repeat(60));
|
|||
|
|
|
|||
|
|
const indexFile = path.join(LOGS_DIR, 'latest-index.json');
|
|||
|
|
const alertFile = path.join(LOGS_DIR, 'alert-status.json');
|
|||
|
|
const repairFile = path.join(LOGS_DIR, 'repair-history.json');
|
|||
|
|
|
|||
|
|
let index, alerts, repairs;
|
|||
|
|
try { index = JSON.parse(fs.readFileSync(indexFile, 'utf8')); } catch { index = { stats: {}, recent_deploys: [] }; }
|
|||
|
|
try { alerts = JSON.parse(fs.readFileSync(alertFile, 'utf8')); } catch { alerts = { alerts: [] }; }
|
|||
|
|
try { repairs = JSON.parse(fs.readFileSync(repairFile, 'utf8')); } catch { repairs = { repairs: [] }; }
|
|||
|
|
|
|||
|
|
const dashboard = {
|
|||
|
|
_meta: {
|
|||
|
|
name: '铸渊部署观测仪表盘',
|
|||
|
|
copyright: '国作登字-2026-A-00037559',
|
|||
|
|
generated_at: new Date().toISOString(),
|
|||
|
|
generator: 'scripts/deploy-log-collector.js'
|
|||
|
|
},
|
|||
|
|
overview: {
|
|||
|
|
total_observed: index.stats.total_observed || 0,
|
|||
|
|
success: index.stats.success || 0,
|
|||
|
|
failure: index.stats.failure || 0,
|
|||
|
|
success_rate: index.stats.total_observed
|
|||
|
|
? ((index.stats.success || 0) / index.stats.total_observed * 100).toFixed(1) + '%'
|
|||
|
|
: 'N/A',
|
|||
|
|
repair_attempted: index.stats.repair_attempted || 0,
|
|||
|
|
repair_success: index.stats.repair_success || 0,
|
|||
|
|
active_alerts: (alerts.alerts || []).filter(a => !a.resolved).length
|
|||
|
|
},
|
|||
|
|
recent_deploys: (index.recent_deploys || []).slice(0, 10),
|
|||
|
|
active_alerts: (alerts.alerts || []).filter(a => !a.resolved).slice(0, 5),
|
|||
|
|
recent_repairs: (repairs.repairs || []).slice(0, 5)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
ensureDir(LOGS_DIR);
|
|||
|
|
const dashboardFile = path.join(LOGS_DIR, 'observer-dashboard.json');
|
|||
|
|
fs.writeFileSync(dashboardFile, JSON.stringify(dashboard, null, 2) + '\n');
|
|||
|
|
|
|||
|
|
console.log('');
|
|||
|
|
console.log('📊 仪表盘概览:');
|
|||
|
|
console.log(` 总观测次数: ${dashboard.overview.total_observed}`);
|
|||
|
|
console.log(` 成功部署: ${dashboard.overview.success}`);
|
|||
|
|
console.log(` 失败部署: ${dashboard.overview.failure}`);
|
|||
|
|
console.log(` 成功率: ${dashboard.overview.success_rate}`);
|
|||
|
|
console.log(` 修复尝试: ${dashboard.overview.repair_attempted}`);
|
|||
|
|
console.log(` 修复成功: ${dashboard.overview.repair_success}`);
|
|||
|
|
console.log(` 活跃告警: ${dashboard.overview.active_alerts}`);
|
|||
|
|
console.log('');
|
|||
|
|
console.log('✅ 仪表盘已更新: data/deploy-logs/observer-dashboard.json');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 工具函数 ────────────────────────────────
|
|||
|
|
function setOutput(key, value) {
|
|||
|
|
if (process.env.GITHUB_OUTPUT) {
|
|||
|
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseArgs() {
|
|||
|
|
const args = process.argv.slice(2);
|
|||
|
|
const cmd = args[0];
|
|||
|
|
const params = {};
|
|||
|
|
for (let i = 1; i < args.length; i++) {
|
|||
|
|
if (args[i].startsWith('--')) {
|
|||
|
|
params[args[i].slice(2)] = args[i + 1] || '';
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { cmd, ...params };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 主入口 ──────────────────────────────────
|
|||
|
|
async function main() {
|
|||
|
|
const args = parseArgs();
|
|||
|
|
|
|||
|
|
switch (args.cmd) {
|
|||
|
|
case 'collect':
|
|||
|
|
await collectLogs();
|
|||
|
|
break;
|
|||
|
|
case 'archive':
|
|||
|
|
await archiveLogs();
|
|||
|
|
break;
|
|||
|
|
case 'dashboard':
|
|||
|
|
await generateDashboard();
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
console.log('📡 铸渊全链路部署观测 · 日志采集器');
|
|||
|
|
console.log('');
|
|||
|
|
console.log('用法:');
|
|||
|
|
console.log(' node deploy-log-collector.js collect');
|
|||
|
|
console.log(' node deploy-log-collector.js archive --log-file <path>');
|
|||
|
|
console.log(' node deploy-log-collector.js dashboard');
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch(err => {
|
|||
|
|
console.error(`❌ 日志采集器错误: ${err.message}`);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|