@namewta/speculo 1.0.6 → 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.
Files changed (57) hide show
  1. package/dist/src/ops-resources.js +1 -1
  2. package/dist/src/ops-resources.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  5. package/template/workflows/ops/H-host-manage/H-host-manage.md +5 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +8 -5
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +25 -25
  10. package/template/workflows/ops/common/examples/README.md +1 -1
  11. package/template/workflows/ops/common/examples/register.example.json +1 -1
  12. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  13. package/template/workflows/ops/common/schemas/host.schema.json +65 -2
  14. package/template/workflows/ops/common/schemas/plan.schema.json +315 -3
  15. package/template/workflows/ops/common/schemas/spec.schema.json +137 -1
  16. package/template/workflows/ops/common/schemas/status.schema.json +143 -1
  17. package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
  18. package/template/workflows/ops/common/service-profiles/redis.md +4 -0
  19. package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
  20. package/template/workflows/ops/common/templates/HOST-README.md +12 -2
  21. package/template/workflows/ops/common/tests/test_ops.mjs +982 -0
  22. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  23. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  24. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  25. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  26. package/template/workflows/ops/common/tools/opslib/agent.mjs +904 -0
  27. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  28. package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
  29. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  30. package/template/workflows/ops/common/tools/opslib/docs.mjs +436 -0
  31. package/template/workflows/ops/common/tools/opslib/execution.mjs +396 -0
  32. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  33. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  34. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  35. package/template/workflows/ops/common/tools/opslib/planner.mjs +781 -0
  36. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  37. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  38. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  39. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  40. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  41. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  42. package/template/workflows/ops/common/tools/ops.py +0 -7
  43. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  44. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  45. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  46. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  47. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  48. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  49. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  50. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  51. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  52. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  53. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  54. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  55. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  56. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  57. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -0,0 +1,436 @@
