@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,781 @@
1
+ /** Compile user-reviewed specifications into immutable, identity-bound execution plans. */
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative as pathRelative, resolve } from "node:path";
4
+ import { posix, win32 } from "node:path";
5
+ import {
6
+ atomicWrite, canonical, credentialsIn, digest, identifier, newId, now, noSymlinks,
7
+ OpsError, readJson, relative, targetJoin, within, withLock, writeJson,
8
+ } from "./core.mjs";
9
+ import { deploymentRoot, load, ledgerLoad, validate, validateStatus } from "./model.mjs";
10
+ import { call as transportCall, hostTransportDigest } from "./transport.mjs";
11
+ import { allocationOperation } from "./services.mjs";
12
+ import { credentialRefs, planReport, remotePaths } from "./docs.mjs";
13
+ import { taskFiles } from "./native_windows.mjs";
14
+ import { engineDigest } from "./execution.mjs";
15
+ import { assertSafeControlPath, defaultFileMode, reservedHostDocumentPaths } from "./control_files.mjs";
16
+
17
+ export const plannerHooks = { call: transportCall };
18
+
19
+ export function runDir(state, plan) {
20
+ if (["I", "H"].includes(plan.worker) && Object.keys(plan.hosts).length === 1) {
21
+ return join(state, "hosts", Object.keys(plan.hosts)[0], "runs", plan.run_id);
22
+ }
23
+ return join(state, "releases", plan.run_id);
24
+ }
25
+
26
+ export function locatePlan(state, value) {
27
+ if (existsSync(value) && statSync(value).isFile()) {
28
+ const resolved = resolve(value);
29
+ noSymlinks(resolved, { allowMissing: false });
30
+ const rel = pathRelative(resolve(state), resolved);
31
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new OpsError("plan must belong to the selected controller state root");
32
+ return resolved;
33
+ }
34
+ identifier(value, "run_id");
35
+ const matches = [];
36
+ const hostsDir = join(state, "hosts");
37
+ if (existsSync(hostsDir)) {
38
+ for (const hid of readdirSync(hostsDir)) {
39
+ const p = join(hostsDir, hid, "runs", value, "plan.json");
40
+ if (existsSync(p)) matches.push(p);
41
+ }
42
+ }
43
+ const direct = join(state, "releases", value, "plan.json");
44
+ if (existsSync(direct)) matches.push(direct);
45
+ if (matches.length !== 1) throw new OpsError("run_id must resolve to exactly one stored plan");
46
+ return matches[0];
47
+ }
48
+
49
+ export function templates(value, ctx, status) {
50
+ if (typeof value === "string") {
51
+ for (const [k, v] of Object.entries(ctx)) value = value.split("{{" + k + "}}").join(v);
52
+ return value.replace(/\{\{(binding|allocation):([a-z0-9-]+):([a-z_]+)\}\}/g, (_m, kind, key, field) => {
53
+ const table = status[kind === "binding" ? "bindings" : "allocations"];
54
+ if (!table[key] || typeof table[key][field] !== "string") throw new OpsError("unknown binding/allocation substitution");
55
+ return table[key][field];
56
+ });
57
+ }
58
+ if (Array.isArray(value)) return value.map((x) => templates(x, ctx, status));
59
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, templates(v, ctx, status)]));
60
+ return value;
61
+ }
62
+
63
+ export function projectPath(host, root, rel) {
64
+ const result = targetJoin({ ...host, root }, rel);
65
+ if (!within(result, root, host.platform)) throw new OpsError("project path escapes root");
66
+ return result;
67
+ }
68
+
69
+ export function envFile(values, { systemd = false } = {}) {
70
+ const lines = [];
71
+ for (const k of Object.keys(values).sort()) {
72
+ let v = values[k];
73
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || typeof v !== "string" || /[\r\n\x00]/.test(v)) {
74
+ throw new OpsError("env files require valid keys and single-line strings; multiline values belong in mounted files");
75
+ }
76
+ if (systemd) v = '"' + v.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + '"';
77
+ lines.push(k + "=" + v);
78
+ }
79
+ return lines.join("\n") + "\n";
80
+ }
81
+
82
+ function pyList(items) {
83
+ return `[${[...items].sort().map((x) => `'${x}'`).join(", ")}]`;
84
+ }
85
+
86
+ export function composeModel(spec, host, root, name, envs, ctx) {
87
+ const model = structuredClone(spec.compose);
88
+ if (![null, undefined, name].includes(model.name)) throw new OpsError("Compose identity cannot override registered deployment identity");
89
+ model.name = name;
90
+ if (!model.services || !Object.keys(model.services).length) throw new OpsError("Compose deployment needs at least one service");
91
+ const mounts = [];
92
+ const allowed = new Set(["image", "build", "command", "entrypoint", "environment", "env_file", "volumes", "ports", "healthcheck", "depends_on",
93
+ "restart", "user", "read_only", "tmpfs", "labels", "networks", "cap_drop", "security_opt", "deploy", "init", "working_dir",
94
+ "mem_limit", "cpus", "stop_grace_period", "logging", "profiles", "writable_root_justification"]);
95
+ for (const [service, s] of Object.entries(model.services)) {
96
+ identifier(service, "compose service");
97
+ const unknown = Object.keys(s).filter((k) => !allowed.has(k));
98
+ if (unknown.length) throw new OpsError("unsupported or unsafe Compose fields: " + pyList(unknown));
99
+ if (!("image" in s) && !("build" in s)) throw new OpsError("service requires a pinned image or build");
100
+ if (!("build" in s) && !/^[^\s]+@sha256:[a-f0-9]{64}$/.test(s.image)) {
101
+ throw new OpsError("production Compose images must be pinned by sha256 digest, not floating tags");
102
+ }
103
+ if ("build" in s) {
104
+ if (!s.build || typeof s.build !== "object" || Object.keys(s.build).some((k) => !["context", "dockerfile", "args", "target"].includes(k))) {
105
+ throw new OpsError("build needs an explicit local context and Dockerfile");
106
+ }
107
+ s.build.context = projectPath(host, root, "compose/build");
108
+ s.build.dockerfile = projectPath(host, root, "compose/Dockerfile");
109
+ }
110
+ if (credentialsIn(s.environment || {}).size) throw new OpsError("credentials must be injected through env/ files, not inline Compose environment");
111
+ if (s.restart === undefined) s.restart = "unless-stopped";
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
+ }
121
+ if (s.tmpfs === undefined) s.tmpfs = ["/tmp", "/run"];
122
+ const labels = s.labels && typeof s.labels === "object" && !Array.isArray(s.labels) ? s.labels : null;
123
+ if (s.labels !== undefined && !labels) throw new OpsError("Compose labels must be a map");
124
+ s.labels = labels || {};
125
+ Object.assign(s.labels, { "ops.managed": "true", "ops.deployment": name });
126
+ let files = s.env_file || [];
127
+ if (typeof files === "string") files = [files];
128
+ if (!files.length && Object.keys(envs).length === 1) files = Object.keys(envs);
129
+ const converted = [];
130
+ for (let f of files) {
131
+ if (typeof f !== "string") throw new OpsError("env_file input must name files from this project's env map");
132
+ if (f.startsWith("env/")) f = f.slice(4);
133
+ if (!(f in envs)) throw new OpsError("Compose references an undeclared environment file: " + f);
134
+ converted.push({ path: projectPath(host, root, "env/" + f), required: true, format: "raw" });
135
+ }
136
+ if (converted.length) s.env_file = converted;
137
+ for (const mount of s.volumes || []) {
138
+ if (!mount || typeof mount !== "object" || Object.keys(mount).some((k) => !["type", "source", "target", "read_only", "bind", "consistency"].includes(k))) {
139
+ throw new OpsError("volume must use explicit safe long syntax");
140
+ }
141
+ if (mount.type !== "bind") throw new OpsError("named/anonymous volumes violate the fixed persistence-root contract");
142
+ let source = mount.source;
143
+ if (!source.startsWith("/") && !source.startsWith("\\") && !/^[A-Za-z]:/.test(source)) source = projectPath(host, root, source);
144
+ if (!within(source, root, host.platform)) throw new OpsError("bind mount outside this project's root");
145
+ const adapter = host.platform === "windows" ? win32 : posix;
146
+ const rel = adapter.relative(root, source).replaceAll("\\", "/");
147
+ const area = rel.split("/")[0];
148
+ if (!["data", "logs", "config", "env", "backups", "run"].includes(area)) throw new OpsError("mount must be under data/logs/config/env/backups/run");
149
+ if (!mount.read_only && !["data", "logs", "backups", "run"].includes(area)) throw new OpsError("config/env mounts must be read-only");
150
+ if (area === "data" && rel.split("/").length < 3) throw new OpsError("data needs component/purpose naming");
151
+ if (typeof mount.target !== "string" || !mount.target.startsWith("/")) throw new OpsError("container target must be absolute");
152
+ mount.source = source;
153
+ mount.bind = { create_host_path: false };
154
+ mounts.push([source, area, mount.read_only ?? false]);
155
+ }
156
+ }
157
+ const dollars = (v) => {
158
+ if (typeof v === "string") return v.replaceAll("$", "$$");
159
+ if (Array.isArray(v)) return v.map(dollars);
160
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, dollars(x)]));
161
+ return v;
162
+ };
163
+ return [dollars(model), mounts];
164
+ }
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
+
233
+ export function compilePlan(state, specPath) {
234
+ const spec = readJson(specPath);
235
+ validate(spec, "spec");
236
+ return withLock(join(state, ".locks", "catalog"), { operation: "plan" }, () => {
237
+ const current = load(state);
238
+ if (current.controller == null) throw new OpsError("initialize the controller first");
239
+ const after = structuredClone(current);
240
+ const ledger = ledgerLoad(state);
241
+ const revisions = spec.resource_updates || {};
242
+ for (const [group, idkey, fixed] of [["hosts", "host_id", ["host_id", "root", "identity", "platform"]], ["projects", "project_id", ["project_id", "kind", "service_type"]]]) {
243
+ for (const item of revisions[group] || []) {
244
+ const previous = current[group][item[idkey]];
245
+ if (previous == null || fixed.some((k) => previous[k] !== item[k])) {
246
+ throw new OpsError("resource update cannot change identity/root/kind; create explicit migration resources");
247
+ }
248
+ after[group][item[idkey]] = structuredClone(item);
249
+ }
250
+ }
251
+ if (spec.public_ingress !== undefined) after.public_ingress = structuredClone(spec.public_ingress);
252
+ const rid = identifier(spec.run_id || newId("run"));
253
+ if (rid in current.releases) throw new OpsError("run ID already exists");
254
+ for (const [group, key] of [["allocations", "allocation_id"], ["bindings", "binding_id"]]) {
255
+ for (const item of spec[group] || []) {
256
+ if (item[key] in after[group] && digest(after[group][item[key]]) !== digest(item)) {
257
+ if (group === "allocations") throw new OpsError("allocation identity/ownership changes require new resource and migration");
258
+ if (!["migrate", "upgrade", "rollback"].includes(spec.operation)) throw new OpsError("binding update requires migration/upgrade/rollback plan");
259
+ }
260
+ after[group][item[key]] = structuredClone(item);
261
+ }
262
+ }
263
+ for (const bid of spec.retire_bindings || []) {
264
+ if (!(bid in after.bindings)) throw new OpsError("unknown binding to retire");
265
+ after.bindings[bid].status = "retired";
266
+ }
267
+ const hosts = new Set([...(spec.hosts || []), ...(revisions.hosts || []).map((h) => h.host_id)]);
268
+ const ops = [];
269
+ const external = {};
270
+ const sourceDigests = {};
271
+ const specs = {};
272
+ const docTargets = [];
273
+ const add = (hostId, did, kind, kw = {}) => {
274
+ hosts.add(hostId);
275
+ const op = { step_id: `step-${String(ops.length + 1).padStart(4, "0")}`, host_id: hostId, deployment_id: did, kind, ...kw };
276
+ ops.push(op);
277
+ return op;
278
+ };
279
+ const fileOp = (hostId, did, path, { content = undefined, binary = undefined, mode = undefined } = {}) => {
280
+ const data = { path, mode: mode ?? defaultFileMode(path) };
281
+ if (content !== undefined) data.content = content;
282
+ else data.content_b64 = Buffer.from(binary).toString("base64");
283
+ return add(hostId, did, "write", data);
284
+ };
285
+ const healthOp = (hostId, did, item, ctx) => {
286
+ const h = templates(item, ctx, after);
287
+ const typ = h.type;
288
+ delete h.type;
289
+ if (typ === "command") {
290
+ if (!h.argv) throw new OpsError("command health check needs argv");
291
+ if (credentialsIn(h.argv).size) throw new OpsError("do not put credentials in command argv");
292
+ const extra = "stdout_pattern" in h ? { stdout_pattern: h.stdout_pattern } : {};
293
+ return add(hostId, did, "verify-command", { argv: h.argv, cwd: ctx.root, timeout: h.timeout ?? 60, env: h.env || {}, ...extra });
294
+ }
295
+ if (typ === "file") {
296
+ let p = h.path;
297
+ if (!within(p, ctx.root, after.hosts[hostId].platform)) p = projectPath(after.hosts[hostId], ctx.root, p);
298
+ return add(hostId, did, "assert-file", { path: p, ...("sha256" in h ? { sha256: h.sha256 } : {}) });
299
+ }
300
+ return add(hostId, did, "health", { type: typ, ...h });
301
+ };
302
+ for (const d of spec.deployments || []) {
303
+ const did = d.deployment_id;
304
+ if (did in specs) throw new OpsError("duplicate deployment in specification");
305
+ specs[did] = d;
306
+ const hid = d.host_id;
307
+ hosts.add(hid);
308
+ if (!(hid in after.hosts) || !(d.project_id in after.projects)) throw new OpsError("register host and project before planning");
309
+ const host = after.hosts[hid];
310
+ const p = after.projects[d.project_id];
311
+ const root = deploymentRoot(host, d.project_id, d.environment, d.instance, d.layout);
312
+ const old = current.deployments[did];
313
+ if (old && ["project_id", "host_id", "environment", "instance", "layout"].some((k) => old[k] !== d[k])) {
314
+ throw new OpsError("deployment identity/path change: create a new deployment and an explicit data-migration plan");
315
+ }
316
+ if (old && spec.operation === "deploy") throw new OpsError("existing deployment requires upgrade/rollback/maintain, not fresh deploy");
317
+ after.deployments[did] = {
318
+ deployment_id: did, project_id: d.project_id, host_id: hid, environment: d.environment,
319
+ instance: d.instance, layout: d.layout, method: d.method, version: d.version,
320
+ observed_version: old ? old.observed_version : null, root, status: "planned",
321
+ installed_at: old ? old.installed_at : null, updated_at: null, run_id: rid,
322
+ compose_name: d.method === "compose" ? "ops-" + did : null, storage: [],
323
+ commands: { start: [], stop: [], verify: [] },
324
+ credential_refs: d.credential_refs || [], backup: d.backup, recovery: d.recovery,
325
+ notes: d.notes || [], source: p.source, service: d.service || {},
326
+ };
327
+ docTargets.push(did);
328
+ }
329
+ const affected = new Set();
330
+ const touchedProvider = new Set([...Object.keys(specs), ...(spec.retire_deployments || [])]);
331
+ const touchedHosts = new Set((spec.host_actions || []).map((x) => x.host_id));
332
+ for (const [did, d] of Object.entries(current.deployments)) {
333
+ if (touchedHosts.has(d.host_id) && d.status !== "retired") { affected.add(did); touchedProvider.add(did); docTargets.push(did); }
334
+ }
335
+ for (const b of Object.values(current.bindings)) {
336
+ if (b.status === "active" && touchedProvider.has(b.provider_deployment_id)) affected.add(b.consumer_deployment_id);
337
+ }
338
+ const missingAck = [...affected].filter((x) => !(spec.acknowledged_consumers || []).includes(x)).sort();
339
+ if (missingAck.length) throw new OpsError("maintenance impact must be acknowledged for consumers: " + missingAck.join(", "));
340
+ for (const item of spec.host_actions || []) {
341
+ const hid = item.host_id;
342
+ if (!(hid in after.hosts)) throw new OpsError("unknown host action target");
343
+ const host = after.hosts[hid];
344
+ const kind = item.kind;
345
+ const hostroot = host.root;
346
+ if (kind === "write-control" || kind === "write-file") {
347
+ if (kind === "write-file") {
348
+ const path = targetJoin(host, relative(item.path));
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 });
353
+ else if ("source" in item) {
354
+ const src = resolve(item.source);
355
+ noSymlinks(src, { allowMissing: false });
356
+ if (statSync(src).size > 16 * 1024 * 1024) throw new OpsError("host file exceeds 16 MiB");
357
+ const data = readFileSync(src);
358
+ sourceDigests[src] = digest(data);
359
+ fileOp(hid, null, path, { binary: data, mode: item.mode });
360
+ } else throw new OpsError("host write-file requires content/source");
361
+ continue;
362
+ }
363
+ const path = item.path;
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
+ }
369
+ }
370
+ (external[hid] ??= []).push(path);
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
+ }
375
+ } else if (kind === "mkdir") add(hid, null, "mkdir", { path: targetJoin(host, relative(item.path)), mode: item.mode ?? 0o750 });
376
+ else if (kind === "quarantine" || kind === "purge-quarantine") {
377
+ const path = item.path;
378
+ if (!within(path, hostroot, host.platform)) throw new OpsError("cleanup must target a registered cache/log path under host_root");
379
+ if (kind === "purge-quarantine" && !within(path, targetJoin(host, "_host/quarantine"), host.platform)) {
380
+ throw new OpsError("purge can only target one previously isolated quarantine item");
381
+ }
382
+ add(hid, null, kind, { path, item_id: `item-${String(ops.length + 1).padStart(4, "0")}` });
383
+ } else if (kind === "defaults") add(hid, null, "defaults", { expected: item.expected_defaults });
384
+ else {
385
+ const argv = item.argv;
386
+ if (!argv || credentialsIn(argv).size) throw new OpsError("host command needs argv without plaintext credential arguments");
387
+ const cwd = item.cwd ?? hostroot;
388
+ if (cwd !== hostroot && !within(cwd, hostroot, host.platform)) throw new OpsError("host action cwd outside root");
389
+ if (!item.writes) throw new OpsError("host command needs explicit write-set declaration");
390
+ for (const path of item.writes) {
391
+ if (!within(path, hostroot, host.platform) && !(external[hid] || []).includes(path)) {
392
+ if (kind !== "install-toolchain") throw new OpsError("host command persistent writes outside root");
393
+ }
394
+ }
395
+ if (kind === "install-toolchain" && !item.expected_defaults) throw new OpsError("toolchain migration requires explicit old default expectations");
396
+ add(hid, null, "command", { argv, cwd, env: item.env || {}, timeout: item.timeout ?? 1800, declared_writes: item.writes, reason: item.reason });
397
+ if (!item.verification) throw new OpsError("host mutations require explicit post-verification");
398
+ for (const h of item.verification) healthOp(hid, null, h, { root: hostroot, host_root: hostroot });
399
+ if (item.expected_defaults) add(hid, null, "defaults", { expected: item.expected_defaults });
400
+ }
401
+ }
402
+ const provisions = {};
403
+ for (const p of spec.provision || []) {
404
+ if (!(p.allocation_id in after.allocations)) throw new OpsError("provision references unknown allocation");
405
+ const a = after.allocations[p.allocation_id];
406
+ if (a.status !== "planned") throw new OpsError("active allocations are reused, not reprovisioned or password-reset");
407
+ (provisions[a.provider_deployment_id] ??= []).push(p);
408
+ }
409
+ const provisionFor = (provider) => {
410
+ if (!(provider in after.deployments)) throw new OpsError("provider deployment not found");
411
+ const dep = after.deployments[provider];
412
+ const host = after.hosts[dep.host_id];
413
+ for (const p of provisions[provider] || []) {
414
+ const a = after.allocations[p.allocation_id];
415
+ const adapter = p.adapter;
416
+ if (adapter === "existing") {
417
+ if (!("existing_verification" in p)) throw new OpsError("adopted allocation requires actual verification");
418
+ const h = healthOp(dep.host_id, provider, p.existing_verification, { root: dep.root, host_root: host.root });
419
+ h.allocation_id = a.allocation_id;
420
+ } else {
421
+ const [cid, version] = a.credential_ref.split("@");
422
+ const account = ledger.entries?.[cid]?.[version]?.values?.username;
423
+ if (account === undefined) throw new OpsError("allocation credential must contain the actual username and password");
424
+ if (account !== p.app_username) throw new OpsError("allocation username differs from plaintext ledger username");
425
+ const op = allocationOperation(after, dep, a, p);
426
+ const kind = op.kind;
427
+ delete op.kind;
428
+ add(dep.host_id, provider, kind, { allocation_id: a.allocation_id, ...op });
429
+ }
430
+ }
431
+ };
432
+ for (const provider of Object.keys(provisions)) {
433
+ if (!(provider in specs)) {
434
+ if (!["completed", "running", "docs_pending"].includes(after.deployments[provider].status)) throw new OpsError("existing provider is not verified active");
435
+ provisionFor(provider);
436
+ docTargets.push(provider);
437
+ }
438
+ }
439
+ const ordered = [];
440
+ const visiting = new Set();
441
+ const visit = (did) => {
442
+ if (ordered.includes(did)) return;
443
+ if (visiting.has(did)) throw new OpsError("dependency cycle");
444
+ visiting.add(did);
445
+ for (const b of Object.values(after.bindings)) {
446
+ if (b.status === "active" && b.consumer_deployment_id === did && b.provider_deployment_id in specs) visit(b.provider_deployment_id);
447
+ }
448
+ visiting.delete(did);
449
+ ordered.push(did);
450
+ };
451
+ for (const did of Object.keys(specs)) visit(did);
452
+ for (const did of ordered) {
453
+ let d = specs[did];
454
+ const dep = after.deployments[did];
455
+ const host = after.hosts[dep.host_id];
456
+ const hid = host.host_id;
457
+ const root = dep.root;
458
+ const ctx = {
459
+ root, host_root: host.root, data: projectPath(host, root, "data"), env: projectPath(host, root, "env"),
460
+ logs: projectPath(host, root, "logs"), artifact: projectPath(host, root, `releases/${rid}/artifact`), run_id: rid,
461
+ };
462
+ d = templates(d, ctx, after);
463
+ for (const path of [root, ...["env", "data", "config", "logs", "backups/owned", "backups/dependencies", "run", `releases/${rid}/artifact`].map((x) => projectPath(host, root, x))]) {
464
+ add(hid, did, "mkdir", { path });
465
+ }
466
+ const marker = { deployment_id: did, project_id: dep.project_id, host_id: hid, environment: dep.environment, instance: dep.instance };
467
+ fileOp(hid, did, projectPath(host, root, ".ops-project.json"), { content: JSON.stringify(marker, null, 2) + "\n" });
468
+ for (const item of d.storage || []) {
469
+ const area = item.area || "data";
470
+ const rel = `${area}/${item.component}/${item.purpose}`;
471
+ const path = projectPath(host, root, rel);
472
+ const extra = {};
473
+ for (const k of ["uid", "gid", "mode"]) if (k in item) extra[k] = item[k];
474
+ add(hid, did, "mkdir", { path, ...extra });
475
+ dep.storage.push({ component: item.component, purpose: item.purpose, path });
476
+ }
477
+ for (const f of d.files || []) {
478
+ let rel = relative(f.path);
479
+ if (["README.md", "OPERATIONS.md", "project.yaml", ".ops-project.json"].includes(rel) || rel.startsWith("run/") || rel.startsWith("env/")) {
480
+ throw new OpsError("generated ownership/docs/env paths cannot be supplied as arbitrary files");
481
+ }
482
+ if (rel.startsWith("artifact/")) rel = `releases/${rid}/` + rel;
483
+ else if (!["compose", "config", "scripts", "service", "data"].includes(rel.split("/")[0])) {
484
+ throw new OpsError("project files must use artifact/compose/config/scripts/service/data");
485
+ }
486
+ const dest = projectPath(host, root, rel);
487
+ if (("content" in f) === ("source" in f)) throw new OpsError("file requires exactly one of content/source");
488
+ if ("content" in f) fileOp(hid, did, dest, { content: f.content, mode: f.mode });
489
+ else {
490
+ let src = f.source;
491
+ if (!isAbsolute(src)) src = join(dirname(specPath), src);
492
+ noSymlinks(src, { allowMissing: false });
493
+ if (!statSync(src).isFile() || statSync(src).size > 16 * 1024 * 1024) throw new OpsError("source must be a regular file <=16 MiB");
494
+ const content = readFileSync(src);
495
+ sourceDigests[resolve(src)] = digest(content);
496
+ fileOp(hid, did, dest, { binary: content, mode: f.mode });
497
+ }
498
+ }
499
+ const envs = d.env || {};
500
+ for (const values of [...Object.values(envs), d.native?.environment || {}]) {
501
+ for (const [key, value] of Object.entries(values)) {
502
+ if (/(?:DATA|UPLOAD|CACHE|LOG|TMP|TEMP|HOME|DIR|STORAGE)/i.test(key) && key !== "JAVA_HOME") {
503
+ if ((value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) && !within(value, root, host.platform)) {
504
+ throw new OpsError("persistent environment path outside project root: " + key);
505
+ }
506
+ }
507
+ if (value.startsWith("sqlite:///")) {
508
+ const sqlitePath = value.slice("sqlite:///".length);
509
+ if (sqlitePath.startsWith("/") && !within(sqlitePath, root, host.platform)) throw new OpsError("SQLite URL escapes project persistence root");
510
+ }
511
+ }
512
+ }
513
+ for (const [filename, values] of Object.entries(envs)) {
514
+ if (relative(filename).includes("/") || !(filename === ".env" || filename.endsWith(".env"))) {
515
+ throw new OpsError("environment filenames must be .env or *.env");
516
+ }
517
+ const envop = fileOp(hid, did, projectPath(host, root, "env/" + filename), { content: envFile(values) });
518
+ envop.env_values = values;
519
+ envop.env_format = "raw";
520
+ }
521
+ const runtimeEnv = {
522
+ OPS_PROJECT_ROOT: root, OPS_DATA_ROOT: ctx.data, OPS_LOG_ROOT: ctx.logs, OPS_RUN_ROOT: projectPath(host, root, "run"),
523
+ XDG_DATA_HOME: projectPath(host, root, "data/app/storage"), XDG_CACHE_HOME: projectPath(host, root, "run/cache"),
524
+ HOME: projectPath(host, root, "data/app/home"), USERPROFILE: projectPath(host, root, "data/app/home"),
525
+ XDG_CONFIG_HOME: projectPath(host, root, "data/app/settings"), APPDATA: projectPath(host, root, "data/app/settings"), LOCALAPPDATA: projectPath(host, root, "data/app/settings"),
526
+ UV_CACHE_DIR: projectPath(host, root, "run/cache/uv"), PIP_CACHE_DIR: projectPath(host, root, "run/cache/pip"), npm_config_cache: projectPath(host, root, "run/cache/npm"),
527
+ TMPDIR: projectPath(host, root, "run/tmp"), TMP: projectPath(host, root, "run/tmp"), TEMP: projectPath(host, root, "run/tmp"),
528
+ };
529
+ if (d.method === "compose") {
530
+ if (!("compose" in d)) throw new OpsError("Compose method requires a model");
531
+ const [model, mounts] = composeModel(d, host, root, dep.compose_name, envs, ctx);
532
+ const written = new Set(ops.filter((o) => o.kind === "write").map((o) => o.path));
533
+ for (const [path, area] of mounts) {
534
+ if (!written.has(path)) add(hid, did, "mkdir", { path });
535
+ if (area === "data" && !dep.storage.some((x) => x.path === path)) {
536
+ const parts = path.replaceAll("\\", "/").split("/");
537
+ dep.storage.push({ component: parts.at(-2), purpose: parts.at(-1), path });
538
+ }
539
+ }
540
+ fileOp(hid, did, projectPath(host, root, "compose/compose.yaml"), { content: JSON.stringify(model, null, 2) + "\n" });
541
+ fileOp(hid, did, projectPath(host, root, "compose/build/.dockerignore"), { content: ".git\n.env\n*.env\ndata/\nbackups/\nOPERATIONS.md\nprivate/\n" });
542
+ add(hid, did, "compose-up", { project_root: root, compose_name: dep.compose_name });
543
+ const base = ["docker", "compose", "--project-name", dep.compose_name, "--project-directory", root, "--file", projectPath(host, root, "compose/compose.yaml")];
544
+ dep.commands.start = [base.concat(["up", "-d", "--wait"])];
545
+ dep.commands.stop = [base.concat(["stop"])];
546
+ dep.commands.verify = [base.concat(["ps"])];
547
+ } else {
548
+ if (!("native" in d)) throw new OpsError("native method requires supervisor and argv");
549
+ const n = d.native;
550
+ const argv = n.argv;
551
+ if (credentialsIn(argv).size) throw new OpsError("native credentials belong in env files, not argv");
552
+ if (argv.slice(1).some((x) => ["-c", "-e", "--eval"].includes(x))) throw new OpsError("native application code must be a fixed artifact, not inline eval");
553
+ for (const arg of argv.slice(1)) {
554
+ const candidate = arg.split("=").pop();
555
+ if ((candidate.startsWith("/") || /^[A-Za-z]:[\\/]/.test(candidate)) && !within(candidate, root, host.platform)) {
556
+ throw new OpsError("native argument references a path outside the project root: " + candidate);
557
+ }
558
+ }
559
+ if (!within(argv[0], root, host.platform) && !(["python3", "python", "java", "node", "bash", "sh", "dotnet"].includes(argv[0]) || isAbsolute(argv[0]))) {
560
+ throw new OpsError("native executable must be explicit or a recognized runtime");
561
+ }
562
+ if (Object.keys(n.environment || {}).some((k) => k in runtimeEnv)) throw new OpsError("native environment cannot override persistence roots");
563
+ const selectedEnvs = n.env_files ?? (Object.keys(envs).length === 1 ? Object.keys(envs) : []);
564
+ if (Object.keys(envs).length > 1 && !("env_files" in n)) throw new OpsError("native deployment with multiple env files requires explicit env_files order");
565
+ const combined = {};
566
+ for (const filename of selectedEnvs) {
567
+ if (!(filename in envs)) throw new OpsError("native references an undeclared env file");
568
+ for (const [key, value] of Object.entries(envs[filename])) {
569
+ if (key in combined && combined[key] !== value) throw new OpsError("conflicting native environment values; reconcile explicitly");
570
+ combined[key] = value;
571
+ }
572
+ }
573
+ Object.assign(combined, n.environment || {});
574
+ if (Object.keys(combined).some((k) => k in runtimeEnv)) throw new OpsError("native env files cannot override reserved persistence roots");
575
+ Object.assign(runtimeEnv, combined);
576
+ for (const rel of ["run/tmp", "run/cache", "data/app/storage", "data/app/home", "data/app/settings", "logs/app"]) {
577
+ add(hid, did, "mkdir", { path: projectPath(host, root, rel) });
578
+ }
579
+ if (!dep.storage.some((x) => x.path === runtimeEnv.XDG_DATA_HOME)) {
580
+ dep.storage.push({ component: "app", purpose: "storage", path: runtimeEnv.XDG_DATA_HOME });
581
+ }
582
+ const supervisor = n.supervisor;
583
+ if (supervisor === "systemd") {
584
+ if (host.platform !== "linux") throw new OpsError("systemd is a Linux adapter");
585
+ if (argv.some((x) => x.includes("\n") || x.includes("\r"))) throw new OpsError("newline in systemd argv");
586
+ const unit = "ops-" + did + ".service";
587
+ const unitpath = "/etc/systemd/system/" + unit;
588
+ const account = n.account;
589
+ if (!account || !/^[a-z_][a-z0-9_-]*[$]?$/.test(account)) throw new OpsError("native systemd requires an explicit service account");
590
+ const envpath = projectPath(host, root, "env/service.env");
591
+ const envop = fileOp(hid, did, envpath, { content: envFile(runtimeEnv, { systemd: true }) });
592
+ envop.env_values = runtimeEnv;
593
+ envop.env_format = "systemd";
594
+ const quote = (s) => '"' + s.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%") + '"';
595
+ const content = "[Unit]\nDescription=OPS " + did + "\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=" + account + "\nWorkingDirectory=" + quote(ctx.artifact) + "\nEnvironmentFile=" + quote(envpath) + "\nExecStart=:" + argv.map(quote).join(" ") + "\nRestart=on-failure\nUMask=0027\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=read-only\nReadWritePaths=" + ["data", "logs", "run", "backups"].map((x) => quote(projectPath(host, root, x))).join(" ") + "\nStandardOutput=append:" + projectPath(host, root, "logs/app/stdout.log") + "\nStandardError=append:" + projectPath(host, root, "logs/app/stderr.log") + "\n\n[Install]\nWantedBy=multi-user.target\n";
596
+ fileOp(hid, did, projectPath(host, root, "service/" + unit), { content, mode: 0o644 });
597
+ (external[hid] ??= []).push(unitpath);
598
+ fileOp(hid, did, unitpath, { content, mode: 0o644 });
599
+ add(hid, did, "grant-runtime", { project_root: root, account, directories: [`releases/${rid}/artifact`, "config", "scripts", "data", "logs", "run"] });
600
+ for (const a of [["systemctl", "daemon-reload"], ["systemctl", "enable", unit], ["systemctl", "restart", unit], ["systemctl", "is-active", "--quiet", unit]]) {
601
+ add(hid, did, a[1] !== "is-active" ? "command" : "verify-command", { argv: a, cwd: root });
602
+ }
603
+ dep.commands = { start: [["systemctl", "start", unit]], stop: [["systemctl", "stop", unit]], verify: [["systemctl", "status", unit]] };
604
+ } else if (supervisor === "windows-task") {
605
+ if (host.platform !== "windows") throw new OpsError("Windows task adapter requires a native Windows host");
606
+ for (const [rel, content] of Object.entries(taskFiles(did, root, argv, runtimeEnv, n))) {
607
+ const taskop = fileOp(hid, did, projectPath(host, root, rel), { content });
608
+ if (rel.endsWith("task.json")) taskop.json_values = JSON.parse(content);
609
+ }
610
+ const a = ["powershell", "-NoProfile", "-NonInteractive", "-File", projectPath(host, root, "service/install-task.ps1")];
611
+ add(hid, did, "command", { argv: a, cwd: root });
612
+ dep.commands = { start: [["schtasks", "/Run", "/TN", "OPS-" + did]], stop: [["schtasks", "/End", "/TN", "OPS-" + did]], verify: [["schtasks", "/Query", "/TN", "OPS-" + did, "/V"]] };
613
+ } else {
614
+ add(hid, did, "command", { argv, cwd: root, env: runtimeEnv, timeout: n.timeout ?? 300, declared_writes: [ctx.data, ctx.logs, projectPath(host, root, "run")] });
615
+ dep.commands = { start: [argv], stop: [], verify: [] };
616
+ dep.notes.push("Native oneshot: successful finite application run, not a resident daemon.");
617
+ }
618
+ }
619
+ for (const h of d.health) healthOp(hid, did, h, ctx);
620
+ const snapshot = { run_id: rid, version: dep.version, source: dep.source, planned_at: now(), method: dep.method };
621
+ fileOp(hid, did, projectPath(host, root, `releases/${rid}/release.json`), { content: JSON.stringify(snapshot, null, 2) + "\n" });
622
+ provisionFor(did);
623
+ }
624
+ for (const did of spec.retire_deployments || []) {
625
+ if (!(did in after.deployments)) throw new OpsError("unknown deployment to retire");
626
+ const dep = after.deployments[did];
627
+ const hid = dep.host_id;
628
+ hosts.add(hid);
629
+ const consumers = Object.values(after.bindings).filter((b) => b.status === "active" && b.provider_deployment_id === did).map((b) => b.consumer_deployment_id);
630
+ if (consumers.length) throw new OpsError("cannot retire shared service with active consumers: " + consumers.join(","));
631
+ for (const b of Object.values(after.bindings)) {
632
+ if (b.consumer_deployment_id === did) b.status = "retired";
633
+ }
634
+ if (dep.method === "compose") add(hid, did, "compose-stop", { project_root: dep.root, compose_name: dep.compose_name });
635
+ else for (const a of dep.commands.stop) add(hid, did, "command", { argv: a, cwd: dep.root });
636
+ const marker = Object.fromEntries(["deployment_id", "project_id", "host_id", "environment", "instance"].map((k) => [k, dep[k]]));
637
+ add(hid, did, "assert-file", { path: projectPath(after.hosts[hid], dep.root, ".ops-project.json"), sha256: digest(Buffer.from(JSON.stringify(marker, null, 2) + "\n")) });
638
+ dep.status = "retired";
639
+ dep.run_id = rid;
640
+ docTargets.push(did);
641
+ }
642
+ const provisionIds = new Set((spec.provision || []).map((x) => x.allocation_id));
643
+ for (const a of spec.allocations || []) {
644
+ if (!(a.allocation_id in current.allocations) && !provisionIds.has(a.allocation_id)) {
645
+ throw new OpsError("new allocation requires a typed provisioning action or verified adoption");
646
+ }
647
+ }
648
+ docTargets.push(...affected);
649
+ for (const b of Object.values(after.bindings)) {
650
+ if (b.status === "active" && docTargets.includes(b.consumer_deployment_id) && b.provider_deployment_id) {
651
+ docTargets.push(b.provider_deployment_id);
652
+ }
653
+ }
654
+ const uniqueDocs = [...new Set(docTargets)].sort();
655
+ for (const did of uniqueDocs) hosts.add(after.deployments[did].host_id);
656
+ if (!hosts.size) throw new OpsError("plan requires at least one registered host");
657
+ if ([...hosts].some((h) => !(h in after.hosts))) throw new OpsError("unregistered target host");
658
+ validateStatus(after);
659
+ if (ops.length > 2000 || canonical(ops).length > 64 * 1024 * 1024) throw new OpsError("plan exceeds bounded 2000 operations/64 MiB; split into reviewed releases");
660
+ const selected = Object.fromEntries([...hosts].sort().map((hid) => [hid, after.hosts[hid]]));
661
+ const inventories = {};
662
+ const snapshots = {};
663
+ const docPreconditions = {};
664
+ for (const [hid, host] of Object.entries(selected)) {
665
+ const paths = new Set(ops.filter((o) => o.host_id === hid && ["write", "quarantine", "purge-quarantine"].includes(o.kind)).map((o) => o.path));
666
+ for (const did of uniqueDocs) {
667
+ if (after.deployments[did].host_id === hid) for (const p of remotePaths(after, after.deployments[did])) paths.add(p);
668
+ }
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);
677
+ for (const did of Object.keys(specs)) {
678
+ const dep = after.deployments[did];
679
+ if (dep.host_id === hid) paths.add(dep.root);
680
+ }
681
+ const needsDocker = uniqueDocs.some((did) => after.deployments[did].host_id === hid && after.deployments[did].method === "compose");
682
+ const inv = plannerHooks.call(host, {
683
+ action: "probe", paths: [...paths].sort(), disk_roots: [host.root], include_docker: needsDocker,
684
+ deep_paths: ops.filter((o) => o.host_id === hid && o.kind === "purge-quarantine").map((o) => o.path),
685
+ }, { timeout: 180 });
686
+ if (needsDocker) {
687
+ const control = inv.docker_control;
688
+ if (!control || control.status !== "observed") throw new OpsError("Docker/Compose preparation is not verified; finish an approved H plan before D");
689
+ if (!control.endpoint.startsWith("unix://") && !control.endpoint.startsWith("npipe://")) {
690
+ throw new OpsError("Docker context points to a different machine; register that machine as the target host");
691
+ }
692
+ const wanted = targetJoin(host, "_runtime/docker");
693
+ if (after.policies.strict_docker_root && control.data_root !== wanted) {
694
+ throw new OpsError("existing Docker data-root differs from unified root; explicit H migration required, never silently move it");
695
+ }
696
+ const version = (control.compose_version.match(/\d+/g) || []).slice(0, 3).map(Number);
697
+ const cmp = (a, b) => { for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) - (b[i] || 0); } return 0; };
698
+ if (cmp(version, [2, 30, 0]) < 0) throw new OpsError("generated raw env_file contract requires Docker Compose >=2.30");
699
+ for (const op of ops) {
700
+ if (op.host_id === hid && op.compose_name) { op.context = control.context; op.expected_docker_id = control.id; }
701
+ }
702
+ for (const did of uniqueDocs) {
703
+ const dep = after.deployments[did];
704
+ if (dep.host_id === hid && dep.method === "compose") {
705
+ Object.assign(dep.service, { docker_context: control.context, docker_id: control.id });
706
+ for (const commands of Object.values(dep.commands)) {
707
+ for (const argv of commands) {
708
+ if (argv[0] === "docker" && argv[1] === "compose") argv.splice(1, 0, "--context", control.context);
709
+ }
710
+ }
711
+ }
712
+ }
713
+ }
714
+ inventories[hid] = inv;
715
+ snapshots[hid] = inv.snapshots;
716
+ const writtenNow = new Set(ops.filter((o) => o.host_id === hid).map((o) => o.path).filter(Boolean));
717
+ docPreconditions[hid] = Object.fromEntries(Object.entries(inv.snapshots).filter(([p]) => !writtenNow.has(p)));
718
+ }
719
+ for (const [did, d] of Object.entries(specs)) {
720
+ const dep = after.deployments[did];
721
+ const snap = snapshots[dep.host_id][dep.root];
722
+ if (!(did in current.deployments) && snap.kind !== "absent" && !d.adopt_existing) {
723
+ throw new OpsError("project directory exists: explicit verified adoption is required for " + did);
724
+ }
725
+ }
726
+ const seen = new Set();
727
+ for (const op of ops) {
728
+ if (["write", "quarantine", "purge-quarantine"].includes(op.kind)) {
729
+ op.expected = snapshots[op.host_id][op.path];
730
+ if (op.path.endsWith(".ops-project.json") && ![null, undefined].includes(op.expected?.owner) && digest(op.expected.owner) !== digest(JSON.parse(op.content))) {
731
+ throw new OpsError("project root is owned by a different deployment");
732
+ }
733
+ if (op.kind === "write") {
734
+ const key = op.host_id + "\0" + op.path;
735
+ if (seen.has(key)) throw new OpsError("two writes to the same file in one plan: " + op.path);
736
+ seen.add(key);
737
+ if (!["file", "absent"].includes(op.expected.kind)) throw new OpsError("file destination is not a regular file");
738
+ }
739
+ }
740
+ }
741
+ const refs = credentialsIn(ops);
742
+ for (const d of Object.values(after.deployments)) for (const r of credentialRefs(after, d)) refs.add(r);
743
+ for (const a of Object.values(after.allocations)) {
744
+ if (uniqueDocs.includes(a.provider_deployment_id)) refs.add(a.credential_ref);
745
+ }
746
+ for (const b of Object.values(after.bindings)) {
747
+ if (uniqueDocs.includes(b.consumer_deployment_id)) refs.add(b.credential_ref);
748
+ }
749
+ const cv = {};
750
+ for (const ref of [...refs].sort()) {
751
+ const [cid, version] = ref.split("@");
752
+ const value = ledger.entries?.[cid]?.[version];
753
+ if (!value) throw new OpsError("missing plaintext credential version: " + ref);
754
+ cv[ref] = digest(value);
755
+ }
756
+ const created = now();
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);
759
+ const plan = {
760
+ schema_version: 1, artifact: "ops-resource-plan", run_id: rid, worker: spec.worker, operation: spec.operation, reason: spec.reason,
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,
763
+ hosts: selected, transport_digests: Object.fromEntries(Object.entries(selected).map(([hid, h]) => [hid, hostTransportDigest(h)])),
764
+ inventories, operations: ops, registry_after: after, credential_versions: cv, document_targets: uniqueDocs, document_preconditions: docPreconditions,
765
+ allow_adopt_roots: spec.allow_adopt_roots || [], external_files: external, affected_consumers: [...affected].sort(), rollback_note: spec.rollback_note,
766
+ risk: spec.risk ?? (spec.worker === "D" ? "production-critical" : "external-mutation"), source_digests: sourceDigests,
767
+ };
768
+ plan.engine_digest = engineDigest();
769
+ validate(plan, "plan");
770
+ const path = runDir(state, plan);
771
+ writeJson(join(path, "plan.json"), plan, { exclusive: true });
772
+ atomicWrite(join(path, "PLAN.md"), planReport(plan), 0o600, { exclusive: true });
773
+ for (const hid of Object.keys(selected)) {
774
+ const link = join(state, "hosts", hid, "runs", rid, "reference.json");
775
+ writeJson(link, { run_id: rid, plan_path: pathRelative(state, join(path, "plan.json")).replaceAll("\\", "/"), plan_digest: digest(plan) }, { exclusive: true });
776
+ }
777
+ return { run_id: rid, plan_path: join(path, "plan.json"), report: join(path, "PLAN.md"), plan_digest: digest(plan), steps: ops.length, status: "awaiting-approval" };
778
+ });
779
+ }
780
+
781
+