@namewta/speculo 1.0.7 → 1.0.9

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 (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/template/workflows/learning/INDEX.md +2 -2
  4. package/template/workflows/learning/Q-question/Q-question.md +45 -0
  5. package/template/workflows/learning/Q-question/inquiry-template.md +69 -0
  6. package/template/workflows/learning/Q-question/lightweight-course-template.md +31 -0
  7. package/template/workflows/learning/common/rules/artifact-contract.md +1 -0
  8. package/template/workflows/learning/common/rules/questioning-policy.md +38 -0
  9. package/template/workflows/learning/common/rules/teaching-policy.md +2 -0
  10. package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +31 -0
  11. package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
  12. package/template/workflows/ops/README.md +6 -3
  13. package/template/workflows/ops/common/USAGE.md +1 -1
  14. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  15. package/template/workflows/ops/common/schemas/host.schema.json +64 -1
  16. package/template/workflows/ops/common/schemas/plan.schema.json +312 -0
  17. package/template/workflows/ops/common/schemas/spec.schema.json +136 -0
  18. package/template/workflows/ops/common/schemas/status.schema.json +142 -0
  19. package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
  20. package/template/workflows/ops/common/service-profiles/redis.md +4 -0
  21. package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
  22. package/template/workflows/ops/common/templates/HOST-README.md +12 -2
  23. package/template/workflows/ops/common/tests/test_ops.mjs +232 -2
  24. package/template/workflows/ops/common/tools/opslib/agent.mjs +44 -13
  25. package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
  26. package/template/workflows/ops/common/tools/opslib/docs.mjs +202 -18
  27. package/template/workflows/ops/common/tools/opslib/execution.mjs +31 -13
  28. package/template/workflows/ops/common/tools/opslib/planner.mjs +108 -14
  29. package/template/workflows/ops/common/tools/opslib/transport.mjs +1 -1
@@ -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 htmlEscape(s) {
22
- return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
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
- export function code(value) {
26
- return "<code>" + htmlEscape(value).replaceAll("\n", "<br>") + "</code>";
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 与公共服务同级。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。DEPLOYMENTS.md 为本机部署手册。\n",
143
- "| APP / 服务 | 实例 | 版本(最近验证) | 目录 | 最近验证时间 |\n|---|---|---|---|---|",
288
+ "APP 与公共服务同级。主机级入口(WireGuard/Nginx/探测)登记为 host_services,不是假的 APP 目录。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n",
289
+ "## 服务一览\n",
290
+ renderOverviewTable(rows, { includeCredentials }),
144
291
  ];
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) out.push(`| ${d.project_id} | ${d.deployment_id} | ${code(d.observed_version || "未验证")} | ${code(d.root)} | ${d.updated_at || "未验证"} |`);
147
- out.push("\n公共服务数据归提供者;其他主机使用的服务通过依赖绑定记录,不在本机创建假的空服务目录。\n");
148
- if (full) {
149
- for (const d of deps) out.push(deploymentReadme(status, d, runId, at, { ledger, includeCredentials: controller || status.policies.server_operations }));
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, "# 共享知识索引\n\n通用规范见 ../docs/standards/DEPLOYMENT-STANDARD.md。只收录经用户确认、带来源和最后验证时间的知识,不存密码。\n");
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
- const globalText = ["# 全域部署总册(明文)\n", `最近文档代次:${rid};生成时间:${at}。\n`, "本地账本和配置副本不等于远端业务数据备份。\n"];
239
- for (const hid of Object.keys(status.hosts).sort()) globalText.push(hostReadme(status, hid, rid, at, { ledger, full: true, controller: true }));
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只收录经用户批准、带来源与最后验证日期的通用知识。运行时环境和密码不自动提升为共享知识。\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 (digest(load(state)) !== plan.registry_digest) throw new OpsError("catalog drift since plan; replan before approval");
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
- result.policies = desired.policies;
128
- result.hosts = structuredClone(desired.hosts);
129
- result.projects = structuredClone(desired.projects);
130
- for (const did of plan.document_targets) {
131
- let proposed = structuredClone(desired.deployments[did]);
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 [aid, a] of Object.entries(desired.allocations)) {
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 [bid, b] of Object.entries(desired.bindings)) {
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].status !== "active" && item.status === "active") item.status = "planned";
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 expected = execution.committed_registry_digest || plan.registry_digest;
261
- if (digest(current) !== expected) throw new OpsError("controller registry changed since this approved run; compile a new plan");
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 });
@@ -12,6 +12,7 @@ import { allocationOperation } from "./services.mjs";
12
12
  import { credentialRefs, planReport, remotePaths } from "./docs.mjs";
13
13
  import { taskFiles } from "./native_windows.mjs";
