@maiyunnet/kebab 9.20.1 → 9.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/doc/kebab-rag.md +472 -33
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/core.d.ts +59 -0
- package/lib/core.js +81 -0
- package/lib/fs.d.ts +12 -1
- package/lib/fs.js +214 -159
- package/package.json +1 -1
- package/sys/master.js +54 -0
- package/sys/monitor/files.d.ts +34 -0
- package/sys/monitor/files.js +260 -0
- package/sys/monitor/watchdog.js +284 -215
- package/sys/monitor.d.ts +28 -3
- package/sys/monitor.js +527 -250
- package/www/example/ctr/test.d.ts +21 -0
- package/www/example/ctr/test.js +250 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** --- master 读取落盘监控数据;只读取有界事件 JSON,Profile 和堆快照仅提供文件元数据 --- */
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as lText from '#kebab/lib/text.js';
|
|
5
|
+
/** --- 单个事件记录读取上限,避免损坏或异常大的文件占用 master 内存 --- */
|
|
6
|
+
const RECORD_LIMIT = 4 * 1024 * 1024;
|
|
7
|
+
/** --- 小型 JSON/文本预览上限,Profile 和堆快照不预览 --- */
|
|
8
|
+
export const PREVIEW_LIMIT = 1024 * 1024;
|
|
9
|
+
const DAY_PATTERN = /^\d{4}\/\d{2}\/\d{2}$/;
|
|
10
|
+
const EVENT_NAME_PATTERN = /^\d{6}-pid-\d+(?:-\d+)?(?:-blocked)?$/;
|
|
11
|
+
const FILE_NAME_PATTERNS = [
|
|
12
|
+
/^(?:event\.json|blocked-event\.json|(?:report|profile)-[\w.-]+\.json)$/,
|
|
13
|
+
/^(?:cpu|blocked-cpu)-[\w.-]+\.cpuprofile$/,
|
|
14
|
+
/^blocked-stack-[\w.-]+\.txt$/,
|
|
15
|
+
/^heap-[\w.-]+\.heapsnapshot$/,
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* --- 仅开放框架生成的监控取证文件名 ---
|
|
19
|
+
* @param name 文件名
|
|
20
|
+
* @returns 是否为诊断文件
|
|
21
|
+
*/
|
|
22
|
+
function isFileName(name) {
|
|
23
|
+
return FILE_NAME_PATTERNS.some(pattern => pattern.test(name));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* --- 解析监控目录内已有路径,阻止目录穿越和符号链接逃逸 ---
|
|
27
|
+
* @param root log/monitor 的绝对路径
|
|
28
|
+
* @param relative 已校验格式的相对路径
|
|
29
|
+
* @returns 真实路径;不存在时返回 null,越界或读取错误时返回 false
|
|
30
|
+
*/
|
|
31
|
+
async function resolveInside(root, relative) {
|
|
32
|
+
try {
|
|
33
|
+
if ((await fs.lstat(root)).isSymbolicLink()) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const rootReal = await fs.realpath(root);
|
|
37
|
+
const target = await fs.realpath(path.join(rootReal, relative));
|
|
38
|
+
const difference = path.relative(rootReal, target);
|
|
39
|
+
if (difference === '..' || difference.startsWith(`..${path.sep}`) || path.isAbsolute(difference)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return error.code === 'ENOENT' ? null : false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* --- 校验事件相对路径,不接受绝对路径、反斜杠或多余层级 ---
|
|
50
|
+
* @param relative 事件路径
|
|
51
|
+
* @returns 是否为日期下的事件目录
|
|
52
|
+
*/
|
|
53
|
+
function isEventPath(relative) {
|
|
54
|
+
if (typeof relative !== 'string') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const parts = relative.split('/');
|
|
58
|
+
return parts.length === 4 && DAY_PATTERN.test(parts.slice(0, 3).join('/')) && EVENT_NAME_PATTERN.test(parts[3]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* --- 有界读取原始事件;没有记录的旧诊断目录仍可列出文件 ---
|
|
62
|
+
* @param directory 事件真实目录
|
|
63
|
+
* @returns 原始数据及读取状态
|
|
64
|
+
*/
|
|
65
|
+
async function readRecord(directory) {
|
|
66
|
+
for (const name of ['event.json', 'blocked-event.json']) {
|
|
67
|
+
let handle = null;
|
|
68
|
+
try {
|
|
69
|
+
// --- 拒绝事件记录本身的符号链接;读取过程中即使文件增长也不会超过上限 ---
|
|
70
|
+
const stat = await fs.lstat(path.join(directory, name));
|
|
71
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
72
|
+
return { 'data': null, 'status': 'unreadable' };
|
|
73
|
+
}
|
|
74
|
+
if (stat.size > RECORD_LIMIT) {
|
|
75
|
+
return { 'data': null, 'status': 'too-large' };
|
|
76
|
+
}
|
|
77
|
+
handle = await fs.open(path.join(directory, name), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
78
|
+
const buffer = Buffer.alloc(stat.size + 1);
|
|
79
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
80
|
+
if (bytesRead > RECORD_LIMIT) {
|
|
81
|
+
return { 'data': null, 'status': 'too-large' };
|
|
82
|
+
}
|
|
83
|
+
const data = lText.parseJson(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
84
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
85
|
+
return { 'data': null, 'status': 'invalid' };
|
|
86
|
+
}
|
|
87
|
+
return { 'data': data, 'status': 'loaded' };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== 'ENOENT') {
|
|
91
|
+
return { 'data': null, 'status': 'unreadable' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await handle?.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { 'data': null, 'status': 'missing' };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* --- 从目录名和事件记录生成简短摘要,不携带大段采样或调用栈 ---
|
|
102
|
+
* @param relative 事件相对路径
|
|
103
|
+
* @param record 原始事件读取结果
|
|
104
|
+
* @returns 事件摘要
|
|
105
|
+
*/
|
|
106
|
+
function summarize(relative, record) {
|
|
107
|
+
const [year, month, day, name] = relative.split('/');
|
|
108
|
+
const parts = name.split('-');
|
|
109
|
+
const time = Number(parts[3]) || new Date(`${year}-${month}-${day}T${parts[0].slice(0, 2)}:${parts[0].slice(2, 4)}:${parts[0].slice(4, 6)}`).getTime();
|
|
110
|
+
const data = record.data;
|
|
111
|
+
const source = name.endsWith('-blocked') ? 'watchdog' : 'main';
|
|
112
|
+
let status = record.status === 'loaded' ? 'active' : record.status;
|
|
113
|
+
if (typeof data?.endReason === 'string') {
|
|
114
|
+
status = data.endReason;
|
|
115
|
+
}
|
|
116
|
+
else if (typeof data?.status === 'string') {
|
|
117
|
+
status = data.status;
|
|
118
|
+
}
|
|
119
|
+
let eventTime = time;
|
|
120
|
+
if (typeof data?.confirmedAt === 'number') {
|
|
121
|
+
eventTime = data.confirmedAt;
|
|
122
|
+
}
|
|
123
|
+
else if (typeof data?.time === 'number') {
|
|
124
|
+
eventTime = data.time;
|
|
125
|
+
}
|
|
126
|
+
let reasons = [];
|
|
127
|
+
if (Array.isArray(data?.reasons)) {
|
|
128
|
+
reasons = data.reasons.filter((reason) => typeof reason === 'string');
|
|
129
|
+
}
|
|
130
|
+
else if (source === 'watchdog') {
|
|
131
|
+
reasons = ['ELOOP_BLOCKED'];
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
'path': relative,
|
|
135
|
+
'pid': typeof data?.pid === 'number' ? data.pid : Number(parts[2]),
|
|
136
|
+
'time': eventTime,
|
|
137
|
+
source, status, reasons,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* --- 获取一天的事件摘要,先分页目录再读取记录 ---
|
|
142
|
+
* @param root log/monitor 的绝对路径
|
|
143
|
+
* @param day YYYY/MM/DD
|
|
144
|
+
* @param offset 跳过目录数量
|
|
145
|
+
* @param limit 返回数量,1-100
|
|
146
|
+
* @returns 列表和总数;目录不存在返回空列表,输入或读取错误返回 false
|
|
147
|
+
*/
|
|
148
|
+
export async function getEvents(root, day, offset = 0, limit = 20) {
|
|
149
|
+
if (typeof day !== 'string' || !DAY_PATTERN.test(day) || !Number.isSafeInteger(offset) ||
|
|
150
|
+
typeof offset !== 'number' || offset < 0 || !Number.isSafeInteger(limit) || typeof limit !== 'number' || limit < 1 || limit > 100) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const directory = await resolveInside(root, day);
|
|
154
|
+
if (directory === false) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (directory === null) {
|
|
158
|
+
return { 'list': [], 'total': 0 };
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const entries = await fs.readdir(directory, { 'withFileTypes': true });
|
|
162
|
+
const names = entries.filter(entry => entry.isDirectory() && EVENT_NAME_PATTERN.test(entry.name))
|
|
163
|
+
.map(entry => entry.name).sort().reverse();
|
|
164
|
+
const list = [];
|
|
165
|
+
for (const name of names.slice(offset, offset + limit)) {
|
|
166
|
+
const relative = `${day}/${name}`;
|
|
167
|
+
const record = await readRecord(path.join(directory, name));
|
|
168
|
+
list.push(summarize(relative, record));
|
|
169
|
+
}
|
|
170
|
+
return { list, 'total': names.length };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* --- 获取允许读取的诊断文件及其元数据,文件路径限制在日期/事件目录下 ---
|
|
178
|
+
* @param root log/monitor 的绝对路径
|
|
179
|
+
* @param relative files 中返回的文件相对路径
|
|
180
|
+
* @returns 真实路径、文件状态和元数据;不存在返回 null,非法路径或读取错误返回 false
|
|
181
|
+
*/
|
|
182
|
+
export async function getFile(root, relative) {
|
|
183
|
+
if (typeof relative !== 'string' || !isEventPath(path.posix.dirname(relative)) || !isFileName(path.posix.basename(relative))) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const file = await resolveInside(root, relative);
|
|
187
|
+
if (file === null || file === false) {
|
|
188
|
+
return file;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const stat = await fs.stat(file);
|
|
192
|
+
if (!stat.isFile()) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
let preview = null;
|
|
196
|
+
if (stat.size <= PREVIEW_LIMIT) {
|
|
197
|
+
if (relative.endsWith('.json')) {
|
|
198
|
+
preview = 'json';
|
|
199
|
+
}
|
|
200
|
+
else if (relative.endsWith('.txt')) {
|
|
201
|
+
preview = 'text';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { 'path': file, stat, 'info': {
|
|
205
|
+
'path': relative, 'name': path.basename(relative), 'size': stat.size, 'mtime': stat.mtimeMs, preview,
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return error.code === 'ENOENT' ? null : false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* --- 获取事件原始数据及文件清单,包含 diagnostics 引用的看门狗取证文件 ---
|
|
214
|
+
* @param root log/monitor 的绝对路径
|
|
215
|
+
* @param relative 事件相对路径
|
|
216
|
+
* @returns 事件详情;不存在返回 null,非法路径或读取错误返回 false
|
|
217
|
+
*/
|
|
218
|
+
export async function getEvent(root, relative) {
|
|
219
|
+
if (!isEventPath(relative)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const directory = await resolveInside(root, relative);
|
|
223
|
+
if (directory === null || directory === false) {
|
|
224
|
+
return directory;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const record = await readRecord(directory);
|
|
228
|
+
const entries = await fs.readdir(directory, { 'withFileTypes': true });
|
|
229
|
+
const candidates = new Set(entries.filter(entry => entry.isFile() && isFileName(entry.name))
|
|
230
|
+
.map(entry => `${relative}/${entry.name}`));
|
|
231
|
+
if (Array.isArray(record.data?.diagnostics)) {
|
|
232
|
+
// --- 主线程事件会引用另一个阻塞目录;只将监控根目录内的文件加入下载清单 ---
|
|
233
|
+
for (const diagnostic of record.data.diagnostics) {
|
|
234
|
+
if (diagnostic === null || typeof diagnostic !== 'object') {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const files = diagnostic.files;
|
|
238
|
+
if (!Array.isArray(files)) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
if (typeof file === 'string' && path.isAbsolute(file)) {
|
|
243
|
+
candidates.add(path.relative(root, file).split(path.sep).join('/'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const files = [];
|
|
249
|
+
for (const candidate of [...candidates].sort()) {
|
|
250
|
+
const file = await getFile(root, candidate);
|
|
251
|
+
if (file !== false && file !== null) {
|
|
252
|
+
files.push(file.info);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { 'summary': summarize(relative, record), 'data': record.data, 'recordStatus': record.status, files };
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|