@namewta/speculo 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
- package/template/workflows/ops/README.md +6 -3
- package/template/workflows/ops/common/USAGE.md +1 -1
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/schemas/host.schema.json +64 -1
- package/template/workflows/ops/common/schemas/plan.schema.json +312 -0
- package/template/workflows/ops/common/schemas/spec.schema.json +136 -0
- package/template/workflows/ops/common/schemas/status.schema.json +142 -0
- package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
- package/template/workflows/ops/common/service-profiles/redis.md +4 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
- package/template/workflows/ops/common/templates/HOST-README.md +12 -2
- package/template/workflows/ops/common/tests/test_ops.mjs +232 -2
- package/template/workflows/ops/common/tools/opslib/agent.mjs +44 -13
- package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
- package/template/workflows/ops/common/tools/opslib/docs.mjs +202 -18
- package/template/workflows/ops/common/tools/opslib/execution.mjs +31 -13
- package/template/workflows/ops/common/tools/opslib/planner.mjs +108 -14
- package/template/workflows/ops/common/tools/opslib/transport.mjs +1 -1
|
@@ -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
|
-
|
|
112
|
-
s.
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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,
|
|
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,
|