@namewta/speculo 1.0.6 → 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.
Files changed (51) 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 +1 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +2 -2
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +24 -24
  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/schemas/host.schema.json +1 -1
  13. package/template/workflows/ops/common/schemas/plan.schema.json +3 -3
  14. package/template/workflows/ops/common/schemas/spec.schema.json +1 -1
  15. package/template/workflows/ops/common/schemas/status.schema.json +1 -1
  16. package/template/workflows/ops/common/tests/test_ops.mjs +752 -0
  17. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  18. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  19. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  20. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  21. package/template/workflows/ops/common/tools/opslib/agent.mjs +873 -0
  22. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  23. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  24. package/template/workflows/ops/common/tools/opslib/docs.mjs +252 -0
  25. package/template/workflows/ops/common/tools/opslib/execution.mjs +378 -0
  26. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  27. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  28. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  29. package/template/workflows/ops/common/tools/opslib/planner.mjs +687 -0
  30. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  31. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  32. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  33. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  34. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  35. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  36. package/template/workflows/ops/common/tools/ops.py +0 -7
  37. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  38. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  40. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  41. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  42. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  43. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  44. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  45. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  46. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  47. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  48. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  49. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  50. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  51. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -0,0 +1,272 @@
1
+ /** Strict resource contracts and atomic controller-side catalog operations. */
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readFileSync } from "node:fs";
6
+ import {
7
+ OpsError, canonical, digest, emptyStatus, exact, identifier, now, readJson, relative,
8
+ rootPath, targetJoin, within, withLock, writeJson,
9
+ } from "./core.mjs";
10
+
11
+ const SCHEMAS = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "schemas");
12
+
13
+ export function validate(value, schemaName) {
14
+ const schema = JSON.parse(readFileSync(join(SCHEMAS, `${schemaName}.schema.json`), "utf8"));
15
+ function walk(v, s, label) {
16
+ if (s.$ref) {
17
+ let node = schema;
18
+ for (const k of s.$ref.replace(/^#\//, "").split("/")) node = node[k];
19
+ return walk(v, node, label);
20
+ }
21
+ if (s.oneOf) {
22
+ let successes = 0;
23
+ for (const branch of s.oneOf) {
24
+ try { walk(v, branch, label); successes++; } catch (error) {
25
+ if (!(error instanceof OpsError)) throw error;
26
+ }
27
+ }
28
+ if (successes !== 1) throw new OpsError(`${label}: oneOf contract not satisfied`);
29
+ }
30
+ const kinds = {
31
+ object: (x) => x !== null && typeof x === "object" && !Array.isArray(x),
32
+ array: Array.isArray,
33
+ string: (x) => typeof x === "string",
34
+ integer: (x) => Number.isInteger(x) && typeof x !== "boolean",
35
+ boolean: (x) => typeof x === "boolean",
36
+ null: (x) => x === null,
37
+ number: (x) => typeof x === "number" && Number.isFinite(x),
38
+ };
39
+ if (s.type) {
40
+ const types = Array.isArray(s.type) ? s.type : [s.type];
41
+ if (!types.some((t) => kinds[t](v))) throw new OpsError(`${label}: wrong type`);
42
+ }
43
+ if ("const" in s && v !== s.const) throw new OpsError(`${label}: wrong constant`);
44
+ if (s.enum && !s.enum.includes(v)) throw new OpsError(`${label}: invalid enum`);
45
+ if (typeof v === "string") {
46
+ if (v.length < (s.minLength ?? 0) || v.length > (s.maxLength ?? 10000000)) throw new OpsError(`${label}: string length`);
47
+ if (s.pattern && !new RegExp(s.pattern).test(v)) throw new OpsError(`${label}: invalid pattern`);
48
+ }
49
+ if (typeof v === "number" && Number.isFinite(v) && typeof v !== "boolean") {
50
+ if (v < (s.minimum ?? -Infinity) || v > (s.maximum ?? Infinity)) throw new OpsError(`${label}: number out of range`);
51
+ }
52
+ if (v && typeof v === "object" && !Array.isArray(v)) {
53
+ if ((s.required ?? []).some((k) => !(k in v))) {
54
+ throw new OpsError(`${label}: missing ${pyList((s.required ?? []).filter((k) => !(k in v)).sort())}`);
55
+ }
56
+ const props = s.properties ?? {};
57
+ for (const [k, item] of Object.entries(v)) {
58
+ if (k in props) walk(item, props[k], `${label}.${k}`);
59
+ else if (s.additionalProperties === false) throw new OpsError(`${label}: unknown field ${k}`);
60
+ else if (s.additionalProperties && typeof s.additionalProperties === "object") walk(item, s.additionalProperties, `${label}.${k}`);
61
+ }
62
+ if (s.propertyNames) for (const k of Object.keys(v)) walk(k, s.propertyNames, `${label}.<key>`);
63
+ }
64
+ if (Array.isArray(v)) {
65
+ if (v.length < (s.minItems ?? 0)) throw new OpsError(`${label}: too few items`);
66
+ if (s.uniqueItems && new Set(v.map((x) => canonical(x).toString("hex"))).size !== v.length) {
67
+ throw new OpsError(`${label}: duplicate items`);
68
+ }
69
+ v.forEach((x, i) => walk(x, s.items ?? {}, `${label}[${i}]`));
70
+ }
71
+ }
72
+ walk(value, schema, schemaName);
73
+ }
74
+
75
+ function pyList(items) {
76
+ return `[${items.map((x) => `'${x}'`).join(", ")}]`;
77
+ }
78
+
79
+ export function deploymentRoot(host, projectId, environment, instance, layout) {
80
+ for (const x of [projectId, environment, instance]) identifier(x);
81
+ if (layout === "flat") return targetJoin(host, projectId);
82
+ if (layout === "instances") return targetJoin(host, projectId, `instances/${environment}/${instance}`);
83
+ throw new OpsError("unknown deployment layout");
84
+ }
85
+
86
+ export function validateHost(host) {
87
+ validate(host, "host");
88
+ identifier(host.host_id);
89
+ if (rootPath(host.root, host.platform) !== host.root) throw new OpsError("host root must be normalized");
90
+ if (host.transport === "local" && host.connection && Object.keys(host.connection).length) {
91
+ throw new OpsError("local host connection must be empty");
92
+ }
93
+ if (host.transport === "ssh") {
94
+ const c = host.connection;
95
+ for (const k of ["hostname", "username", "known_hosts", "node"]) {
96
+ if (!c?.[k]) throw new OpsError("ssh connection missing " + k);
97
+ }
98
+ if (!/^[A-Za-z0-9_.:-]+$/.test(c.hostname) || c.hostname.startsWith("-")) throw new OpsError("unsafe SSH hostname");
99
+ if (!/^[A-Za-z0-9_.-]+$/.test(c.username) || c.username.startsWith("-")) throw new OpsError("unsafe SSH username");
100
+ const nodePattern = host.platform === "windows" ? /^[A-Za-z0-9_./:\\ +-]+$/ : /^[\/A-Za-z0-9_.+-]+$/;
101
+ if (!nodePattern.test(c.node) || c.node.startsWith("-")) {
102
+ throw new OpsError("SSH Node must be a safe executable path, not a command");
103
+ }
104
+ if (!["posix", "powershell"].includes(c.shell ?? "posix")) throw new OpsError("unsupported remote shell");
105
+ }
106
+ if (!/^[a-f0-9]{64}$/.test(host.identity)) throw new OpsError("host identity must be a probed machine digest");
107
+ }
108
+
109
+ export function validateStatus(s) {
110
+ if (s.schema_version !== 3) {
111
+ throw new OpsError("ops-legacy-state: preserve the old state; run import-legacy into a new empty state root");
112
+ }
113
+ validate(s, "status");
114
+ const roots = new Set();
115
+ const physicalRoots = [];
116
+ for (const [hid, h] of Object.entries(s.hosts)) {
117
+ validateHost(h);
118
+ if (hid !== h.host_id) throw new OpsError("host index identity mismatch");
119
+ const physical = `${h.identity}:${h.platform === "windows" ? h.root.toLowerCase() : h.root}`;
120
+ if (roots.has(physical)) throw new OpsError("duplicate physical host/root registered under different IDs");
121
+ roots.add(physical);
122
+ for (const [identity, other, platform] of physicalRoots) {
123
+ if (identity === h.identity && platform !== h.platform) throw new OpsError("same physical identity has inconsistent platform");
124
+ if (identity === h.identity && (within(h.root, other, platform) || within(other, h.root, platform))) {
125
+ throw new OpsError("overlapping registered host roots");
126
+ }
127
+ }
128
+ physicalRoots.push([h.identity, h.root, h.platform]);
129
+ }
130
+ for (const [pid, p] of Object.entries(s.projects)) {
131
+ validate(p, "project"); identifier(pid);
132
+ if (pid !== p.project_id) throw new OpsError("project index identity mismatch");
133
+ if (p.kind === "shared-service" && !p.service_type) throw new OpsError("shared provider requires service_type");
134
+ }
135
+ const used = new Set();
136
+ const occupied = [];
137
+ for (const [did, d] of Object.entries(s.deployments)) {
138
+ validate(d, "deployment");
139
+ if (did !== d.deployment_id) throw new OpsError("deployment index mismatch");
140
+ if (!(d.host_id in s.hosts) || !(d.project_id in s.projects)) throw new OpsError("orphan deployment");
141
+ const h = s.hosts[d.host_id];
142
+ const expected = deploymentRoot(h, d.project_id, d.environment, d.instance, d.layout);
143
+ if (d.root !== expected) throw new OpsError("deployment root does not follow host/project policy");
144
+ const slot = `${h.identity}:${h.platform === "windows" ? expected.toLowerCase() : expected}`;
145
+ if (used.has(slot)) throw new OpsError("two deployments occupy the same directory; use explicit instances layout");
146
+ used.add(slot);
147
+ for (const [identity, other, platform] of occupied) {
148
+ if (identity === h.identity && platform !== h.platform) throw new OpsError("same physical identity has inconsistent platform");
149
+ if (identity === h.identity && (within(expected, other, platform) || within(other, expected, platform))) {
150
+ throw new OpsError("overlapping flat/instances deployment roots require an explicit migration");
151
+ }
152
+ }
153
+ occupied.push([h.identity, expected, h.platform]);
154
+ for (const item of d.storage) {
155
+ if (!within(item.path, d.root, h.platform)) throw new OpsError("persistent path outside APP root");
156
+ }
157
+ }
158
+ for (const [aid, a] of Object.entries(s.allocations)) {
159
+ validate(a, "allocation");
160
+ if (aid !== a.allocation_id || !(a.provider_deployment_id in s.deployments)) throw new OpsError("orphan allocation");
161
+ const pd = s.deployments[a.provider_deployment_id];
162
+ if (s.projects[pd.project_id].kind !== "shared-service") throw new OpsError("allocation provider must be shared-service");
163
+ if (!(a.owner_project_id in s.projects)) throw new OpsError("allocation owner missing");
164
+ }
165
+ const resources = new Set();
166
+ for (const a of Object.values(s.allocations)) {
167
+ if (a.status === "retired") continue;
168
+ const key = `${a.provider_deployment_id}|${a.resource_kind}|${a.resource_name}`;
169
+ if (resources.has(key)) throw new OpsError("duplicate logical allocation: use one allocation for replicas");
170
+ resources.add(key);
171
+ }
172
+ const edges = {};
173
+ for (const [bid, b] of Object.entries(s.bindings)) {
174
+ validate(b, "binding");
175
+ if (bid !== b.binding_id || !(b.consumer_deployment_id in s.deployments)) throw new OpsError("orphan binding");
176
+ const consumer = s.deployments[b.consumer_deployment_id];
177
+ if (b.mode === "shared") {
178
+ if (!(b.allocation_id in s.allocations)) throw new OpsError("binding allocation missing");
179
+ const a = s.allocations[b.allocation_id];
180
+ if (a.provider_deployment_id !== b.provider_deployment_id) throw new OpsError("binding provider disagrees with allocation");
181
+ if (a.owner_project_id !== consumer.project_id || a.environment !== consumer.environment) {
182
+ if (!(a.shared_owners ?? []).includes(consumer.project_id)) throw new OpsError("cross-app/environment sharing requires explicit shared owners");
183
+ }
184
+ if (b.credential_ref !== a.credential_ref) throw new OpsError("binding must use allocation's application credential");
185
+ if (a.status === "retired" && b.status === "active") throw new OpsError("active consumer uses retired allocation");
186
+ (edges[b.consumer_deployment_id] ??= new Set()).add(b.provider_deployment_id);
187
+ } else if (b.mode === "external") {
188
+ if (b.provider_deployment_id != null || b.allocation_id != null) throw new OpsError("external binding cannot claim local provider data");
189
+ } else if (b.mode === "dedicated") {
190
+ if (b.allocation_id != null || b.provider_deployment_id != null) throw new OpsError("dedicated component cannot reference shared provider/allocation");
191
+ }
192
+ if (b.status === "active" && consumer.status === "retired") throw new OpsError("retired deployment retains active binding");
193
+ }
194
+ const done = new Set();
195
+ function visit(node, trail) {
196
+ if (trail.has(node)) throw new OpsError("cyclic service dependencies");
197
+ if (done.has(node)) return;
198
+ for (const nxt of edges[node] ?? []) visit(nxt, new Set([...trail, node]));
199
+ done.add(node);
200
+ }
201
+ for (const node of Object.keys(edges)) visit(node, new Set());
202
+ }
203
+
204
+ export function load(state) {
205
+ const s = readJson(join(state, "status.json"));
206
+ validateStatus(s);
207
+ return s;
208
+ }
209
+
210
+ export function save(state, s, { bump = true } = {}) {
211
+ validateStatus(s);
212
+ if (bump) s.revision += 1;
213
+ s.updated_at = now();
214
+ writeJson(join(state, "status.json"), s);
215
+ }
216
+
217
+ export function ledgerLoad(state) {
218
+ const p = join(state, "private", "credentials.json");
219
+ if (!existsSync(p)) return { schema_version: 1, entries: {} };
220
+ if (process.platform !== "win32" && (statSync(p).mode & 0o077)) {
221
+ throw new OpsError("plaintext ledger permissions too broad; run chmod 600 explicitly");
222
+ }
223
+ return readJson(p);
224
+ }
225
+
226
+ export function putCredential(state, value) {
227
+ exact(value, new Set(["credential_id", "version", "values", "purpose"]), new Set(["credential_id", "version", "values", "purpose"]), "credential");
228
+ identifier(value.credential_id);
229
+ if (!Number.isInteger(value.version) || value.version < 1 || !value.values || !Object.keys(value.values).length) {
230
+ throw new OpsError("credential version/values invalid");
231
+ }
232
+ for (const [k, v] of Object.entries(value.values)) {
233
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || typeof v !== "string" || !v || v.includes("\x00")) {
234
+ throw new OpsError("credential fields must be named nonempty strings");
235
+ }
236
+ }
237
+ return withLock(join(state, ".locks", "catalog"), { operation: "credential-import" }, () => {
238
+ load(state);
239
+ const ledger = ledgerLoad(state);
240
+ const entries = ledger.entries[value.credential_id] ??= {};
241
+ const version = String(value.version);
242
+ if (version in entries) {
243
+ if (digest(entries[version].values) !== digest(value.values)) {
244
+ throw new OpsError("immutable credential version: use a new explicit version");
245
+ }
246
+ return { credential_ref: `${value.credential_id}@${version}`, status: "unchanged" };
247
+ }
248
+ entries[version] = { values: value.values, purpose: value.purpose, created_at: now() };
249
+ writeJson(join(state, "private", "credentials.json"), ledger);
250
+ return { credential_ref: `${value.credential_id}@${version}`, status: "recorded-not-rotated-on-server" };
251
+ });
252
+ }
253
+
254
+ export function register(state, request) {
255
+ exact(request, new Set(["hosts", "projects"]), new Set(), "registration");
256
+ return withLock(join(state, ".locks", "catalog"), { operation: "register" }, () => {
257
+ const s = load(state);
258
+ for (const [group, idkey] of [["hosts", "host_id"], ["projects", "project_id"]]) {
259
+ for (const item of request[group] ?? []) {
260
+ const key = item[idkey];
261
+ if (key in s[group] && digest(s[group][key]) !== digest(item)) {
262
+ throw new OpsError(`registered identity is immutable through register: ${group}/${key}; use a reviewed migration`);
263
+ }
264
+ s[group][key] = item;
265
+ }
266
+ }
267
+ save(state, s);
268
+ return { revision: s.revision, hosts: Object.keys(s.hosts), projects: Object.keys(s.projects) };
269
+ });
270
+ }
271
+
272
+ export { emptyStatus };
@@ -1,11 +1,10 @@
1
- """Native Windows Task Scheduler renderer; generated scripts are separately approved."""
2
- import json
3
- from .core import OpsError
1
+ /** Native Windows Task Scheduler renderer; generated scripts are separately approved. */
2
+ import { OpsError } from "./core.mjs";
4
3
 
5
- def task_files(did,root,argv,env,native):
6
- if not native.get("account"):raise OpsError("Windows native task requires an explicit current service account")
7
- config={"name":"OPS-"+did,"root":root,"argv":argv,"environment":env,"account":native["account"],"run_level":native.get("run_level","Limited")}
8
- install=r'''$ErrorActionPreference = 'Stop'
4
+ export function taskFiles(did, root, argv, env, native) {
5
+ if (!native.account) throw new OpsError("Windows native task requires an explicit current service account");
6
+ const config = { name: "OPS-" + did, root, argv, environment: env, account: native.account, run_level: native.run_level ?? "Limited" };
7
+ const install = `$ErrorActionPreference = 'Stop'
9
8
  $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'task.json') -Raw | ConvertFrom-Json
10
9
  $current = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
11
10
  if ($config.account -ne $current) { throw 'Cross-account task requires a separately reviewed credential/ACL adapter; do not guess passwords.' }
@@ -18,15 +17,20 @@ $old = Get-ScheduledTask -TaskName $config.name -ErrorAction SilentlyContinue
18
17
  if ($old) { Stop-ScheduledTask -TaskName $config.name -ErrorAction SilentlyContinue }
19
18
  Register-ScheduledTask -TaskName $config.name -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
20
19
  Start-ScheduledTask -TaskName $config.name
21
- '''
22
- runner=r'''$ErrorActionPreference = 'Stop'
20
+ `;
21
+ const runner = `$ErrorActionPreference = 'Stop'
23
22
  $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'task.json') -Raw | ConvertFrom-Json
24
23
  $config.environment.PSObject.Properties | ForEach-Object { [Environment]::SetEnvironmentVariable($_.Name, [string]$_.Value, 'Process') }
25
24
  Set-Location -LiteralPath $config.root
26
25
  $exe = [string]$config.argv[0]
27
26
  $arguments = @($config.argv | Select-Object -Skip 1)
28
- $log = Join-Path $config.root 'logs\app\task.log'
27
+ $log = Join-Path $config.root 'logs\\app\\task.log'
29
28
  & $exe @arguments *>> $log
30
29
  exit $LASTEXITCODE
31
- '''
32
- return {"service/task.json":json.dumps(config,ensure_ascii=False,indent=2)+"\n","service/install-task.ps1":install,"service/run-task.ps1":runner}
30
+ `;
31
+ return {
32
+ "service/task.json": JSON.stringify(config, null, 2) + "\n",
33
+ "service/install-task.ps1": install,
34
+ "service/run-task.ps1": runner,
35
+ };
36
+ }