@namewta/speculo 1.0.5 → 1.0.7
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/README.md +1 -1
- package/dist/src/kernel.d.ts +23 -2
- package/dist/src/kernel.js +9 -0
- package/dist/src/kernel.js.map +1 -1
- package/dist/src/ops-resources.d.ts +3 -0
- package/dist/src/ops-resources.js +188 -0
- package/dist/src/ops-resources.js.map +1 -0
- package/dist/src/refresh.js +20 -2
- package/dist/src/refresh.js.map +1 -1
- package/dist/src/structured.js +7 -0
- package/dist/src/structured.js.map +1 -1
- package/dist/src/workflows.js +1 -1
- package/dist/src/workflows.js.map +1 -1
- package/package.json +1 -1
- package/template/.speculo/README.md +3 -3
- package/template/.speculo/kernel/README.md +3 -0
- package/template/.speculo/kernel/checkpoint.schema.json +138 -4
- package/template/commands/archive-and-consolidate.md +5 -1
- package/template/commands/status.md +2 -2
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +34 -0
- package/template/workflows/ops/H-host-manage/H-host-manage.md +30 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +28 -0
- package/template/workflows/ops/INDEX.md +15 -17
- package/template/workflows/ops/README.md +59 -93
- package/template/workflows/ops/_state/status.json +15 -3
- package/template/workflows/ops/common/CAPABILITIES.md +23 -0
- package/template/workflows/ops/common/USAGE.md +112 -0
- package/template/workflows/ops/common/examples/README.md +7 -0
- package/template/workflows/ops/common/examples/binding.example.json +12 -0
- package/template/workflows/ops/common/examples/compose-app.example.json +56 -0
- package/template/workflows/ops/common/examples/credential.example.json +9 -0
- package/template/workflows/ops/common/examples/environment-request.example.json +7 -0
- package/template/workflows/ops/common/examples/register.example.json +44 -0
- package/template/workflows/ops/common/examples/shared-allocation.example.json +37 -0
- package/template/workflows/ops/common/rules/activation-and-memory.md +5 -20
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +27 -0
- package/template/workflows/ops/common/rules/recovery.md +15 -0
- package/template/workflows/ops/common/rules/shared-services.md +13 -0
- package/template/workflows/ops/common/schemas/allocation.schema.json +92 -0
- package/template/workflows/ops/common/schemas/approval.schema.json +41 -21
- package/template/workflows/ops/common/schemas/binding.schema.json +73 -0
- package/template/workflows/ops/common/schemas/deployment.schema.json +244 -0
- package/template/workflows/ops/common/schemas/host.schema.json +88 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +1573 -0
- package/template/workflows/ops/common/schemas/project.schema.json +55 -11
- package/template/workflows/ops/common/schemas/spec.schema.json +1032 -0
- package/template/workflows/ops/common/schemas/status.schema.json +758 -17
- package/template/workflows/ops/common/service-profiles/custom.md +5 -0
- package/template/workflows/ops/common/service-profiles/docker-engine.md +11 -0
- package/template/workflows/ops/common/service-profiles/minio.md +7 -0
- package/template/workflows/ops/common/service-profiles/mysql.md +7 -0
- package/template/workflows/ops/common/service-profiles/redis.md +7 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +13 -0
- package/template/workflows/ops/common/templates/EXECUTION-PLAN.md +15 -0
- package/template/workflows/ops/common/templates/HOST-README.md +11 -0
- package/template/workflows/ops/common/templates/PROJECT-README.md +15 -0
- package/template/workflows/ops/common/tests/test_ops.mjs +752 -0
- package/template/workflows/ops/common/tools/bootstrap.ps1 +18 -0
- package/template/workflows/ops/common/tools/bootstrap.sh +27 -0
- package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
- package/template/workflows/ops/common/tools/ops.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/agent.mjs +873 -0
- package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
- package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
- package/template/workflows/ops/common/tools/opslib/docs.mjs +252 -0
- package/template/workflows/ops/common/tools/opslib/execution.mjs +378 -0
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
- package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
- package/template/workflows/ops/common/tools/opslib/native_windows.mjs +36 -0
- package/template/workflows/ops/common/tools/opslib/planner.mjs +687 -0
- package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
- package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
- package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +43 -1012
- package/template/workflows/ops/manifest.json +60 -1
- package/template/workflows/ops/runtime-contract.json +1 -6
- package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +0 -73
- package/template/workflows/ops/A-archive-and-learn/promotion-plan-template.md +0 -41
- package/template/workflows/ops/A-archive-and-learn/retrospective-template.md +0 -53
- package/template/workflows/ops/E-execute-and-stabilize/E-execute-and-stabilize.md +0 -82
- package/template/workflows/ops/E-execute-and-stabilize/attempt-summary-template.md +0 -44
- package/template/workflows/ops/E-execute-and-stabilize/diagnosis-template.md +0 -24
- package/template/workflows/ops/E-execute-and-stabilize/handoff-template.md +0 -32
- package/template/workflows/ops/E-execute-and-stabilize/rollback-template.md +0 -26
- package/template/workflows/ops/E-execute-and-stabilize/verification-state-template.json +0 -30
- package/template/workflows/ops/E-execute-and-stabilize/verification-template.md +0 -55
- package/template/workflows/ops/H-computer-hygiene/H-computer-hygiene.md +0 -105
- package/template/workflows/ops/H-computer-hygiene/examples/README.md +0 -9
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.environment.md +0 -45
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.json +0 -970
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.md +0 -216
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.environment.md +0 -45
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.json +0 -970
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.md +0 -216
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.environment.md +0 -45
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.json +0 -969
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.md +0 -216
- package/template/workflows/ops/H-computer-hygiene/references/00-sources.md +0 -58
- package/template/workflows/ops/H-computer-hygiene/references/01-safety.md +0 -52
- package/template/workflows/ops/H-computer-hygiene/references/10-windows.md +0 -47
- package/template/workflows/ops/H-computer-hygiene/references/11-macos.md +0 -46
- package/template/workflows/ops/H-computer-hygiene/references/12-linux.md +0 -13
- package/template/workflows/ops/H-computer-hygiene/references/20-toolchains.md +0 -83
- package/template/workflows/ops/H-computer-hygiene/references/21-path-migration.md +0 -64
- package/template/workflows/ops/H-computer-hygiene/references/22-repositories.md +0 -35
- package/template/workflows/ops/H-computer-hygiene/references/30-software-removal.md +0 -41
- package/template/workflows/ops/H-computer-hygiene/references/40-report-spec.md +0 -54
- package/template/workflows/ops/H-computer-hygiene/references/50-speculo-integration.md +0 -26
- package/template/workflows/ops/H-computer-hygiene/references/source-register.json +0 -406
- package/template/workflows/ops/H-computer-hygiene/rules/catalog.json +0 -191
- package/template/workflows/ops/H-computer-hygiene/scripts/hygiene.py +0 -1348
- package/template/workflows/ops/H-computer-hygiene/scripts/run.ps1 +0 -7
- package/template/workflows/ops/H-computer-hygiene/scripts/run.sh +0 -5
- package/template/workflows/ops/H-computer-hygiene/templates/config.example.json +0 -13
- package/template/workflows/ops/H-computer-hygiene/templates/maven-settings.fragment.xml +0 -6
- package/template/workflows/ops/H-computer-hygiene/templates/native-evidence.example.json +0 -19
- package/template/workflows/ops/H-computer-hygiene/tests/cli_smoke.py +0 -65
- package/template/workflows/ops/H-computer-hygiene/tests/make_examples.py +0 -114
- package/template/workflows/ops/H-computer-hygiene/tests/test_hygiene.py +0 -486
- package/template/workflows/ops/I-intake-and-assess/I-intake-and-assess.md +0 -73
- package/template/workflows/ops/I-intake-and-assess/change-status-template.json +0 -26
- package/template/workflows/ops/I-intake-and-assess/collector-catalog.md +0 -28
- package/template/workflows/ops/I-intake-and-assess/deployment-dossier-template.md +0 -62
- package/template/workflows/ops/I-intake-and-assess/global-change-status-template.json +0 -26
- package/template/workflows/ops/I-intake-and-assess/project-detection.md +0 -38
- package/template/workflows/ops/I-intake-and-assess/request-template.md +0 -44
- package/template/workflows/ops/I-intake-and-assess/system-report-template.md +0 -34
- package/template/workflows/ops/I-intake-and-assess/target-profile-template.json +0 -24
- package/template/workflows/ops/P-plan-and-approve/P-plan-and-approve.md +0 -72
- package/template/workflows/ops/P-plan-and-approve/plan-review-template.md +0 -78
- package/template/workflows/ops/_state/archive/.gitkeep +0 -1
- package/template/workflows/ops/_state/changes/.gitkeep +0 -1
- package/template/workflows/ops/common/rules/artifact-contract.md +0 -38
- package/template/workflows/ops/common/rules/closure-and-learning.md +0 -27
- package/template/workflows/ops/common/rules/evidence-and-redaction.md +0 -22
- package/template/workflows/ops/common/rules/execution-loop.md +0 -27
- package/template/workflows/ops/common/rules/path-and-scope-contract.md +0 -21
- package/template/workflows/ops/common/rules/plan-and-approval.md +0 -25
- package/template/workflows/ops/common/rules/project-and-change-scope.md +0 -20
- package/template/workflows/ops/common/rules/target-profile-and-release-gates.md +0 -44
- package/template/workflows/ops/common/schemas/attempt.schema.json +0 -42
- package/template/workflows/ops/common/schemas/change-status.schema.json +0 -43
- package/template/workflows/ops/common/schemas/deployment-model.schema.json +0 -26
- package/template/workflows/ops/common/schemas/implementation-plan.schema.json +0 -221
- package/template/workflows/ops/common/schemas/inventory-snapshot.schema.json +0 -31
- package/template/workflows/ops/common/schemas/journal-event.schema.json +0 -22
- package/template/workflows/ops/common/schemas/promotion-approval.schema.json +0 -20
- package/template/workflows/ops/common/schemas/promotion-manifest.schema.json +0 -22
- package/template/workflows/ops/common/schemas/target-profile.schema.json +0 -32
- package/template/workflows/ops/common/schemas/verification-state.schema.json +0 -30
- package/template/workflows/ops/common/tools/close-change.mjs +0 -177
|
@@ -0,0 +1,752 @@
|
|
|
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 { initialize, analyze, cliHooks } from "../tools/opslib/cli.mjs";
|
|
17
|
+
import { taskFiles } from "../tools/opslib/native_windows.mjs";
|
|
18
|
+
|
|
19
|
+
const { OpsError, UnknownResult, digest, writeJson, readJson, withLock, relative, identifier, rootPath, targetJoin, emptyStatus, now, resolveSecrets, redact, atomicWrite, canonical } = core;
|
|
20
|
+
const PASSWORD = 'TEST-ONLY-$literal-quote"-slash\\-not-production';
|
|
21
|
+
|
|
22
|
+
function deployment(project = "app-a", did = "app-a-prod", root = "/srv/ops", method = "native", host = null) {
|
|
23
|
+
host = host || { platform: "linux", root };
|
|
24
|
+
const depRoot = targetJoin(host, project);
|
|
25
|
+
return {
|
|
26
|
+
deployment_id: did, project_id: project, host_id: "node-a", environment: "prod", instance: "main", layout: "flat",
|
|
27
|
+
method, version: "v1", observed_version: "v1", root: depRoot, status: "completed",
|
|
28
|
+
installed_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", run_id: "run-old",
|
|
29
|
+
compose_name: method === "compose" ? "ops-" + did : null, storage: [], commands: { start: [], stop: [], verify: [] },
|
|
30
|
+
credential_refs: [], backup: "A project-specific backup is required.", recovery: "No automatic shared-database restore.",
|
|
31
|
+
notes: [], source: { type: "local", location: "/source/" + project, revision: "v1" }, service: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function minimalInventory(host, request) {
|
|
36
|
+
const snapshots = {};
|
|
37
|
+
for (const p of request.paths || []) {
|
|
38
|
+
snapshots[p] = (request.deep_paths || []).includes(p) ? agent.treeState(p) : agent.fileState(p);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
identity: host.identity, observed_at: now(), platform: host.platform, hostname: "fixture", architecture: "fixture",
|
|
42
|
+
account: "fixture", uid: null, node: process.execPath, tools: {}, defaults: {},
|
|
43
|
+
diagnostics: { memory: {}, issues: [], disks: {} },
|
|
44
|
+
docker_control: {
|
|
45
|
+
status: "observed", context: "fixture-context", endpoint: "unix:///fixture/docker.sock",
|
|
46
|
+
id: "fixture-docker-id", data_root: targetJoin(host, "_runtime/docker"), compose_version: "2.30.0",
|
|
47
|
+
},
|
|
48
|
+
snapshots,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setupFixture() {
|
|
53
|
+
const tmp = mkdtempSync(join(tmpdir(), "ops-tests-"));
|
|
54
|
+
const state = join(tmp, "controller");
|
|
55
|
+
const target = join(tmp, "server");
|
|
56
|
+
const prevProbe = cliHooks.probeLocal;
|
|
57
|
+
cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock-initial-inventory-only" });
|
|
58
|
+
initialize(state, "control-a");
|
|
59
|
+
cliHooks.probeLocal = prevProbe;
|
|
60
|
+
const platform = process.platform === "win32" ? "windows" : "linux";
|
|
61
|
+
const host = {
|
|
62
|
+
host_id: "node-a", display_name: "Node A", platform, transport: "local",
|
|
63
|
+
root: target, identity: agent.fingerprint(), connection: {},
|
|
64
|
+
};
|
|
65
|
+
const project = {
|
|
66
|
+
project_id: "app-a", display_name: "APP A", kind: "app", service_type: null,
|
|
67
|
+
source: { type: "local", location: join(tmp, "source"), revision: "v1" },
|
|
68
|
+
};
|
|
69
|
+
model.register(state, { hosts: [host], projects: [project] });
|
|
70
|
+
model.putCredential(state, { credential_id: "app-auth", version: 1, purpose: "Synthetic test only", values: { username: "app-user", password: PASSWORD } });
|
|
71
|
+
const spec = (version = "v1", operation = "deploy", program = null) => {
|
|
72
|
+
if (program == null) {
|
|
73
|
+
program = `import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
const dir = join(process.env.OPS_DATA_ROOT, "app", "storage");
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const p = join(dir, "value.json");
|
|
78
|
+
const n = existsSync(p) ? JSON.parse(readFileSync(p, "utf8")).count + 1 : 1;
|
|
79
|
+
if (process.env.APP_USERNAME !== "app-user") throw new Error("username");
|
|
80
|
+
if (!process.env.APP_PASSWORD.startsWith("TEST-ONLY-")) throw new Error("password");
|
|
81
|
+
writeFileSync(p, JSON.stringify({ count: n, version: ${JSON.stringify(version)} }));
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
schema_version: 1, worker: "D", operation, reason: "Disposable isolated test only",
|
|
86
|
+
rollback_note: "Retain old data; use a new explicitly approved recovery plan.",
|
|
87
|
+
deployments: [{
|
|
88
|
+
deployment_id: "app-a-prod", project_id: "app-a", host_id: "node-a", environment: "prod", instance: "main",
|
|
89
|
+
layout: "flat", method: "native", version,
|
|
90
|
+
files: [{ path: "artifact/main.mjs", content: program }],
|
|
91
|
+
env: { "app.env": { APP_USERNAME: "{{credential:app-auth@1:username}}", APP_PASSWORD: "{{credential:app-auth@1:password}}" } },
|
|
92
|
+
native: { supervisor: "oneshot", argv: [process.execPath, "{{artifact}}/main.mjs"] },
|
|
93
|
+
credential_refs: ["app-auth@1"],
|
|
94
|
+
health: [{ type: "file", path: "data/app/storage/value.json" }],
|
|
95
|
+
backup: "Back up only after process exit; this fixture has no shared data.",
|
|
96
|
+
recovery: "A new matching-version plan preserves and verifies data.",
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
const plan = (s = null) => {
|
|
101
|
+
const f = join(tmp, "input.json");
|
|
102
|
+
writeJson(f, s || spec());
|
|
103
|
+
const prev = planner.plannerHooks.call;
|
|
104
|
+
planner.plannerHooks.call = (h, request) => minimalInventory(h, request);
|
|
105
|
+
try {
|
|
106
|
+
const result = planner.compilePlan(state, f);
|
|
107
|
+
return [result, readJson(result.plan_path)];
|
|
108
|
+
} finally { planner.plannerHooks.call = prev; }
|
|
109
|
+
};
|
|
110
|
+
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.");
|
|
111
|
+
const runSpec = (s = null) => {
|
|
112
|
+
const [info, p] = plan(s);
|
|
113
|
+
approve(info);
|
|
114
|
+
const result = execution.apply(state, info.run_id);
|
|
115
|
+
return [info, p, result];
|
|
116
|
+
};
|
|
117
|
+
const graph = () => {
|
|
118
|
+
const s = model.load(state);
|
|
119
|
+
s.deployments["app-a-prod"] = deployment("app-a", "app-a-prod", host.root, "native", host);
|
|
120
|
+
s.deployments["app-a-prod"].credential_refs = ["app-auth@1"];
|
|
121
|
+
s.projects["mysql-main"] = {
|
|
122
|
+
project_id: "mysql-main", display_name: "MySQL", kind: "shared-service", service_type: "mysql",
|
|
123
|
+
source: { type: "image", location: "mysql@sha256:" + "a".repeat(64), revision: "digest-pinned" },
|
|
124
|
+
};
|
|
125
|
+
const d = deployment("mysql-main", "mysql-prod", host.root, "compose", host);
|
|
126
|
+
d.credential_refs = ["mysql-admin@1"];
|
|
127
|
+
d.service = { compose_service: "mysql" };
|
|
128
|
+
s.deployments["mysql-prod"] = d;
|
|
129
|
+
s.allocations["app-db"] = {
|
|
130
|
+
allocation_id: "app-db", provider_deployment_id: "mysql-prod", owner_project_id: "app-a", environment: "prod",
|
|
131
|
+
data_group: "app-a-prod", resource_kind: "database", resource_name: "app_a_prod", credential_ref: "app-auth@1",
|
|
132
|
+
shared_owners: [], status: "active", recovery_scope: "logical", notes: "Fixture logical resource",
|
|
133
|
+
};
|
|
134
|
+
s.bindings["app-db-binding"] = {
|
|
135
|
+
binding_id: "app-db-binding", consumer_deployment_id: "app-a-prod", mode: "shared",
|
|
136
|
+
provider_deployment_id: "mysql-prod", allocation_id: "app-db", endpoint: "mysql-main:3306",
|
|
137
|
+
credential_ref: "app-auth@1", status: "active", component: "mysql", network: "explicit-intranet",
|
|
138
|
+
};
|
|
139
|
+
return s;
|
|
140
|
+
};
|
|
141
|
+
return { tmp, state, target, platform, host, project, spec, plan, approve, runSpec, graph, cleanup: () => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runFx(fn) {
|
|
145
|
+
const fx = setupFixture();
|
|
146
|
+
try { return fn(fx); } finally { fx.cleanup(); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("canonical order", () => {
|
|
150
|
+
assert.equal(digest({ b: 1, a: 2 }), digest({ a: 2, b: 1 }));
|
|
151
|
+
});
|
|
152
|
+
test("nonfinite rejected", () => {
|
|
153
|
+
assert.throws(() => canonical(Number.NaN));
|
|
154
|
+
});
|
|
155
|
+
test("duplicate json rejected", () => {
|
|
156
|
+
const d = mkdtempSync(join(tmpdir(), "ops-dup-"));
|
|
157
|
+
const p = join(d, "x.json");
|
|
158
|
+
writeFileSync(p, '{"x":1,"x":2}');
|
|
159
|
+
assert.throws(() => readJson(p), OpsError);
|
|
160
|
+
rmSync(d, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
test("exclusive artifact", () => {
|
|
163
|
+
const d = mkdtempSync(join(tmpdir(), "ops-ex-"));
|
|
164
|
+
const p = join(d, "a.json");
|
|
165
|
+
writeJson(p, { x: 1 }, { exclusive: true });
|
|
166
|
+
assert.throws(() => writeJson(p, { x: 2 }, { exclusive: true }), OpsError);
|
|
167
|
+
assert.deepEqual(readJson(p), { x: 1 });
|
|
168
|
+
rmSync(d, { recursive: true, force: true });
|
|
169
|
+
});
|
|
170
|
+
test("lock is not autobroken", () => {
|
|
171
|
+
const d = mkdtempSync(join(tmpdir(), "ops-lock-"));
|
|
172
|
+
withLock(join(d, "lock"), { why: "test" }, () => {
|
|
173
|
+
assert.throws(() => withLock(join(d, "lock"), {}, () => {}), OpsError);
|
|
174
|
+
});
|
|
175
|
+
rmSync(d, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
test("symlink rejected", () => {
|
|
178
|
+
const d = mkdtempSync(join(tmpdir(), "ops-link-"));
|
|
179
|
+
mkdirSync(join(d, "real"));
|
|
180
|
+
try { symlinkSync(join(d, "real"), join(d, "link"), "dir"); }
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (process.platform === "win32") { rmSync(d, { recursive: true, force: true }); return; }
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
assert.throws(() => atomicWrite(join(d, "link", "a.txt"), "x"), OpsError);
|
|
186
|
+
rmSync(d, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
test("file permissions", () => {
|
|
189
|
+
const d = mkdtempSync(join(tmpdir(), "ops-perm-"));
|
|
190
|
+
const p = join(d, "secret");
|
|
191
|
+
atomicWrite(p, PASSWORD);
|
|
192
|
+
if (process.platform !== "win32") {
|
|
193
|
+
assert.equal(statSync(p).mode & 0o777, 0o600);
|
|
194
|
+
}
|
|
195
|
+
rmSync(d, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("credential raw values", () => {
|
|
199
|
+
const ledger = { entries: { x: { 1: { values: { password: PASSWORD } } } } };
|
|
200
|
+
assert.equal(resolveSecrets("{{credential:x@1:password}}", ledger), PASSWORD);
|
|
201
|
+
});
|
|
202
|
+
test("credential missing", () => {
|
|
203
|
+
assert.throws(() => resolveSecrets("{{credential:x@1:password}}", { entries: {} }), OpsError);
|
|
204
|
+
});
|
|
205
|
+
test("redaction", () => {
|
|
206
|
+
assert.ok(!redact("pw " + PASSWORD, [PASSWORD]).includes(PASSWORD));
|
|
207
|
+
});
|
|
208
|
+
test("env dollar literal", () => {
|
|
209
|
+
assert.equal(planner.envFile({ P: 'a$$"z' }), 'P=a$$"z\n');
|
|
210
|
+
});
|
|
211
|
+
test("env newline rejected", () => {
|
|
212
|
+
assert.throws(() => planner.envFile({ P: "a\nb" }), OpsError);
|
|
213
|
+
});
|
|
214
|
+
test("env key rejected", () => {
|
|
215
|
+
assert.throws(() => planner.envFile({ "P-INVALID": "a" }), OpsError);
|
|
216
|
+
});
|
|
217
|
+
test("systemd env quote", () => {
|
|
218
|
+
assert.equal(planner.envFile({ P: 'a"\\b' }, { systemd: true }), 'P="a\\"\\\\b"\n');
|
|
219
|
+
});
|
|
220
|
+
test("markdown secret backticks", () => {
|
|
221
|
+
const l = { entries: { x: { 1: { purpose: "test", values: { password: 'a```b|c$"' } } } } };
|
|
222
|
+
assert.ok(docs.credentialsText(["x@1"], l).includes('a```b|c$"'));
|
|
223
|
+
});
|
|
224
|
+
test("windows native json secret", () => {
|
|
225
|
+
const vals = taskFiles("app-a", "C:\\Ops\\app-a", ["python", "C:\\Ops\\app-a\\main.py"], { P: "{{credential:x@1:password}}" }, { account: "X\\User" });
|
|
226
|
+
const original = { kind: "write", json_values: JSON.parse(vals["service/task.json"]), content: vals["service/task.json"] };
|
|
227
|
+
const actual = execution.resolvedOp(original, { entries: { x: { 1: { values: { password: PASSWORD } } } } });
|
|
228
|
+
assert.equal(JSON.parse(Buffer.from(actual.content_b64, "base64").toString("utf8")).environment.P, PASSWORD);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
for (const [index, value] of ["", "../a", "a/../b", "a//b", "/a", "a\\b", "C:a", "a/", "./a", "a\x00b"].entries()) {
|
|
232
|
+
test(`relative reject ${String(index).padStart(2, "0")}`, () => {
|
|
233
|
+
assert.throws(() => relative(value), OpsError);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (const [index, value] of ["app-a", "mysql-main", "a1", "host-22"].entries()) {
|
|
237
|
+
test(`identifier valid ${String(index).padStart(2, "0")}`, () => {
|
|
238
|
+
assert.equal(identifier(value), value);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
for (const [index, value] of ["", "App", "a_b", "-a", "a-", "a/b", "con", "com1", "lpt9", "a".repeat(81)].entries()) {
|
|
242
|
+
test(`identifier reject ${String(index).padStart(2, "0")}`, () => {
|
|
243
|
+
assert.throws(() => identifier(value), OpsError);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
for (const [index, value] of ["/", "/etc", "/var", "/tmp", "/srv", "relative", "/srv/../etc"].entries()) {
|
|
247
|
+
test(`posix root reject ${String(index).padStart(2, "0")}`, () => {
|
|
248
|
+
assert.throws(() => rootPath(value, "linux"), OpsError);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
for (const [index, value] of ["C:\\", "C:\\Windows", "C:\\Users", "\\\\server\\share\\ops", "C:\\Ops\\..\\data", "C:\\Ops:stream"].entries()) {
|
|
252
|
+
test(`windows root reject ${String(index).padStart(2, "0")}`, () => {
|
|
253
|
+
assert.throws(() => rootPath(value, "windows"), OpsError);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
test("empty v3 valid", () => { model.validateStatus(emptyStatus()); });
|
|
258
|
+
|
|
259
|
+
test("resource contracts", async (t) => {
|
|
260
|
+
const each = (name, fn) => t.test(name, () => runFx(fn));
|
|
261
|
+
await each("unknown schema field", (fx) => {
|
|
262
|
+
const s = model.load(fx.state); s.secret = "bad";
|
|
263
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
264
|
+
});
|
|
265
|
+
await each("legacy blocked", (fx) => {
|
|
266
|
+
assert.throws(() => model.validateStatus({ schema_version: 2, active: [], archived: [] }), OpsError);
|
|
267
|
+
});
|
|
268
|
+
await each("credential version immutable", (fx) => {
|
|
269
|
+
assert.throws(() => model.putCredential(fx.state, { credential_id: "app-auth", version: 1, purpose: "x", values: { username: "u", password: "changed" } }), OpsError);
|
|
270
|
+
});
|
|
271
|
+
await each("credential permissions rejected", (fx) => {
|
|
272
|
+
if (process.platform === "win32") return;
|
|
273
|
+
chmodSync(join(fx.state, "private", "credentials.json"), 0o644);
|
|
274
|
+
assert.throws(() => model.ledgerLoad(fx.state), OpsError);
|
|
275
|
+
chmodSync(join(fx.state, "private", "credentials.json"), 0o600);
|
|
276
|
+
});
|
|
277
|
+
await each("valid shared graph", (fx) => { model.validateStatus(fx.graph()); });
|
|
278
|
+
await each("duplicate physical host", (fx) => {
|
|
279
|
+
const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b" };
|
|
280
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
281
|
+
});
|
|
282
|
+
await each("host root nested", (fx) => {
|
|
283
|
+
const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b", root: join(fx.target, "nested") };
|
|
284
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
285
|
+
});
|
|
286
|
+
await each("identity platform mismatch", (fx) => {
|
|
287
|
+
const other = fx.platform === "windows" ? "linux" : "windows";
|
|
288
|
+
const otherRoot = other === "linux" ? "/srv/ops" : "C:\\Ops";
|
|
289
|
+
const s = model.load(fx.state); s.hosts["node-b"] = { ...fx.host, host_id: "node-b", platform: other, root: otherRoot };
|
|
290
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
291
|
+
});
|
|
292
|
+
await each("dedicated cannot claim shared provider", (fx) => {
|
|
293
|
+
const s = fx.graph(); Object.assign(s.bindings["app-db-binding"], { mode: "dedicated", allocation_id: null });
|
|
294
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
295
|
+
});
|
|
296
|
+
await each("project root cannot override", (fx) => {
|
|
297
|
+
const s = fx.graph(); s.deployments["app-a-prod"].root = join(fx.target, "elsewhere");
|
|
298
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
299
|
+
});
|
|
300
|
+
await each("native data outside root", (fx) => {
|
|
301
|
+
const s = fx.graph(); s.deployments["app-a-prod"].storage = [{ component: "app", purpose: "data", path: "/tmp/data" }];
|
|
302
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
303
|
+
});
|
|
304
|
+
await each("flat instances overlap", (fx) => {
|
|
305
|
+
const s = fx.graph();
|
|
306
|
+
const d = structuredClone(s.deployments["app-a-prod"]);
|
|
307
|
+
Object.assign(d, { deployment_id: "app-a-test", layout: "instances", environment: "test", root: join(fx.target, "app-a", "instances", "test", "main") });
|
|
308
|
+
s.deployments[d.deployment_id] = d;
|
|
309
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
310
|
+
});
|
|
311
|
+
await each("orphan provider", (fx) => {
|
|
312
|
+
const s = fx.graph(); s.allocations["app-db"].provider_deployment_id = "missing";
|
|
313
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
314
|
+
});
|
|
315
|
+
await each("duplicate allocation", (fx) => {
|
|
316
|
+
const s = fx.graph(); s.allocations["app-db-copy"] = { ...s.allocations["app-db"], allocation_id: "app-db-copy" };
|
|
317
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
318
|
+
});
|
|
319
|
+
await each("wrong binding password version", (fx) => {
|
|
320
|
+
const s = fx.graph(); s.bindings["app-db-binding"].credential_ref = "app-auth@2";
|
|
321
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
322
|
+
});
|
|
323
|
+
await each("external cannot claim managed data", (fx) => {
|
|
324
|
+
const s = fx.graph(); s.bindings["app-db-binding"].mode = "external";
|
|
325
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
326
|
+
});
|
|
327
|
+
await each("retired provider allocation", (fx) => {
|
|
328
|
+
const s = fx.graph(); s.allocations["app-db"].status = "retired";
|
|
329
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
330
|
+
});
|
|
331
|
+
await each("cross environment not implicit", (fx) => {
|
|
332
|
+
const s = fx.graph(); s.allocations["app-db"].environment = "test";
|
|
333
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
334
|
+
});
|
|
335
|
+
await each("unknown project kind", (fx) => {
|
|
336
|
+
const s = fx.graph(); s.projects["mysql-main"].kind = "other";
|
|
337
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
338
|
+
});
|
|
339
|
+
await each("strict docker cannot be disabled", (fx) => {
|
|
340
|
+
const s = fx.graph(); s.policies.strict_docker_root = false;
|
|
341
|
+
assert.throws(() => model.validateStatus(s), OpsError);
|
|
342
|
+
});
|
|
343
|
+
await each("provider admin app separation", (fx) => {
|
|
344
|
+
const s = fx.graph();
|
|
345
|
+
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);
|
|
346
|
+
});
|
|
347
|
+
await each("mysql no global admin grants", (fx) => {
|
|
348
|
+
const s = fx.graph();
|
|
349
|
+
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);
|
|
350
|
+
});
|
|
351
|
+
await each("mysql explicit scoped grants", (fx) => {
|
|
352
|
+
const s = fx.graph();
|
|
353
|
+
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"] });
|
|
354
|
+
assert.deepEqual(op.privileges, ["SELECT"]);
|
|
355
|
+
assert.equal(op.kind, "mysql-allocation");
|
|
356
|
+
});
|
|
357
|
+
await each("source docs preserve deployment revision", (fx) => {
|
|
358
|
+
const s = fx.graph();
|
|
359
|
+
s.projects["app-a"].source.revision = "newer-project-source";
|
|
360
|
+
const text = docs.deploymentReadme(s, s.deployments["app-a-prod"], "run-test", now());
|
|
361
|
+
assert.ok(!text.includes("newer-project-source"));
|
|
362
|
+
assert.ok(text.includes("v1"));
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("planner contracts", async (t) => {
|
|
367
|
+
const each = (name, fn) => t.test(name, () => runFx(fn));
|
|
368
|
+
await each("plan writes no target", (fx) => {
|
|
369
|
+
const [info, plan] = fx.plan();
|
|
370
|
+
assert.equal(existsSync(fx.target), false);
|
|
371
|
+
assert.ok(!readFileSync(info.report, "utf8").includes(PASSWORD));
|
|
372
|
+
assert.equal(plan.registry_digest, digest(model.load(fx.state)));
|
|
373
|
+
});
|
|
374
|
+
await each("missing health rejected", (fx) => {
|
|
375
|
+
const s = fx.spec(); s.deployments[0].health = [];
|
|
376
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
377
|
+
});
|
|
378
|
+
await each("unknown spec field rejected", (fx) => {
|
|
379
|
+
const s = fx.spec(); s.do_anything = true;
|
|
380
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
381
|
+
});
|
|
382
|
+
await each("inline native code rejected", (fx) => {
|
|
383
|
+
const s = fx.spec(); s.deployments[0].native.argv = [process.execPath, "-c", "print(1)"];
|
|
384
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
385
|
+
});
|
|
386
|
+
await each("native outside arg rejected", (fx) => {
|
|
387
|
+
const s = fx.spec(); s.deployments[0].native.argv = [process.execPath, "/tmp/other.py"];
|
|
388
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
389
|
+
});
|
|
390
|
+
await each("env path escape rejected", (fx) => {
|
|
391
|
+
const s = fx.spec(); s.deployments[0].env["app.env"].APP_DATA_DIR = "/tmp/app-data";
|
|
392
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
393
|
+
});
|
|
394
|
+
await each("env reserved root rejected", (fx) => {
|
|
395
|
+
const s = fx.spec(); s.deployments[0].env["app.env"].OPS_DATA_ROOT = "{{data}}";
|
|
396
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
397
|
+
});
|
|
398
|
+
await each("native multiple env requires order", (fx) => {
|
|
399
|
+
const s = fx.spec(); s.deployments[0].env["extra.env"] = { EXTRA: "1" };
|
|
400
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
401
|
+
});
|
|
402
|
+
await each("unknown env file rejected", (fx) => {
|
|
403
|
+
const s = fx.spec(); s.deployments[0].native.env_files = ["missing.env"];
|
|
404
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
405
|
+
});
|
|
406
|
+
await each("env vars reach native command", (fx) => {
|
|
407
|
+
const [, p] = fx.plan();
|
|
408
|
+
const commands = p.operations.filter((x) => x.kind === "command");
|
|
409
|
+
assert.ok("APP_PASSWORD" in commands[0].env);
|
|
410
|
+
assert.ok(commands[0].env.HOME.startsWith(join(fx.target, "app-a")));
|
|
411
|
+
});
|
|
412
|
+
await each("host command requires writes", (fx) => {
|
|
413
|
+
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);
|
|
414
|
+
});
|
|
415
|
+
await each("host command requires verification", (fx) => {
|
|
416
|
+
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);
|
|
417
|
+
});
|
|
418
|
+
await each("project file traversal", (fx) => {
|
|
419
|
+
const s = fx.spec(); s.deployments[0].files[0].path = "../else.py";
|
|
420
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
421
|
+
});
|
|
422
|
+
await each("plaintext readme cannot be arbitrary file", (fx) => {
|
|
423
|
+
const s = fx.spec(); s.deployments[0].files.push({ path: "README.md", content: "fake success" });
|
|
424
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
425
|
+
});
|
|
426
|
+
await each("resource update root fixed", (fx) => {
|
|
427
|
+
const s = fx.spec(); s.resource_updates = { hosts: [{ ...fx.host, root: join(fx.tmp, "else") }] };
|
|
428
|
+
assert.throws(() => fx.plan(s), OpsError);
|
|
429
|
+
});
|
|
430
|
+
await each("resource source update planned", (fx) => {
|
|
431
|
+
const s = fx.spec();
|
|
432
|
+
const p = structuredClone(fx.project);
|
|
433
|
+
p.source.revision = "v2";
|
|
434
|
+
s.resource_updates = { projects: [p] };
|
|
435
|
+
const [, plan] = fx.plan(s);
|
|
436
|
+
assert.equal(plan.registry_after.deployments["app-a-prod"].source.revision, "v2");
|
|
437
|
+
assert.equal(model.load(fx.state).projects["app-a"].source.revision, "v1");
|
|
438
|
+
});
|
|
439
|
+
await each("wrong approval digest", (fx) => {
|
|
440
|
+
const [info] = fx.plan();
|
|
441
|
+
assert.throws(() => execution.approval(fx.state, info.run_id, "0".repeat(64), "test", "explicit statement"), OpsError);
|
|
442
|
+
});
|
|
443
|
+
await each("state drift blocks approval", (fx) => {
|
|
444
|
+
const [info] = fx.plan();
|
|
445
|
+
model.register(fx.state, {});
|
|
446
|
+
assert.throws(() => fx.approve(info), OpsError);
|
|
447
|
+
});
|
|
448
|
+
await each("approval is immutable", (fx) => {
|
|
449
|
+
const [info] = fx.plan();
|
|
450
|
+
fx.approve(info);
|
|
451
|
+
assert.throws(() => fx.approve(info), OpsError);
|
|
452
|
+
});
|
|
453
|
+
await each("plan tamper blocks apply", (fx) => {
|
|
454
|
+
const [info, p] = fx.plan();
|
|
455
|
+
fx.approve(info);
|
|
456
|
+
p.reason = "changed";
|
|
457
|
+
writeJson(info.plan_path, p);
|
|
458
|
+
assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
|
|
459
|
+
});
|
|
460
|
+
await each("engine drift blocks apply", (fx) => {
|
|
461
|
+
const [info] = fx.plan();
|
|
462
|
+
fx.approve(info);
|
|
463
|
+
const prev = execution.executionHooks.engineDigest;
|
|
464
|
+
execution.executionHooks.engineDigest = () => "0".repeat(64);
|
|
465
|
+
try { assert.throws(() => execution.apply(fx.state, info.run_id), OpsError); }
|
|
466
|
+
finally { execution.executionHooks.engineDigest = prev; }
|
|
467
|
+
});
|
|
468
|
+
await each("credential drift blocks apply", (fx) => {
|
|
469
|
+
const [info] = fx.plan();
|
|
470
|
+
fx.approve(info);
|
|
471
|
+
const l = model.ledgerLoad(fx.state);
|
|
472
|
+
l.entries["app-auth"]["1"].values.password = "changed";
|
|
473
|
+
writeJson(join(fx.state, "private", "credentials.json"), l);
|
|
474
|
+
assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
|
|
475
|
+
});
|
|
476
|
+
await each("existing unowned project not overwritten", (fx) => {
|
|
477
|
+
mkdirSync(join(fx.target, "app-a"), { recursive: true });
|
|
478
|
+
assert.throws(() => fx.plan(), OpsError);
|
|
479
|
+
});
|
|
480
|
+
await each("compose requires digest", (fx) => {
|
|
481
|
+
assert.throws(() => planner.composeModel({ compose: { services: { app: { image: "example:latest" } } } }, fx.host, join(fx.target, "app-a"), "ops-app-a", {}, {}), OpsError);
|
|
482
|
+
});
|
|
483
|
+
await each("compose rejects named volume", (fx) => {
|
|
484
|
+
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);
|
|
485
|
+
});
|
|
486
|
+
await each("compose rejects cross app data", (fx) => {
|
|
487
|
+
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);
|
|
488
|
+
});
|
|
489
|
+
await each("compose rejects writable root", (fx) => {
|
|
490
|
+
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);
|
|
491
|
+
});
|
|
492
|
+
await each("compose safe persistence model", (fx) => {
|
|
493
|
+
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": {} }, {});
|
|
494
|
+
assert.equal(value.services.app.read_only, true);
|
|
495
|
+
assert.equal(value.services.app.volumes[0].bind.create_host_path, false);
|
|
496
|
+
assert.equal(value.services.app.env_file[0].format, "raw");
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("real native and dual docs", () => runFx((fx) => {
|
|
501
|
+
const [info, , result] = fx.runSpec();
|
|
502
|
+
assert.equal(result.status, "completed", JSON.stringify(result));
|
|
503
|
+
const dep = model.load(fx.state).deployments["app-a-prod"];
|
|
504
|
+
assert.equal(dep.observed_version, "v1");
|
|
505
|
+
const remote = join(fx.target, "app-a");
|
|
506
|
+
const local = join(fx.state, "hosts", "node-a", "deployments", "app-a-prod");
|
|
507
|
+
assert.ok(!readFileSync(join(remote, "README.md"), "utf8").includes(PASSWORD));
|
|
508
|
+
assert.ok(readFileSync(join(remote, "OPERATIONS.md"), "utf8").includes(PASSWORD));
|
|
509
|
+
assert.ok(readFileSync(join(local, "README.md"), "utf8").includes(PASSWORD));
|
|
510
|
+
assert.equal(readJson(join(local, "docs-receipt.json")).status, "both-sides-verified");
|
|
511
|
+
assert.deepEqual(readFileSync(join(remote, "env", "app.env")), readFileSync(join(local, "server-files", "env", "app.env")));
|
|
512
|
+
assert.equal(readJson(join(remote, "data", "app", "storage", "value.json")).count, 1);
|
|
513
|
+
for (const f of [join(dirname(info.plan_path), "journal.jsonl"), join(dirname(info.plan_path), "execution.json")]) {
|
|
514
|
+
assert.ok(!readFileSync(f, "utf8").includes(PASSWORD));
|
|
515
|
+
}
|
|
516
|
+
}));
|
|
517
|
+
test("upgrade and rollback preserve data", () => runFx((fx) => {
|
|
518
|
+
let r;
|
|
519
|
+
[, , r] = fx.runSpec();
|
|
520
|
+
assert.equal(r.status, "completed");
|
|
521
|
+
[, , r] = fx.runSpec(fx.spec("v2", "upgrade"));
|
|
522
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
523
|
+
[, , r] = fx.runSpec(fx.spec("v1", "rollback"));
|
|
524
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
525
|
+
const value = readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json"));
|
|
526
|
+
assert.deepEqual(value, { count: 3, version: "v1" });
|
|
527
|
+
assert.equal(readdirSync(join(fx.target, "app-a", "releases")).length, 3);
|
|
528
|
+
}));
|
|
529
|
+
test("retirement preserves data and credentials", () => runFx((fx) => {
|
|
530
|
+
let r, p;
|
|
531
|
+
[, , r] = fx.runSpec();
|
|
532
|
+
assert.equal(r.status, "completed");
|
|
533
|
+
[, 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." });
|
|
534
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
535
|
+
assert.equal(model.load(fx.state).deployments["app-a-prod"].status, "retired");
|
|
536
|
+
assert.equal(existsSync(join(fx.target, "app-a", "data", "app", "storage", "value.json")), true);
|
|
537
|
+
assert.equal(existsSync(join(fx.target, "app-a", "env", "app.env")), true);
|
|
538
|
+
assert.equal(p.operations.some((o) => ["quarantine", "purge-quarantine"].includes(o.kind)), false);
|
|
539
|
+
}));
|
|
540
|
+
test("second apply blocked resume completed no repeat", () => runFx((fx) => {
|
|
541
|
+
const [info, , r] = fx.runSpec();
|
|
542
|
+
assert.equal(r.status, "completed");
|
|
543
|
+
assert.throws(() => execution.apply(fx.state, info.run_id), OpsError);
|
|
544
|
+
const again = execution.apply(fx.state, info.run_id, { resume: true });
|
|
545
|
+
assert.equal(again.unchanged, true);
|
|
546
|
+
assert.equal(readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json")).count, 1);
|
|
547
|
+
}));
|
|
548
|
+
test("docs retry does not repeat native command", () => runFx((fx) => {
|
|
549
|
+
const [info] = fx.plan();
|
|
550
|
+
fx.approve(info);
|
|
551
|
+
const real = execution.executionHooks.call;
|
|
552
|
+
let failed = false;
|
|
553
|
+
execution.executionHooks.call = (host, request, kw) => {
|
|
554
|
+
if (request.action === "step" && String(request.operation?.step_id || "").startsWith("docs-") && !failed) {
|
|
555
|
+
failed = true;
|
|
556
|
+
throw new UnknownResult("simulated document transport interruption before dispatch");
|
|
557
|
+
}
|
|
558
|
+
return real(host, request, kw);
|
|
559
|
+
};
|
|
560
|
+
let r;
|
|
561
|
+
try { r = execution.apply(fx.state, info.run_id); }
|
|
562
|
+
finally { execution.executionHooks.call = real; }
|
|
563
|
+
assert.equal(r.status, "docs_pending", JSON.stringify(r));
|
|
564
|
+
assert.equal(existsSync(join(fx.state, "hosts", "node-a", "deployments", "app-a-prod", "README.md")), true);
|
|
565
|
+
r = execution.apply(fx.state, info.run_id, { docsOnly: true });
|
|
566
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
567
|
+
assert.equal(readJson(join(fx.target, "app-a", "data", "app", "storage", "value.json")).count, 1);
|
|
568
|
+
}));
|
|
569
|
+
test("actual failed command not replayed", () => runFx((fx) => {
|
|
570
|
+
const [info, , r] = fx.runSpec(fx.spec("v1", "deploy", "process.exit(7);\n"));
|
|
571
|
+
assert.equal(r.status, "partial", JSON.stringify(r));
|
|
572
|
+
const dep = model.load(fx.state).deployments["app-a-prod"];
|
|
573
|
+
assert.equal(dep.status, "failed");
|
|
574
|
+
assert.equal(dep.observed_version, null);
|
|
575
|
+
const again = execution.apply(fx.state, info.run_id, { resume: true });
|
|
576
|
+
assert.equal(again.status, "failed");
|
|
577
|
+
assert.ok(JSON.stringify(again).includes("not replayed"));
|
|
578
|
+
}));
|
|
579
|
+
test("unknown identity blocks target before data", () => runFx((fx) => {
|
|
580
|
+
const [info] = fx.plan();
|
|
581
|
+
fx.approve(info);
|
|
582
|
+
const wrong = { ...fx.host, identity: "0".repeat(64) };
|
|
583
|
+
assert.throws(() => transport.call(wrong, { action: "probe" }), OpsError);
|
|
584
|
+
assert.equal(existsSync(fx.target), false);
|
|
585
|
+
}));
|
|
586
|
+
test("remote file drift stops before overwrite", () => runFx((fx) => {
|
|
587
|
+
const [info] = fx.plan();
|
|
588
|
+
fx.approve(info);
|
|
589
|
+
const dest = join(fx.target, "app-a", "env", "app.env");
|
|
590
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
591
|
+
writeFileSync(dest, "UNAPPROVED=1\n");
|
|
592
|
+
const r = execution.apply(fx.state, info.run_id);
|
|
593
|
+
assert.equal(r.status, "failed");
|
|
594
|
+
assert.equal(readFileSync(dest, "utf8"), "UNAPPROVED=1\n");
|
|
595
|
+
}));
|
|
596
|
+
test("journal tamper detected", () => runFx((fx) => {
|
|
597
|
+
const [info, , r] = fx.runSpec();
|
|
598
|
+
const j = join(dirname(info.plan_path), "journal.jsonl");
|
|
599
|
+
const rows = readFileSync(j, "utf8").split(/\r?\n/).filter(Boolean);
|
|
600
|
+
const value = JSON.parse(rows[0]);
|
|
601
|
+
value.kind = "tampered";
|
|
602
|
+
rows[0] = JSON.stringify(value);
|
|
603
|
+
writeFileSync(j, rows.join("\n") + "\n");
|
|
604
|
+
assert.throws(() => execution.verifyJournal(j), OpsError);
|
|
605
|
+
}));
|
|
606
|
+
test("quarantine then separate purge", () => runFx((fx) => {
|
|
607
|
+
let hspec = {
|
|
608
|
+
schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Disposable cache fixture",
|
|
609
|
+
rollback_note: "Keep isolated cache until separate purge is approved.",
|
|
610
|
+
host_actions: [{ host_id: "node-a", kind: "write-file", reason: "Create only fixture cache", path: "_host/cache/demo/cache.txt", content: "Disposable cache" }],
|
|
611
|
+
};
|
|
612
|
+
let r, info;
|
|
613
|
+
[, , r] = fx.runSpec(hspec);
|
|
614
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
615
|
+
hspec = { ...hspec, host_actions: [{ host_id: "node-a", kind: "quarantine", reason: "Explicit isolate", path: join(fx.target, "_host", "cache", "demo", "cache.txt") }] };
|
|
616
|
+
[info, , r] = fx.runSpec(hspec);
|
|
617
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
618
|
+
const q = join(fx.target, "_host", "quarantine", info.run_id, "item-0001");
|
|
619
|
+
assert.equal(existsSync(q), true);
|
|
620
|
+
hspec = { ...hspec, host_actions: [{ host_id: "node-a", kind: "purge-quarantine", reason: "Separate irreversible deletion of this one approved cache fixture", path: q }] };
|
|
621
|
+
[, , r] = fx.runSpec(hspec);
|
|
622
|
+
assert.equal(r.status, "completed", JSON.stringify(r));
|
|
623
|
+
assert.equal(existsSync(q), false);
|
|
624
|
+
}));
|
|
625
|
+
|
|
626
|
+
function agentFixture() {
|
|
627
|
+
const tmp = mkdtempSync(join(tmpdir(), "ops-agent-test-"));
|
|
628
|
+
const root = join(tmp, "target");
|
|
629
|
+
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: [] };
|
|
630
|
+
agent.main(req);
|
|
631
|
+
return {
|
|
632
|
+
tmp, root, req,
|
|
633
|
+
step: (op) => agent.main({ ...req, action: "step", operation: op, operation_digest: digest(op) }),
|
|
634
|
+
cleanup: () => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} },
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
test("same receipt is not reexecuted", () => {
|
|
639
|
+
const fx = agentFixture();
|
|
640
|
+
try {
|
|
641
|
+
const op = { step_id: "step-1", kind: "write", path: join(fx.root, "a.txt"), content_b64: Buffer.from("A").toString("base64"), expected: { kind: "absent" } };
|
|
642
|
+
const one = fx.step(op); const two = fx.step(op);
|
|
643
|
+
assert.deepEqual(one, two);
|
|
644
|
+
} finally { fx.cleanup(); }
|
|
645
|
+
});
|
|
646
|
+
test("started only is unknown", () => {
|
|
647
|
+
const fx = agentFixture();
|
|
648
|
+
try {
|
|
649
|
+
const p = join(fx.root, "_host", "runs", "run-test", "receipts");
|
|
650
|
+
mkdirSync(p, { recursive: true });
|
|
651
|
+
writeFileSync(join(p, "step-1.started.json"), "{}");
|
|
652
|
+
const result = fx.step({ step_id: "step-1", kind: "mkdir", path: join(fx.root, "x") });
|
|
653
|
+
assert.equal(result.status, "unknown");
|
|
654
|
+
assert.equal(existsSync(join(fx.root, "x")), false);
|
|
655
|
+
} finally { fx.cleanup(); }
|
|
656
|
+
});
|
|
657
|
+
test("target lock other owner rejected", () => {
|
|
658
|
+
const fx = agentFixture();
|
|
659
|
+
try { assert.throws(() => agent.main({ ...fx.req, run_id: "run-other" }), agent.Failure); }
|
|
660
|
+
finally { fx.cleanup(); }
|
|
661
|
+
});
|
|
662
|
+
test("write beyond root fails", () => {
|
|
663
|
+
const fx = agentFixture();
|
|
664
|
+
try {
|
|
665
|
+
const result = fx.step({ step_id: "step-1", kind: "write", path: join(dirname(fx.root), "outside"), content_b64: "WA==", expected: { kind: "absent" } });
|
|
666
|
+
assert.equal(result.status, "failed");
|
|
667
|
+
} finally { fx.cleanup(); }
|
|
668
|
+
});
|
|
669
|
+
test("identical bytes still secure permissions", () => {
|
|
670
|
+
const fx = agentFixture();
|
|
671
|
+
try {
|
|
672
|
+
const p = join(fx.root, "secret");
|
|
673
|
+
writeFileSync(p, "X");
|
|
674
|
+
if (process.platform !== "win32") chmodSync(p, 0o644);
|
|
675
|
+
const result = fx.step({ step_id: "step-1", kind: "write", path: p, content_b64: "WA==", expected: agent.fileState(p), mode: 0o600 });
|
|
676
|
+
assert.equal(result.status, "succeeded");
|
|
677
|
+
if (process.platform !== "win32") assert.equal(statSync(p).mode & 0o777, 0o600);
|
|
678
|
+
} finally { fx.cleanup(); }
|
|
679
|
+
});
|
|
680
|
+
test("quarantine data forbidden", () => {
|
|
681
|
+
const fx = agentFixture();
|
|
682
|
+
try {
|
|
683
|
+
const p = join(fx.root, "app-a", "data", "logs", "a");
|
|
684
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
685
|
+
writeFileSync(p, "business");
|
|
686
|
+
const result = fx.step({ step_id: "step-1", kind: "quarantine", path: p, item_id: "item-1", expected: agent.fileState(p) });
|
|
687
|
+
assert.equal(result.status, "failed");
|
|
688
|
+
assert.equal(existsSync(p), true);
|
|
689
|
+
} finally { fx.cleanup(); }
|
|
690
|
+
});
|
|
691
|
+
test("purge needs isolation receipt", () => {
|
|
692
|
+
const fx = agentFixture();
|
|
693
|
+
try {
|
|
694
|
+
const p = join(fx.root, "_host", "quarantine", "run-old", "item-1");
|
|
695
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
696
|
+
writeFileSync(p, "unknown");
|
|
697
|
+
const result = fx.step({ step_id: "step-1", kind: "purge-quarantine", path: p, expected: agent.treeState(p) });
|
|
698
|
+
assert.equal(result.status, "failed");
|
|
699
|
+
} finally { fx.cleanup(); }
|
|
700
|
+
});
|
|
701
|
+
test("mirror requires https", () => {
|
|
702
|
+
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);
|
|
703
|
+
});
|
|
704
|
+
test("mirror requires approval", () => {
|
|
705
|
+
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);
|
|
706
|
+
});
|
|
707
|
+
test("analyze does not run repo", () => {
|
|
708
|
+
const fx = agentFixture();
|
|
709
|
+
try {
|
|
710
|
+
const source = join(fx.root, "source");
|
|
711
|
+
mkdirSync(source);
|
|
712
|
+
writeFileSync(join(source, "package.json"), '{"scripts":{"postinstall":"NEVER-RUN"}}');
|
|
713
|
+
writeFileSync(join(source, ".env"), PASSWORD);
|
|
714
|
+
const result = analyze(source);
|
|
715
|
+
assert.deepEqual(result.detected, ["node"]);
|
|
716
|
+
assert.ok(!JSON.stringify(result).includes(PASSWORD));
|
|
717
|
+
assert.equal(result.evidence.length, 1);
|
|
718
|
+
} finally { fx.cleanup(); }
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("ssh contract", () => {
|
|
722
|
+
const d = mkdtempSync(join(tmpdir(), "ops-ssh-"));
|
|
723
|
+
const kh = join(d, "known_hosts");
|
|
724
|
+
writeFileSync(kh, "fixture pinned key\n");
|
|
725
|
+
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" } };
|
|
726
|
+
const captured = {};
|
|
727
|
+
const prev = transport.transportHooks.spawnSync;
|
|
728
|
+
transport.transportHooks.spawnSync = (cmd, args, kw) => {
|
|
729
|
+
captured.argv = [cmd, ...args];
|
|
730
|
+
Object.assign(captured, kw);
|
|
731
|
+
return { status: 0, stdout: '{"ok":true,"result":{"scope":"simulated"}}', stderr: "" };
|
|
732
|
+
};
|
|
733
|
+
try {
|
|
734
|
+
transport.call(h, { action: "step", secrets: [PASSWORD] });
|
|
735
|
+
const joined = captured.argv.join(" ");
|
|
736
|
+
assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
|
|
737
|
+
assert.ok(captured.argv.includes("BatchMode=yes"));
|
|
738
|
+
assert.ok(!joined.includes(PASSWORD));
|
|
739
|
+
assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
|
|
740
|
+
} finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("knownhosts digest drift", () => {
|
|
744
|
+
const d = mkdtempSync(join(tmpdir(), "ops-kh-"));
|
|
745
|
+
const kh = join(d, "known_hosts");
|
|
746
|
+
writeFileSync(kh, "first");
|
|
747
|
+
const h = { transport: "ssh", connection: { known_hosts: kh } };
|
|
748
|
+
const before = transport.hostTransportDigest(h);
|
|
749
|
+
writeFileSync(kh, "second");
|
|
750
|
+
assert.notEqual(before, transport.hostTransportDigest(h));
|
|
751
|
+
rmSync(d, { recursive: true, force: true });
|
|
752
|
+
});
|