@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,982 @@
1
+ /** OPS safety/contract tests. Adapter simulations are explicitly separate from real local subprocess tests. */
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync, existsSync, symlinkSync, readdirSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import test from "node:test";
8
+ import * as core from "../tools/opslib/core.mjs";
9
+ import * as model from "../tools/opslib/model.mjs";
10
+ import * as planner from "../tools/opslib/planner.mjs";
11
+ import * as execution from "../tools/opslib/execution.mjs";
12
+ import * as agent from "../tools/opslib/agent.mjs";
13
+ import * as transport from "../tools/opslib/transport.mjs";
14
+ import * as docs from "../tools/opslib/docs.mjs";
15
+ import * as services from "../tools/opslib/services.mjs";
16
+ import * as controlFiles from "../tools/opslib/control_files.mjs";
17
+ import { initialize, analyze, cliHooks } from "../tools/opslib/cli.mjs";
18
+ import { taskFiles } from "../tools/opslib/native_windows.mjs";
19
+
20
+ const { OpsError, UnknownResult, digest, writeJson, readJson, withLock, relative, identifier, rootPath, targetJoin, emptyStatus, now, resolveSecrets, redact, atomicWrite, canonical } = core;
21
+ const PASSWORD = 'TEST-ONLY-$literal-quote"-slash\\-not-production';
22
+
23
+ function deployment(project = "app-a", did = "app-a-prod", root = "/srv/ops", method = "native", host = null) {
24
+ host = host || { platform: "linux", root };
25
+ const depRoot = targetJoin(host, project);
26
+ return {
27
+ deployment_id: did, project_id: project, host_id: "node-a", environment: "prod", instance: "main", layout: "flat",
28
+ method, version: "v1", observed_version: "v1", root: depRoot, status: "completed",
29
+ installed_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", run_id: "run-old",
30
+ compose_name: method === "compose" ? "ops-" + did : null, storage: [], commands: { start: [], stop: [], verify: [] },
31
+ credential_refs: [], backup: "A project-specific backup is required.", recovery: "No automatic shared-database restore.",
32
+ notes: [], source: { type: "local", location: "/source/" + project, revision: "v1" }, service: {},
33
+ };
34
+ }
35
+
36
+ function minimalInventory(host, request) {
37
+ const snapshots = {};
38
+ for (const p of request.paths || []) {
39
+ snapshots[p] = (request.deep_paths || []).includes(p) ? agent.treeState(p) : agent.fileState(p);
40
+ }
41
+ return {
42
+ identity: host.identity, observed_at: now(), platform: host.platform, hostname: "fixture", architecture: "fixture",
43
+ account: "fixture", uid: null, node: process.execPath, tools: {}, defaults: {},
44
+ diagnostics: { memory: {}, issues: [], disks: {} },
45
+ docker_control: {
46
+ status: "observed", context: "fixture-context", endpoint: "unix:///fixture/docker.sock",
47
+ id: "fixture-docker-id", data_root: targetJoin(host, "_runtime/docker"), compose_version: "2.30.0",
48
+ },
49
+ snapshots,
50
+ };
51
+ }
52
+
53
+ function setupFixture() {
54
+ const tmp = mkdtempSync(join(tmpdir(), "ops-tests-"));
55
+ const state = join(tmp, "controller");
56
+ const target = join(tmp, "server");
57
+ const prevProbe = cliHooks.probeLocal;
58
+ cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock-initial-inventory-only" });
59
+ initialize(state, "control-a");
60
+ cliHooks.probeLocal = prevProbe;
61
+ const platform = process.platform === "win32" ? "windows" : "linux";
62
+ const host = {
63
+ host_id: "node-a", display_name: "Node A", platform, transport: "local",
64
+ root: target, identity: agent.fingerprint(), connection: {},
65
+ };
66
+ const project = {
67
+ project_id: "app-a", display_name: "APP A", kind: "app", service_type: null,
68
+ source: { type: "local", location: join(tmp, "source"), revision: "v1" },
69
+ };
70
+ model.register(state, { hosts: [host], projects: [project] });
71
+ model.putCredential(state, { credential_id: "app-auth", version: 1, purpose: "Synthetic test only", values: { username: "app-user", password: PASSWORD } });
72
+ const spec = (version = "v1", operation = "deploy", program = null) => {
73
+ if (program == null) {
74
+ program = `import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
75
+ import { join } from "node:path";
76
+ const dir = join(process.env.OPS_DATA_ROOT, "app", "storage");
77
+ mkdirSync(dir, { recursive: true });
78
+ const p = join(dir, "value.json");
79
+ const n = existsSync(p) ? JSON.parse(readFileSync(p, "utf8")).count + 1 : 1;
80
+ if (process.env.APP_USERNAME !== "app-user") throw new Error("username");
81
+ if (!process.env.APP_PASSWORD.startsWith("TEST-ONLY-")) throw new Error("password");
82
+ writeFileSync(p, JSON.stringify({ count: n, version: ${JSON.stringify(version)} }));
83
+ `;
84
+ }
85
+ return {
86
+ schema_version: 1, worker: "D", operation, reason: "Disposable isolated test only",
87
+ rollback_note: "Retain old data; use a new explicitly approved recovery plan.",
88
+ deployments: [{
89
+ deployment_id: "app-a-prod", project_id: "app-a", host_id: "node-a", environment: "prod", instance: "main",
90
+ layout: "flat", method: "native", version,
91
+ files: [{ path: "artifact/main.mjs", content: program }],
92
+ env: { "app.env": { APP_USERNAME: "{{credential:app-auth@1:username}}", APP_PASSWORD: "{{credential:app-auth@1:password}}" } },
93
+ native: { supervisor: "oneshot", argv: [process.execPath, "{{artifact}}/main.mjs"] },
94
+ credential_refs: ["app-auth@1"],
95
+ health: [{ type: "file", path: "data/app/storage/value.json" }],
96
+ backup: "Back up only after process exit; this fixture has no shared data.",
97
+ recovery: "A new matching-version plan preserves and verifies data.",
98
+ }],
99
+ };
100
+ };
101
+ const plan = (s = null) => {
102
+ const f = join(tmp, "input.json");
103
+ writeJson(f, s || spec());
104
+ const prev = planner.plannerHooks.call;
105
+ planner.plannerHooks.call = (h, request) => minimalInventory(h, request);
106
+ try {
107
+ const result = planner.compilePlan(state, f);
108
+ return [result, readJson(result.plan_path)];
109
+ } finally { planner.plannerHooks.call = prev; }
110
+ };
111
+ const approve = (info) => execution.approval(state, info.run_id, info.plan_digest, "test-admin", "Approve only the exact isolated test plan and its fixture data.");
112
+ const runSpec = (s = null) => {
113
+ const [info, p] = plan(s);
114
+ approve(info);
115
+ const result = execution.apply(state, info.run_id);
116
+ return [info, p, result];
117
+ };
118
+ const graph = () => {
119
+ const s = model.load(state);
120
+ s.deployments["app-a-prod"] = deployment("app-a", "app-a-prod", host.root, "native", host);
121
+ s.deployments["app-a-prod"].credential_refs = ["app-auth@1"];
122
+ s.projects["mysql-main"] = {
123
+ project_id: "mysql-main", display_name: "MySQL", kind: "shared-service", service_type: "mysql",
124
+ source: { type: "image", location: "mysql@sha256:" + "a".repeat(64), revision: "digest-pinned" },
125
+ };
126
+ const d = deployment("mysql-main", "mysql-prod", host.root, "compose", host);
127
+ d.credential_refs = ["mysql-admin@1"];
128
+ d.service = { compose_service: "mysql" };
129
+ s.deployments["mysql-prod"] = d;
130
+ s.allocations["app-db"] = {
131
+ allocation_id: "app-db", provider_deployment_id: "mysql-prod", owner_project_id: "app-a", environment: "prod",
132
+ data_group: "app-a-prod", resource_kind: "database", resource_name: "app_a_prod", credential_ref: "app-auth@1",
133
+ shared_owners: [], status: "active", recovery_scope: "logical", notes: "Fixture logical resource",
134
+ };
135
+ s.bindings["app-db-binding"] = {
136
+ binding_id: "app-db-binding", consumer_deployment_id: "app-a-prod", mode: "shared",
137
+ provider_deployment_id: "mysql-prod", allocation_id: "app-db", endpoint: "mysql-main:3306",
138
+ credential_ref: "app-auth@1", status: "active", component: "mysql", network: "explicit-intranet",
139
+ };
140
+ return s;
141
+ };
142
+ return { tmp, state, target, platform, host, project, spec, plan, approve, runSpec, graph, cleanup: () => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} } };
143
+ }
144
+
145
+ function runFx(fn) {
146
+ const fx = setupFixture();
147
+ try { return fn(fx); } finally { fx.cleanup(); }
148
+ }
149
+
150
+ test("canonical order", () => {
151
+ assert.equal(digest({ b: 1, a: 2 }), digest({ a: 2, b: 1 }));
152
+ });
153
+ test("nonfinite rejected", () => {
154
+ assert.throws(() => canonical(Number.NaN));
155
+ });
156
+ test("duplicate json rejected", () => {
157
+ const d = mkdtempSync(join(tmpdir(), "ops-dup-"));
158
+ const p = join(d, "x.json");
159
+ writeFileSync(p, '{"x":1,"x":2}');
160
+ assert.throws(() => readJson(p), OpsError);
161
+ rmSync(d, { recursive: true, force: true });
162
+ });
163
+ test("exclusive artifact", () => {
164
+ const d = mkdtempSync(join(tmpdir(), "ops-ex-"));
165
+ const p = join(d, "a.json");
166
+ writeJson(p, { x: 1 }, { exclusive: true });
167
+ assert.throws(() => writeJson(p, { x: 2 }, { exclusive: true }), OpsError);
168
+ assert.deepEqual(readJson(p), { x: 1 });
169
+ rmSync(d, { recursive: true, force: true });
170
+ });
171
+ test("lock is not autobroken", () => {
172
+ const d = mkdtempSync(join(tmpdir(), "ops-lock-"));
173
+ withLock(join(d, "lock"), { why: "test" }, () => {
174
+ assert.throws(() => withLock(join(d, "lock"), {}, () => {}), OpsError);
175
+ });
176
+ rmSync(d, { recursive: true, force: true });
177
+ });
178
+ test("symlink rejected", () => {
179
+ const d = mkdtempSync(join(tmpdir(), "ops-link-"));
180
+ mkdirSync(join(d, "real"));
181
+ try { symlinkSync(join(d, "real"), join(d, "link"), "dir"); }
182
+ catch (e) {
183
+ if (process.platform === "win32") { rmSync(d, { recursive: true, force: true }); return; }
184
+ throw e;
185
+ }
186
+ assert.throws(() => atomicWrite(join(d, "link", "a.txt"), "x"), OpsError);
187
+ rmSync(d, { recursive: true, force: true });
188
+ });
189
+ test("file permissions", () => {
190
+ const d = mkdtempSync(join(tmpdir(), "ops-perm-"));
191
+ const p = join(d, "secret");
192
+ atomicWrite(p, PASSWORD);
193
+ if (process.platform !== "win32") {
194
+ assert.equal(statSync(p).mode & 0o777, 0o600);
195
+ }
196
+ rmSync(d, { recursive: true, force: true });
197
+ });
198
+
199
+ test("credential raw values", () => {
200
+ const ledger = { entries: { x: { 1: { values: { password: PASSWORD } } } } };
201
+ assert.equal(resolveSecrets("{{credential:x@1:password}}", ledger), PASSWORD);
202
+ });
203
+ test("credential missing", () => {
204
+ assert.throws(() => resolveSecrets("{{credential:x@1:password}}", { entries: {} }), OpsError);
205
+ });
206
+ test("redaction", () => {
207
+ assert.ok(!redact("pw " + PASSWORD, [PASSWORD]).includes(PASSWORD));
208
+ });
209
+ test("env dollar literal", () => {
210
+ assert.equal(planner.envFile({ P: 'a$$"z' }), 'P=a$$"z\n');
211
+ });
212
+ test("env newline rejected", () => {
213
+ assert.throws(() => planner.envFile({ P: "a\nb" }), OpsError);
214
+ });
215
+ test("env key rejected", () => {
216
+ assert.throws(() => planner.envFile({ "P-INVALID": "a" }), OpsError);
217
+ });
218
+ test("systemd env quote", () => {
219
+ assert.equal(planner.envFile({ P: 'a"\\b' }, { systemd: true }), 'P="a\\"\\\\b"\n');
220
+ });
221
+ test("markdown secret backticks", () => {
222
+ const l = { entries: { x: { 1: { purpose: "test", values: { password: 'a```b|c$"' } } } } };
223
+ assert.ok(docs.credentialsText(["x@1"], l).includes('a```b|c$"'));
224
+ });
225
+ test("windows native json secret", () => {
226
+ const vals = taskFiles("app-a", "C:\\Ops\\app-a", ["python", "C:\\Ops\\app-a\\main.py"], { P: "{{credential:x@1:password}}" }, { account: "X\\User" });
227
+ const original = { kind: "write", json_values: JSON.parse(vals["service/task.json"]), content: vals["service/task.json"] };
228
+ const actual = execution.resolvedOp(original, { entries: { x: { 1: { values: { password: PASSWORD } } } } });
229
+ assert.equal(JSON.parse(Buffer.from(actual.content_b64, "base64").toString("utf8")).environment.P, PASSWORD);
230
+ });
231
+
232
+ for (const [index, value] of ["", "../a", "a/../b", "a//b", "/a", "a\\b", "C:a", "a/", "./a", "a\x00b"].entries()) {
233
+ test(`relative reject ${String(index).padStart(2, "0")}`, () => {
234
+ assert.throws(() => relative(value), OpsError);
235
+ });
236
+ }
237
+ for (const [index, value] of ["app-a", "mysql-main", "a1", "host-22"].entries()) {
238
+ test(`identifier valid ${String(index).padStart(2, "0")}`, () => {
239
+ assert.equal(identifier(value), value);
240
+ });
241
+ }
242
+ for (const [index, value] of ["", "App", "a_b", "-a", "a-", "a/b", "con", "com1", "lpt9", "a".repeat(81)].entries()) {
243
+ test(`identifier reject ${String(index).padStart(2, "0")}`, () => {
244
+ assert.throws(() => identifier(value), OpsError);
245
+ });
246
+ }
247
+ for (const [index, value] of ["/", "/etc", "/var", "/tmp", "/srv", "relative", "/srv/../etc"].entries()) {
248
+ test(`posix root reject ${String(index).padStart(2, "0")}`, () => {
249
+ assert.throws(() => rootPath(value, "linux"), OpsError);
250
+ });
251
+ }
252
+ for (const [index, value] of ["C:\\", "C:\\Windows", "C:\\Users", "\\\\server\\share\\ops", "C:\\Ops\\..\\data", "C:\\Ops:stream"].entries()) {
253
+ test(`windows root reject ${String(index).padStart(2, "0")}`, () => {
254
+ assert.throws(() => rootPath(value, "windows"), OpsError);
255
+ });
256
+ }
257
+
258
+ test("empty v3 valid", () => { model.validateStatus(emptyStatus()); });
259
+
260
+ test("resource contracts", async (t) => {
261
+ const each = (name, fn) => t.test(name, () => runFx(fn));
262
+ await each("unknown schema field", (fx) => {
263
+ const s = model.load(fx.state); s.secret = "bad";
264
+ assert.throws(() => model.validateStatus(s), OpsError);
265
+ });
266
+ await each("legacy blocked", (fx) => {
267
+ assert.throws(() => model.validateStatus({ schema_version: 2, active: [], archived: [] }), OpsError);
268
+ });
269
+ await each("credential version immutable", (fx) => {
270
+ assert.throws(() => model.putCredential(fx.state, { credential_id: "app-auth", version: 1, purpose: "x", values: { username: "u", password: "changed" } }), OpsError);
271
+ });
272
+ await each("credential permissions rejected", (fx) => {
273
+ if (process.platform === "win32") return;
274
+ chmodSync(join(fx.state, "private", "credentials.json"), 0o644);
275
+ assert.throws(() => model.ledgerLoad(fx.state), OpsError);
276
+ chmodSync(join(fx.state, "private", "credentials.json"), 0o600);
277
+ });
278
+ await each("valid shared graph", (fx) => { model.validateStatus(fx.graph()); });
279
+ await each("duplicate physical host", (fx) => {
280
+ const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b" };
281
+ assert.throws(() => model.validateStatus(s), OpsError);
282
+ });
283
+ await each("host root nested", (fx) => {
284
+ const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b", root: join(fx.target, "nested") };
285
+ assert.throws(() => model.validateStatus(s), OpsError);
286
+ });
287
+ await each("identity platform mismatch", (fx) => {
288
+ const other = fx.platform === "windows" ? "linux" : "windows";
289
+ const otherRoot = other === "linux" ? "/srv/ops" : "C:\\Ops";
290
+ const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b", platform: other, root: otherRoot };
291
+ assert.throws(() => model.validateStatus(s), OpsError);
292
+ });
293
+ await each("dedicated cannot claim shared provider", (fx) => {
294
+ const s = fx.graph(); Object.assign(s.bindings["app-db-binding"], { mode: "dedicated", allocation_id: null });
295
+ assert.throws(() => model.validateStatus(s), OpsError);
296
+ });
297
+ await each("project root cannot override", (fx) => {
298
+ const s = fx.graph(); s.deployments["app-a-prod"].root = join(fx.target, "elsewhere");
299
+ assert.throws(() => model.validateStatus(s), OpsError);
300
+ });
301
+ await each("native data outside root", (fx) => {
302
+ const s = fx.graph(); s.deployments["app-a-prod"].storage = [{ component: "app", purpose: "data", path: "/tmp/data" }];
303
+ assert.throws(() => model.validateStatus(s), OpsError);
304
+ });
305
+ await each("flat instances overlap", (fx) => {
306
+ const s = fx.graph();
307
+ const d = structuredClone(s.deployments["app-a-prod"]);
308
+ Object.assign(d, { deployment_id: "app-a-test", layout: "instances", environment: "test", root: join(fx.target, "app-a", "instances", "test", "main") });
309
+ s.deployments[d.deployment_id] = d;
310
+ assert.throws(() => model.validateStatus(s), OpsError);
311
+ });
312
+ await each("orphan provider", (fx) => {
313
+ const s = fx.graph(); s.allocations["app-db"].provider_deployment_id = "missing";
314
+ assert.throws(() => model.validateStatus(s), OpsError);
315
+ });
316
+ await each("duplicate allocation", (fx) => {
317
+ const s = fx.graph(); s.allocations["app-db-copy"] = { ...s.allocations["app-db"], allocation_id: "app-db-copy" };
318
+ assert.throws(() => model.validateStatus(s), OpsError);
319
+ });
320
+ await each("wrong binding password version", (fx) => {
321
+ const s = fx.graph(); s.bindings["app-db-binding"].credential_ref = "app-auth@2";
322
+ assert.throws(() => model.validateStatus(s), OpsError);
323
+ });
324
+ await each("external cannot claim managed data", (fx) => {
325
+ const s = fx.graph(); s.bindings["app-db-binding"].mode = "external";
326
+ assert.throws(() => model.validateStatus(s), OpsError);
327
+ });
328
+ await each("retired provider allocation", (fx) => {
329
+ const s = fx.graph(); s.allocations["app-db"].status = "retired";
330
+ assert.throws(() => model.validateStatus(s), OpsError);
331
+ });
332
+ await each("cross environment not implicit", (fx) => {
333
+ const s = fx.graph(); s.allocations["app-db"].environment = "test";
334
+ assert.throws(() => model.validateStatus(s), OpsError);
335
+ });
336
+ await each("unknown project kind", (fx) => {
337
+ const s = fx.graph(); s.projects["mysql-main"].kind = "other";
338
+ assert.throws(() => model.validateStatus(s), OpsError);
339
+ });
340
+ await each("strict docker cannot be disabled", (fx) => {
341
+ const s = fx.graph(); s.policies.strict_docker_root = false;
342
+ assert.throws(() => model.validateStatus(s), OpsError);
343
+ });
344
+ await each("provider admin app separation", (fx) => {
345
+ const s = fx.graph();
346
+ assert.throws(() => services.allocationOperation(s, s.deployments["mysql-prod"], s.allocations["app-db"], { adapter: "mysql", admin_credential_ref: "app-auth@1", app_username: "app-user" }), OpsError);
347
+ });
348
+ await each("mysql no global admin grants", (fx) => {
349
+ const s = fx.graph();
350
+ assert.throws(() => services.allocationOperation(s, s.deployments["mysql-prod"], s.allocations["app-db"], { adapter: "mysql", admin_credential_ref: "mysql-admin@1", app_username: "app-user", privileges: ["SUPER"] }), OpsError);
351
+ });
352
+ await each("mysql explicit scoped grants", (fx) => {
353
+ const s = fx.graph();
354
+ const op = services.allocationOperation(s, s.deployments["mysql-prod"], s.allocations["app-db"], { adapter: "mysql", admin_credential_ref: "mysql-admin@1", app_username: "app-user", privileges: ["SELECT"] });
355
+ assert.deepEqual(op.privileges, ["SELECT"]);
356
+ assert.equal(op.kind, "mysql-allocation");
357
+ });
358
+ await each("source docs preserve deployment revision", (fx) => {
359
+ const s = fx.graph();
360
+ s.projects["app-a"].source.revision = "newer-project-source";
361
+ const text = docs.deploymentReadme(s, s.deployments["app-a-prod"], "run-test", now());
362
+ assert.ok(!text.includes("newer-project-source"));
363
+ assert.ok(text.includes("v1"));
364
+ });
365
+ });
366
+
367
+ test("planner contracts", async (t) => {
368
+ const each = (name, fn) => t.test(name, () => runFx(fn));
369
+ await each("plan writes no target", (fx) => {
370
+ const [info, plan] = fx.plan();
371
+ assert.equal(existsSync(fx.target), false);
372
+ assert.ok(!readFileSync(info.report, "utf8").includes(PASSWORD));
373
+ assert.equal(plan.registry_digest, digest(model.load(fx.state)));
374
+ });
375
+ await each("missing health rejected", (fx) => {
376
+ const s = fx.spec(); s.deployments[0].health = [];
377
+ assert.throws(() => fx.plan(s), OpsError);
378
+ });
379
+ await each("unknown spec field rejected", (fx) => {
380
+ const s = fx.spec(); s.do_anything = true;
381
+ assert.throws(() => fx.plan(s), OpsError);
382
+ });
383
+ await each("inline native code rejected", (fx) => {
384
+ const s = fx.spec(); s.deployments[0].native.argv = [process.execPath, "-c", "print(1)"];
385
+ assert.throws(() => fx.plan(s), OpsError);
386
+ });
387
+ await each("native outside arg rejected", (fx) => {
388
+ const s = fx.spec(); s.deployments[0].native.argv = [process.execPath, "/tmp/other.py"];
389
+ assert.throws(() => fx.plan(s), OpsError);
390
+ });
391
+ await each("env path escape rejected", (fx) => {
392
+ const s = fx.spec(); s.deployments[0].env["app.env"].APP_DATA_DIR = "/tmp/app-data";
393
+ assert.throws(() => fx.plan(s), OpsError);
394
+ });
395
+ await each("env reserved root rejected", (fx) => {
396
+ const s = fx.spec(); s.deployments[0].env["app.env"].OPS_DATA_ROOT = "{{data}}";
397
+ assert.throws(() => fx.plan(s), OpsError);
398
+ });
399
+ await each("native multiple env requires order", (fx) => {
400
+ const s = fx.spec(); s.deployments[0].env["extra.env"] = { EXTRA: "1" };
401
+ assert.throws(() => fx.plan(s), OpsError);
402
+ });
403
+ await each("unknown env file rejected", (fx) => {
404
+ const s = fx.spec(); s.deployments[0].native.env_files = ["missing.env"];
405
+ assert.throws(() => fx.plan(s), OpsError);
406
+ });
407
+ await each("env vars reach native command", (fx) => {
408
+ const [, p] = fx.plan();
409
+ const commands = p.operations.filter((x) => x.kind === "command");
410
+ assert.ok("APP_PASSWORD" in commands[0].env);
411
+ assert.ok(commands[0].env.HOME.startsWith(join(fx.target, "app-a")));
412
+ });
413
+ await each("host command requires writes", (fx) => {
414
+ assert.throws(() => fx.plan({ schema_version: 1, worker: "H", operation: "prepare", reason: "test", rollback_note: "test", host_actions: [{ host_id: "node-a", kind: "command", reason: "test", argv: ["echo", "safe"] }] }), OpsError);
415
+ });
416
+ await each("host command requires verification", (fx) => {
417
+ assert.throws(() => fx.plan({ schema_version: 1, worker: "H", operation: "prepare", reason: "test", rollback_note: "test", host_actions: [{ host_id: "node-a", kind: "command", reason: "test", argv: ["echo", "safe"], writes: [join(fx.target, "_host", "x")] }] }), OpsError);
418
+ });
419
+ await each("project file traversal", (fx) => {
420
+ const s = fx.spec(); s.deployments[0].files[0].path = "../else.py";
421
+ assert.throws(() => fx.plan(s), OpsError);
422
+ });
423
+ await each("plaintext readme cannot be arbitrary file", (fx) => {
424
+ const s = fx.spec(); s.deployments[0].files.push({ path: "README.md", content: "fake success" });
425
+ assert.throws(() => fx.plan(s), OpsError);
426
+ });
427
+ await each("resource update root fixed", (fx) => {
428
+ const s = fx.spec(); s.resource_updates = { hosts: [{ ...fx.host, root: join(fx.tmp, "else") }] };
429
+ assert.throws(() => fx.plan(s), OpsError);
430
+ });
431
+ await each("resource source update planned", (fx) => {
432
+ const s = fx.spec();
433
+ const p = structuredClone(fx.project);
434
+ p.source.revision = "v2";
435
+ s.resource_updates = { projects: [p] };
436
+ const [, plan] = fx.plan(s);
437
+ assert.equal(plan.registry_after.deployments["app-a-prod"].source.revision, "v2");
438
+ assert.equal(model.load(fx.state).projects["app-a"].source.revision, "v1");
439
+ });
440
+ await each("wrong approval digest", (fx) => {
441
+ const [info] = fx.plan();
442
+ assert.throws(() => execution.approval(fx.state, info.run_id, "0".repeat(64), "test", "explicit statement"), OpsError);
443
+ });
444
+ await each("unrelated host registration does not block approval", (fx) => {
445
+ const [info] = fx.plan();
446
+ model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
447
+ fx.approve(info);
448
+ });
449
+ await each("touched host catalog drift blocks approval", (fx) => {
450
+ const [info] = fx.plan();
451
+ const s = model.load(fx.state);
452
+ s.hosts["node-a"] = { ...s.hosts["node-a"], display_name: "Renamed" };
453
+ model.save(fx.state, s);
454
+ assert.throws(() => fx.approve(info), OpsError);
455
+ });
456
+ await each("approval is immutable", (fx) => {
457
+ const [info] = fx.plan();
458
+ fx.approve(info);
459
+ assert.throws(() => fx.approve(info), OpsError);
460
+ });
461
+ await each("plan tamper blocks apply", (fx) => {
462
+ const [info, p] = fx.plan();
463
+ fx.approve(info);
464
+ p.reason = "changed";
465
+ writeJson(info.plan_path, p);
466
+ assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
467
+ });
468
+ await each("engine drift blocks apply", (fx) => {
469
+ const [info] = fx.plan();
470
+ fx.approve(info);
471
+ const prev = execution.executionHooks.engineDigest;
472
+ execution.executionHooks.engineDigest = () => "0".repeat(64);
473
+ try { assert.throws(() => execution.apply(fx.state, info.run_id), OpsError); }
474
+ finally { execution.executionHooks.engineDigest = prev; }
475
+ });
476
+ await each("credential drift blocks apply", (fx) => {
477
+ const [info] = fx.plan();
478
+ fx.approve(info);
479
+ const l = model.ledgerLoad(fx.state);
480
+ l.entries["app-auth"]["1"].values.password = "changed";
481
+ writeJson(join(fx.state, "private", "credentials.json"), l);
482
+ assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
483
+ });
484
+ await each("existing unowned project not overwritten", (fx) => {
485
+ mkdirSync(join(fx.target, "app-a"), { recursive: true });
486
+ assert.throws(() => fx.plan(), OpsError);
487
+ });
488
+ await each("compose requires digest", (fx) => {
489
+ assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example:latest" } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
490
+ });
491
+ await each("compose rejects named volume", (fx) => {
492
+ assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), volumes: [{ type: "volume", source: "named", target: "/data" }] } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
493
+ });
494
+ await each("compose rejects cross app data", (fx) => {
495
+ assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), volumes: [{ type: "bind", source: join(fx.target, "app-b", "data", "a", "b"), target: "/data" }] } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
496
+ });
497
+ await each("compose rejects writable root", (fx) => {
498
+ assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), read_only: false } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
499
+ });
500
+ await each("compose allows justified writable root", (fx) => {
501
+ const [value] = planner.composeModel({
502
+ compose: {
503
+ services: {
504
+ app: {
505
+ image: "example@sha256:" + "a".repeat(64),
506
+ read_only: false,
507
+ writable_root_justification: "elasticsearch keystore tmp must be created at process start",
508
+ },
509
+ },
510
+ },
511
+ }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {});
512
+ assert.equal(value.services.app.read_only, false);
513
+ assert.equal("writable_root_justification" in value.services.app, false);
514
+ });
515
+ await each("config files default to 0644", (fx) => {
516
+ const s = fx.spec();
517
+ s.deployments[0].files.push({ path: "config/redis.conf", content: "port 6379\n" });
518
+ const [, plan] = fx.plan(s);
519
+ const conf = plan.operations.find((o) => o.kind === "write" && String(o.path).replaceAll("\\", "/").endsWith("config/redis.conf"));
520
+ const env = plan.operations.find((o) => o.kind === "write" && String(o.path).replaceAll("\\", "/").includes("/env/"));
521
+ assert.equal(conf.mode, 0o644);
522
+ assert.equal(env.mode, 0o600);
523
+ });
524
+ await each("host write-file cannot overlay generated docs", (fx) => {
525
+ assert.throws(() => fx.plan({
526
+ schema_version: 1, worker: "H", operation: "prepare", reason: "test overlay",
527
+ rollback_note: "Retain generated host documentation.",
528
+ host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Overwrite generated README", path: "README.md", content: "fake success" }],
529
+ }), OpsError);
530
+ });
531
+ await each("declared nginx control file is planned", (fx) => {
532
+ const [, plan] = fx.plan({
533
+ schema_version: 1, worker: "H", operation: "prepare", reason: "Persist reviewed nginx site",
534
+ rollback_note: "Restore previous nginx conf from backup copy.",
535
+ host_actions: [{
536
+ host_id: "node-a", kind: "write-control", reason: "Public ingress site for reviewed backend",
537
+ path: "/etc/nginx/conf.d/app.conf", content: "server { listen 57492; }\n",
538
+ rollback: "Restore /etc/nginx/conf.d/app.conf from the run backup.",
539
+ verification: [{ type: "command", argv: [process.execPath, "-e", "process.exit(0)"] }],
540
+ }],
541
+ });
542
+ assert.ok(plan.external_files["node-a"].includes("/etc/nginx/conf.d/app.conf"));
543
+ assert.equal(plan.operations.find((o) => o.path === "/etc/nginx/conf.d/app.conf").mode, 0o644);
544
+ });
545
+ await each("undeclared control path rejected", (fx) => {
546
+ assert.throws(() => fx.plan({
547
+ schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
548
+ rollback_note: "Do not write arbitrary system files.",
549
+ host_actions: [{ host_id: "node-a", kind: "write-control", reason: "not a control file", path: "/etc/passwd", content: "x\n" }],
550
+ }), OpsError);
551
+ });
552
+ await each("core systemd unit control rejected", (fx) => {
553
+ assert.throws(() => fx.plan({
554
+ schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
555
+ rollback_note: "Do not replace sshd unit files.",
556
+ host_actions: [{
557
+ host_id: "node-a", kind: "write-control", reason: "core unit forbidden",
558
+ path: "/etc/systemd/system/sshd.service", content: "[Unit]\n",
559
+ rollback: "Restore sshd.service from package.",
560
+ verification: [{ type: "command", argv: [process.execPath, "-e", "process.exit(0)"] }],
561
+ }],
562
+ }), OpsError);
563
+ });
564
+ await each("declared control file needs verification", (fx) => {
565
+ assert.throws(() => fx.plan({
566
+ schema_version: 1, worker: "H", operation: "prepare", reason: "should fail",
567
+ rollback_note: "Restore previous nginx conf from backup copy.",
568
+ host_actions: [{
569
+ host_id: "node-a", kind: "write-control", reason: "Public ingress site",
570
+ path: "/etc/nginx/nginx.conf", content: "http {}\n",
571
+ rollback: "Restore nginx.conf from backup.",
572
+ }],
573
+ }), OpsError);
574
+ });
575
+ await each("compose safe persistence model", (fx) => {
576
+ const [value] = planner.composeModel({ compose: { services: { app: { image: "example@sha256:" + "a".repeat(64), volumes: [{ type: "bind", source: "data/app/storage", target: "/data" }] } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", { "app.env": {} }, {});
577
+ assert.equal(value.services.app.read_only, true);
578
+ assert.equal(value.services.app.volumes[0].bind.create_host_path, false);
579
+ assert.equal(value.services.app.env_file[0].format, "raw");
580
+ });
581
+ });
582
+
583
+ test("real native and dual docs", () => runFx((fx) => {
584
+ const [info, , result] = fx.runSpec();
585
+ assert.equal(result.status, "completed", JSON.stringify(result));
586
+ const dep = model.load(fx.state).deployments["app-a-prod"];
587
+ assert.equal(dep.observed_version, "v1");
588
+ const remote = join(fx.target, "app-a");
589
+ const local = join(fx.state, "hosts", "node-a", "deployments", "app-a-prod");
590
+ assert.ok(!readFileSync(join(remote, "README.md"), "utf8").includes(PASSWORD));
591
+ assert.ok(readFileSync(join(remote, "OPERATIONS.md"), "utf8").includes(PASSWORD));
592
+ assert.ok(readFileSync(join(local, "README.md"), "utf8").includes(PASSWORD));
593
+ assert.equal(readJson(join(local, "docs-receipt.json")).status, "both-sides-verified");
594
+ assert.deepEqual(readFileSync(join(remote, "env", "app.env")), readFileSync(join(local, "server-files", "env", "app.env")));
595
+ assert.equal(readJson(join(remote, "data", "app", "storage", "value.json")).count, 1);
596
+ for (const f of [join(dirname(info.plan_path), "journal.jsonl"), join(dirname(info.plan_path), "execution.json")]) {
597
+ assert.ok(!readFileSync(f, "utf8").includes(PASSWORD));
598
+ }
599
+ }));
600
+ test("upgrade and rollback preserve data", () => runFx((fx) => {
601
+ let r;
602
+ [, , r] = fx.runSpec();
603
+ assert.equal(r.status, "completed");
604
+ [, , r] = fx.runSpec(fx.spec("v2", "upgrade"));
605
+ assert.equal(r.status, "completed", JSON.stringify(r));
606
+ [, , r] = fx.runSpec(fx.spec("v1", "rollback"));
607
+ assert.equal(r.status, "completed", JSON.stringify(r));
608
+ const value = readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json"));
609
+ assert.deepEqual(value, { count: 3, version: "v1" });
610
+ assert.equal(readdirSync(join(fx.target, "app-a", "releases")).length, 3);
611
+ }));
612
+ test("retirement preserves data and credentials", () => runFx((fx) => {
613
+ let r, p;
614
+ [, , r] = fx.runSpec();
615
+ assert.equal(r.status, "completed");
616
+ [, p, r] = fx.runSpec({ schema_version: 1, worker: "D", operation: "uninstall", reason: "Stop only the disposable app", retire_deployments: ["app-a-prod"], rollback_note: "All data and credentials retained." });
617
+ assert.equal(r.status, "completed", JSON.stringify(r));
618
+ assert.equal(model.load(fx.state).deployments["app-a-prod"].status, "retired");
619
+ assert.equal(existsSync(join(fx.target, "app-a", "data", "app", "storage", "value.json")), true);
620
+ assert.equal(existsSync(join(fx.target, "app-a", "env", "app.env")), true);
621
+ assert.equal(p.operations.some((o) => ["quarantine", "purge-quarantine"].includes(o.kind)), false);
622
+ }));
623
+ test("second apply blocked resume completed no repeat", () => runFx((fx) => {
624
+ const [info, , r] = fx.runSpec();
625
+ assert.equal(r.status, "completed");
626
+ assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
627
+ const again = execution.apply(fx.state, info.run_id, { resume: true });
628
+ assert.equal(again.unchanged, true);
629
+ assert.equal(readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json")).count, 1);
630
+ }));
631
+ test("docs retry does not repeat native command", () => runFx((fx) => {
632
+ const [info] = fx.plan();
633
+ fx.approve(info);
634
+ const real = execution.executionHooks.call;
635
+ let failed = false;
636
+ execution.executionHooks.call = (host, request, kw) => {
637
+ if (request.action === "step" && String(request.operation?.step_id || "").startsWith("docs-") && !failed) {
638
+ failed = true;
639
+ throw new UnknownResult("simulated document transport interruption before dispatch");
640
+ }
641
+ return real(host, request, kw);
642
+ };
643
+ let r;
644
+ try { r = execution.apply(fx.state, info.run_id); }
645
+ finally { execution.executionHooks.call = real; }
646
+ assert.equal(r.status, "docs_pending", JSON.stringify(r));
647
+ assert.equal(existsSync(join(fx.state, "hosts", "node-a", "deployments", "app-a-prod", "README.md")), true);
648
+ r = execution.apply(fx.state, info.run_id, { docsOnly: true });
649
+ assert.equal(r.status, "completed", JSON.stringify(r));
650
+ assert.equal(readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json")).count, 1);
651
+ }));
652
+ test("actual failed command not replayed", () => runFx((fx) => {
653
+ const [info, , r] = fx.runSpec(fx.spec("v1", "deploy", "process.exit(7);\n"));
654
+ assert.equal(r.status, "partial", JSON.stringify(r));
655
+ const dep = model.load(fx.state).deployments["app-a-prod"];
656
+ assert.equal(dep.status, "failed");
657
+ assert.equal(dep.observed_version, null);
658
+ const again = execution.apply(fx.state, info.run_id, { resume: true });
659
+ assert.equal(again.status, "failed");
660
+ assert.ok(JSON.stringify(again).includes("not replayed"));
661
+ }));
662
+ test("unknown identity blocks target before data", () => runFx((fx) => {
663
+ const [info] = fx.plan();
664
+ fx.approve(info);
665
+ const wrong = { ...fx.host, identity: "0".repeat(64) };
666
+ assert.throws(() => transport.call(wrong, { action: "probe" }), OpsError);
667
+ assert.equal(existsSync(fx.target), false);
668
+ }));
669
+ test("remote file drift stops before overwrite", () => runFx((fx) => {
670
+ const [info] = fx.plan();
671
+ fx.approve(info);
672
+ const dest = join(fx.target, "app-a", "env", "app.env");
673
+ mkdirSync(dirname(dest), { recursive: true });
674
+ writeFileSync(dest, "UNAPPROVED=1\n");
675
+ const r = execution.apply(fx.state, info.run_id);
676
+ assert.equal(r.status, "failed");
677
+ assert.equal(readFileSync(dest, "utf8"), "UNAPPROVED=1\n");
678
+ }));
679
+ test("journal tamper detected", () => runFx((fx) => {
680
+ const [info, , r] = fx.runSpec();
681
+ const j = join(dirname(info.plan_path), "journal.jsonl");
682
+ const rows = readFileSync(j, "utf8").split(/\r?\n/).filter(Boolean);
683
+ const value = JSON.parse(rows[0]);
684
+ value.kind = "tampered";
685
+ rows[0] = JSON.stringify(value);
686
+ writeFileSync(j, rows.join("\n") + "\n");
687
+ assert.throws(() => execution.verifyJournal(j), OpsError);
688
+ }));
689
+ test("quarantine then separate purge", () => runFx((fx) => {
690
+ let hspec = {
691
+ schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Disposable cache fixture",
692
+ rollback_note: "Keep isolated cache until separate purge is approved.",
693
+ host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache", path: "_host/cache/demo/cache.txt", content: "Disposable cache" }],
694
+ };
695
+ let r, info;
696
+ [, , r] = fx.runSpec(hspec);
697
+ assert.equal(r.status, "completed", JSON.stringify(r));
698
+ hspec = { ...hspec, host_actions: [{ host_id: "node-a", kind: "quarantine", reason: "Explicit isolate", path: join(fx.target, "_host", "cache", "demo", "cache.txt") }] };
699
+ [info, , r] = fx.runSpec(hspec);
700
+ assert.equal(r.status, "completed", JSON.stringify(r));
701
+ const q = join(fx.target, "_host", "quarantine", info.run_id, "item-0001");
702
+ assert.equal(existsSync(q), true);
703
+ hspec = { ...hspec, host_actions: [{ host_id: "node-a", kind: "purge-quarantine", reason: "Separate irreversible deletion of this one approved cache fixture", path: q }] };
704
+ [, , r] = fx.runSpec(hspec);
705
+ assert.equal(r.status, "completed", JSON.stringify(r));
706
+ assert.equal(existsSync(q), false);
707
+ }));
708
+
709
+ function agentFixture() {
710
+ const tmp = mkdtempSync(join(tmpdir(), "ops-agent-test-"));
711
+ const root = join(tmp, "target");
712
+ const req = { action: "lock", root, identity: agent.fingerprint(), host_id: "node-a", run_id: "run-test", controller_id: "control-a", plan_digest: "a".repeat(64), external_files: [] };
713
+ agent.main(req);
714
+ return {
715
+ tmp, root, req,
716
+ step: (op) => agent.main({ ...req, action: "step", operation: op, operation_digest: digest(op) }),
717
+ cleanup: () => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} },
718
+ };
719
+ }
720
+
721
+ test("same receipt is not reexecuted", () => {
722
+ const fx = agentFixture();
723
+ try {
724
+ const op = { step_id: "step-1", kind: "write", path: join(fx.root, "a.txt"), content_b64: Buffer.from("A").toString("base64"), expected: { kind: "absent" } };
725
+ const one = fx.step(op); const two = fx.step(op);
726
+ assert.deepEqual(one, two);
727
+ } finally { fx.cleanup(); }
728
+ });
729
+ test("started only is unknown", () => {
730
+ const fx = agentFixture();
731
+ try {
732
+ const p = join(fx.root, "_host", "runs", "run-test", "receipts");
733
+ mkdirSync(p, { recursive: true });
734
+ writeFileSync(join(p, "step-1.started.json"), "{}");
735
+ const result = fx.step({ step_id: "step-1", kind: "mkdir", path: join(fx.root, "x") });
736
+ assert.equal(result.status, "unknown");
737
+ assert.equal(existsSync(join(fx.root, "x")), false);
738
+ } finally { fx.cleanup(); }
739
+ });
740
+ test("target lock other owner rejected", () => {
741
+ const fx = agentFixture();
742
+ try { assert.throws(() => agent.main({ ...fx.req, run_id: "run-other" }), agent.Failure); }
743
+ finally { fx.cleanup(); }
744
+ });
745
+ test("write beyond root fails", () => {
746
+ const fx = agentFixture();
747
+ try {
748
+ const result = fx.step({ step_id: "step-1", kind: "write", path: join(dirname(fx.root), "outside"), content_b64: "WA==", expected: { kind: "absent" } });
749
+ assert.equal(result.status, "failed");
750
+ } finally { fx.cleanup(); }
751
+ });
752
+ test("identical bytes still secure permissions", () => {
753
+ const fx = agentFixture();
754
+ try {
755
+ const p = join(fx.root, "secret");
756
+ writeFileSync(p, "X");
757
+ if (process.platform !== "win32") chmodSync(p, 0o644);
758
+ const result = fx.step({ step_id: "step-1", kind: "write", path: p, content_b64: "WA==", expected: agent.fileState(p), mode: 0o600 });
759
+ assert.equal(result.status, "succeeded");
760
+ if (process.platform !== "win32") assert.equal(statSync(p).mode & 0o777, 0o600);
761
+ } finally { fx.cleanup(); }
762
+ });
763
+ test("quarantine data forbidden", () => {
764
+ const fx = agentFixture();
765
+ try {
766
+ const p = join(fx.root, "app-a", "data", "logs", "a");
767
+ mkdirSync(dirname(p), { recursive: true });
768
+ writeFileSync(p, "business");
769
+ const result = fx.step({ step_id: "step-1", kind: "quarantine", path: p, item_id: "item-1", expected: agent.fileState(p) });
770
+ assert.equal(result.status, "failed");
771
+ assert.equal(existsSync(p), true);
772
+ } finally { fx.cleanup(); }
773
+ });
774
+ test("purge needs isolation receipt", () => {
775
+ const fx = agentFixture();
776
+ try {
777
+ const p = join(fx.root, "_host", "quarantine", "run-old", "item-1");
778
+ mkdirSync(dirname(p), { recursive: true });
779
+ writeFileSync(p, "unknown");
780
+ const result = fx.step({ step_id: "step-1", kind: "purge-quarantine", path: p, expected: agent.treeState(p) });
781
+ assert.equal(result.status, "failed");
782
+ } finally { fx.cleanup(); }
783
+ });
784
+ test("mirror requires https", () => {
785
+ assert.throws(() => agent.benchmarkMirrors({ candidates: [{ id: "x", ecosystem: "python", url: "http://example.invalid/a", expected_sha256: "0".repeat(64), trust: "official", approved: true }] }), agent.Failure);
786
+ });
787
+ test("mirror requires approval", () => {
788
+ assert.throws(() => agent.benchmarkMirrors({ candidates: [{ id: "x", ecosystem: "python", url: "https://example.invalid/a", expected_sha256: "0".repeat(64), trust: "official", approved: false }] }), agent.Failure);
789
+ });
790
+ test("analyze does not run repo", () => {
791
+ const fx = agentFixture();
792
+ try {
793
+ const source = join(fx.root, "source");
794
+ mkdirSync(source);
795
+ writeFileSync(join(source, "package.json"), '{"scripts":{"postinstall":"NEVER-RUN"}}');
796
+ writeFileSync(join(source, ".env"), PASSWORD);
797
+ const result = analyze(source);
798
+ assert.deepEqual(result.detected, ["node"]);
799
+ assert.ok(!JSON.stringify(result).includes(PASSWORD));
800
+ assert.equal(result.evidence.length, 1);
801
+ } finally { fx.cleanup(); }
802
+ });
803
+
804
+ test("ssh contract", () => {
805
+ const d = mkdtempSync(join(tmpdir(), "ops-ssh-"));
806
+ const kh = join(d, "known_hosts");
807
+ writeFileSync(kh, "fixture pinned key\n");
808
+ const h = { host_id: "node-a", platform: "linux", transport: "ssh", identity: "a".repeat(64), connection: { hostname: "host.invalid", username: "ops", known_hosts: kh, node: "node" } };
809
+ const captured = {};
810
+ const prev = transport.transportHooks.spawnSync;
811
+ transport.transportHooks.spawnSync = (cmd, args, kw) => {
812
+ captured.argv = [cmd, ...args];
813
+ Object.assign(captured, kw);
814
+ return { status: 0, stdout: '{"ok":true,"result":{"scope":"simulated"}}', stderr: "" };
815
+ };
816
+ try {
817
+ transport.call(h, { action: "step", secrets: [PASSWORD] });
818
+ const joined = captured.argv.join(" ");
819
+ assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
820
+ assert.ok(captured.argv.includes("BatchMode=yes"));
821
+ assert.ok(!joined.includes(PASSWORD));
822
+ assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
823
+ assert.ok(Buffer.isBuffer(captured.input));
824
+ assert.equal(captured.encoding, "buffer");
825
+ } finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
826
+ });
827
+
828
+ test("knownhosts digest drift", () => {
829
+ const d = mkdtempSync(join(tmpdir(), "ops-kh-"));
830
+ const kh = join(d, "known_hosts");
831
+ writeFileSync(kh, "first");
832
+ const h = { transport: "ssh", connection: { known_hosts: kh } };
833
+ const before = transport.hostTransportDigest(h);
834
+ writeFileSync(kh, "second");
835
+ assert.notEqual(before, transport.hostTransportDigest(h));
836
+ rmSync(d, { recursive: true, force: true });
837
+ });
838
+
839
+ test("command keeps raw docker json when secrets appear", () => {
840
+ const fx = agentFixture();
841
+ try {
842
+ const secret = "1234";
843
+ const json = JSON.stringify({ State: { Status: "running" }, Env: ["MYSQL_ROOT_PASSWORD=" + secret] });
844
+ const prev = agent.agentHooks.spawnSync;
845
+ agent.agentHooks.spawnSync = () => ({ status: 0, stdout: Buffer.from(json), stderr: Buffer.alloc(0) });
846
+ try {
847
+ const r = agent.command(["docker", "inspect", "x"], { cwd: fx.root, secrets: [secret] });
848
+ assert.equal(JSON.parse(r.stdout).Env[0], "MYSQL_ROOT_PASSWORD=" + secret);
849
+ assert.ok(!r.log_stdout.includes(secret));
850
+ assert.equal(agent.parseDockerJson(r.stdout, "inspect").State.Status, "running");
851
+ } finally { agent.agentHooks.spawnSync = prev; }
852
+ } finally { fx.cleanup(); }
853
+ });
854
+
855
+ test("command stdin is buffer with encoding buffer", () => {
856
+ const fx = agentFixture();
857
+ try {
858
+ const captured = {};
859
+ const prev = agent.agentHooks.spawnSync;
860
+ agent.agentHooks.spawnSync = (_cmd, _args, kw) => {
861
+ Object.assign(captured, kw);
862
+ return { status: 0, stdout: Buffer.from("ok"), stderr: Buffer.alloc(0) };
863
+ };
864
+ try {
865
+ agent.command(["echo"], { cwd: fx.root, stdin: "hello" });
866
+ assert.ok(Buffer.isBuffer(captured.input));
867
+ assert.equal(captured.input.toString("utf8"), "hello");
868
+ assert.equal(captured.encoding, "buffer");
869
+ } finally { agent.agentHooks.spawnSync = prev; }
870
+ } finally { fx.cleanup(); }
871
+ });
872
+
873
+ test("inspect container gate requires running healthy", () => {
874
+ agent.inspectContainerGate({ Name: "/ok", State: { Status: "running" } });
875
+ agent.inspectContainerGate({ Name: "/okh", State: { Status: "running", Health: { Status: "healthy" } } });
876
+ assert.throws(() => agent.inspectContainerGate({ Name: "/exited", State: { Status: "exited" } }), agent.Failure);
877
+ assert.throws(() => agent.inspectContainerGate({ Name: "/oom", State: { Status: "running", OOMKilled: true } }), agent.Failure);
878
+ assert.throws(() => agent.inspectContainerGate({ Name: "/restart", State: { Status: "running", Restarting: true } }), agent.Failure);
879
+ assert.throws(() => agent.inspectContainerGate({ Name: "/starting", State: { Status: "running", Health: { Status: "starting" } } }), agent.Failure);
880
+ });
881
+
882
+ test("control file helpers", () => {
883
+ assert.equal(controlFiles.isBuiltinControlPath("/etc/docker/daemon.json"), true);
884
+ assert.equal(controlFiles.isDeclaredControlPath("/etc/nginx/conf.d/app.conf"), true);
885
+ assert.equal(controlFiles.isDeclaredControlPath("/etc/wireguard/wg0.conf"), true);
886
+ assert.equal(controlFiles.defaultFileMode("config/redis.conf"), 0o644);
887
+ assert.equal(controlFiles.defaultFileMode("env/app.env"), 0o600);
888
+ assert.throws(() => controlFiles.assertSafeControlPath("/etc/shadow"), OpsError);
889
+ assert.throws(() => controlFiles.assertSafeControlPath("/etc/systemd/system/sshd.service", { reason: "reviewed unit", rollback: "restore package unit" }), OpsError);
890
+ });
891
+
892
+ test("disjoint host plans apply sequentially", () => runFx((fx) => {
893
+ model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
894
+ const specA = {
895
+ schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Host A cache fixture",
896
+ rollback_note: "Keep isolated cache until a separate purge is approved.",
897
+ host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache A", path: "_host/cache/a/cache.txt", content: "A" }],
898
+ };
899
+ const specB = {
900
+ schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-b"], reason: "Host B cache fixture",
901
+ rollback_note: "Keep isolated cache until a separate purge is approved.",
902
+ host_actions: [{ host_id: "node-b", kind: "write-file", reason: "Create only fixture cache B", path: "_host/cache/b/cache.txt", content: "B" }],
903
+ };
904
+ const [infoA] = fx.plan(specA);
905
+ const [infoB] = fx.plan(specB);
906
+ fx.approve(infoA);
907
+ fx.approve(infoB);
908
+ const rB = execution.apply(fx.state, infoB.run_id);
909
+ assert.equal(rB.status, "completed", JSON.stringify(rB));
910
+ const rA = execution.apply(fx.state, infoA.run_id);
911
+ assert.equal(rA.status, "completed", JSON.stringify(rA));
912
+ assert.equal(readFileSync(join(fx.target, "_host", "cache", "a", "cache.txt"), "utf8"), "A");
913
+ assert.equal(readFileSync(join(fx.tmp, "server-b", "_host", "cache", "b", "cache.txt"), "utf8"), "B");
914
+ }));
915
+
916
+ test("host and fleet handbooks are markdown service tables", () => runFx((fx) => {
917
+ const s = fx.graph();
918
+ s.hosts["node-a"].host_services = [{
919
+ id: "wg0", kind: "wireguard", unit: "wg-quick@wg0", listen_port: 51820,
920
+ tunnel_address: "10.77.0.2/24", config_path: "/etc/wireguard/wg0.conf",
921
+ notes: "入口隧道,不是 Clash 出口",
922
+ }, {
923
+ id: "nginx-public", kind: "nginx", unit: "nginx.service", listen_port: 57492,
924
+ public_url: "http://203.0.113.10:57492/", config_path: "/etc/nginx/conf.d/app.conf",
925
+ }];
926
+ s.public_ingress = {
927
+ schema_version: 1, title: "公网访问内网", path: "knowledge/public-ingress.json",
928
+ not_the_same_as: "Clash TUN 是出口",
929
+ mappings: [{ public: "http://203.0.113.10:57492/", layer4: "57492/tcp", via: ["nginx-public", "wg0"], backend: "10.77.0.2:8080" }],
930
+ open_http_checklist: ["在入口主机增加 Nginx listen", "更新 WG AllowedIPs"],
931
+ forbidden_ports: [22, 3306],
932
+ };
933
+ const fleet = docs.fleetDocument(s, ["node-a"], "run-test", now(), { ledger: model.ledgerLoad(fx.state), includeCredentials: true });
934
+ const server = docs.hostReadme(s, "node-a", "run-test", now(), { full: true });
935
+ for (const text of [fleet, server, docs.code("/srv/ops")]) {
936
+ assert.ok(!text.includes("<code>"));
937
+ assert.ok(!text.includes("<br>"));
938
+ }
939
+ assert.ok(fleet.includes("服务一览"));
940
+ assert.ok(fleet.includes("说明"));
941
+ assert.ok(fleet.includes("公网访问内网"));
942
+ assert.ok(fleet.includes("10.77.0.2:8080"));
943
+ assert.ok(fleet.includes("wg-quick@wg0"));
944
+ assert.ok(fleet.includes("新开公网 HTTP"));
945
+ assert.ok(fleet.includes(PASSWORD));
946
+ assert.ok(!server.includes(PASSWORD));
947
+ assert.ok(server.includes("服务一览"));
948
+ assert.ok(docs.hostServicesDocument(s.hosts["node-a"]).includes("wg0"));
949
+ }));
950
+
951
+ test("host services and public ingress are delivered", () => runFx((fx) => {
952
+ const host = { ...fx.host, host_services: [{
953
+ id: "wg0", kind: "wireguard", unit: "wg-quick@wg0", listen_port: 51820,
954
+ tunnel_address: "10.77.0.2/24", config_path: "/etc/wireguard/wg0.conf",
955
+ }] };
956
+ const spec = {
957
+ schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"],
958
+ reason: "Record host ingress ledger",
959
+ rollback_note: "Keep previous host-services ledger until a new plan replaces it.",
960
+ resource_updates: { hosts: [host] },
961
+ public_ingress: {
962
+ schema_version: 1, title: "公网访问内网", path: "knowledge/public-ingress.json",
963
+ mappings: [{ public: "http://203.0.113.10:57492/", layer4: "57492/tcp", via: ["wg0"], backend: "10.77.0.2:8080" }],
964
+ open_http_checklist: ["更新 Nginx 与 WG", "刷新 public-ingress.json"],
965
+ forbidden_ports: [22],
966
+ },
967
+ host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache", path: "_host/cache/demo/cache.txt", content: "cache" }],
968
+ };
969
+ const [, , r] = fx.runSpec(spec);
970
+ assert.equal(r.status, "completed", JSON.stringify(r));
971
+ const catalog = model.load(fx.state);
972
+ assert.equal(catalog.hosts["node-a"].host_services[0].id, "wg0");
973
+ assert.equal(catalog.public_ingress.mappings[0].backend, "10.77.0.2:8080");
974
+ const remoteServices = readJson(join(fx.target, "knowledge", "host-services.json"));
975
+ assert.equal(remoteServices.services[0].unit, "wg-quick@wg0");
976
+ const remoteIngress = readJson(join(fx.target, "knowledge", "public-ingress.json"));
977
+ assert.ok(remoteIngress.mappings[0].public.includes("57492"));
978
+ const fleet = readFileSync(join(fx.state, "FLEET-DEPLOYMENTS.md"), "utf8");
979
+ assert.ok(fleet.includes("服务一览"));
980
+ assert.ok(fleet.includes("公网访问内网"));
981
+ assert.ok(!fleet.includes("<code>"));
982
+ }));