@namewta/speculo 1.0.7 → 1.0.8
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/package.json +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/README.md +6 -3
- package/template/workflows/ops/common/USAGE.md +1 -1
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/schemas/host.schema.json +64 -1
- package/template/workflows/ops/common/schemas/plan.schema.json +312 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +136 -0
- package/template/workflows/ops/common/schemas/status.schema.json +142 -0
- package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
- package/template/workflows/ops/common/service-profiles/redis.md +4 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
- package/template/workflows/ops/common/templates/HOST-README.md +12 -2
- package/template/workflows/ops/common/tests/test_ops.mjs +232 -2
- package/template/workflows/ops/common/tools/opslib/agent.mjs +44 -13
- package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
- package/template/workflows/ops/common/tools/opslib/docs.mjs +202 -18
- package/template/workflows/ops/common/tools/opslib/execution.mjs +31 -13
- package/template/workflows/ops/common/tools/opslib/planner.mjs +108 -14
- package/template/workflows/ops/common/tools/opslib/transport.mjs +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Exact-path exceptions for system control files. Not a generic /etc write grant. */
|
|
2
|
+
import { OpsError } from "./core.mjs";
|
|
3
|
+
|
|
4
|
+
const FORBIDDEN_UNITS = new Set([
|
|
5
|
+
"sshd.service", "docker.service", "containerd.service",
|
|
6
|
+
"systemd-networkd.service", "systemd-resolved.service", "systemd-logind.service",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function isBuiltinControlPath(path) {
|
|
10
|
+
return path === "/etc/docker/daemon.json" || /^\/etc\/systemd\/system\/ops-[a-z0-9-]+\.service$/.test(path);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isDeclaredControlPath(path) {
|
|
14
|
+
if (path === "/etc/nginx/nginx.conf") return true;
|
|
15
|
+
if (/^\/etc\/nginx\/conf\.d\/[A-Za-z0-9._-]+\.conf$/.test(path)) return true;
|
|
16
|
+
if (/^\/etc\/wireguard\/[A-Za-z0-9_-]+\.conf$/.test(path)) return true;
|
|
17
|
+
if (/^\/etc\/systemd\/system\/[A-Za-z0-9@_.-]+\.service$/.test(path)) return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertSafeControlPath(path, { reason = "", rollback = "" } = {}) {
|
|
22
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0") || path.includes("//") || path.split("/").includes("..")) {
|
|
23
|
+
throw new OpsError("control file must be a normalized absolute path");
|
|
24
|
+
}
|
|
25
|
+
if (isBuiltinControlPath(path)) return "builtin";
|
|
26
|
+
if (!isDeclaredControlPath(path)) {
|
|
27
|
+
throw new OpsError("unrecognized external control file; persistent data cannot be an exception");
|
|
28
|
+
}
|
|
29
|
+
const unit = path.split("/").pop();
|
|
30
|
+
if (path.startsWith("/etc/systemd/system/") && FORBIDDEN_UNITS.has(unit)) {
|
|
31
|
+
throw new OpsError("core systemd unit is not an OPS control-file exception: " + unit);
|
|
32
|
+
}
|
|
33
|
+
if (typeof reason !== "string" || reason.trim().length < 8) {
|
|
34
|
+
throw new OpsError("declared control files need a review reason");
|
|
35
|
+
}
|
|
36
|
+
if (typeof rollback !== "string" || rollback.trim().length < 8) {
|
|
37
|
+
throw new OpsError("declared control files need an explicit rollback instruction");
|
|
38
|
+
}
|
|
39
|
+
return "declared";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function reservedHostDocumentPaths(host, targetJoin) {
|
|
43
|
+
return [
|
|
44
|
+
targetJoin(host, "README.md"),
|
|
45
|
+
targetJoin(host, "DEPLOYMENTS.md"),
|
|
46
|
+
targetJoin(host, "docs/standards/DEPLOYMENT-STANDARD.md"),
|
|
47
|
+
targetJoin(host, "knowledge/INDEX.md"),
|
|
48
|
+
targetJoin(host, "knowledge/host-services.json"),
|
|
49
|
+
targetJoin(host, "knowledge/public-ingress.json"),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function defaultFileMode(path) {
|
|
54
|
+
const n = String(path).replaceAll("\\", "/");
|
|
55
|
+
if (/(?:^|\/)(?:config|service)\//.test(n) || /\.(?:conf|acl)$/.test(n)) return 0o644;
|
|
56
|
+
return 0o600;
|
|
57
|
+
}
|
|
@@ -16,14 +16,20 @@ README 记录实际版本、来源、主机、时间、路径、依赖、启停
|
|
|
16
16
|
旧环境默认值恢复和健康验证失败不得清理原环境。缓存隔离不等于释放空间;禁止把 data/env/backups 或数据库持久日志当成垃圾。
|
|
17
17
|
|
|
18
18
|
计划 → 明确批准 → 执行 → 实际验证 → 双边文档回执完成。远程断线意味着结果未知,不能重跑迁移或重新生成密码。
|
|
19
|
+
|
|
20
|
+
主机级入口(WireGuard、Nginx、探测单元)登记在 knowledge/host-services.json,不是假的 APP 部署。跨主机公网入口规范登记在 knowledge/public-ingress.json。主机/全域总册 = 服务一览表(含主机级入口)+ 特定服务规范;缺一不算完整主机手册。
|
|
19
21
|
`;
|
|
20
22
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
+
export function code(value) {
|
|
24
|
+
const text = String(value ?? "");
|
|
25
|
+
if (!text.includes("`")) return "`" + text + "`";
|
|
26
|
+
const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
27
|
+
const ticks = "`".repeat(longest + 1);
|
|
28
|
+
return ticks + " " + text + " " + ticks;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
return "
|
|
31
|
+
function cell(value) {
|
|
32
|
+
return String(value ?? "").replaceAll("|", "\\|").replaceAll("\r", "").replaceAll("\n", " ");
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function block(value, language = "json") {
|
|
@@ -47,6 +53,20 @@ export function credentialRefs(status, dep) {
|
|
|
47
53
|
return [...refs].sort();
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
function firstCredential(refs, ledger) {
|
|
57
|
+
for (const ref of refs) {
|
|
58
|
+
const [cid, v] = ref.split("@");
|
|
59
|
+
const item = ledger?.entries?.[cid]?.[v];
|
|
60
|
+
if (!item?.values) continue;
|
|
61
|
+
return {
|
|
62
|
+
ref,
|
|
63
|
+
username: item.values.username || item.values.user || item.values.access_key || "",
|
|
64
|
+
password: item.values.password || item.values.secret || item.values.secret_key || item.values.token || "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
export function credentialsText(refs, ledger) {
|
|
51
71
|
const out = ["## 明文账号密码\n", "本文件是受限明文交付,不是脱敏报告。只向获授权管理员及对应运行账户开放。\n"];
|
|
52
72
|
if (!refs.length) return [...out, "本部署没有登记密码凭据。密钥认证本身不存在登录密码;未知旧密码不能伪造。\n"].join("\n");
|
|
@@ -86,6 +106,130 @@ export function dependencies(status, dep) {
|
|
|
86
106
|
return lines.join("\n");
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
function deploymentAccess(status, dep) {
|
|
110
|
+
const bindings = Object.values(status.bindings).filter((b) => b.consumer_deployment_id === dep.deployment_id && b.status === "active");
|
|
111
|
+
if (bindings.length) return bindings.map((b) => b.endpoint).filter(Boolean).join(";") || "—";
|
|
112
|
+
const providers = Object.values(status.bindings).filter((b) => b.provider_deployment_id === dep.deployment_id && b.status === "active");
|
|
113
|
+
if (providers.length) return providers.map((b) => b.endpoint).filter(Boolean).join(";") || dep.root;
|
|
114
|
+
return dep.root;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function persistenceCell(dep) {
|
|
118
|
+
if (!dep.storage?.length) return "无登记业务持久化";
|
|
119
|
+
return dep.storage.map((p) => `${p.component}/${p.purpose}: ${p.path}`).join(";");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hostServiceAccess(svc) {
|
|
123
|
+
return svc.public_url || (svc.listen && svc.listen.length ? svc.listen.join(";") : "") || svc.tunnel_address || (svc.listen_port ? String(svc.listen_port) : "") || svc.config_path || svc.unit || "—";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hostServicePersistence(svc) {
|
|
127
|
+
return [svc.config_path, svc.unit, svc.tunnel_address].filter(Boolean).join(";") || "主机级控制文件";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function overviewRows(status, hid, { ledger = null, includeCredentials = false } = {}) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
const host = status.hosts[hid];
|
|
133
|
+
for (const svc of host.host_services || []) {
|
|
134
|
+
rows.push({
|
|
135
|
+
host_id: hid,
|
|
136
|
+
service: svc.id + " / " + svc.kind,
|
|
137
|
+
access: hostServiceAccess(svc),
|
|
138
|
+
username: includeCredentials ? (svc.kind === "wireguard" ? "密钥认证" : "无登录口令") : "见控制端总册",
|
|
139
|
+
password: includeCredentials ? "无登录口令" : "不在服务端公开",
|
|
140
|
+
version: svc.unit || svc.kind,
|
|
141
|
+
updated: "主机级服务",
|
|
142
|
+
persistence: hostServicePersistence(svc),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const deps = Object.values(status.deployments).filter((d) => d.host_id === hid).sort((a, b) => a.deployment_id < b.deployment_id ? -1 : 1);
|
|
146
|
+
for (const d of deps) {
|
|
147
|
+
const cred = includeCredentials ? firstCredential(credentialRefs(status, d), ledger) : null;
|
|
148
|
+
rows.push({
|
|
149
|
+
host_id: hid,
|
|
150
|
+
service: d.project_id + " / " + d.deployment_id,
|
|
151
|
+
access: deploymentAccess(status, d),
|
|
152
|
+
username: includeCredentials ? (cred?.username || "密钥认证/无登录口令") : "见 OPERATIONS",
|
|
153
|
+
password: includeCredentials ? (cred?.password || "无登录口令") : "不在服务端公开",
|
|
154
|
+
version: d.observed_version || d.version || "未验证",
|
|
155
|
+
updated: d.updated_at || "未验证",
|
|
156
|
+
persistence: persistenceCell(d),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return rows;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderOverviewTable(rows, { includeCredentials = false } = {}) {
|
|
163
|
+
const header = includeCredentials
|
|
164
|
+
? "| 主机 | 服务 | 访问地址 | 账号 | 密码 | 版本 | 最近部署 | 持久化目录 |\n|---|---|---|---|---|---|---|---|"
|
|
165
|
+
: "| 主机 | 服务 | 访问地址 | 账号 | 版本 | 最近部署 | 持久化目录 |\n|---|---|---|---|---|---|---|";
|
|
166
|
+
const lines = [header];
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
if (includeCredentials) {
|
|
169
|
+
lines.push(`| ${cell(r.host_id)} | ${cell(r.service)} | ${cell(r.access)} | ${cell(r.username)} | ${cell(r.password)} | ${cell(r.version)} | ${cell(r.updated)} | ${cell(r.persistence)} |`);
|
|
170
|
+
} else {
|
|
171
|
+
lines.push(`| ${cell(r.host_id)} | ${cell(r.service)} | ${cell(r.access)} | ${cell(r.username)} | ${cell(r.version)} | ${cell(r.updated)} | ${cell(r.persistence)} |`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!rows.length) {
|
|
175
|
+
lines.push(includeCredentials
|
|
176
|
+
? "| — | 未登记 APP、公共服务或主机级入口 | — | — | — | — | — | — |"
|
|
177
|
+
: "| — | 未登记 APP、公共服务或主机级入口 | — | — | — | — | — |");
|
|
178
|
+
}
|
|
179
|
+
return lines.join("\n") + "\n";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function ingressSection(status) {
|
|
183
|
+
const ing = status.public_ingress;
|
|
184
|
+
if (!ing) return "";
|
|
185
|
+
const out = [
|
|
186
|
+
"## 公网访问内网\n",
|
|
187
|
+
"入口(用户 → 公网 Nginx → 隧道 → 内网服务)与出口(内网客户端 TUN)不是同一条连接。" + (ing.not_the_same_as ? " " + ing.not_the_same_as : "") + "\n",
|
|
188
|
+
"| 公网 | 四层 | 路径 | 后端 |\n|---|---|---|---|",
|
|
189
|
+
];
|
|
190
|
+
for (const m of ing.mappings || []) {
|
|
191
|
+
out.push(`| ${cell(m.public)} | ${cell(m.layer4 || "—")} | ${cell((m.via || []).join(" → ") || "—")} | ${cell(m.backend)} |`);
|
|
192
|
+
}
|
|
193
|
+
if (!(ing.mappings || []).length) out.push("| — | — | 未登记映射 | — |");
|
|
194
|
+
out.push("\n### 新开公网 HTTP 服务\n");
|
|
195
|
+
const steps = ing.open_http_checklist?.length
|
|
196
|
+
? ing.open_http_checklist
|
|
197
|
+
: [
|
|
198
|
+
"在入口主机登记/更新 Nginx 站点与 listen 端口,写入 host_services。",
|
|
199
|
+
"确认 WireGuard 对端与 AllowedIPs,内网后端只绑隧道地址。",
|
|
200
|
+
"更新 knowledge/public-ingress.json 映射行后重新编制文档计划。",
|
|
201
|
+
"不要把数据库、SSH、Redis 管理口直接暴露到公网。",
|
|
202
|
+
];
|
|
203
|
+
for (const s of steps) out.push("- " + s);
|
|
204
|
+
if (ing.forbidden_ports?.length) {
|
|
205
|
+
out.push("\n### 禁止暴露的端口\n");
|
|
206
|
+
out.push(ing.forbidden_ports.map((p) => "- " + p).join("\n"));
|
|
207
|
+
}
|
|
208
|
+
return out.join("\n") + "\n";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hostNotes(status, hid) {
|
|
212
|
+
const host = status.hosts[hid];
|
|
213
|
+
const out = [`### 主机 ${hid} / ${host.display_name}\n`, `持久化根:${code(host.root)}。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n`];
|
|
214
|
+
for (const svc of host.host_services || []) {
|
|
215
|
+
out.push(`#### ${svc.id}(${svc.kind})\n`);
|
|
216
|
+
if (svc.notes) out.push(svc.notes + "\n");
|
|
217
|
+
if (svc.unit) out.push("- 单元:" + code(svc.unit) + "\n");
|
|
218
|
+
if (svc.config_path) out.push("- 配置:" + code(svc.config_path) + "\n");
|
|
219
|
+
if (svc.tunnel_address) out.push("- 隧道地址:" + code(svc.tunnel_address) + "\n");
|
|
220
|
+
if (svc.listen_port) out.push("- ListenPort:" + String(svc.listen_port) + "\n");
|
|
221
|
+
if (svc.public_url) out.push("- 公网 URL:" + code(svc.public_url) + "\n");
|
|
222
|
+
}
|
|
223
|
+
const deps = Object.values(status.deployments).filter((d) => d.host_id === hid).sort((a, b) => a.deployment_id < b.deployment_id ? -1 : 1);
|
|
224
|
+
for (const d of deps) {
|
|
225
|
+
out.push(`#### ${d.project_id} / ${d.deployment_id}\n`);
|
|
226
|
+
out.push(`目录 ${code(d.root)};启停与备份细节见项目 README。备份:${d.backup} 恢复:${d.recovery}\n`);
|
|
227
|
+
if (d.notes?.length) out.push(d.notes.join("\n\n") + "\n");
|
|
228
|
+
}
|
|
229
|
+
if (!(host.host_services || []).length && !deps.length) out.push("本机尚未登记 APP、公共服务或主机级入口。\n");
|
|
230
|
+
return out.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
89
233
|
export function deploymentReadme(status, dep, runId, generatedAt, { ledger = null, includeCredentials = false, controller = false } = {}) {
|
|
90
234
|
const host = status.hosts[dep.host_id];
|
|
91
235
|
const project = status.projects[dep.project_id];
|
|
@@ -136,21 +280,49 @@ export function deploymentReadme(status, dep, runId, generatedAt, { ledger = nul
|
|
|
136
280
|
|
|
137
281
|
export function hostReadme(status, hid, runId, at, { ledger = null, full = false, controller = false } = {}) {
|
|
138
282
|
const host = status.hosts[hid];
|
|
283
|
+
const includeCredentials = Boolean(full && controller && ledger);
|
|
284
|
+
const rows = overviewRows(status, hid, { ledger, includeCredentials });
|
|
139
285
|
const out = [
|
|
140
286
|
`# 主机 ${host.display_name} / ${hid}\n`,
|
|
141
287
|
`主机持久化根:${code(host.root)};更新:${at};运行:${runId}。\n`,
|
|
142
|
-
"APP
|
|
143
|
-
"
|
|
288
|
+
"APP 与公共服务同级。主机级入口(WireGuard/Nginx/探测)登记为 host_services,不是假的 APP 目录。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n",
|
|
289
|
+
"## 服务一览\n",
|
|
290
|
+
renderOverviewTable(rows, { includeCredentials }),
|
|
144
291
|
];
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
out.push("\n
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
292
|
+
if (status.public_ingress) out.push("\n" + ingressSection(status));
|
|
293
|
+
out.push("\n## 说明\n", hostNotes(status, hid));
|
|
294
|
+
out.push("\n公共服务数据归提供者;其他主机使用的服务通过依赖绑定记录。未写入 host_services / 部署账本的入口不会在下次文档交付中出现。\n");
|
|
295
|
+
return out.join("\n") + "\n";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function fleetDocument(status, hostIds, runId, at, { ledger = null, includeCredentials = false } = {}) {
|
|
299
|
+
const ids = [...hostIds].sort();
|
|
300
|
+
const rows = ids.flatMap((hid) => overviewRows(status, hid, { ledger, includeCredentials }));
|
|
301
|
+
const out = [
|
|
302
|
+
"# 全域部署总册(明文)\n",
|
|
303
|
+
`最近文档代次:${runId};生成时间:${at}。本地账本和配置副本不等于远端业务数据备份。\n`,
|
|
304
|
+
"## 服务一览\n",
|
|
305
|
+
renderOverviewTable(rows, { includeCredentials }),
|
|
306
|
+
];
|
|
307
|
+
if (status.public_ingress) out.push("\n" + ingressSection(status));
|
|
308
|
+
out.push("\n## 说明\n");
|
|
309
|
+
for (const hid of ids) out.push(hostNotes(status, hid));
|
|
151
310
|
return out.join("\n") + "\n";
|
|
152
311
|
}
|
|
153
312
|
|
|
313
|
+
export function hostServicesDocument(host) {
|
|
314
|
+
return JSON.stringify({
|
|
315
|
+
schema_version: 1,
|
|
316
|
+
host_id: host.host_id,
|
|
317
|
+
services: host.host_services || [],
|
|
318
|
+
}, null, 2) + "\n";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function publicIngressDocument(status) {
|
|
322
|
+
if (!status.public_ingress) return null;
|
|
323
|
+
return JSON.stringify(status.public_ingress, null, 2) + "\n";
|
|
324
|
+
}
|
|
325
|
+
|
|
154
326
|
export function remotePaths(status, dep) {
|
|
155
327
|
const names = ["README.md", "project.yaml", "run/release-state.json"];
|
|
156
328
|
if (status.policies.server_operations) names.push("OPERATIONS.md");
|
|
@@ -188,6 +360,14 @@ export function planReport(plan) {
|
|
|
188
360
|
return out.join("\n");
|
|
189
361
|
}
|
|
190
362
|
|
|
363
|
+
const KNOWLEDGE_INDEX = `# 共享知识索引
|
|
364
|
+
|
|
365
|
+
通用规范见 ../docs/standards/DEPLOYMENT-STANDARD.md。
|
|
366
|
+
主机级服务账本:host-services.json。
|
|
367
|
+
跨主机公网入口:public-ingress.json。
|
|
368
|
+
只收录经用户确认、带来源和最后验证时间的知识,不存密码。
|
|
369
|
+
`;
|
|
370
|
+
|
|
191
371
|
export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
192
372
|
const at = now();
|
|
193
373
|
const rid = plan.run_id;
|
|
@@ -228,21 +408,25 @@ export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
|
|
|
228
408
|
addRemote(hid, targetJoin(h, "README.md"), hostReadme(status, hid, rid, at));
|
|
229
409
|
addRemote(hid, targetJoin(h, "DEPLOYMENTS.md"), hostReadme(status, hid, rid, at, { ledger, full: true }));
|
|
230
410
|
addRemote(hid, targetJoin(h, "docs/standards/DEPLOYMENT-STANDARD.md"), STANDARD);
|
|
411
|
+
addRemote(hid, targetJoin(h, "knowledge/host-services.json"), hostServicesDocument(h));
|
|
412
|
+
const ingressJson = publicIngressDocument(status);
|
|
413
|
+
if (ingressJson) addRemote(hid, targetJoin(h, "knowledge/public-ingress.json"), ingressJson);
|
|
231
414
|
const knowledgePath = targetJoin(h, "knowledge/INDEX.md");
|
|
232
415
|
if ((plan.document_preconditions[hid]?.[knowledgePath] ?? { kind: "file" }).kind === "absent") {
|
|
233
|
-
addRemote(hid, knowledgePath,
|
|
416
|
+
addRemote(hid, knowledgePath, KNOWLEDGE_INDEX);
|
|
234
417
|
}
|
|
235
418
|
local[`hosts/${hid}/README.md`] = hostReadme(status, hid, rid, at);
|
|
236
419
|
local[`hosts/${hid}/DEPLOYMENTS.md`] = hostReadme(status, hid, rid, at, { ledger, full: true, controller: true });
|
|
420
|
+
local[`hosts/${hid}/knowledge/host-services.json`] = hostServicesDocument(h);
|
|
421
|
+
if (ingressJson) local[`hosts/${hid}/knowledge/public-ingress.json`] = ingressJson;
|
|
237
422
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
local["FLEET-DEPLOYMENTS.md"] = globalText.join("\n");
|
|
241
|
-
local["README.md"] = "# OPS 控制端\n\n主机记录位于 hosts/<host_id>/;项目关联位于 status.json;项目部署记录位于 hosts/<host_id>/deployments/<deployment_id>/;完整明文总册位于 FLEET-DEPLOYMENTS.md;凭据账本位于 private/credentials.json;执行证据按 host runs / release 保存。\n\n不得清理此运行态目录来替换静态 workflow。双边文档完成由每次运行 docs-receipt.json 证明。\n";
|
|
423
|
+
local["FLEET-DEPLOYMENTS.md"] = fleetDocument(status, Object.keys(status.hosts), rid, at, { ledger, includeCredentials: true });
|
|
424
|
+
local["README.md"] = "# OPS 控制端\n\n主机记录位于 hosts/<host_id>/;项目关联位于 status.json;项目部署记录位于 hosts/<host_id>/deployments/<deployment_id>/;完整明文总册位于 FLEET-DEPLOYMENTS.md;凭据账本位于 private/credentials.json;执行证据按 host runs / release 保存。主机级入口见 hosts/<id>/knowledge/host-services.json,跨主机公网入口见 knowledge/public-ingress.json。\n\n不得清理此运行态目录来替换静态 workflow。双边文档完成由每次运行 docs-receipt.json 证明。\n";
|
|
242
425
|
local["docs/standards/DEPLOYMENT-STANDARD.md"] = STANDARD;
|
|
243
426
|
if (!existsSync(join(state, "knowledge", "INDEX.md"))) {
|
|
244
|
-
local["knowledge/INDEX.md"] = "# 共享知识索引\n\n
|
|
427
|
+
local["knowledge/INDEX.md"] = "# 共享知识索引\n\n只收录经用户批准、带来源与最后验证日期的通用知识。运行时环境和密码不自动提升为共享知识。跨主机入口规范见各主机 knowledge/public-ingress.json。\n";
|
|
245
428
|
}
|
|
429
|
+
if (status.public_ingress) local["knowledge/public-ingress.json"] = publicIngressDocument(status);
|
|
246
430
|
return {
|
|
247
431
|
schema_version: 1, run_id: rid, plan_digest: digest(plan), generated_at: at,
|
|
248
432
|
remote: [...remote.values()],
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
relative, resolveSecrets, secure, UnknownResult, withLock, writeJson,
|
|
9
9
|
} from "./core.mjs";
|
|
10
10
|
import { load, save, ledgerLoad, validate, validateStatus } from "./model.mjs";
|
|
11
|
-
import { locatePlan, envFile } from "./planner.mjs";
|
|
11
|
+
import { locatePlan, envFile, sliceDigest } from "./planner.mjs";
|
|
12
12
|
import { call as transportCall, hostTransportDigest } from "./transport.mjs";
|
|
13
13
|
import { deliveryBundle } from "./docs.mjs";
|
|
14
14
|
|
|
@@ -43,7 +43,7 @@ export function approval(state, run, expected, by, statement) {
|
|
|
43
43
|
};
|
|
44
44
|
validate(value, "approval");
|
|
45
45
|
return withLock(join(state, ".locks", "catalog"), { operation: "approve", run_id: plan.run_id }, () => {
|
|
46
|
-
if (
|
|
46
|
+
if (sliceDigest(load(state), plan.registry_scope) !== plan.registry_slice_digest) throw new OpsError("touched catalog slice drifted since plan; replan before approval");
|
|
47
47
|
writeJson(join(dirname(path), "approval.json"), value, { exclusive: true });
|
|
48
48
|
return { run_id: plan.run_id, status: "approved", plan_digest: expected };
|
|
49
49
|
});
|
|
@@ -124,15 +124,25 @@ export function commitObservations(state, current, plan, execution) {
|
|
|
124
124
|
const result = structuredClone(current);
|
|
125
125
|
const desired = plan.registry_after;
|
|
126
126
|
const records = execution.steps;
|
|
127
|
-
|
|
128
|
-
result.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
const scope = plan.registry_scope || {};
|
|
128
|
+
if (scope.policies) result.policies = structuredClone(desired.policies);
|
|
129
|
+
for (const hid of scope.hosts || []) {
|
|
130
|
+
if (desired.hosts?.[hid]) result.hosts[hid] = structuredClone(desired.hosts[hid]);
|
|
131
|
+
else delete result.hosts[hid];
|
|
132
|
+
}
|
|
133
|
+
for (const pid of scope.projects || []) {
|
|
134
|
+
if (desired.projects?.[pid]) result.projects[pid] = structuredClone(desired.projects[pid]);
|
|
135
|
+
else delete result.projects[pid];
|
|
136
|
+
}
|
|
137
|
+
if (scope.public_ingress) result.public_ingress = structuredClone(desired.public_ingress ?? null);
|
|
138
|
+
const deploymentIds = new Set([...(scope.deployments || []), ...plan.document_targets]);
|
|
139
|
+
for (const did of deploymentIds) {
|
|
140
|
+
let proposed = desired.deployments?.[did] ? structuredClone(desired.deployments[did]) : null;
|
|
132
141
|
const old = current.deployments[did];
|
|
133
142
|
const ops = plan.operations.filter((o) => o.deployment_id === did);
|
|
134
143
|
const statuses = ops.map((o) => records[o.step_id]?.status ?? "not-started");
|
|
135
144
|
const changing = ops.length > 0;
|
|
145
|
+
if (!proposed && !old) continue;
|
|
136
146
|
if (changing && statuses.every((x) => x === "succeeded")) {
|
|
137
147
|
proposed.status = proposed.status === "retired" ? "retired" : "docs_pending";
|
|
138
148
|
proposed.observed_version = proposed.version;
|
|
@@ -146,7 +156,9 @@ export function commitObservations(state, current, plan, execution) {
|
|
|
146
156
|
else { proposed.status = "planned"; proposed.observed_version = null; }
|
|
147
157
|
result.deployments[did] = proposed;
|
|
148
158
|
}
|
|
149
|
-
for (const
|
|
159
|
+
for (const aid of scope.allocations || []) {
|
|
160
|
+
const a = desired.allocations?.[aid];
|
|
161
|
+
if (!a) continue;
|
|
150
162
|
if (aid in current.allocations && current.allocations[aid].status !== "planned") {
|
|
151
163
|
result.allocations[aid] = structuredClone(current.allocations[aid]);
|
|
152
164
|
continue;
|
|
@@ -156,11 +168,13 @@ export function commitObservations(state, current, plan, execution) {
|
|
|
156
168
|
item.status = provisioning.length && provisioning.every((o) => records[o.step_id]?.status === "succeeded") ? "active" : "planned";
|
|
157
169
|
result.allocations[aid] = item;
|
|
158
170
|
}
|
|
159
|
-
for (const
|
|
171
|
+
for (const bid of scope.bindings || []) {
|
|
172
|
+
const b = desired.bindings?.[bid];
|
|
173
|
+
if (!b) continue;
|
|
160
174
|
const item = structuredClone(b);
|
|
161
175
|
const consumer = result.deployments[b.consumer_deployment_id];
|
|
162
176
|
if (item.status === "active" && (!consumer || ["planned", "failed", "unknown"].includes(consumer.status))) item.status = "planned";
|
|
163
|
-
if (item.mode === "shared" && result.allocations[item.allocation_id]
|
|
177
|
+
if (item.mode === "shared" && result.allocations[item.allocation_id]?.status !== "active" && item.status === "active") item.status = "planned";
|
|
164
178
|
result.bindings[bid] = item;
|
|
165
179
|
}
|
|
166
180
|
result.releases[plan.run_id] = {
|
|
@@ -171,6 +185,7 @@ export function commitObservations(state, current, plan, execution) {
|
|
|
171
185
|
};
|
|
172
186
|
save(state, result);
|
|
173
187
|
execution.committed_registry_digest = digest(result);
|
|
188
|
+
execution.committed_slice_digest = sliceDigest(result, plan.registry_scope);
|
|
174
189
|
return result;
|
|
175
190
|
}
|
|
176
191
|
|
|
@@ -256,9 +271,10 @@ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
|
|
|
256
271
|
const ledger = ledgerLoad(state);
|
|
257
272
|
const execution = existsSync(execPath)
|
|
258
273
|
? readJson(execPath)
|
|
259
|
-
: { schema_version: 1, run_id: plan.run_id, plan_path: path, status: "executing", started_at: now(), steps: {}, errors: [], committed_registry_digest: null };
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
274
|
+
: { schema_version: 1, run_id: plan.run_id, plan_path: path, status: "executing", started_at: now(), steps: {}, errors: [], committed_registry_digest: null, committed_slice_digest: null };
|
|
275
|
+
const expectedSlice = execution.committed_slice_digest || plan.registry_slice_digest;
|
|
276
|
+
if (!plan.registry_scope || !plan.registry_slice_digest) throw new OpsError("plan missing registry_scope; compile a new plan");
|
|
277
|
+
if (sliceDigest(current, plan.registry_scope) !== expectedSlice) throw new OpsError("controller registry changed since this approved run; compile a new plan");
|
|
262
278
|
if (execution.status === "completed") return { run_id: plan.run_id, status: "completed", unchanged: true, report: join(folder, "RESULT.md") };
|
|
263
279
|
verifyJournal(join(folder, "journal.jsonl"));
|
|
264
280
|
for (const [hid, h] of Object.entries(plan.hosts)) {
|
|
@@ -319,6 +335,7 @@ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
|
|
|
319
335
|
current.releases[plan.run_id].updated_at = now();
|
|
320
336
|
save(state, current);
|
|
321
337
|
execution.committed_registry_digest = digest(current);
|
|
338
|
+
execution.committed_slice_digest = sliceDigest(current, plan.registry_scope);
|
|
322
339
|
} catch (e) {
|
|
323
340
|
if (!(e instanceof OpsError || e.code)) throw e;
|
|
324
341
|
execution.status = "docs_pending";
|
|
@@ -364,6 +381,7 @@ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
|
|
|
364
381
|
current.releases[plan.run_id].status = execution.status;
|
|
365
382
|
save(state, current);
|
|
366
383
|
execution.committed_registry_digest = digest(current);
|
|
384
|
+
execution.committed_slice_digest = sliceDigest(current, plan.registry_scope);
|
|
367
385
|
}
|
|
368
386
|
writeJson(execPath, execution);
|
|
369
387
|
journal(folder, { kind: "attempt-end", status: execution.status, errors: execution.errors.length });
|