@namewta/speculo 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,