1
+ /** Deterministic server/controller documentation and protected plaintext delivery bundles. */
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { digest, now, OpsError, targetJoin } from "./core.mjs";
5
+
6
+ export const STANDARD = `# OPS 部署与持久化规范
7
+
8
+ APP 和公共服务同级:host_root/project_id。明确启用多实例时使用 project_id/instances/environment/instance。
9
+
10
+ 每个项目的 compose 或 service、env、config、data、logs、backups、releases 和 README 聚合在该项目目录。业务数据必须位于 data/component/purpose;不能回退到临时目录、代码目录、用户默认目录、匿名卷或命名卷。Docker 全局 data-root 另登记在 host_root/_runtime/docker;既有引擎迁移必须单独审批。
11
+
12
+ README 记录实际版本、来源、主机、时间、路径、依赖、启停和备份恢复。它默认不含密码。OPERATIONS.md 和部署机明文账本按策略保存真实账号密码,权限必须限制;不得进入 Git、镜像构建上下文、Web 静态目录或普通日志。
13
+
14
+ 共享服务拥有物理数据;APP 只拥有获批的逻辑数据库、桶、命名空间和应用账号。复制 APP 目录不是共享依赖完整备份。卸载 APP 不删除公共服务、共享网络、逻辑资源或任何数据。单 APP 回滚不允许恢复整个共享实例。
15
+
16
+ 旧环境默认值恢复和健康验证失败不得清理原环境。缓存隔离不等于释放空间;禁止把 data/env/backups 或数据库持久日志当成垃圾。
17
+
18
+ 计划 → 明确批准 → 执行 → 实际验证 → 双边文档回执完成。远程断线意味着结果未知,不能重跑迁移或重新生成密码。
19
+
20
+ 主机级入口(WireGuard、Nginx、探测单元)登记在 knowledge/host-services.json,不是假的 APP 部署。跨主机公网入口规范登记在 knowledge/public-ingress.json。主机/全域总册 = 服务一览表(含主机级入口)+ 特定服务规范;缺一不算完整主机手册。
21
+ `;
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;
29
+ }
30
+
31
+ function cell(value) {
32
+ return String(value ?? "").replaceAll("|", "\\|").replaceAll("\r", "").replaceAll("\n", " ");
33
+ }
34
+
35
+ export function block(value, language = "json") {
36
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
37
+ const ticks = "`".repeat(Math.max(3, Math.max(0, ...(text.match(/`+/g) || []).map((x) => x.length)) + 1));
38
+ return ticks + language + "\n" + text + "\n" + ticks + "\n";
39
+ }
40
+
41
+ export function pathFor(status, dep, relativeName) {
42
+ return targetJoin({ ...status.hosts[dep.host_id], root: dep.root }, relativeName);
43
+ }
44
+
45
+ export function credentialRefs(status, dep) {
46
+ const refs = new Set(dep.credential_refs);
47
+ for (const b of Object.values(status.bindings)) {
48
+ if (b.status === "active" && b.consumer_deployment_id === dep.deployment_id) refs.add(b.credential_ref);
49
+ }
50
+ for (const a of Object.values(status.allocations)) {
51
+ if (a.provider_deployment_id === dep.deployment_id && a.status !== "retired") refs.add(a.credential_ref);
52
+ }
53
+ return [...refs].sort();
54
+ }
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
+
70
+ export function credentialsText(refs, ledger) {
71
+ const out = ["## 明文账号密码\n", "本文件是受限明文交付,不是脱敏报告。只向获授权管理员及对应运行账户开放。\n"];
72
+ if (!refs.length) return [...out, "本部署没有登记密码凭据。密钥认证本身不存在登录密码;未知旧密码不能伪造。\n"].join("\n");
73
+ for (const ref of refs) {
74
+ const [cid, v] = ref.split("@");
75
+ const item = ledger.entries?.[cid]?.[v];
76
+ if (!item) throw new OpsError("document delivery blocked by missing credential: " + ref);
77
+ out.push("### " + ref + "\n", code(item.purpose) + "\n");
78
+ for (const [key, value] of Object.entries(item.values)) out.push("**" + key + "**\n", block(value, "text"));
79
+ }
80
+ return out.join("\n");
81
+ }
82
+
83
+ export function dependencies(status, dep) {
84
+ const lines = ["## 依赖与数据归属\n"];
85
+ const bindings = Object.values(status.bindings).filter((b) => b.consumer_deployment_id === dep.deployment_id && b.status === "active");
86
+ if (!bindings.length) lines.push("未登记共享或外部服务依赖;专用组件的数据仍归本项目目录。\n");
87
+ for (const b of bindings) {
88
+ lines.push("### " + b.component + " / " + b.binding_id + "\n", `模式:${b.mode};端点:${code(b.endpoint)};网络:${code(b.network)}。\n`);
89
+ if (b.mode === "shared") {
90
+ const a = status.allocations[b.allocation_id];
91
+ const provider = status.deployments[b.provider_deployment_id];
92
+ lines.push(`提供者:${provider.project_id} / ${provider.deployment_id},主机 ${provider.host_id};服务目录 ${code(provider.root)}。\n`);
93
+ lines.push(`逻辑资源:${code(a.resource_name)}(${a.resource_kind});数据组 ${a.data_group};账号版本 ${a.credential_ref};恢复粒度 ${a.recovery_scope}。\n`);
94
+ for (const storage of provider.storage) lines.push(`提供者物理路径:${code(storage.path)}(${storage.component}/${storage.purpose})。\n`);
95
+ lines.push("公共服务的物理数据不复制到 APP 目录;单 APP 恢复不得覆盖其他消费者。\n");
96
+ }
97
+ }
98
+ const consumers = Object.values(status.bindings).filter((b) => b.provider_deployment_id === dep.deployment_id && b.status === "active");
99
+ if (consumers.length) {
100
+ lines.push("## 公共服务消费者\n\n| APP 实例 | 所在主机 | 分配 | 凭据版本 |\n|---|---|---|---|\n");
101
+ for (const b of consumers) {
102
+ const consumer = status.deployments[b.consumer_deployment_id];
103
+ lines.push(`| ${consumer.deployment_id} | ${consumer.host_id} | ${b.allocation_id} | ${b.credential_ref} |\n`);
104
+ }
105
+ }
106
+ return lines.join("\n");
107
+ }
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
+
233
+ export function deploymentReadme(status, dep, runId, generatedAt, { ledger = null, includeCredentials = false, controller = false } = {}) {
234
+ const host = status.hosts[dep.host_id];
235
+ const project = status.projects[dep.project_id];
236
+ const out = [
237
+ `# ${project.display_name} — ${dep.deployment_id}\n`,
238
+ `文档代次:${runId};生成时间(UTC):${generatedAt}。部署验证与双边交付以部署机对应运行记录和 docs-receipt.json 为准。\n`,
239
+ "## 部署事实\n\n| 项目 | 值 |\n|---|---|",
240
+ `| 主机 / 账户 | ${host.host_id} / ${code(host.connection.username ?? "本机执行账户")} |`,
241
+ `| 连接 | ${code(host.connection.hostname ?? "local")},SSH 端口 ${host.connection.port ?? "不适用"} |`,
242
+ `| 类型 / 方式 | ${project.kind} / ${dep.method} |`,
243
+ `| 生命周期状态 | ${dep.status} |`,
244
+ `| 环境 / 实例 | ${dep.environment} / ${dep.instance} |`,
245
+ `| 计划版本 | ${code(dep.version)} |`,
246
+ `| 最后运行验证版本 | ${code(dep.observed_version || "尚未验证")} |`,
247
+ `| 首次部署时间(UTC) | ${dep.installed_at || "尚未完成"} |`,
248
+ `| 最近部署验证时间(UTC) | ${dep.updated_at || "尚未验证"} |`,
249
+ `| 项目根目录 | ${code(dep.root)} |`,
250
+ `| 来源 | ${code(dep.source.location)} |`,
251
+ `| 固定来源版本 | ${code(dep.source.revision)} |\n`,
252
+ "## 目录与持久化\n",
253
+ `部署定义:${code(dep.method === "compose" ? pathFor(status, dep, "compose/compose.yaml") : pathFor(status, dep, "service"))}。\n`,
254
+ `环境文件:${code(pathFor(status, dep, "env"))};配置:${code(pathFor(status, dep, "config"))}。\n`,
255
+ `日志:${code(pathFor(status, dep, "logs"))};发布历史:${code(pathFor(status, dep, "releases"))};备份:${code(pathFor(status, dep, "backups"))}。\n`,
256
+ "| 组件 | 用途 | 自有持久化路径 |\n|---|---|---|",
257
+ ];
258
+ for (const p of dep.storage) out.push(`| ${p.component} | ${p.purpose} | ${code(p.path)} |`);
259
+ if (!dep.storage.length) out.push("| — | 无登记的业务持久化内容 | 不应生成匿名数据路径 |");
260
+ out.push("\n" + dependencies(status, dep), "## 日常操作\n");
261
+ for (const [title, key] of [["启动/运行", "start"], ["停止(保留数据)", "stop"], ["查看与验证", "verify"]]) {
262
+ out.push("### " + title + "\n");
263
+ if (!dep.commands[key].length) out.push("不适用或由已批准运行计划执行。\n");
264
+ for (const argv of dep.commands[key]) out.push(block(argv));
265
+ }
266
+ out.push(
267
+ "## 更新步骤\n",
268
+ "先盘点主机与依赖;固定版本;检查备份及恢复能力;生成新计划并确认;分批执行;验证运行与数据;核对服务端和部署机文档回执。不得直接删除 data/env/backups,也不得用旧批准授权新迁移。\n",
269
+ "## 备份\n", dep.backup + "\n", "## 恢复与回滚\n", dep.recovery + "\n",
270
+ "切换旧代码不能自动恢复数据库结构。共享服务整实例恢复需要全部消费者维护审批。\n",
271
+ );
272
+ if (dep.notes.length) out.push("## 限制与备注\n", dep.notes.join("\n\n") + "\n");
273
+ const replicas = Object.values(status.deployments).filter((d) => d.project_id === dep.project_id);
274
+ out.push("## 同项目部署位置\n", replicas.sort((a, b) => a.deployment_id < b.deployment_id ? -1 : 1).map((d) => `- ${d.host_id} / ${d.deployment_id}:${code(d.root)}`).join("\n") + "\n");
275
+ if (controller) out.push("\n本目录是部署机的配置、凭据、运行证据和远端文档记录;不声称自动复制了远端业务数据。数据备份需要独立的备份策略和回执。\n");
276
+ if (includeCredentials) out.push(credentialsText(credentialRefs(status, dep), ledger));
277
+ else out.push("\n## 凭据记录\n\n本 README 默认不写密码。获授权管理员读取本项目 OPERATIONS.md(启用时)或部署机明文手册;密码未知时必须补齐,不能编造。\n");
278
+ return out.join("\n") + "\n";
279
+ }
280
+
281
+ export function hostReadme(status, hid, runId, at, { ledger = null, full = false, controller = false } = {}) {
282
+ const host = status.hosts[hid];
283
+ const includeCredentials = Boolean(full && controller && ledger);
284
+ const rows = overviewRows(status, hid, { ledger, includeCredentials });
285
+ const out = [
286
+ `# 主机 ${host.display_name} / ${hid}\n`,
287
+ `主机持久化根:${code(host.root)};更新:${at};运行:${runId}。\n`,
288
+ "APP 与公共服务同级。主机级入口(WireGuard/Nginx/探测)登记为 host_services,不是假的 APP 目录。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n",
289
+ "## 服务一览\n",
290
+ renderOverviewTable(rows, { includeCredentials }),
291
+ ];
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));
310
+ return out.join("\n") + "\n";
311
+ }
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
+
326
+ export function remotePaths(status, dep) {
327
+ const names = ["README.md", "project.yaml", "run/release-state.json"];
328
+ if (status.policies.server_operations) names.push("OPERATIONS.md");
329
+ if (status.projects[dep.project_id].kind === "shared-service") names.push("consumers.md");
330
+ if (dep.layout === "instances") {
331
+ return names.map((n) => pathFor(status, dep, n)).concat([targetJoin(status.hosts[dep.host_id], dep.project_id + "/README.md")]);
332
+ }
333
+ return names.map((n) => pathFor(status, dep, n));
334
+ }
335
+
336
+ export function planReport(plan) {
337
+ const out = [
338
+ `# OPS 执行计划 ${plan.run_id}\n`,
339
+ `Worker:${plan.worker};操作:${plan.operation};风险:${plan.risk}。\n`,
340
+ `计划摘要:\`${digest(plan)}\`\n\n创建:${plan.created_at};批准有效截止:${plan.expires_at}。\n`,
341
+ "## 目标与理由\n", plan.reason + "\n",
342
+ ];
343
+ for (const [hid, h] of Object.entries(plan.hosts)) out.push(`主机 ${hid}:${h.transport} / ${h.platform} / 根 ${code(h.root)} / 身份 ${h.identity}。\n`);
344
+ out.push("## 受影响消费者\n", plan.affected_consumers.join(", ") || "没有登记的既有消费者受影响。", "\n## 完整动作集合\n");
345
+ for (const op of plan.operations) {
346
+ out.push(`### ${op.step_id} — ${op.host_id} / ${op.deployment_id || "host"} / ${op.kind}\n`);
347
+ const display = Object.fromEntries(Object.entries(op).filter(([k]) => k !== "content_b64"));
348
+ if ("content_b64" in op) display.payload_sha256 = digest(Buffer.from(op.content_b64, "base64"));
349
+ out.push(block(display));
350
+ if (op.secret_argv_acknowledged) out.push("**该 MinIO 管理动作的应用密码会短暂出现在受特权用户可见的 mc 子进程参数中。批准包含此风险。**\n");
351
+ }
352
+ out.push(
353
+ "## 明文文件与双边交付\n",
354
+ "服务器 README 默认不含密码;受限 OPERATIONS.md 和部署机手册按已批准策略写真实明文。env 和本地配置副本按 0600/受限 ACL 写入。目标与部署机文件均回读校验;缺一不可标记 completed。\n",
355
+ block({ document_targets: plan.document_targets, credential_versions: Object.keys(plan.credential_versions), policies: plan.registry_after.policies }),
356
+ "## 数据保护和恢复\n", plan.rollback_note + "\n",
357
+ "没有默认删除数据、逻辑资源、公共服务、卷或备份的动作。失败停止,断线标记 unknown;同一计划不能盲目重跑迁移。\n",
358
+ "## 批准\n", "必须由用户确认上述目标、完整写入集合、凭据版本、影响集合和恢复限制。approve 必须携带本报告对应的完整摘要;编辑计划会使旧批准失效。\n",
359
+ );
360
+ return out.join("\n");
361
+ }
362
+
363
+ const KNOWLEDGE_INDEX = `# 共享知识索引
364
+
365
+ 通用规范见 ../docs/standards/DEPLOYMENT-STANDARD.md。
366
+ 主机级服务账本:host-services.json。
367
+ 跨主机公网入口:public-ingress.json。
368
+ 只收录经用户确认、带来源和最后验证时间的知识,不存密码。
369
+ `;
370
+
371
+ export function deliveryBundle(state, plan, status, ledger, verifiedIds) {
372
+ const at = now();
373
+ const rid = plan.run_id;
374
+ const remote = new Map();
375
+ const local = {};
376
+ const addRemote = (hid, path, text) => {
377
+ const expected = plan.document_preconditions[hid]?.[path];
378
+ if (expected === undefined) throw new OpsError("document write not included in approved preconditions: " + path);
379
+ remote.set(hid + "\0" + path, { host_id: hid, path, content: text, sha256: digest(Buffer.from(text, "utf8")), expected });
380
+ };
381
+ for (const did of [...new Set(verifiedIds)].sort()) {
382
+ const d = status.deployments[did];
383
+ const hid = d.host_id;
384
+ const readme = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: status.policies.server_readme_credentials });
385
+ addRemote(hid, pathFor(status, d, "README.md"), readme);
386
+ const operations = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: true });
387
+ if (status.policies.server_operations) addRemote(hid, pathFor(status, d, "OPERATIONS.md"), operations);
388
+ addRemote(hid, pathFor(status, d, "project.yaml"), JSON.stringify(d, null, 2) + "\n");
389
+ addRemote(hid, pathFor(status, d, "run/release-state.json"), JSON.stringify({
390
+ run_id: rid, version: d.version, observed_version: d.observed_version, verified_at: d.updated_at, document_generation: at,
391
+ }, null, 2) + "\n");
392
+ if (status.projects[d.project_id].kind === "shared-service") addRemote(hid, pathFor(status, d, "consumers.md"), dependencies(status, d));
393
+ if (d.layout === "instances") {
394
+ const projectroot = targetJoin(status.hosts[hid], d.project_id);
395
+ const peers = Object.values(status.deployments).filter((x) => x.host_id === hid && x.project_id === d.project_id);
396
+ const text = "# " + d.project_id + " 实例索引\n\n" + peers.map((x) => `- ${x.environment}/${x.instance}:${code(x.root)},版本 ${code(x.version)}`).join("\n") + "\n";
397
+ addRemote(hid, targetJoin({ ...status.hosts[hid], root: projectroot }, "README.md"), text);
398
+ }
399
+ const prefix = `hosts/${hid}/deployments/${did}`;
400
+ local[prefix + "/README.md"] = deploymentReadme(status, d, rid, at, { ledger, includeCredentials: true, controller: true });
401
+ local[prefix + "/OPERATIONS.md"] = operations;
402
+ local[prefix + "/deployment.json"] = JSON.stringify(d, null, 2) + "\n";
403
+ local[prefix + "/server/README.md"] = readme;
404
+ }
405
+ const touchedHosts = [...new Set([...verifiedIds.map((d) => status.deployments[d].host_id), ...Object.keys(plan.hosts)])].sort();
406
+ for (const hid of touchedHosts) {
407
+ const h = status.hosts[hid];
408
+ addRemote(hid, targetJoin(h, "README.md"), hostReadme(status, hid, rid, at));
409
+ addRemote(hid, targetJoin(h, "DEPLOYMENTS.md"), hostReadme(status, hid, rid, at, { ledger, full: true }));
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);
414
+ const knowledgePath = targetJoin(h, "knowledge/INDEX.md");
415
+ if ((plan.document_preconditions[hid]?.[knowledgePath] ?? { kind: "file" }).kind === "absent") {
416
+ addRemote(hid, knowledgePath, KNOWLEDGE_INDEX);
417
+ }
418
+ local[`hosts/${hid}/README.md`] = hostReadme(status, hid, rid, at);
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;
422
+ }
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";
425
+ local["docs/standards/DEPLOYMENT-STANDARD.md"] = STANDARD;
426
+ if (!existsSync(join(state, "knowledge", "INDEX.md"))) {
427
+ local["knowledge/INDEX.md"] = "# 共享知识索引\n\n只收录经用户批准、带来源与最后验证日期的通用知识。运行时环境和密码不自动提升为共享知识。跨主机入口规范见各主机 knowledge/public-ingress.json。\n";
428
+ }
429
+ if (status.public_ingress) local["knowledge/public-ingress.json"] = publicIngressDocument(status);
430
+ return {
431
+ schema_version: 1, run_id: rid, plan_digest: digest(plan), generated_at: at,
432
+ remote: [...remote.values()],
433
+ local: Object.entries(local).map(([p, text]) => ({ path: p, content: text, sha256: digest(Buffer.from(text, "utf8")) })),
434
+ acks: {},
435
+ };
436
+ }