14
14
  import { engineDigest } from "./execution.mjs";
15
+ import { assertSafeControlPath, defaultFileMode, reservedHostDocumentPaths } from "./control_files.mjs";
15
16
 
16
17
  export const plannerHooks = { call: transportCall };
17
18
 
@@ -90,7 +91,7 @@ export function composeModel(spec, host, root, name, envs, ctx) {
90
91
  const mounts = [];
91
92
  const allowed = new Set(["image", "build", "command", "entrypoint", "environment", "env_file", "volumes", "ports", "healthcheck", "depends_on",
92
93
  "restart", "user", "read_only", "tmpfs", "labels", "networks", "cap_drop", "security_opt", "deploy", "init", "working_dir",
93
- "mem_limit", "cpus", "stop_grace_period", "logging", "profiles"]);
94
+ "mem_limit", "cpus", "stop_grace_period", "logging", "profiles", "writable_root_justification"]);
94
95
  for (const [service, s] of Object.entries(model.services)) {
95
96
  identifier(service, "compose service");
96
97
  const unknown = Object.keys(s).filter((k) => !allowed.has(k));
@@ -108,8 +109,15 @@ export function composeModel(spec, host, root, name, envs, ctx) {
108
109
  }
109
110
  if (credentialsIn(s.environment || {}).size) throw new OpsError("credentials must be injected through env/ files, not inline Compose environment");
110
111
  if (s.restart === undefined) s.restart = "unless-stopped";
111
- if (s.read_only === false) throw new OpsError("writable container rootfs hides undeclared persistence; declare bind/tmpfs paths instead");
112
- s.read_only = true;
112
+ const justification = s.writable_root_justification;
113
+ delete s.writable_root_justification;
114
+ if (s.read_only === false) {
115
+ if (typeof justification !== "string" || justification.trim().length < 12) {
116
+ throw new OpsError("writable container rootfs hides undeclared persistence; declare bind/tmpfs paths instead");
117
+ }
118
+ } else {
119
+ s.read_only = true;
120
+ }
113
121
  if (s.tmpfs === undefined) s.tmpfs = ["/tmp", "/run"];
114
122
  const labels = s.labels && typeof s.labels === "object" && !Array.isArray(s.labels) ? s.labels : null;
115
123
  if (s.labels !== undefined && !labels) throw new OpsError("Compose labels must be a map");
@@ -155,6 +163,73 @@ export function composeModel(spec, host, root, name, envs, ctx) {
155
163
  return [dollars(model), mounts];
156
164
  }
157
165
 
166
+ export function catalogSlice(status, scope) {
167
+ if (!scope || typeof scope !== "object") throw new OpsError("plan missing registry_scope; compile a new plan");
168
+ const pick = (group) => Object.fromEntries((scope[group] || []).map((id) => [id, status[group]?.[id] ?? null]));
169
+ const slice = {
170
+ hosts: pick("hosts"),
171
+ projects: pick("projects"),
172
+ deployments: pick("deployments"),
173
+ allocations: pick("allocations"),
174
+ bindings: pick("bindings"),
175
+ };
176
+ if (scope.policies) slice.policies = status.policies;
177
+ if (scope.public_ingress) slice.public_ingress = status.public_ingress ?? null;
178
+ return slice;
179
+ }
180
+
181
+ export function sliceDigest(status, scope) {
182
+ return digest(catalogSlice(status, scope));
183
+ }
184
+
185
+ export function computeRegistryScope(current, after, selected, ops, uniqueDocs) {
186
+ const hosts = new Set(Object.keys(selected || {}));
187
+ const deployments = new Set(uniqueDocs || []);
188
+ const allocations = new Set();
189
+ const bindings = new Set();
190
+ const projects = new Set();
191
+ for (const op of ops || []) {
192
+ if (op.host_id) hosts.add(op.host_id);
193
+ if (op.deployment_id) deployments.add(op.deployment_id);
194
+ if (op.allocation_id) allocations.add(op.allocation_id);
195
+ }
196
+ const catalog = after || current;
197
+ for (const did of [...deployments]) {
198
+ const d = catalog.deployments?.[did] || current.deployments?.[did];
199
+ if (d) { hosts.add(d.host_id); projects.add(d.project_id); }
200
+ }
201
+ for (const [bid, b] of Object.entries(catalog.bindings || {})) {
202
+ if (deployments.has(b.consumer_deployment_id) || deployments.has(b.provider_deployment_id)) {
203
+ bindings.add(bid);
204
+ if (b.allocation_id) allocations.add(b.allocation_id);
205
+ if (b.provider_deployment_id) deployments.add(b.provider_deployment_id);
206
+ const provider = catalog.deployments?.[b.provider_deployment_id];
207
+ if (provider) { hosts.add(provider.host_id); projects.add(provider.project_id); }
208
+ const consumer = catalog.deployments?.[b.consumer_deployment_id];
209
+ if (consumer) { hosts.add(consumer.host_id); projects.add(consumer.project_id); }
210
+ }
211
+ }
212
+ for (const [aid, a] of Object.entries(catalog.allocations || {})) {
213
+ if (allocations.has(aid) || deployments.has(a.provider_deployment_id)) {
214
+ allocations.add(aid);
215
+ if (a.provider_deployment_id) deployments.add(a.provider_deployment_id);
216
+ }
217
+ }
218
+ for (const did of deployments) {
219
+ const d = catalog.deployments?.[did] || current.deployments?.[did];
220
+ if (d) { hosts.add(d.host_id); projects.add(d.project_id); }
221
+ }
222
+ return {
223
+ hosts: [...hosts].sort(),
224
+ projects: [...projects].sort(),
225
+ deployments: [...deployments].sort(),
226
+ allocations: [...allocations].sort(),
227
+ bindings: [...bindings].sort(),
228
+ policies: digest(current.policies) !== digest(after.policies),
229
+ public_ingress: digest(current.public_ingress ?? null) !== digest(after.public_ingress ?? null),
230
+ };
231
+ }
232
+
158
233
  export function compilePlan(state, specPath) {
159
234
  const spec = readJson(specPath);
160
235
  validate(spec, "spec");
@@ -173,6 +248,7 @@ export function compilePlan(state, specPath) {
173
248
  after[group][item[idkey]] = structuredClone(item);
174
249
  }
175
250
  }
251
+ if (spec.public_ingress !== undefined) after.public_ingress = structuredClone(spec.public_ingress);
176
252
  const rid = identifier(spec.run_id || newId("run"));
177
253
  if (rid in current.releases) throw new OpsError("run ID already exists");
178
254
  for (const [group, key] of [["allocations", "allocation_id"], ["bindings", "binding_id"]]) {
@@ -200,8 +276,8 @@ export function compilePlan(state, specPath) {
200
276
  ops.push(op);
201
277
  return op;
202
278
  };
203
- const fileOp = (hostId, did, path, { content = undefined, binary = undefined, mode = 0o600 } = {}) => {
204
- const data = { path, mode };
279
+ const fileOp = (hostId, did, path, { content = undefined, binary = undefined, mode = undefined } = {}) => {
280
+ const data = { path, mode: mode ?? defaultFileMode(path) };
205
281
  if (content !== undefined) data.content = content;
206
282
  else data.content_b64 = Buffer.from(binary).toString("base64");
207
283
  return add(hostId, did, "write", data);
@@ -270,23 +346,32 @@ export function compilePlan(state, specPath) {
270
346
  if (kind === "write-control" || kind === "write-file") {
271
347
  if (kind === "write-file") {
272
348
  const path = targetJoin(host, relative(item.path));
273
- if ("content" in item) fileOp(hid, null, path, { content: item.content, mode: item.mode ?? 0o600 });
349
+ if (reservedHostDocumentPaths(host, targetJoin).includes(path)) {
350
+ throw new OpsError("generated host documentation cannot be supplied as a host write-file: " + path);
351
+ }
352
+ if ("content" in item) fileOp(hid, null, path, { content: item.content, mode: item.mode });
274
353
  else if ("source" in item) {
275
354
  const src = resolve(item.source);
276
355
  noSymlinks(src, { allowMissing: false });
277
356
  if (statSync(src).size > 16 * 1024 * 1024) throw new OpsError("host file exceeds 16 MiB");
278
357
  const data = readFileSync(src);
279
358
  sourceDigests[src] = digest(data);
280
- fileOp(hid, null, path, { binary: data, mode: item.mode ?? 0o600 });
359
+ fileOp(hid, null, path, { binary: data, mode: item.mode });
281
360
  } else throw new OpsError("host write-file requires content/source");
282
361
  continue;
283
362
  }
284
363
  const path = item.path;
285
- if (path !== "/etc/docker/daemon.json" && !/^\/etc\/systemd\/system\/ops-[a-z0-9-]+\.service$/.test(path)) {
286
- throw new OpsError("unrecognized external control file; persistent data cannot be an exception");
364
+ const cls = assertSafeControlPath(path, { reason: item.reason, rollback: item.rollback || spec.rollback_note });
365
+ if (cls === "declared") {
366
+ if (!item.verification || !item.verification.length) {
367
+ throw new OpsError("declared control files require explicit post-verification");
368
+ }
287
369
  }
288
370
  (external[hid] ??= []).push(path);
289
- fileOp(hid, null, path, { content: item.content });
371
+ fileOp(hid, null, path, { content: item.content, mode: item.mode ?? 0o644 });
372
+ if (item.verification) {
373
+ for (const h of item.verification) healthOp(hid, null, h, { root: hostroot, host_root: hostroot });
374
+ }
290
375
  } else if (kind === "mkdir") add(hid, null, "mkdir", { path: targetJoin(host, relative(item.path)), mode: item.mode ?? 0o750 });
291
376
  else if (kind === "quarantine" || kind === "purge-quarantine") {
292
377
  const path = item.path;
@@ -400,7 +485,7 @@ export function compilePlan(state, specPath) {
400
485
  }
401
486
  const dest = projectPath(host, root, rel);
402
487
  if (("content" in f) === ("source" in f)) throw new OpsError("file requires exactly one of content/source");
403
- if ("content" in f) fileOp(hid, did, dest, { content: f.content, mode: f.mode ?? 0o600 });
488
+ if ("content" in f) fileOp(hid, did, dest, { content: f.content, mode: f.mode });
404
489
  else {
405
490
  let src = f.source;
406
491
  if (!isAbsolute(src)) src = join(dirname(specPath), src);
@@ -408,7 +493,7 @@ export function compilePlan(state, specPath) {
408
493
  if (!statSync(src).isFile() || statSync(src).size > 16 * 1024 * 1024) throw new OpsError("source must be a regular file <=16 MiB");
409
494
  const content = readFileSync(src);
410
495
  sourceDigests[resolve(src)] = digest(content);
411
- fileOp(hid, did, dest, { binary: content, mode: f.mode ?? 0o600 });
496
+ fileOp(hid, did, dest, { binary: content, mode: f.mode });
412
497
  }
413
498
  }
414
499
  const envs = d.env || {};
@@ -581,7 +666,14 @@ export function compilePlan(state, specPath) {
581
666
  for (const did of uniqueDocs) {
582
667
  if (after.deployments[did].host_id === hid) for (const p of remotePaths(after, after.deployments[did])) paths.add(p);
583
668
  }
584
- for (const p of [targetJoin(host, "knowledge/INDEX.md"), targetJoin(host, "README.md"), targetJoin(host, "DEPLOYMENTS.md"), targetJoin(host, "docs/standards/DEPLOYMENT-STANDARD.md")]) paths.add(p);
669
+ for (const p of [
670
+ targetJoin(host, "knowledge/INDEX.md"),
671
+ targetJoin(host, "knowledge/host-services.json"),
672
+ targetJoin(host, "knowledge/public-ingress.json"),
673
+ targetJoin(host, "README.md"),
674
+ targetJoin(host, "DEPLOYMENTS.md"),
675
+ targetJoin(host, "docs/standards/DEPLOYMENT-STANDARD.md"),
676
+ ]) paths.add(p);
585
677
  for (const did of Object.keys(specs)) {
586
678
  const dep = after.deployments[did];
587
679
  if (dep.host_id === hid) paths.add(dep.root);
@@ -663,9 +755,11 @@ export function compilePlan(state, specPath) {
663
755
  }
664
756
  const created = now();
665
757
  const expires = new Date(Date.now() + (spec.expires_hours ?? 24) * 3600 * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
758
+ const registry_scope = computeRegistryScope(current, after, selected, ops, uniqueDocs);
666
759
  const plan = {
667
760
  schema_version: 1, artifact: "ops-resource-plan", run_id: rid, worker: spec.worker, operation: spec.operation, reason: spec.reason,
668
- created_at: created, expires_at: expires, controller_id: current.controller.controller_id, registry_digest: digest(current), registry_revision: current.revision,
761
+ created_at: created, expires_at: expires, controller_id: current.controller.controller_id,
762
+ registry_digest: digest(current), registry_slice_digest: sliceDigest(current, registry_scope), registry_scope, registry_revision: current.revision,
669
763
  hosts: selected, transport_digests: Object.fromEntries(Object.entries(selected).map(([hid, h]) => [hid, hostTransportDigest(h)])),
670
764
  inventories, operations: ops, registry_after: after, credential_versions: cv, document_targets: uniqueDocs, document_preconditions: docPreconditions,
671
765
  allow_adopt_roots: spec.allow_adopt_roots || [], external_files: external, affected_consumers: [...affected].sort(), rollback_note: spec.rollback_note,
@@ -83,7 +83,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
83
83
  let p;
84
84
  try {
85
85
  p = transportHooks.spawnSync(argv[0], argv.slice(1), {
86
- input: content,
86
+ input: Buffer.from(content, "utf8"),
87
87
  encoding: "buffer",
88
88
  timeout: timeout * 1000,
89
89
  maxBuffer: 64 * 1024 * 1024,