rainskills 0.1.11 → 0.1.13
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/README.md +1 -1
- package/SKILL.md +5 -1
- package/marketplace/rainskills/.claude-plugin/plugin.json +1 -1
- package/marketplace/rainskills/.codex-plugin/plugin.json +1 -1
- package/marketplace/rainskills/skills/rainskills/SKILL.md +6 -2
- package/package.json +1 -1
- package/rainbond-app-assistant/SKILL.md +8 -4
- package/rainbond-app-version-assistant/SKILL.md +6 -2
- package/rainbond-delivery-verifier/SKILL.md +6 -2
- package/rainbond-env-sync/SKILL.md +6 -2
- package/rainbond-fullstack-bootstrap/SKILL.md +6 -2
- package/rainbond-fullstack-troubleshooter/SKILL.md +6 -2
- package/rainbond-opensource-app-deploy/SKILL.md +6 -2
- package/rainbond-platform-installer/SKILL.md +9 -6
- package/rainbond-platform-installer/references/installation-policy.md +10 -7
- package/rainbond-platform-installer/scripts/host-cluster-installer.js +658 -49
- package/rainbond-platform-installer/scripts/installed-version.js +1 -1
- package/rainbond-platform-installer/scripts/platform-installer.js +4 -1
- package/rainbond-platform-query/SKILL.md +6 -2
- package/rainbond-project-init/SKILL.md +6 -2
- package/rainbond-template-installer/SKILL.md +6 -2
|
@@ -4,10 +4,12 @@ const crypto = require("node:crypto");
|
|
|
4
4
|
const fs = require("node:fs");
|
|
5
5
|
const http = require("node:http");
|
|
6
6
|
const https = require("node:https");
|
|
7
|
+
const net = require("node:net");
|
|
7
8
|
const os = require("node:os");
|
|
8
9
|
const path = require("node:path");
|
|
9
10
|
const readline = require("node:readline/promises");
|
|
10
11
|
const { spawn, spawnSync } = require("node:child_process");
|
|
12
|
+
const { TextDecoder } = require("node:util");
|
|
11
13
|
const YAML = require("yaml");
|
|
12
14
|
|
|
13
15
|
const POLICY = require("../references/installation-policy.json");
|
|
@@ -19,10 +21,14 @@ const REQUIRED_ROLES = ["etcd", "master", "worker", "rbd-gateway", "rbd-chaos"];
|
|
|
19
21
|
const ALL_ROLES = [...REQUIRED_ROLES, "nfs-server"];
|
|
20
22
|
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential)/i;
|
|
21
23
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
22
|
-
const SAFE_ADDRESS = /^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|(?:[0-9a-fA-F]*:){2,}[0-9a-fA-F:.]+)$/;
|
|
23
24
|
const SAFE_REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
24
25
|
const CRITICAL_WORKLOADS = ["rbd-api", "rbd-gateway", "rbd-app-ui"];
|
|
25
26
|
const MAX_CHILD_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
const MAX_SERVER_INPUT_BYTES = 1024 * 1024;
|
|
28
|
+
const MAX_SERVER_INPUT_HOSTS = 100;
|
|
29
|
+
const MAX_SERVER_INPUT_ISSUES = 200;
|
|
30
|
+
const SERVER_INPUT_FIELDS = ["public_ip", "private_ip", "ssh_port", "password"];
|
|
31
|
+
const SERVER_INPUT_VALIDATION_ERROR = "servers.txt 解析结果无效";
|
|
26
32
|
const CHILD_OUTPUT_LIMIT_ERROR = "子进程输出超过安全上限";
|
|
27
33
|
const TEMPLATE_ADDRESSES = new Map([
|
|
28
34
|
["192.0.2.101", "node1"],
|
|
@@ -68,6 +74,63 @@ function renderHostClusterConfigPrompt({ configPath, platform = process.platform
|
|
|
68
74
|
].join("\n");
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
function createHostServerInputTemplate() {
|
|
78
|
+
const heading = [
|
|
79
|
+
"# Rainbond 多节点服务器信息",
|
|
80
|
+
"# 密码只写入受保护的本地和远端安装文件,不会写入聊天、日志或状态;不要填写私钥或 Token。",
|
|
81
|
+
"# 超过三台服务器时,复制完整的 [server-N] 区块并按顺序编号。",
|
|
82
|
+
];
|
|
83
|
+
const sections = Array.from({ length: 3 }, (_, index) => [
|
|
84
|
+
`[server-${index + 1}]`,
|
|
85
|
+
"# 公网 IP:控制端通过 SSH 访问该服务器的地址;没有独立公网 IP 时可与内网 IP 相同",
|
|
86
|
+
"public_ip=",
|
|
87
|
+
"# 内网 IP:集群节点之间通信使用的地址",
|
|
88
|
+
"private_ip=",
|
|
89
|
+
"# SSH 端口:按服务器实际端口填写,不要求为 22",
|
|
90
|
+
"ssh_port=22",
|
|
91
|
+
"# root 密码:保留所有特殊字符;不要添加引号",
|
|
92
|
+
"password=",
|
|
93
|
+
].join("\n"));
|
|
94
|
+
return Buffer.from(`${heading.join("\n")}\n\n${sections.join("\n\n")}\n`, "utf8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hostServerInputOpenCommand(inputPath, platform = process.platform) {
|
|
98
|
+
const filePath = String(inputPath);
|
|
99
|
+
if (!filePath || /[\0\r\n]/.test(filePath)) throw new Error("servers.txt 路径无效");
|
|
100
|
+
if (platform === "darwin") return `open ${quotePosixArgument(filePath)}`;
|
|
101
|
+
if (platform === "linux") return `xdg-open ${quotePosixArgument(filePath)}`;
|
|
102
|
+
if (platform === "win32") return `explorer.exe "${filePath.replace(/"/g, '""')}"`;
|
|
103
|
+
throw new Error("不支持的控制端平台");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderHostServerInputPrompt({ inputPath, platform = process.platform, issues = [] }) {
|
|
107
|
+
const normalizedPath = String(inputPath).replace(/\\/g, "/");
|
|
108
|
+
const linkTarget = encodeURI(normalizedPath).replace(/#/g, "%23");
|
|
109
|
+
const heading = issues.length > 0
|
|
110
|
+
? ["服务器信息还需要调整:", ...issues.map((issue, index) => `${index + 1}. ${issue}`)]
|
|
111
|
+
: ["服务器信息文件已生成。"];
|
|
112
|
+
return [
|
|
113
|
+
...heading,
|
|
114
|
+
"",
|
|
115
|
+
`[点击打开 servers.txt](<${linkTarget}>)`,
|
|
116
|
+
"",
|
|
117
|
+
"如果链接无法打开,请在系统终端执行:",
|
|
118
|
+
"",
|
|
119
|
+
"```sh",
|
|
120
|
+
hostServerInputOpenCommand(inputPath, platform),
|
|
121
|
+
"```",
|
|
122
|
+
"",
|
|
123
|
+
"请为每台服务器填写 public_ip、private_ip、ssh_port 和 password。",
|
|
124
|
+
"- 公网 IP:控制端 SSH 地址;没有独立公网 IP 时可与内网 IP 相同。",
|
|
125
|
+
"- 内网 IP:节点通信地址。",
|
|
126
|
+
"- SSH 端口:填写每台服务器的实际端口,不强制为 22。",
|
|
127
|
+
"- root 密码:只写入受保护的本地和远端安装文件,不得发送到聊天。",
|
|
128
|
+
"密码只写入受保护的本地和远端安装文件,不会写入聊天、日志或状态;不要填写私钥或 Token。",
|
|
129
|
+
"",
|
|
130
|
+
"编辑完成后回复“已完成”,我会检查全部服务器信息。",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
71
134
|
function clusterConfigCommandArgument(configPath, platform = process.platform) {
|
|
72
135
|
const value = String(configPath || "");
|
|
73
136
|
if (!value || /[\0\r\n]/.test(value)) throw new Error("cluster.yaml 路径无效");
|
|
@@ -237,6 +300,178 @@ function deepClone(value) {
|
|
|
237
300
|
return JSON.parse(JSON.stringify(value));
|
|
238
301
|
}
|
|
239
302
|
|
|
303
|
+
function normalizeServerInputAddress(value) {
|
|
304
|
+
if (typeof value !== "string" || value.includes("%")) return null;
|
|
305
|
+
const ipVersion = net.isIP(value);
|
|
306
|
+
if (ipVersion === 4) return value;
|
|
307
|
+
if (ipVersion === 6) {
|
|
308
|
+
try {
|
|
309
|
+
return new URL(`http://[${value}]/`).hostname.slice(1, -1);
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (value.length > 253) return null;
|
|
315
|
+
const labels = value.split(".");
|
|
316
|
+
if (labels.some((label) => (
|
|
317
|
+
label.length < 1
|
|
318
|
+
|| label.length > 63
|
|
319
|
+
|| !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label)
|
|
320
|
+
))) return null;
|
|
321
|
+
return value.toLowerCase();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function parseHostServerInput(input) {
|
|
325
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
|
326
|
+
const issues = [];
|
|
327
|
+
const issueSet = new Set();
|
|
328
|
+
let diagnosticsTruncated = false;
|
|
329
|
+
const add = (message) => {
|
|
330
|
+
if (!message || issueSet.has(message)) return;
|
|
331
|
+
if (issues.length >= MAX_SERVER_INPUT_ISSUES) {
|
|
332
|
+
diagnosticsTruncated = true;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
issueSet.add(message);
|
|
336
|
+
issues.push(message);
|
|
337
|
+
};
|
|
338
|
+
const result = (hosts) => {
|
|
339
|
+
if (diagnosticsTruncated) issues.push("servers.txt 问题过多,诊断已截断");
|
|
340
|
+
return { hosts, issues };
|
|
341
|
+
};
|
|
342
|
+
if (bytes.length > MAX_SERVER_INPUT_BYTES) {
|
|
343
|
+
return { hosts: [], issues: ["servers.txt 大小不能超过 1 MiB"] };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let text;
|
|
347
|
+
try {
|
|
348
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
349
|
+
} catch {
|
|
350
|
+
return { hosts: [], issues: ["servers.txt 必须使用有效的 UTF-8 编码"] };
|
|
351
|
+
}
|
|
352
|
+
if (/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(text)
|
|
353
|
+
|| /(?:^|[^\r])\r(?!\n)/.test(text)) {
|
|
354
|
+
add("servers.txt 不能包含 NUL 或其他控制字符");
|
|
355
|
+
text = text
|
|
356
|
+
.replace(/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
|
|
357
|
+
.replace(/\r(?!\n)/g, "");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const sections = [];
|
|
361
|
+
let current = null;
|
|
362
|
+
let ignoreRemainingInput = false;
|
|
363
|
+
const lines = text.split("\n");
|
|
364
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
365
|
+
if (ignoreRemainingInput) continue;
|
|
366
|
+
const lineNumber = lineIndex + 1;
|
|
367
|
+
const line = lines[lineIndex].endsWith("\r") ? lines[lineIndex].slice(0, -1) : lines[lineIndex];
|
|
368
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
369
|
+
|
|
370
|
+
const sectionMatch = /^\[server-(\d+)\]$/.exec(line);
|
|
371
|
+
if (sectionMatch) {
|
|
372
|
+
if (sections.length >= MAX_SERVER_INPUT_HOSTS) {
|
|
373
|
+
add("servers.txt 最多允许 100 个节点区块");
|
|
374
|
+
current = null;
|
|
375
|
+
ignoreRemainingInput = true;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
current = {
|
|
379
|
+
number: Number(sectionMatch[1]),
|
|
380
|
+
numberText: sectionMatch[1],
|
|
381
|
+
line: lineNumber,
|
|
382
|
+
fields: new Map(),
|
|
383
|
+
};
|
|
384
|
+
sections.push(current);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
388
|
+
add(`第 ${lineNumber} 行是未知区块`);
|
|
389
|
+
current = null;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const separator = line.indexOf("=");
|
|
394
|
+
if (separator < 0) {
|
|
395
|
+
add(`第 ${lineNumber} 行字段格式无效`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!current) {
|
|
399
|
+
add(`第 ${lineNumber} 行字段不属于任何 server 区块`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const field = line.slice(0, separator);
|
|
403
|
+
if (!SERVER_INPUT_FIELDS.includes(field)) {
|
|
404
|
+
add(`第 ${lineNumber} 行包含未知字段`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (current.fields.has(field)) {
|
|
408
|
+
add(`第 ${lineNumber} 行的 ${field} 字段重复`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const rawValue = line.slice(separator + 1);
|
|
412
|
+
current.fields.set(field, field === "password" ? rawValue : rawValue.trim());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (sections.length < 3) add("servers.txt 至少需要 3 个节点区块");
|
|
416
|
+
const sectionNumbers = new Set();
|
|
417
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
418
|
+
const section = sections[index];
|
|
419
|
+
const label = `server-${section.number}`;
|
|
420
|
+
if (sectionNumbers.has(section.number)) add(`第 ${section.line} 行的 ${label} 区块重复`);
|
|
421
|
+
sectionNumbers.add(section.number);
|
|
422
|
+
if (section.numberText !== String(index + 1)) add(`第 ${section.line} 行的区块编号必须从 server-1 开始连续`);
|
|
423
|
+
for (const field of SERVER_INPUT_FIELDS) {
|
|
424
|
+
if (!section.fields.has(field)) {
|
|
425
|
+
add(`${label} 的 ${field} 字段缺失`);
|
|
426
|
+
} else if (field === "password" && section.fields.get(field).trim() === "") {
|
|
427
|
+
add(`${label} 的 password 未填写或只有空白`);
|
|
428
|
+
} else if (field !== "password" && section.fields.get(field) === "") {
|
|
429
|
+
add(`${label} 的 ${field} 字段未填写`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const hosts = sections.map((section) => ({
|
|
435
|
+
publicIp: section.fields.get("public_ip") || "",
|
|
436
|
+
privateIp: section.fields.get("private_ip") || "",
|
|
437
|
+
sshPort: Number(section.fields.get("ssh_port")),
|
|
438
|
+
password: section.fields.get("password") || "",
|
|
439
|
+
}));
|
|
440
|
+
const sshEndpoints = new Set();
|
|
441
|
+
const privateAddresses = new Set();
|
|
442
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
443
|
+
const section = sections[index];
|
|
444
|
+
const host = hosts[index];
|
|
445
|
+
const label = `server-${section.number}`;
|
|
446
|
+
const publicIp = section.fields.get("public_ip");
|
|
447
|
+
const privateIp = section.fields.get("private_ip");
|
|
448
|
+
const portText = section.fields.get("ssh_port");
|
|
449
|
+
const publicNormalized = typeof publicIp === "string" && publicIp !== ""
|
|
450
|
+
? normalizeServerInputAddress(publicIp)
|
|
451
|
+
: null;
|
|
452
|
+
const privateNormalized = typeof privateIp === "string" && privateIp !== ""
|
|
453
|
+
? normalizeServerInputAddress(privateIp)
|
|
454
|
+
: null;
|
|
455
|
+
const publicValid = publicNormalized !== null;
|
|
456
|
+
const privateValid = privateNormalized !== null;
|
|
457
|
+
const portValid = typeof portText === "string" && /^\d+$/.test(portText)
|
|
458
|
+
&& Number.isInteger(host.sshPort) && host.sshPort >= 1 && host.sshPort <= 65535;
|
|
459
|
+
if (typeof publicIp === "string" && publicIp !== "" && !publicValid) add(`${label} 的 public_ip 地址无效`);
|
|
460
|
+
if (typeof privateIp === "string" && privateIp !== "" && !privateValid) add(`${label} 的 private_ip 地址无效`);
|
|
461
|
+
if (typeof portText === "string" && portText !== "" && !portValid) add(`${label} 的 ssh_port 必须是 1 到 65535 的整数`);
|
|
462
|
+
if (publicValid && portValid) {
|
|
463
|
+
const endpoint = `${publicNormalized}\0${host.sshPort}`;
|
|
464
|
+
if (sshEndpoints.has(endpoint)) add(`${label} 的 SSH endpoint 与前面的区块重复`);
|
|
465
|
+
sshEndpoints.add(endpoint);
|
|
466
|
+
}
|
|
467
|
+
if (privateValid) {
|
|
468
|
+
if (privateAddresses.has(privateNormalized)) add(`${label} 的 private_ip 与前面的区块重复`);
|
|
469
|
+
privateAddresses.add(privateNormalized);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return result(hosts);
|
|
473
|
+
}
|
|
474
|
+
|
|
240
475
|
function parseClusterDocument(input) {
|
|
241
476
|
const text = Buffer.isBuffer(input) ? input.toString("utf8") : String(input || "");
|
|
242
477
|
if (text.includes("\0")) throw new Error("cluster.yaml 不能包含 NUL 字节");
|
|
@@ -301,6 +536,61 @@ function createHostClusterTemplate() {
|
|
|
301
536
|
return Buffer.from(String(document), "utf8");
|
|
302
537
|
}
|
|
303
538
|
|
|
539
|
+
function createClusterConfigFromServerInput(hosts) {
|
|
540
|
+
if (!Array.isArray(hosts) || hosts.length < 3 || hosts.length > MAX_SERVER_INPUT_HOSTS) {
|
|
541
|
+
throw new Error(SERVER_INPUT_VALIDATION_ERROR);
|
|
542
|
+
}
|
|
543
|
+
const nodeNames = Array.from({ length: hosts.length }, (_, index) => `node${index + 1}`);
|
|
544
|
+
const generatedHosts = [];
|
|
545
|
+
for (let index = 0; index < hosts.length; index += 1) {
|
|
546
|
+
if (!Object.prototype.hasOwnProperty.call(hosts, index)) {
|
|
547
|
+
throw new Error(SERVER_INPUT_VALIDATION_ERROR);
|
|
548
|
+
}
|
|
549
|
+
const item = hosts[index];
|
|
550
|
+
if (!item || typeof item !== "object" || Array.isArray(item)
|
|
551
|
+
|| typeof item.publicIp !== "string" || normalizeServerInputAddress(item.publicIp) === null
|
|
552
|
+
|| typeof item.privateIp !== "string" || normalizeServerInputAddress(item.privateIp) === null
|
|
553
|
+
|| !Number.isInteger(item.sshPort) || item.sshPort < 1 || item.sshPort > 65535
|
|
554
|
+
|| typeof item.password !== "string" || item.password.trim() === "") {
|
|
555
|
+
throw new Error(SERVER_INPUT_VALIDATION_ERROR);
|
|
556
|
+
}
|
|
557
|
+
generatedHosts.push({
|
|
558
|
+
name: nodeNames[index],
|
|
559
|
+
address: item.publicIp,
|
|
560
|
+
internalAddress: item.privateIp,
|
|
561
|
+
user: "root",
|
|
562
|
+
password: item.password,
|
|
563
|
+
port: item.sshPort,
|
|
564
|
+
...(index === 0 ? { bootstrap: true } : {}),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const config = {
|
|
568
|
+
hosts: generatedHosts,
|
|
569
|
+
roleGroups: {
|
|
570
|
+
etcd: nodeNames.slice(0, 3),
|
|
571
|
+
master: nodeNames.slice(0, 3),
|
|
572
|
+
worker: nodeNames,
|
|
573
|
+
"rbd-gateway": nodeNames.slice(0, 2),
|
|
574
|
+
"rbd-chaos": nodeNames,
|
|
575
|
+
"nfs-server": ["node1"],
|
|
576
|
+
},
|
|
577
|
+
storage: {
|
|
578
|
+
nfs: { enabled: true, sharePath: "/nfs-data/k8s", storageClass: { enabled: true } },
|
|
579
|
+
existingStorageClass: { enabled: false },
|
|
580
|
+
},
|
|
581
|
+
registry: { external: { enabled: false } },
|
|
582
|
+
database: { mysql: { enabled: false }, custom: { enabled: false } },
|
|
583
|
+
};
|
|
584
|
+
try {
|
|
585
|
+
validateClusterTopology(config);
|
|
586
|
+
} catch {
|
|
587
|
+
throw new Error(SERVER_INPUT_VALIDATION_ERROR);
|
|
588
|
+
}
|
|
589
|
+
const document = new YAML.Document(config);
|
|
590
|
+
document.options.lineWidth = 0;
|
|
591
|
+
return Buffer.from(String(document), "utf8");
|
|
592
|
+
}
|
|
593
|
+
|
|
304
594
|
function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
|
|
305
595
|
let value;
|
|
306
596
|
try {
|
|
@@ -317,7 +607,8 @@ function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
|
|
|
317
607
|
const hosts = Array.isArray(value.hosts) ? value.hosts : [];
|
|
318
608
|
if (!Array.isArray(value.hosts) || hosts.length === 0) add("hosts 至少需要配置一个节点");
|
|
319
609
|
const names = new Set();
|
|
320
|
-
const
|
|
610
|
+
const sshEndpoints = new Set();
|
|
611
|
+
const internalAddresses = new Set();
|
|
321
612
|
const normalized = [];
|
|
322
613
|
for (let index = 0; index < hosts.length; index += 1) {
|
|
323
614
|
const raw = hosts[index];
|
|
@@ -329,15 +620,21 @@ function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
|
|
|
329
620
|
const address = String(raw.address || "");
|
|
330
621
|
const internalAddress = String(raw.internalAddress || raw.address || "");
|
|
331
622
|
const port = raw.port === undefined ? 22 : Number(raw.port);
|
|
623
|
+
const normalizedAddress = normalizeServerInputAddress(address);
|
|
624
|
+
const normalizedInternalAddress = normalizeServerInputAddress(internalAddress);
|
|
332
625
|
if (!SAFE_NAME.test(name)) add(`hosts[${index}].name 无效`);
|
|
333
626
|
if (names.has(name) && name) add(`节点名称重复:${name}`);
|
|
334
627
|
if (name) names.add(name);
|
|
335
|
-
if (
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
628
|
+
if (normalizedAddress === null) add(`节点 ${name || index} 的 address 无效`);
|
|
629
|
+
if (normalizedInternalAddress === null) add(`节点 ${name || index} 的 internalAddress 无效`);
|
|
630
|
+
if (normalizedAddress !== null && Number.isInteger(port) && port >= 1 && port <= 65535) {
|
|
631
|
+
const endpoint = `${normalizedAddress}\0${port}`;
|
|
632
|
+
if (sshEndpoints.has(endpoint)) add(`节点地址(SSH endpoint)重复:${address}:${port}`);
|
|
633
|
+
sshEndpoints.add(endpoint);
|
|
634
|
+
}
|
|
635
|
+
if (normalizedInternalAddress !== null) {
|
|
636
|
+
if (internalAddresses.has(normalizedInternalAddress)) add(`节点 internalAddress 重复:${internalAddress}`);
|
|
637
|
+
internalAddresses.add(normalizedInternalAddress);
|
|
341
638
|
}
|
|
342
639
|
if (String(raw.user || "root") !== "root") add(`节点 ${name || index} 只支持 root SSH 用户`);
|
|
343
640
|
if (typeof raw.password !== "string" || raw.password.trim() === "") add(`节点 ${name || index} 的 password 未填写`);
|
|
@@ -416,10 +713,10 @@ function normalizeHost(raw, index) {
|
|
|
416
713
|
const user = String(raw.user || "root");
|
|
417
714
|
const port = raw.port === undefined ? 22 : Number(raw.port);
|
|
418
715
|
if (!SAFE_NAME.test(name)) throw new Error(`节点名称无效:${name || `(index ${index})`}`);
|
|
419
|
-
if (
|
|
716
|
+
if (normalizeServerInputAddress(address) === null) {
|
|
420
717
|
throw new Error(`节点 address 无效:${name}`);
|
|
421
718
|
}
|
|
422
|
-
if (
|
|
719
|
+
if (normalizeServerInputAddress(internalAddress) === null) {
|
|
423
720
|
throw new Error(`节点 internalAddress 无效:${name}`);
|
|
424
721
|
}
|
|
425
722
|
if (user !== "root") throw new Error(`ROI 主机安装只支持 root SSH 用户:${name}`);
|
|
@@ -449,14 +746,20 @@ function validateClusterTopology(config) {
|
|
|
449
746
|
if (!Array.isArray(config.hosts) || config.hosts.length === 0) throw new Error("集群至少需要一个节点");
|
|
450
747
|
const hosts = config.hosts.map(normalizeHost);
|
|
451
748
|
const names = new Set();
|
|
452
|
-
const
|
|
749
|
+
const sshEndpoints = new Set();
|
|
750
|
+
const internalAddresses = new Set();
|
|
453
751
|
for (const item of hosts) {
|
|
454
752
|
if (names.has(item.name)) throw new Error(`节点名称必须唯一:${item.name}`);
|
|
455
753
|
names.add(item.name);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
754
|
+
const normalizedAddress = normalizeServerInputAddress(item.address);
|
|
755
|
+
const endpoint = `${normalizedAddress}\0${item.port}`;
|
|
756
|
+
if (sshEndpoints.has(endpoint)) throw new Error(`节点地址(SSH endpoint)存在冲突:${item.address}:${item.port}`);
|
|
757
|
+
sshEndpoints.add(endpoint);
|
|
758
|
+
const normalizedInternalAddress = normalizeServerInputAddress(item.internalAddress);
|
|
759
|
+
if (internalAddresses.has(normalizedInternalAddress)) {
|
|
760
|
+
throw new Error(`节点 internalAddress 存在冲突:${item.internalAddress}`);
|
|
459
761
|
}
|
|
762
|
+
internalAddresses.add(normalizedInternalAddress);
|
|
460
763
|
}
|
|
461
764
|
const roleGroups = config.roleGroups;
|
|
462
765
|
if (!roleGroups || typeof roleGroups !== "object" || Array.isArray(roleGroups)) {
|
|
@@ -613,6 +916,92 @@ function readSafeClusterSource(filePath, {
|
|
|
613
916
|
return { bytes, value: parsed };
|
|
614
917
|
}
|
|
615
918
|
|
|
919
|
+
function readProtectedServerInput(filePath, {
|
|
920
|
+
platform = process.platform,
|
|
921
|
+
stateStore,
|
|
922
|
+
fsImpl = fs,
|
|
923
|
+
currentUid = typeof process.getuid === "function" ? process.getuid() : null,
|
|
924
|
+
} = {}) {
|
|
925
|
+
if (!stateStore || typeof stateStore.assertProtectedRegularFile !== "function") {
|
|
926
|
+
throw new Error("servers.txt 必须通过受保护文件检查");
|
|
927
|
+
}
|
|
928
|
+
const readOnce = () => {
|
|
929
|
+
let opened;
|
|
930
|
+
try {
|
|
931
|
+
const noFollow = platform === "win32" ? 0 : (fsImpl.constants.O_NOFOLLOW || 0);
|
|
932
|
+
opened = fsImpl.openSync(filePath, fsImpl.constants.O_RDONLY | noFollow);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
if (["ELOOP", "EMLINK"].includes(error.code)) throw new Error("拒绝读取符号链接 servers.txt");
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
const info = fsImpl.fstatSync(opened);
|
|
939
|
+
if (!info.isFile() || info.isSymbolicLink?.()) throw new Error("servers.txt 不是普通文件");
|
|
940
|
+
if (currentUid !== null && info.uid !== currentUid) throw new Error("servers.txt 不属于当前用户");
|
|
941
|
+
if (platform !== "win32" && (info.mode & 0o777) !== 0o600) {
|
|
942
|
+
throw new Error("servers.txt 权限必须精确为 0600");
|
|
943
|
+
}
|
|
944
|
+
if (info.size > MAX_SERVER_INPUT_BYTES) throw new Error("servers.txt 大小不能超过 1 MiB");
|
|
945
|
+
const bytes = Buffer.alloc(info.size);
|
|
946
|
+
let offset = 0;
|
|
947
|
+
while (offset < bytes.length) {
|
|
948
|
+
const count = fsImpl.readSync(opened, bytes, offset, bytes.length - offset, offset);
|
|
949
|
+
if (count === 0) throw new Error("servers.txt 在读取期间发生变化,拒绝继续");
|
|
950
|
+
offset += count;
|
|
951
|
+
}
|
|
952
|
+
const extra = Buffer.alloc(1);
|
|
953
|
+
if (fsImpl.readSync(opened, extra, 0, 1, offset) !== 0) {
|
|
954
|
+
throw new Error("servers.txt 在读取期间增长或超过 1 MiB,拒绝继续");
|
|
955
|
+
}
|
|
956
|
+
const afterInfo = fsImpl.fstatSync(opened);
|
|
957
|
+
if (sourceIdentity(info) !== sourceIdentity(afterInfo)) {
|
|
958
|
+
throw new Error("servers.txt 在 descriptor 读取期间发生变化,拒绝继续");
|
|
959
|
+
}
|
|
960
|
+
return { bytes, info: afterInfo };
|
|
961
|
+
} finally {
|
|
962
|
+
fsImpl.closeSync(opened);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const inspectWindowsAcl = () => {
|
|
967
|
+
const protectedAcl = stateStore.assertProtectedRegularFile(filePath);
|
|
968
|
+
const acl = typeof stateStore.assertSafeExternalRegularFile === "function"
|
|
969
|
+
? stateStore.assertSafeExternalRegularFile(filePath)
|
|
970
|
+
: protectedAcl;
|
|
971
|
+
if (!acl || typeof acl.fileIdentity !== "string" || acl.fileIdentity === "") {
|
|
972
|
+
throw new Error("Windows servers.txt ACL 检查缺少 file identity");
|
|
973
|
+
}
|
|
974
|
+
return acl;
|
|
975
|
+
};
|
|
976
|
+
const beforeAcl = platform === "win32"
|
|
977
|
+
? inspectWindowsAcl()
|
|
978
|
+
: (stateStore.assertProtectedRegularFile(filePath), null);
|
|
979
|
+
const first = readOnce();
|
|
980
|
+
if (platform === "win32") {
|
|
981
|
+
if (beforeAcl.fileIdentity !== contentIdentity(first.bytes)) {
|
|
982
|
+
throw new Error("Windows servers.txt ACL identity 与首次 fd 字节不匹配");
|
|
983
|
+
}
|
|
984
|
+
const afterAcl = inspectWindowsAcl();
|
|
985
|
+
const second = readOnce();
|
|
986
|
+
if (afterAcl.fileIdentity !== contentIdentity(second.bytes)) {
|
|
987
|
+
throw new Error("Windows servers.txt ACL identity 与再次 fd 字节不匹配");
|
|
988
|
+
}
|
|
989
|
+
if (beforeAcl.fileIdentity !== afterAcl.fileIdentity
|
|
990
|
+
|| sourceIdentity(first.info) !== sourceIdentity(second.info)
|
|
991
|
+
|| sha256(first.bytes) !== sha256(second.bytes)) {
|
|
992
|
+
throw new Error("servers.txt 在 ACL 检查和读取期间发生变化,拒绝继续");
|
|
993
|
+
}
|
|
994
|
+
} else {
|
|
995
|
+
stateStore.assertProtectedRegularFile(filePath);
|
|
996
|
+
const second = readOnce();
|
|
997
|
+
if (sourceIdentity(first.info) !== sourceIdentity(second.info)
|
|
998
|
+
|| sha256(first.bytes) !== sha256(second.bytes)) {
|
|
999
|
+
throw new Error("servers.txt 在两次受保护读取期间字节发生变化,拒绝继续");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return first.bytes;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
616
1005
|
function atomicWriteProtectedBytes(destinationPath, bytes, { stateStore, mode = 0o600 } = {}) {
|
|
617
1006
|
const target = path.resolve(destinationPath);
|
|
618
1007
|
const directory = path.dirname(target);
|
|
@@ -921,9 +1310,33 @@ async function probeRemoteArchitecture(bootstrap, {
|
|
|
921
1310
|
throw new Error("bootstrap 节点的 CPU 架构不受 ROI 支持");
|
|
922
1311
|
}
|
|
923
1312
|
|
|
1313
|
+
function renderTopologyNodeLine(node) {
|
|
1314
|
+
return `- ${node.name}: ${node.roles.join(", ")}${node.bootstrap ? " (bootstrap)" : ""}`;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function renderValidatedTopologyStatus(summary, configPath) {
|
|
1318
|
+
const etcdNodes = (summary.nodes || []).filter(({ roles }) => roles.includes("etcd"));
|
|
1319
|
+
const lines = [
|
|
1320
|
+
"",
|
|
1321
|
+
"[RAINSKILLS_STATUS:host_cluster_config_valid]",
|
|
1322
|
+
"集群配置检查通过:",
|
|
1323
|
+
`- 节点:${summary.hosts} 个`,
|
|
1324
|
+
`- etcd:${etcdNodes.length} 个`,
|
|
1325
|
+
`- bootstrap:${summary.bootstrap}`,
|
|
1326
|
+
`- 存储模式:${summary.storageMode}`,
|
|
1327
|
+
`受保护配置:${configPath}`,
|
|
1328
|
+
"",
|
|
1329
|
+
"自动角色分配:",
|
|
1330
|
+
...(summary.nodes || []).map(renderTopologyNodeLine),
|
|
1331
|
+
"",
|
|
1332
|
+
"正在准备所有服务器的 SSH 连接。",
|
|
1333
|
+
];
|
|
1334
|
+
return `${lines.join("\n")}\n`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
924
1337
|
function renderConfirmationSummary(summary) {
|
|
925
1338
|
const lines = ["\nROI 主机集群安装确认", "", `节点拓扑:${summary.hosts} 个节点`];
|
|
926
|
-
for (const node of summary.nodes || []) lines.push(
|
|
1339
|
+
for (const node of summary.nodes || []) lines.push(renderTopologyNodeLine(node));
|
|
927
1340
|
lines.push("", "将发生的系统变更:安装 RKE2、Containerd、Rainbond 及所选存储组件;写入 /opt/rainbond 和 /var/lib/rancher。", `受保护配置:${summary.configPath}`, "");
|
|
928
1341
|
return `${lines.join("\n")}\n`;
|
|
929
1342
|
}
|
|
@@ -1284,35 +1697,59 @@ function reuseLockedRoiArtifact({
|
|
|
1284
1697
|
};
|
|
1285
1698
|
}
|
|
1286
1699
|
|
|
1287
|
-
function
|
|
1700
|
+
function normalizeSensitiveValues(sensitiveValues = []) {
|
|
1701
|
+
if (!Array.isArray(sensitiveValues)) return [];
|
|
1702
|
+
return [...new Set(sensitiveValues
|
|
1703
|
+
.filter((value) => typeof value === "string" && value.length > 0))]
|
|
1704
|
+
.sort((left, right) => right.length - left.length);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function selectSafeRedactionMarker(sensitiveValues) {
|
|
1708
|
+
const candidates = ["[REDACTED]", "[FILTERED]", "[MASKED]", "***", ""];
|
|
1709
|
+
return candidates.find((candidate) => (
|
|
1710
|
+
sensitiveValues.every((sensitiveValue) => !candidate.includes(sensitiveValue))
|
|
1711
|
+
)) ?? "";
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function redactLiteralSensitiveValues(value, sensitiveValues, marker) {
|
|
1715
|
+
let safe = String(value || "");
|
|
1716
|
+
for (const sensitiveValue of sensitiveValues) {
|
|
1717
|
+
safe = safe.split(sensitiveValue).join(marker);
|
|
1718
|
+
}
|
|
1719
|
+
return safe;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function createLineRedactor(sensitiveValues = []) {
|
|
1723
|
+
const literalValues = normalizeSensitiveValues(sensitiveValues);
|
|
1724
|
+
const marker = selectSafeRedactionMarker(literalValues);
|
|
1288
1725
|
let sensitiveBlockIndent = null;
|
|
1289
1726
|
let pemBlock = false;
|
|
1290
1727
|
let structuredBlockIndent = null;
|
|
1291
1728
|
return (value) => {
|
|
1292
|
-
const line = String(value || "").replace(/\r$/, "");
|
|
1729
|
+
const line = redactLiteralSensitiveValues(String(value || "").replace(/\r$/, ""), literalValues, marker);
|
|
1293
1730
|
const indent = line.match(/^\s*/)?.[0].length || 0;
|
|
1294
1731
|
if (pemBlock) {
|
|
1295
1732
|
if (/-----END [A-Z0-9 ]+-----/.test(line)) pemBlock = false;
|
|
1296
|
-
return `${line.slice(0, indent)}
|
|
1733
|
+
return `${line.slice(0, indent)}${marker}`;
|
|
1297
1734
|
}
|
|
1298
1735
|
const pemBegin = /-----BEGIN [A-Z0-9 ]+-----/.test(line);
|
|
1299
1736
|
if (pemBegin) {
|
|
1300
1737
|
if (!/-----END [A-Z0-9 ]+-----/.test(line)) pemBlock = true;
|
|
1301
|
-
return `${line.slice(0, indent)}
|
|
1738
|
+
return `${line.slice(0, indent)}${marker}`;
|
|
1302
1739
|
}
|
|
1303
1740
|
if (structuredBlockIndent !== null) {
|
|
1304
|
-
if (!line.trim() || indent > structuredBlockIndent) return `${line.slice(0, indent)}
|
|
1741
|
+
if (!line.trim() || indent > structuredBlockIndent) return `${line.slice(0, indent)}${marker}`;
|
|
1305
1742
|
structuredBlockIndent = null;
|
|
1306
|
-
if (/^[}\]],?$/.test(line.trim())) return `${line.slice(0, indent)}
|
|
1743
|
+
if (/^[}\]],?$/.test(line.trim())) return `${line.slice(0, indent)}${marker}`;
|
|
1307
1744
|
}
|
|
1308
1745
|
if (sensitiveBlockIndent !== null) {
|
|
1309
|
-
if (!line.trim() || indent > sensitiveBlockIndent) return `${line.slice(0, indent)}
|
|
1746
|
+
if (!line.trim() || indent > sensitiveBlockIndent) return `${line.slice(0, indent)}${marker}`;
|
|
1310
1747
|
sensitiveBlockIndent = null;
|
|
1311
1748
|
}
|
|
1312
1749
|
if (
|
|
1313
1750
|
/\b(?:Bearer|Basic)\s+\S+/i.test(line)
|
|
1314
1751
|
|| /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/.test(line)
|
|
1315
|
-
) return `${line.slice(0, indent)}
|
|
1752
|
+
) return `${line.slice(0, indent)}${marker}`;
|
|
1316
1753
|
const keyMatches = [...line.matchAll(/(?:password|passwd|token|secret|private.?key|credential|database|registry|authorization|proxy-authorization|set-cookie|cookie|api[-_ ]?key|apikey|grjwt|jwt)/ig)];
|
|
1317
1754
|
if (keyMatches.length === 0) return line;
|
|
1318
1755
|
const candidates = keyMatches.map((keyMatch) => {
|
|
@@ -1331,20 +1768,20 @@ function createLineRedactor() {
|
|
|
1331
1768
|
!earliest || candidate.separator < earliest.separator ? candidate : earliest
|
|
1332
1769
|
), null);
|
|
1333
1770
|
const separator = firstCandidate?.separator ?? -1;
|
|
1334
|
-
if (separator < 0) return
|
|
1771
|
+
if (separator < 0) return marker;
|
|
1335
1772
|
const remainder = blockCandidate?.remainder ?? firstCandidate.remainder;
|
|
1336
1773
|
if (/-----BEGIN [A-Z0-9 ]+-----/.test(remainder) && !/-----END [A-Z0-9 ]+-----/.test(remainder)) pemBlock = true;
|
|
1337
1774
|
else if (/^[\[{]/.test(remainder) || /[\[{]\s*$/.test(line)) structuredBlockIndent = indent;
|
|
1338
1775
|
else if (!remainder || /^[|>][+-]?$/.test(remainder)) sensitiveBlockIndent = indent;
|
|
1339
|
-
return `${line.slice(0, separator + 1)}
|
|
1776
|
+
return `${line.slice(0, separator + 1)} ${marker}`;
|
|
1340
1777
|
};
|
|
1341
1778
|
}
|
|
1342
1779
|
|
|
1343
|
-
function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES) {
|
|
1780
|
+
function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES, sensitiveValues = []) {
|
|
1344
1781
|
const outputLimit = boundedOutputLimit(maxBytes);
|
|
1345
1782
|
const input = String(value || "");
|
|
1346
1783
|
if (Buffer.byteLength(input) > outputLimit) throw childOutputLimitError();
|
|
1347
|
-
const redactLine = createLineRedactor();
|
|
1784
|
+
const redactLine = createLineRedactor(sensitiveValues);
|
|
1348
1785
|
const output = [];
|
|
1349
1786
|
let pending = "";
|
|
1350
1787
|
let outputBytes = 0;
|
|
@@ -1368,14 +1805,14 @@ function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES) {
|
|
|
1368
1805
|
return output.join("");
|
|
1369
1806
|
}
|
|
1370
1807
|
|
|
1371
|
-
function protectedInstallLogBytes(execution) {
|
|
1808
|
+
function protectedInstallLogBytes(execution, sensitiveValues = []) {
|
|
1372
1809
|
const rawStdout = String(execution?.stdout || "");
|
|
1373
1810
|
const rawStderr = String(execution?.stderr || "");
|
|
1374
1811
|
if (Buffer.byteLength(rawStdout) + Buffer.byteLength(rawStderr) > MAX_CHILD_OUTPUT_BYTES) {
|
|
1375
1812
|
throw childOutputLimitError();
|
|
1376
1813
|
}
|
|
1377
|
-
const stdout =
|
|
1378
|
-
const stderr =
|
|
1814
|
+
const stdout = redactInstallLog(rawStdout, MAX_CHILD_OUTPUT_BYTES, sensitiveValues);
|
|
1815
|
+
const stderr = redactInstallLog(rawStderr, MAX_CHILD_OUTPUT_BYTES, sensitiveValues);
|
|
1379
1816
|
const stdoutLength = Buffer.byteLength(stdout);
|
|
1380
1817
|
const stderrLength = Buffer.byteLength(stderr);
|
|
1381
1818
|
const separatorLength = stdoutLength > 0 && stderrLength > 0 ? 1 : 0;
|
|
@@ -1392,6 +1829,17 @@ function assertSafeRemotePath(value) {
|
|
|
1392
1829
|
return normalized;
|
|
1393
1830
|
}
|
|
1394
1831
|
|
|
1832
|
+
const ROI_COMPLETION_RECEIPT_LINE = "RECEIPT_PHASE=completed";
|
|
1833
|
+
|
|
1834
|
+
function isExactRoiCompletionReceiptLine(value) {
|
|
1835
|
+
const line = String(value || "");
|
|
1836
|
+
return (line.endsWith("\r") ? line.slice(0, -1) : line) === ROI_COMPLETION_RECEIPT_LINE;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function hasExactRoiCompletionReceipt(value) {
|
|
1840
|
+
return String(value || "").split("\n").some(isExactRoiCompletionReceiptLine);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1395
1843
|
function spawnRedactedAttached(command, args, {
|
|
1396
1844
|
spawnFn = spawn,
|
|
1397
1845
|
registerChild = () => {},
|
|
@@ -1399,6 +1847,7 @@ function spawnRedactedAttached(command, args, {
|
|
|
1399
1847
|
stderrWriter = process.stderr,
|
|
1400
1848
|
maxOutputBytes,
|
|
1401
1849
|
input,
|
|
1850
|
+
sensitiveValues = [],
|
|
1402
1851
|
} = {}) {
|
|
1403
1852
|
return new Promise((resolve, reject) => {
|
|
1404
1853
|
const child = spawnFn(command, args, { stdio: [input === undefined ? "inherit" : "pipe", "pipe", "pipe"] });
|
|
@@ -1406,11 +1855,15 @@ function spawnRedactedAttached(command, args, {
|
|
|
1406
1855
|
let cleared = false;
|
|
1407
1856
|
let receivedBytes = 0;
|
|
1408
1857
|
let emittedBytes = 0;
|
|
1858
|
+
let receiptCompleted = false;
|
|
1409
1859
|
const outputLimit = boundedOutputLimit(maxOutputBytes);
|
|
1410
1860
|
const collected = { stdout: [], stderr: [] };
|
|
1411
1861
|
const pending = { stdout: "", stderr: "" };
|
|
1412
1862
|
const buffers = { stdout: "", stderr: "" };
|
|
1413
|
-
const redactors = {
|
|
1863
|
+
const redactors = {
|
|
1864
|
+
stdout: createLineRedactor(sensitiveValues),
|
|
1865
|
+
stderr: createLineRedactor(sensitiveValues),
|
|
1866
|
+
};
|
|
1414
1867
|
const unregister = registerChild(child, false);
|
|
1415
1868
|
const clear = () => {
|
|
1416
1869
|
if (cleared) return;
|
|
@@ -1451,14 +1904,18 @@ function spawnRedactedAttached(command, args, {
|
|
|
1451
1904
|
buffers[name] += String(chunk);
|
|
1452
1905
|
let newline;
|
|
1453
1906
|
while ((newline = buffers[name].indexOf("\n")) >= 0) {
|
|
1454
|
-
const
|
|
1907
|
+
const rawLine = buffers[name].slice(0, newline);
|
|
1908
|
+
if (name === "stdout" && isExactRoiCompletionReceiptLine(rawLine)) receiptCompleted = true;
|
|
1909
|
+
const safe = `${redactors[name](rawLine)}\n`;
|
|
1455
1910
|
buffers[name] = buffers[name].slice(newline + 1);
|
|
1456
1911
|
if (!emitSafe(name, writer, safe)) return;
|
|
1457
1912
|
}
|
|
1458
1913
|
};
|
|
1459
1914
|
const flush = (name, writer) => {
|
|
1460
1915
|
if (!buffers[name]) return;
|
|
1461
|
-
const
|
|
1916
|
+
const rawLine = buffers[name];
|
|
1917
|
+
if (name === "stdout" && isExactRoiCompletionReceiptLine(rawLine)) receiptCompleted = true;
|
|
1918
|
+
const safe = redactors[name](rawLine);
|
|
1462
1919
|
buffers[name] = "";
|
|
1463
1920
|
emitSafe(name, writer, safe);
|
|
1464
1921
|
};
|
|
@@ -1481,7 +1938,14 @@ function spawnRedactedAttached(command, args, {
|
|
|
1481
1938
|
for (const name of ["stdout", "stderr"]) {
|
|
1482
1939
|
if (pending[name]) collected[name].push(pending[name]);
|
|
1483
1940
|
}
|
|
1484
|
-
resolve({
|
|
1941
|
+
resolve({
|
|
1942
|
+
code,
|
|
1943
|
+
signal,
|
|
1944
|
+
stdout: collected.stdout.join(""),
|
|
1945
|
+
stderr: collected.stderr.join(""),
|
|
1946
|
+
redacted: true,
|
|
1947
|
+
receiptCompleted,
|
|
1948
|
+
});
|
|
1485
1949
|
});
|
|
1486
1950
|
if (input !== undefined) child.stdin.end(input);
|
|
1487
1951
|
});
|
|
@@ -1576,6 +2040,7 @@ async function executeRoiInstall({
|
|
|
1576
2040
|
sshSpawn,
|
|
1577
2041
|
write = (value) => process.stderr.write(value),
|
|
1578
2042
|
abortState,
|
|
2043
|
+
sensitiveValues = [],
|
|
1579
2044
|
}) {
|
|
1580
2045
|
assertOperationNotAborted(abortState);
|
|
1581
2046
|
const item = normalizeHost(bootstrap, 0);
|
|
@@ -1610,8 +2075,11 @@ async function executeRoiInstall({
|
|
|
1610
2075
|
registerChild,
|
|
1611
2076
|
spawnFn: sshSpawn,
|
|
1612
2077
|
input: REMOTE_ROI_LAUNCH_SCRIPT,
|
|
2078
|
+
sensitiveValues,
|
|
1613
2079
|
});
|
|
1614
|
-
|
|
2080
|
+
const receiptCompleted = execution.receiptCompleted === true
|
|
2081
|
+
|| (execution.receiptCompleted === undefined && hasExactRoiCompletionReceipt(execution.stdout));
|
|
2082
|
+
atomicWriteProtectedBytes(logPath, protectedInstallLogBytes(execution, sensitiveValues), { stateStore });
|
|
1615
2083
|
if (execution.signal || execution.code === 130) {
|
|
1616
2084
|
persistState({ stage: "executing", status: "interrupted", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
|
|
1617
2085
|
write(`\n安装已中断,状态已保留。继续时执行:\n ${resumeArgv.join(" ")}\n`);
|
|
@@ -1621,7 +2089,7 @@ async function executeRoiInstall({
|
|
|
1621
2089
|
persistState({ stage: "executing", status: "failed", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
|
|
1622
2090
|
throw new Error(`ROI 主机集群安装失败,退出码 ${execution.code}`);
|
|
1623
2091
|
}
|
|
1624
|
-
if (
|
|
2092
|
+
if (!receiptCompleted) {
|
|
1625
2093
|
persistState({ stage: "executing", status: "failed", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
|
|
1626
2094
|
throw new Error("ROI 已返回成功,但远端 completed receipt 验证结果缺失");
|
|
1627
2095
|
}
|
|
@@ -1810,6 +2278,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1810
2278
|
const hostRoot = path.join(paths.root, "host-cluster");
|
|
1811
2279
|
stateStore.ensurePrivateDirectory(hostRoot);
|
|
1812
2280
|
const configPath = path.join(hostRoot, "cluster.yaml");
|
|
2281
|
+
const serverInputPath = path.join(hostRoot, "servers.txt");
|
|
1813
2282
|
const driverStatePath = path.join(hostRoot, "state.json");
|
|
1814
2283
|
const logPath = path.join(hostRoot, "roi.log");
|
|
1815
2284
|
const loadedDriverState = loadDriverState(driverStatePath, stateStore);
|
|
@@ -1827,13 +2296,20 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1827
2296
|
if (fs.existsSync(configPath)) {
|
|
1828
2297
|
throw new Error("发现来源不明的受保护 cluster.yaml,已停止;请将其作为外部文件重新导入,或移走后重新生成模板");
|
|
1829
2298
|
}
|
|
2299
|
+
if (fs.existsSync(serverInputPath)) {
|
|
2300
|
+
throw new Error("发现来源不明的受保护 servers.txt,已停止;请移走后重新开始");
|
|
2301
|
+
}
|
|
1830
2302
|
persistDriverState({
|
|
1831
|
-
config_source: options.clusterConfig ? "imported-file" : "generated-
|
|
2303
|
+
config_source: options.clusterConfig ? "imported-file" : "generated-server-input",
|
|
1832
2304
|
...(options.clusterConfig ? { import_source_path: path.resolve(options.clusterConfig) } : {}),
|
|
2305
|
+
...(!options.clusterConfig ? { server_input_path: serverInputPath } : {}),
|
|
1833
2306
|
});
|
|
1834
2307
|
} else if (!driverState.config_source) {
|
|
1835
2308
|
if (driverState.stage !== "configuration" && driverState.config_sha256) {
|
|
1836
|
-
persistDriverState({
|
|
2309
|
+
persistDriverState({
|
|
2310
|
+
config_source: "imported-file",
|
|
2311
|
+
import_source_sha256: driverState.config_sha256,
|
|
2312
|
+
});
|
|
1837
2313
|
} else {
|
|
1838
2314
|
throw new Error("主机集群配置来源状态不完整,拒绝读取 cluster.yaml");
|
|
1839
2315
|
}
|
|
@@ -1842,13 +2318,103 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1842
2318
|
if (driverState.config_source === "generated-template" && options.clusterConfig) {
|
|
1843
2319
|
throw new Error("当前流程已绑定自动生成的 cluster.yaml,不能切换为外部导入配置");
|
|
1844
2320
|
}
|
|
2321
|
+
if (driverState.config_source === "generated-server-input" && options.clusterConfig) {
|
|
2322
|
+
throw new Error("当前流程已绑定自动生成的 servers.txt,不能切换为外部导入配置");
|
|
2323
|
+
}
|
|
1845
2324
|
if (driverState.config_source === "imported-file" && options.clusterConfig
|
|
1846
2325
|
&& path.resolve(options.clusterConfig) !== driverState.import_source_path) {
|
|
1847
2326
|
throw new Error("当前流程已绑定另一份外部 cluster.yaml,不能切换配置来源");
|
|
1848
2327
|
}
|
|
1849
2328
|
|
|
1850
2329
|
let config;
|
|
1851
|
-
if (driverState.config_source === "generated-
|
|
2330
|
+
if (driverState.config_source === "generated-server-input") {
|
|
2331
|
+
if (driverState.server_input_path !== serverInputPath) {
|
|
2332
|
+
throw new Error("主机集群 servers.txt 路径状态不匹配,拒绝继续");
|
|
2333
|
+
}
|
|
2334
|
+
if (!fs.existsSync(serverInputPath)) {
|
|
2335
|
+
if (fs.existsSync(configPath)) {
|
|
2336
|
+
throw new Error("servers.txt 缺失且发现无法验证来源的 cluster.yaml,拒绝继续");
|
|
2337
|
+
}
|
|
2338
|
+
const template = createHostServerInputTemplate();
|
|
2339
|
+
createProtectedBytesExclusive(serverInputPath, template, { stateStore });
|
|
2340
|
+
persistDriverState({
|
|
2341
|
+
stage: "configuration",
|
|
2342
|
+
status: "waiting_user",
|
|
2343
|
+
server_input_template_sha256: sha256(template),
|
|
2344
|
+
});
|
|
2345
|
+
write("\n[RAINSKILLS_USER_INPUT_REQUIRED:host_cluster_server_input]\n");
|
|
2346
|
+
writeUserMessage(write, "platform.host-cluster-server-input", renderHostServerInputPrompt({
|
|
2347
|
+
inputPath: serverInputPath,
|
|
2348
|
+
platform: dependencies.platform || process.platform,
|
|
2349
|
+
}));
|
|
2350
|
+
return { waiting: true, waitingStage: "waiting-host-cluster-server-input", inputPath: serverInputPath };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const inputBytes = readProtectedServerInput(serverInputPath, {
|
|
2354
|
+
platform: dependencies.platform || process.platform,
|
|
2355
|
+
stateStore,
|
|
2356
|
+
});
|
|
2357
|
+
const inputSha256 = sha256(inputBytes);
|
|
2358
|
+
if (driverState.server_input_sha256 && driverState.server_input_sha256 !== inputSha256) {
|
|
2359
|
+
throw new Error("恢复时 servers.txt 字节发生变化,拒绝继续");
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (driverState.server_input_sha256) {
|
|
2363
|
+
if (!driverState.generated_config_sha256 || !driverState.config_sha256) {
|
|
2364
|
+
throw new Error("自动生成的 cluster.yaml 摘要状态不完整,拒绝继续");
|
|
2365
|
+
}
|
|
2366
|
+
if (!fs.existsSync(configPath)) throw new Error("已锁定的 cluster.yaml 缺失,拒绝恢复");
|
|
2367
|
+
const locked = readSafeClusterSource(configPath, {
|
|
2368
|
+
platform: dependencies.platform || process.platform,
|
|
2369
|
+
sourceStateStore: stateStore,
|
|
2370
|
+
});
|
|
2371
|
+
const lockedSha256 = sha256(locked.bytes);
|
|
2372
|
+
if (lockedSha256 !== driverState.generated_config_sha256
|
|
2373
|
+
|| lockedSha256 !== driverState.config_sha256) {
|
|
2374
|
+
throw new Error("恢复时 cluster.yaml 字节发生变化,拒绝继续");
|
|
2375
|
+
}
|
|
2376
|
+
config = locked.value;
|
|
2377
|
+
} else {
|
|
2378
|
+
const parsed = parseHostServerInput(inputBytes);
|
|
2379
|
+
if (parsed.issues.length > 0) {
|
|
2380
|
+
if (fs.existsSync(configPath)) {
|
|
2381
|
+
throw new Error("servers.txt 无效且发现无法验证的 cluster.yaml,拒绝继续");
|
|
2382
|
+
}
|
|
2383
|
+
persistDriverState({ stage: "configuration", status: "waiting_user" });
|
|
2384
|
+
write("\n[RAINSKILLS_USER_INPUT_REQUIRED:host_cluster_server_input]\n");
|
|
2385
|
+
writeUserMessage(write, "platform.host-cluster-server-input", renderHostServerInputPrompt({
|
|
2386
|
+
inputPath: serverInputPath,
|
|
2387
|
+
platform: dependencies.platform || process.platform,
|
|
2388
|
+
issues: parsed.issues,
|
|
2389
|
+
}));
|
|
2390
|
+
return {
|
|
2391
|
+
waiting: true,
|
|
2392
|
+
waitingStage: "waiting-host-cluster-server-input",
|
|
2393
|
+
inputPath: serverInputPath,
|
|
2394
|
+
issues: parsed.issues,
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
const expectedConfigBytes = createClusterConfigFromServerInput(parsed.hosts);
|
|
2399
|
+
if (!fs.existsSync(configPath)) {
|
|
2400
|
+
createProtectedBytesExclusive(configPath, expectedConfigBytes, { stateStore });
|
|
2401
|
+
}
|
|
2402
|
+
const generated = readSafeClusterSource(configPath, {
|
|
2403
|
+
platform: dependencies.platform || process.platform,
|
|
2404
|
+
sourceStateStore: stateStore,
|
|
2405
|
+
});
|
|
2406
|
+
if (!generated.bytes.equals(expectedConfigBytes)) {
|
|
2407
|
+
throw new Error("已有 cluster.yaml 与 servers.txt 自动生成结果不匹配,拒绝采用");
|
|
2408
|
+
}
|
|
2409
|
+
const generatedSha256 = sha256(generated.bytes);
|
|
2410
|
+
persistDriverState({
|
|
2411
|
+
server_input_sha256: inputSha256,
|
|
2412
|
+
generated_config_sha256: generatedSha256,
|
|
2413
|
+
config_sha256: generatedSha256,
|
|
2414
|
+
});
|
|
2415
|
+
config = generated.value;
|
|
2416
|
+
}
|
|
2417
|
+
} else if (driverState.config_source === "generated-template") {
|
|
1852
2418
|
if (!fs.existsSync(configPath)) {
|
|
1853
2419
|
const template = createHostClusterTemplate();
|
|
1854
2420
|
createProtectedBytesExclusive(configPath, template, { stateStore });
|
|
@@ -1882,7 +2448,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1882
2448
|
}
|
|
1883
2449
|
config = diagnostic.value;
|
|
1884
2450
|
} else if (driverState.config_source === "imported-file") {
|
|
1885
|
-
|
|
2451
|
+
const readImportSource = () => {
|
|
1886
2452
|
const requestedSource = String(options.clusterConfig || driverState.import_source_path || "").trim();
|
|
1887
2453
|
if (!requestedSource) throw new Error("外部 cluster.yaml 来源缺失,无法继续导入");
|
|
1888
2454
|
const sourcePath = path.resolve(requestedSource);
|
|
@@ -1892,18 +2458,55 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1892
2458
|
sourceStateStore: stateStore,
|
|
1893
2459
|
});
|
|
1894
2460
|
validateClusterTopology(source.value);
|
|
1895
|
-
|
|
1896
|
-
|
|
2461
|
+
return { ...source, path: sourcePath, sha256: sha256(source.bytes) };
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
let protectedCopy;
|
|
2465
|
+
if (!fs.existsSync(configPath)) {
|
|
2466
|
+
const source = readImportSource();
|
|
2467
|
+
if (driverState.import_source_sha256 && driverState.import_source_sha256 !== source.sha256) {
|
|
1897
2468
|
throw new Error("外部 cluster.yaml 在导入恢复期间发生变化,拒绝继续");
|
|
1898
2469
|
}
|
|
1899
|
-
if (!driverState.import_source_sha256) persistDriverState({ import_source_path: sourcePath, import_source_sha256: sourceSha256 });
|
|
1900
2470
|
createProtectedBytesExclusive(configPath, source.bytes, { stateStore });
|
|
2471
|
+
protectedCopy = readSafeClusterSource(configPath, {
|
|
2472
|
+
platform: dependencies.platform || process.platform,
|
|
2473
|
+
sourceStateStore: stateStore,
|
|
2474
|
+
});
|
|
2475
|
+
if (!protectedCopy.bytes.equals(source.bytes)) {
|
|
2476
|
+
throw new Error("受保护的导入 cluster.yaml 副本与外部 source 字节不匹配,拒绝继续");
|
|
2477
|
+
}
|
|
2478
|
+
persistDriverState({
|
|
2479
|
+
import_source_path: source.path,
|
|
2480
|
+
import_source_sha256: source.sha256,
|
|
2481
|
+
config_sha256: source.sha256,
|
|
2482
|
+
});
|
|
2483
|
+
} else {
|
|
2484
|
+
stateStore.assertProtectedRegularFile(configPath);
|
|
2485
|
+
protectedCopy = readSafeClusterSource(configPath, {
|
|
2486
|
+
platform: dependencies.platform || process.platform,
|
|
2487
|
+
sourceStateStore: stateStore,
|
|
2488
|
+
});
|
|
2489
|
+
const protectedSha256 = sha256(protectedCopy.bytes);
|
|
2490
|
+
if (driverState.import_source_sha256) {
|
|
2491
|
+
if (driverState.import_source_sha256 !== protectedSha256) {
|
|
2492
|
+
throw new Error("受保护的导入 cluster.yaml 副本与已锁定 source 摘要不匹配,拒绝继续");
|
|
2493
|
+
}
|
|
2494
|
+
} else {
|
|
2495
|
+
if (!driverState.import_source_path) {
|
|
2496
|
+
throw new Error("导入来源状态不完整,缺少受信任的外部 cluster.yaml 路径");
|
|
2497
|
+
}
|
|
2498
|
+
const source = readImportSource();
|
|
2499
|
+
if (!protectedCopy.bytes.equals(source.bytes)) {
|
|
2500
|
+
throw new Error("受保护的导入 cluster.yaml 副本与外部 source 字节不匹配,拒绝继续");
|
|
2501
|
+
}
|
|
2502
|
+
persistDriverState({
|
|
2503
|
+
import_source_path: source.path,
|
|
2504
|
+
import_source_sha256: source.sha256,
|
|
2505
|
+
config_sha256: source.sha256,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
1901
2508
|
}
|
|
1902
|
-
|
|
1903
|
-
config = readSafeClusterSource(configPath, {
|
|
1904
|
-
platform: dependencies.platform || process.platform,
|
|
1905
|
-
sourceStateStore: stateStore,
|
|
1906
|
-
}).value;
|
|
2509
|
+
config = protectedCopy.value;
|
|
1907
2510
|
} else {
|
|
1908
2511
|
throw new Error("不支持的主机集群配置来源");
|
|
1909
2512
|
}
|
|
@@ -1920,7 +2523,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1920
2523
|
if (driverState.config_sha256 && driverState.config_sha256 !== configSha256) throw new Error("恢复时 cluster.yaml 字节发生变化,拒绝继续");
|
|
1921
2524
|
if (!driverState.config_sha256) persistDriverState({ config_sha256: configSha256 });
|
|
1922
2525
|
if (driverState.stage === "configuration") {
|
|
1923
|
-
write(
|
|
2526
|
+
write(renderValidatedTopologyStatus(summarizeTopology(config), configPath));
|
|
1924
2527
|
}
|
|
1925
2528
|
const prepared = await prepareHostSshSessions(topology, { ...dependencies, configPath });
|
|
1926
2529
|
const closeSessions = () => {
|
|
@@ -1983,6 +2586,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
|
|
|
1983
2586
|
resumeArgv,
|
|
1984
2587
|
write: dependencies.write,
|
|
1985
2588
|
abortState: dependencies.abortState,
|
|
2589
|
+
sensitiveValues: topology.hosts.map(({ password }) => password),
|
|
1986
2590
|
});
|
|
1987
2591
|
} catch (error) {
|
|
1988
2592
|
if (error.code !== "RAINSKILLS_HOST_CLUSTER_INTERRUPTED") throw error;
|
|
@@ -2111,7 +2715,9 @@ module.exports = {
|
|
|
2111
2715
|
acquireRoiArtifact,
|
|
2112
2716
|
atomicWriteProtectedBytes,
|
|
2113
2717
|
confirmRoiInstall,
|
|
2718
|
+
createClusterConfigFromServerInput,
|
|
2114
2719
|
createHostClusterTemplate,
|
|
2720
|
+
createHostServerInputTemplate,
|
|
2115
2721
|
createProtectedBytesExclusive,
|
|
2116
2722
|
defaultSshRunner,
|
|
2117
2723
|
defaultTransfer,
|
|
@@ -2122,14 +2728,17 @@ module.exports = {
|
|
|
2122
2728
|
inspectRemoteCluster,
|
|
2123
2729
|
installHostCluster,
|
|
2124
2730
|
parseClusterDocument,
|
|
2731
|
+
parseHostServerInput,
|
|
2125
2732
|
probeRemoteArchitecture,
|
|
2126
2733
|
probeRemoteRoiVersion,
|
|
2127
2734
|
prepareHostSshSessions,
|
|
2128
2735
|
probeConsole,
|
|
2736
|
+
readProtectedServerInput,
|
|
2129
2737
|
readSafeClusterSource,
|
|
2130
2738
|
reconcileHostExecution,
|
|
2131
2739
|
redactInstallLog,
|
|
2132
2740
|
renderHostClusterConfigPrompt,
|
|
2741
|
+
renderHostServerInputPrompt,
|
|
2133
2742
|
renderHostClusterSshAuthenticationPrompt,
|
|
2134
2743
|
renderConfirmationSummary,
|
|
2135
2744
|
runClusterWizard,
|