@crvouga/mockingbird-service-aws-speech 3.2.0 → 3.4.0
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/CHANGELOG.md +18 -4
- package/DISCOVERY.md +23 -0
- package/README.md +42 -5
- package/dist/{chunk-6XMOS247.js → chunk-M7JQW4G3.js} +3 -2
- package/dist/{chunk-6XMOS247.js.map → chunk-M7JQW4G3.js.map} +2 -2
- package/dist/chunk-PFJYH66F.js +6423 -0
- package/dist/chunk-PFJYH66F.js.map +7 -0
- package/dist/cli.js +4084 -164
- package/dist/cli.js.map +4 -4
- package/dist/index.d.ts +354 -41
- package/dist/index.js +1 -1
- package/dist/server.d.ts +709 -82
- package/dist/server.js +2 -2
- package/package.json +3 -2
- package/dist/chunk-KXMGRBLP.js +0 -4528
- package/dist/chunk-KXMGRBLP.js.map +0 -7
package/dist/cli.js
CHANGED
|
@@ -2,97 +2,3192 @@
|
|
|
2
2
|
import {
|
|
3
3
|
listenH2c,
|
|
4
4
|
serveTarget
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-M7JQW4G3.js";
|
|
6
6
|
import {
|
|
7
7
|
createClock,
|
|
8
|
+
normalizeBootControls,
|
|
9
|
+
normalizeControls,
|
|
10
|
+
ownedTransport,
|
|
8
11
|
parseDuration,
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
parseFaultHeader,
|
|
13
|
+
resolveAdminPrefix,
|
|
14
|
+
restoreClock
|
|
15
|
+
} from "./chunk-PFJYH66F.js";
|
|
11
16
|
|
|
12
17
|
// ../../adapters/node/dist/cli.js
|
|
13
|
-
import { readFile as
|
|
14
|
-
import { createRequire } from "node:module";
|
|
15
|
-
import { resolve } from "node:path";
|
|
18
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
19
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
20
|
+
import { resolve as resolve3 } from "node:path";
|
|
16
21
|
import { pathToFileURL } from "node:url";
|
|
17
22
|
import { parseArgs } from "node:util";
|
|
18
23
|
|
|
19
24
|
// ../../adapters/node/dist/fleet.js
|
|
25
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
|
26
|
+
import { createHash as createHash4, randomUUID as randomUUID4 } from "node:crypto";
|
|
27
|
+
import { chmod, readFile as readFile2, rename, rm, writeFile } from "node:fs/promises";
|
|
28
|
+
|
|
29
|
+
// ../../adapters/node/dist/fleet-management.js
|
|
30
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
31
|
+
import { createHash as createHash3, randomBytes, randomUUID as randomUUID3 } from "node:crypto";
|
|
32
|
+
|
|
33
|
+
// ../../adapters/node/dist/fleet-archive.js
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
var canonical = (value) => {
|
|
36
|
+
if (Array.isArray(value))
|
|
37
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
38
|
+
if (value !== null && typeof value === "object")
|
|
39
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
};
|
|
42
|
+
var digest = (value) => createHash("sha256").update(canonical(value)).digest("hex");
|
|
43
|
+
var sealArchive = (content) => ({
|
|
44
|
+
...content,
|
|
45
|
+
sha256: digest(content)
|
|
46
|
+
});
|
|
47
|
+
function validateArchive(value) {
|
|
48
|
+
if (!value || typeof value !== "object")
|
|
49
|
+
throw new Error("expected fleet archive");
|
|
50
|
+
const doc = value;
|
|
51
|
+
const { sha256, ...content } = doc;
|
|
52
|
+
if (doc.format !== "mockingbird.fleet/v1" || typeof doc.sourceId !== "string" || typeof doc.createdAt !== "string" || !doc.namespaces || typeof doc.namespaces !== "object" || Array.isArray(doc.namespaces) || sha256 !== digest(content))
|
|
53
|
+
throw new Error("unsupported or corrupt fleet archive");
|
|
54
|
+
for (const [namespace, entry] of Object.entries(doc.namespaces)) {
|
|
55
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace) || !entry || !Number.isFinite(entry.clock?.now) || typeof entry.clock?.frozen !== "boolean" || !entry.services || !Object.keys(entry.services).length)
|
|
56
|
+
throw new Error("invalid archive namespace");
|
|
57
|
+
for (const [name, child] of Object.entries(entry.services))
|
|
58
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || !child || child.version !== 1 || !["http", "postgres", "redis", "external"].includes(child.protocol))
|
|
59
|
+
throw new Error("unsupported archive service/version");
|
|
60
|
+
}
|
|
61
|
+
return doc;
|
|
62
|
+
}
|
|
63
|
+
function inspectArchive(input) {
|
|
64
|
+
const archive = validateArchive(input);
|
|
65
|
+
return {
|
|
66
|
+
format: archive.format,
|
|
67
|
+
createdAt: archive.createdAt,
|
|
68
|
+
sha256: archive.sha256,
|
|
69
|
+
namespaces: Object.fromEntries(Object.entries(archive.namespaces).map(([name, ns]) => [
|
|
70
|
+
name,
|
|
71
|
+
{
|
|
72
|
+
clock: ns.clock,
|
|
73
|
+
services: Object.fromEntries(Object.entries(ns.services).map(([service, state]) => [
|
|
74
|
+
service,
|
|
75
|
+
{
|
|
76
|
+
protocol: state.protocol,
|
|
77
|
+
version: state.version,
|
|
78
|
+
bytes: Buffer.byteLength(canonical(state.state))
|
|
79
|
+
}
|
|
80
|
+
]))
|
|
81
|
+
}
|
|
82
|
+
]))
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function diffArchives(left, right, options = {}) {
|
|
86
|
+
const a = validateArchive(left), b = validateArchive(right);
|
|
87
|
+
const limit = Math.min(1e3, Math.max(1, options.limit ?? 100));
|
|
88
|
+
const changes = [];
|
|
89
|
+
let total = 0;
|
|
90
|
+
const walk = (namespace, service, path, before, after) => {
|
|
91
|
+
if (canonical(before) === canonical(after))
|
|
92
|
+
return;
|
|
93
|
+
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
94
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]))
|
|
95
|
+
walk(namespace, service, `${path}/${key}`, before[key], after[key]);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
total++;
|
|
99
|
+
if (changes.length < limit)
|
|
100
|
+
changes.push({
|
|
101
|
+
namespace,
|
|
102
|
+
service,
|
|
103
|
+
path,
|
|
104
|
+
kind: before === void 0 ? "added" : after === void 0 ? "removed" : "changed",
|
|
105
|
+
...options.values ? { before, after } : {}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
for (const ns of /* @__PURE__ */ new Set([...Object.keys(a.namespaces), ...Object.keys(b.namespaces)])) {
|
|
109
|
+
if (options.namespace !== void 0 && options.namespace !== ns)
|
|
110
|
+
continue;
|
|
111
|
+
const an = a.namespaces[ns], bn = b.namespaces[ns];
|
|
112
|
+
walk(ns, "$clock", "/clock", an?.clock, bn?.clock);
|
|
113
|
+
for (const service of /* @__PURE__ */ new Set([
|
|
114
|
+
...Object.keys(an?.services ?? {}),
|
|
115
|
+
...Object.keys(bn?.services ?? {})
|
|
116
|
+
])) {
|
|
117
|
+
if (options.service !== void 0 && options.service !== service)
|
|
118
|
+
continue;
|
|
119
|
+
const x = an?.services[service], y = bn?.services[service];
|
|
120
|
+
if (x && y && (x.protocol !== y.protocol || x.version !== y.version))
|
|
121
|
+
throw new Error("incompatible snapshot comparison");
|
|
122
|
+
const normalize = (state) => {
|
|
123
|
+
if (state && typeof state === "object") {
|
|
124
|
+
const obj = state;
|
|
125
|
+
if (obj.format === "mockingbird.postgres/v1" && obj.inventory) {
|
|
126
|
+
if (options.values)
|
|
127
|
+
throw new Error("raw PostgreSQL row diff is unsupported; use bounded namespace inspection");
|
|
128
|
+
const { databases: _databases, ...metadata } = obj;
|
|
129
|
+
return metadata;
|
|
130
|
+
}
|
|
131
|
+
if (obj.$redis === "map" && Array.isArray(obj.value))
|
|
132
|
+
return Object.fromEntries(obj.value.map((pair) => [
|
|
133
|
+
typeof pair[0] === "string" ? pair[0] : canonical(pair[0]),
|
|
134
|
+
normalize(pair[1])
|
|
135
|
+
]));
|
|
136
|
+
if (Array.isArray(state))
|
|
137
|
+
return Object.fromEntries(state.map((value, index) => [String(index), normalize(value)]));
|
|
138
|
+
}
|
|
139
|
+
if (state && typeof state === "object" && Array.isArray(state.records)) {
|
|
140
|
+
const { records, ...rest } = state;
|
|
141
|
+
return {
|
|
142
|
+
...rest,
|
|
143
|
+
records: Object.fromEntries(records.map((r) => [`${r.collection}/${r.id}`, r]))
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (state && typeof state === "object" && !Array.isArray(state))
|
|
147
|
+
return Object.fromEntries(Object.entries(state).map(([k, v]) => [k, normalize(v)]));
|
|
148
|
+
return state;
|
|
149
|
+
};
|
|
150
|
+
walk(ns, service, "/state", normalize(x?.state), normalize(y?.state));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { format: "mockingbird.fleet-diff/v1", total, truncated: total > changes.length, changes };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ../../adapters/node/dist/fleet-bindings.js
|
|
157
|
+
var object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
158
|
+
async function planRestoreBindings(input, plan, resolveSecret) {
|
|
159
|
+
const source = validateArchive(input), copy = structuredClone(source);
|
|
160
|
+
if (!plan || !Array.isArray(plan.bindings) || !object(plan.endpoints))
|
|
161
|
+
throw new Error("expected explicit restore bindings and endpoint references");
|
|
162
|
+
const changes = [];
|
|
163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
164
|
+
const endpoint = (reference) => {
|
|
165
|
+
const value = plan.endpoints[reference];
|
|
166
|
+
if (typeof value !== "string")
|
|
167
|
+
throw new Error(`unresolved endpoint reference ${reference}`);
|
|
168
|
+
const url = new URL(value);
|
|
169
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
170
|
+
throw new Error(`invalid endpoint reference ${reference}`);
|
|
171
|
+
return url.href;
|
|
172
|
+
};
|
|
173
|
+
for (const binding of plan.bindings) {
|
|
174
|
+
const identity = `${binding.namespace}/${binding.service}/${binding.endpointId}`;
|
|
175
|
+
if (seen.has(identity))
|
|
176
|
+
throw new Error(`ambiguous binding ${identity}`);
|
|
177
|
+
seen.add(identity);
|
|
178
|
+
const section = copy.namespaces[binding.namespace]?.services[binding.service];
|
|
179
|
+
if (section?.protocol !== "http" || !object(section.state) || !object(section.state.webhooks) || !Array.isArray(section.state.webhooks.endpoints))
|
|
180
|
+
throw new Error(`unsupported callback binding ${identity}`);
|
|
181
|
+
const hub = section.state.webhooks;
|
|
182
|
+
const matches = hub.endpoints.filter((e) => object(e) && e.id === binding.endpointId);
|
|
183
|
+
if (matches.length !== 1)
|
|
184
|
+
throw new Error(`unresolved or ambiguous endpoint ${identity}`);
|
|
185
|
+
const current = matches[0], old = String(current.url), destination2 = endpoint(binding.endpointRef);
|
|
186
|
+
if (current.signUrl !== void 0 && binding.signing === void 0)
|
|
187
|
+
throw new Error(`signing policy required for ${identity}`);
|
|
188
|
+
if (binding.signing !== void 0 && !["preserve", "replace"].includes(binding.signing))
|
|
189
|
+
throw new Error(`unsupported signing policy ${identity}`);
|
|
190
|
+
const signUrl = binding.signing === "replace" ? endpoint(binding.signingRef ?? binding.endpointRef) : current.signUrl;
|
|
191
|
+
let secret;
|
|
192
|
+
if (binding.secretRef !== void 0) {
|
|
193
|
+
if (!resolveSecret)
|
|
194
|
+
throw new Error(`unresolved secret reference ${binding.secretRef}`);
|
|
195
|
+
try {
|
|
196
|
+
secret = await resolveSecret(binding.secretRef);
|
|
197
|
+
if (typeof secret !== "string" || !secret)
|
|
198
|
+
throw new Error();
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error(`unresolved secret reference ${binding.secretRef}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const apply = (value) => {
|
|
204
|
+
value.url = destination2;
|
|
205
|
+
if (signUrl !== void 0)
|
|
206
|
+
value.signUrl = signUrl;
|
|
207
|
+
if (secret !== void 0)
|
|
208
|
+
value.secret = secret;
|
|
209
|
+
};
|
|
210
|
+
apply(current);
|
|
211
|
+
if (Array.isArray(hub.payloads)) {
|
|
212
|
+
for (const pair of hub.payloads)
|
|
213
|
+
if (Array.isArray(pair) && object(pair[1]) && object(pair[1].endpoint) && pair[1].endpoint.id === binding.endpointId)
|
|
214
|
+
apply(pair[1].endpoint);
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(hub.deliveries)) {
|
|
217
|
+
for (const pair of hub.deliveries)
|
|
218
|
+
if (Array.isArray(pair) && object(pair[1]) && pair[1].endpointId === binding.endpointId)
|
|
219
|
+
pair[1].url = destination2;
|
|
220
|
+
}
|
|
221
|
+
if (Array.isArray(section.state.records)) {
|
|
222
|
+
for (const row of section.state.records)
|
|
223
|
+
if (object(row) && row.collection === "runtime_webhook_endpoints" && object(row.value) && row.value.id === binding.endpointId)
|
|
224
|
+
apply(row.value);
|
|
225
|
+
}
|
|
226
|
+
changes.push({
|
|
227
|
+
namespace: binding.namespace,
|
|
228
|
+
service: binding.service,
|
|
229
|
+
endpointId: binding.endpointId,
|
|
230
|
+
endpointRef: binding.endpointRef,
|
|
231
|
+
...binding.secretRef ? { secretRef: binding.secretRef } : {},
|
|
232
|
+
previousFingerprint: digest(old),
|
|
233
|
+
destinationFingerprint: digest(destination2),
|
|
234
|
+
signing: binding.signing ?? "not-configured"
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const { sha256: _sha, ...content } = copy;
|
|
238
|
+
return {
|
|
239
|
+
report: {
|
|
240
|
+
version: 1,
|
|
241
|
+
source: source.sha256,
|
|
242
|
+
changes,
|
|
243
|
+
limitations: [
|
|
244
|
+
"opaque payloads, tokens, issuer material and application URLs are unchanged",
|
|
245
|
+
"protocol key rebinding requires a registered participant transformation"
|
|
246
|
+
]
|
|
247
|
+
},
|
|
248
|
+
archive: sealArchive(content)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
var rebaseArchiveNamespace = (input, source, destination2) => {
|
|
252
|
+
const archive = validateArchive(input);
|
|
253
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(destination2) || !archive.namespaces[source])
|
|
254
|
+
throw new Error("invalid namespace fork");
|
|
255
|
+
const { sha256: _sha, ...content } = archive;
|
|
256
|
+
return sealArchive({
|
|
257
|
+
...content,
|
|
258
|
+
namespaces: { [destination2]: structuredClone(archive.namespaces[source]) }
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ../../adapters/node/dist/fleet-instrumentation.js
|
|
20
263
|
import { randomUUID } from "node:crypto";
|
|
21
|
-
|
|
264
|
+
var FleetInstrumentation = class {
|
|
265
|
+
options;
|
|
266
|
+
auditEntries = [];
|
|
267
|
+
started = /* @__PURE__ */ new Map();
|
|
268
|
+
terminal = /* @__PURE__ */ new Set();
|
|
269
|
+
metrics = {
|
|
270
|
+
started: 0,
|
|
271
|
+
succeeded: 0,
|
|
272
|
+
failed: 0,
|
|
273
|
+
cancelled: 0,
|
|
274
|
+
recoveryRequired: 0,
|
|
275
|
+
phaseEvents: 0,
|
|
276
|
+
auditFailures: 0,
|
|
277
|
+
rollbackAttempts: 0,
|
|
278
|
+
rollbackFailures: 0,
|
|
279
|
+
telemetryFailures: 0,
|
|
280
|
+
durationsMs: { count: 0, sum: 0, max: 0 }
|
|
281
|
+
};
|
|
282
|
+
constructor(options = {}) {
|
|
283
|
+
this.options = options;
|
|
284
|
+
}
|
|
285
|
+
async audit(input) {
|
|
286
|
+
const event = { ...input, id: randomUUID(), timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
287
|
+
let accepted = true;
|
|
288
|
+
try {
|
|
289
|
+
if (this.options.audit) {
|
|
290
|
+
await this.options.audit.write(event);
|
|
291
|
+
event.durable = true;
|
|
292
|
+
} else
|
|
293
|
+
event.durable = false;
|
|
294
|
+
} catch {
|
|
295
|
+
this.metrics.auditFailures++;
|
|
296
|
+
event.durable = false;
|
|
297
|
+
accepted = this.options.audit?.failurePolicy !== "closed";
|
|
298
|
+
}
|
|
299
|
+
this.auditEntries.push(event);
|
|
300
|
+
if (this.auditEntries.length > 200)
|
|
301
|
+
this.auditEntries.shift();
|
|
302
|
+
return accepted;
|
|
303
|
+
}
|
|
304
|
+
phase(operation, recoveryRequired) {
|
|
305
|
+
if (!this.started.has(operation.id)) {
|
|
306
|
+
this.started.set(operation.id, performance.now());
|
|
307
|
+
this.metrics.started++;
|
|
308
|
+
}
|
|
309
|
+
const elapsedMs = Math.max(0, performance.now() - this.started.get(operation.id));
|
|
310
|
+
this.metrics.phaseEvents++;
|
|
311
|
+
if (operation.phase === "rolling-back")
|
|
312
|
+
this.metrics.rollbackAttempts++;
|
|
313
|
+
if (operation.phase === "rollback-failed")
|
|
314
|
+
this.metrics.rollbackFailures++;
|
|
315
|
+
if (operation.status !== "running" && !this.terminal.has(operation.id)) {
|
|
316
|
+
this.terminal.add(operation.id);
|
|
317
|
+
if (operation.status === "succeeded")
|
|
318
|
+
this.metrics.succeeded++;
|
|
319
|
+
else if (operation.status === "cancelled")
|
|
320
|
+
this.metrics.cancelled++;
|
|
321
|
+
else
|
|
322
|
+
this.metrics.failed++;
|
|
323
|
+
if (recoveryRequired)
|
|
324
|
+
this.metrics.recoveryRequired++;
|
|
325
|
+
this.metrics.durationsMs.count++;
|
|
326
|
+
this.metrics.durationsMs.sum += elapsedMs;
|
|
327
|
+
this.metrics.durationsMs.max = Math.max(this.metrics.durationsMs.max, elapsedMs);
|
|
328
|
+
}
|
|
329
|
+
const action = operation.action.replace(/\/namespaces\/[^/]+/g, "/namespaces/{namespace}").replace(/\/snapshots\/[^/]+/g, "/snapshots/{id}").replace(/\/scenarios\/[^/]+/g, "/scenarios/{name}");
|
|
330
|
+
try {
|
|
331
|
+
const sent = this.options.telemetry?.({
|
|
332
|
+
...operation.participant ? { participant: operation.participant } : {},
|
|
333
|
+
operationId: operation.id,
|
|
334
|
+
phase: operation.phase,
|
|
335
|
+
action,
|
|
336
|
+
services: operation.services ?? [],
|
|
337
|
+
status: operation.status,
|
|
338
|
+
elapsedMs,
|
|
339
|
+
recoveryRequired
|
|
340
|
+
});
|
|
341
|
+
if (sent)
|
|
342
|
+
void sent.catch(() => {
|
|
343
|
+
this.metrics.telemetryFailures++;
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
this.metrics.telemetryFailures++;
|
|
347
|
+
}
|
|
348
|
+
while (this.started.size > 200) {
|
|
349
|
+
const id = this.started.keys().next().value;
|
|
350
|
+
this.started.delete(id);
|
|
351
|
+
this.terminal.delete(id);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
snapshot() {
|
|
355
|
+
return {
|
|
356
|
+
version: 1,
|
|
357
|
+
metrics: structuredClone(this.metrics),
|
|
358
|
+
cardinality: "aggregate-no-namespace-labels"
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
auditTrail() {
|
|
362
|
+
return {
|
|
363
|
+
version: 1,
|
|
364
|
+
entries: structuredClone(this.auditEntries),
|
|
365
|
+
retention: 200,
|
|
366
|
+
durableSink: Boolean(this.options.audit),
|
|
367
|
+
failurePolicy: this.options.audit?.failurePolicy ?? "memory-only"
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ../../adapters/node/dist/fleet-journal.js
|
|
373
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
374
|
+
import { join } from "node:path";
|
|
375
|
+
var validate = (record) => {
|
|
376
|
+
if (record?.version !== 1 || !Number.isSafeInteger(record.revision) || record.revision < 1 || !/^[a-f0-9-]{36}$/.test(record.operationId) || !Array.isArray(record.scope) || record.scope.length > 1e3 || record.scope.some((ns) => !/^[A-Za-z0-9_.-]{1,64}$/.test(ns)) || ![
|
|
377
|
+
"prepared",
|
|
378
|
+
"committing",
|
|
379
|
+
"uploading",
|
|
380
|
+
"publishing",
|
|
381
|
+
"succeeded",
|
|
382
|
+
"failed",
|
|
383
|
+
"recovery-required",
|
|
384
|
+
"resolved"
|
|
385
|
+
].includes(record.phase) || [record.before, record.after].some((id) => id !== void 0 && !/^[a-f0-9]{64}$/.test(id)) || Object.keys(record).some((key) => ![
|
|
386
|
+
"version",
|
|
387
|
+
"revision",
|
|
388
|
+
"operationId",
|
|
389
|
+
"controller",
|
|
390
|
+
"namespace",
|
|
391
|
+
"scope",
|
|
392
|
+
"action",
|
|
393
|
+
"phase",
|
|
394
|
+
"timestamp",
|
|
395
|
+
"requestFingerprint",
|
|
396
|
+
"before",
|
|
397
|
+
"after",
|
|
398
|
+
"writerGeneration",
|
|
399
|
+
"resolution"
|
|
400
|
+
].includes(key)))
|
|
401
|
+
throw new Error("invalid durable operation metadata");
|
|
402
|
+
};
|
|
403
|
+
async function reconcileOperation(record, repository) {
|
|
404
|
+
validate(record);
|
|
405
|
+
if (["succeeded", "failed", "resolved"].includes(record.phase))
|
|
406
|
+
return { status: "terminal", operationId: record.operationId };
|
|
407
|
+
if (record.phase === "prepared")
|
|
408
|
+
return { status: "abandoned-before-commit", operationId: record.operationId };
|
|
409
|
+
if (record.after) {
|
|
410
|
+
try {
|
|
411
|
+
const archive = await repository.get(record.after), publication = await repository.publicationMetadata?.();
|
|
412
|
+
if (archive.sha256 === record.after && publication?.id === record.after && record.writerGeneration !== void 0 && publication.generation === record.writerGeneration)
|
|
413
|
+
return {
|
|
414
|
+
status: "verified-publication",
|
|
415
|
+
operationId: record.operationId,
|
|
416
|
+
archive: record.after
|
|
417
|
+
};
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (record.before)
|
|
422
|
+
try {
|
|
423
|
+
await repository.get(record.before);
|
|
424
|
+
} catch {
|
|
425
|
+
return {
|
|
426
|
+
status: "recovery-required",
|
|
427
|
+
operationId: record.operationId,
|
|
428
|
+
reason: "before-state archive unavailable",
|
|
429
|
+
scope: record.scope
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
status: "recovery-required",
|
|
434
|
+
operationId: record.operationId,
|
|
435
|
+
reason: "commit or rollback cannot be proven",
|
|
436
|
+
scope: record.scope
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ../../adapters/node/dist/fleet-observations.js
|
|
441
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
442
|
+
var hash = (text2) => createHash2("sha256").update(text2).digest("hex");
|
|
443
|
+
var boundedText = async (stream, max) => {
|
|
444
|
+
if (!stream)
|
|
445
|
+
return "";
|
|
446
|
+
const reader = stream.getReader(), chunks = [];
|
|
447
|
+
let bytes = 0, timedOut = false;
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
timedOut = true;
|
|
450
|
+
void reader.cancel().catch(() => {
|
|
451
|
+
});
|
|
452
|
+
}, 100);
|
|
453
|
+
try {
|
|
454
|
+
for (; ; ) {
|
|
455
|
+
const result = await reader.read();
|
|
456
|
+
if (result.done)
|
|
457
|
+
break;
|
|
458
|
+
bytes += result.value.length;
|
|
459
|
+
if (bytes > max)
|
|
460
|
+
throw new Error("capture byte limit");
|
|
461
|
+
chunks.push(result.value);
|
|
462
|
+
}
|
|
463
|
+
if (timedOut)
|
|
464
|
+
throw new Error("capture deadline");
|
|
465
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
466
|
+
} finally {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
void reader.cancel().catch(() => {
|
|
469
|
+
});
|
|
470
|
+
reader.releaseLock();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
var FleetObservations = class {
|
|
474
|
+
processId;
|
|
475
|
+
limits;
|
|
476
|
+
sequence = 0;
|
|
477
|
+
entries = [];
|
|
478
|
+
baselines = /* @__PURE__ */ new Map();
|
|
479
|
+
traces = /* @__PURE__ */ new Map();
|
|
480
|
+
constructor(processId, limits = { events: 500, traceEvents: 200, traceBytes: 1024 * 1024 }) {
|
|
481
|
+
this.processId = processId;
|
|
482
|
+
this.limits = limits;
|
|
483
|
+
}
|
|
484
|
+
record(event) {
|
|
485
|
+
this.entries.push({ ...event, id: ++this.sequence });
|
|
486
|
+
if (this.entries.length > this.limits.events)
|
|
487
|
+
this.entries.shift();
|
|
488
|
+
}
|
|
489
|
+
baseline(namespace, services) {
|
|
490
|
+
const id = randomUUID2();
|
|
491
|
+
this.baselines.set(id, { namespace, services, sequence: this.sequence });
|
|
492
|
+
while (this.baselines.size > 200)
|
|
493
|
+
this.baselines.delete(this.baselines.keys().next().value);
|
|
494
|
+
return { id, namespace, services, sequence: this.sequence };
|
|
495
|
+
}
|
|
496
|
+
assertClean(id, namespace, diagnostics, options = {}) {
|
|
497
|
+
const baseline = this.baselines.get(id);
|
|
498
|
+
if (!baseline || baseline.namespace !== namespace)
|
|
499
|
+
throw new Error("assertion baseline not found in this namespace");
|
|
500
|
+
const allowances = options.allowances ?? [];
|
|
501
|
+
if (!Array.isArray(allowances) || allowances.some((a) => !["unmatched", "rejected", "fault"].includes(a.kind) || !a.service || !a.pathFingerprint && !a.operationId || !Number.isSafeInteger(a.maxCount) || a.maxCount < 1))
|
|
502
|
+
throw new Error("allowances must identify a service and exact operation or path fingerprint with a bounded count");
|
|
503
|
+
const consumed = /* @__PURE__ */ new Map(), failures = [];
|
|
504
|
+
if (this.entries.length && baseline.sequence < (this.entries[0]?.id ?? 0) - 1)
|
|
505
|
+
failures.push({ kind: "evidence_truncated" });
|
|
506
|
+
for (const entry of this.entries) {
|
|
507
|
+
if (entry.id <= baseline.sequence || entry.namespace !== namespace || !baseline.services.includes(entry.service))
|
|
508
|
+
continue;
|
|
509
|
+
const kinds = entry.kind === "webhook" ? entry.deliveryState === "failed" || entry.deliveryState === "dropped" ? ["failed_delivery"] : [] : entry.fault ? ["fault"] : entry.unmatched ? ["unmatched"] : (entry.status ?? 0) >= 400 ? ["rejected"] : [];
|
|
510
|
+
for (const kind of kinds) {
|
|
511
|
+
const allowed = allowances.find((a) => a.kind === kind && a.service === entry.service && (!a.pathFingerprint || a.pathFingerprint === entry.pathFingerprint) && (!a.operationId || a.operationId === entry.operationId) && (a.status === void 0 || a.status === entry.status) && (consumed.get(a) ?? 0) < a.maxCount);
|
|
512
|
+
if (allowed)
|
|
513
|
+
consumed.set(allowed, (consumed.get(allowed) ?? 0) + 1);
|
|
514
|
+
else
|
|
515
|
+
failures.push({
|
|
516
|
+
kind,
|
|
517
|
+
service: entry.service,
|
|
518
|
+
method: entry.method,
|
|
519
|
+
pathFingerprint: entry.pathFingerprint,
|
|
520
|
+
operationId: entry.operationId,
|
|
521
|
+
status: entry.status,
|
|
522
|
+
journalReference: entry.id
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const service of baseline.services) {
|
|
527
|
+
const d = diagnostics[service];
|
|
528
|
+
if (!d)
|
|
529
|
+
throw new Error(`missing assertion diagnostics for ${service}`);
|
|
530
|
+
if (d.activeRequests + d.activeConnections + d.blockedCommands + d.pendingWebhooks + (d.activeJobs ?? 0) > 0 || !options.allowCheckpointablePending && d.pendingJobs > 0)
|
|
531
|
+
failures.push({ kind: "leftover_work", service, diagnostics: d });
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
format: "mockingbird.assertions/v1",
|
|
535
|
+
clean: failures.length === 0,
|
|
536
|
+
namespace,
|
|
537
|
+
baseline: id,
|
|
538
|
+
failures: failures.slice(0, 100),
|
|
539
|
+
truncated: failures.length > 100,
|
|
540
|
+
inspectedThrough: this.sequence
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
startTrace(namespace, input, includeBodies = false, limitations = []) {
|
|
544
|
+
const baseline = validateArchive(input);
|
|
545
|
+
if (!baseline.namespaces[namespace])
|
|
546
|
+
throw new Error("trace baseline lacks selected namespace");
|
|
547
|
+
if (Buffer.byteLength(JSON.stringify(baseline)) > 8 * 1024 * 1024)
|
|
548
|
+
throw new Error("trace baseline exceeds 8 MiB limit");
|
|
549
|
+
if (this.traces.size >= 4 && !this.traces.has(namespace))
|
|
550
|
+
throw new Error("trace capacity reached");
|
|
551
|
+
const artifact = {
|
|
552
|
+
format: "mockingbird.trace/v1",
|
|
553
|
+
sourceProcess: this.processId,
|
|
554
|
+
namespace,
|
|
555
|
+
baseline: structuredClone(baseline),
|
|
556
|
+
events: [],
|
|
557
|
+
limitations: [...limitations],
|
|
558
|
+
truncated: false,
|
|
559
|
+
active: true
|
|
560
|
+
};
|
|
561
|
+
this.traces.set(namespace, { artifact, includeBodies, bytes: 0, inFlight: 0, next: 0 });
|
|
562
|
+
return structuredClone(artifact);
|
|
563
|
+
}
|
|
564
|
+
limitation(artifact, limitation) {
|
|
565
|
+
if (!artifact.limitations.includes(limitation))
|
|
566
|
+
artifact.limitations.push(limitation);
|
|
567
|
+
}
|
|
568
|
+
beginHTTP(namespace, service, request, logicalPath) {
|
|
569
|
+
const trace = this.traces.get(namespace);
|
|
570
|
+
if (!trace?.artifact.active)
|
|
571
|
+
return void 0;
|
|
572
|
+
const url = new URL(request.url), omitted = [];
|
|
573
|
+
if (logicalPath)
|
|
574
|
+
url.pathname = logicalPath;
|
|
575
|
+
for (const key of [...url.searchParams.keys()])
|
|
576
|
+
if (/(token|key|password|secret|signature|credential)/i.test(key)) {
|
|
577
|
+
url.searchParams.set(key, "[redacted]");
|
|
578
|
+
omitted.push("sensitive query fields redacted");
|
|
579
|
+
}
|
|
580
|
+
if ([...request.headers.keys()].some((h) => /authorization|api-key|token|secret|cookie/i.test(h)))
|
|
581
|
+
omitted.push("credential headers require explicit replay bindings");
|
|
582
|
+
const event = {
|
|
583
|
+
sequence: ++trace.next,
|
|
584
|
+
kind: "http",
|
|
585
|
+
service,
|
|
586
|
+
method: request.method,
|
|
587
|
+
path: `${url.pathname}${url.search}`,
|
|
588
|
+
contentType: request.headers.get("content-type") ?? "",
|
|
589
|
+
omitted
|
|
590
|
+
};
|
|
591
|
+
if (trace.inFlight++)
|
|
592
|
+
this.limitation(trace.artifact, "concurrent interactions are not certified for serial replay");
|
|
593
|
+
let body = Promise.resolve({ omitted: [] });
|
|
594
|
+
if (request.body) {
|
|
595
|
+
if (!trace.includeBodies)
|
|
596
|
+
body = Promise.resolve({ omitted: ["request body omitted"] });
|
|
597
|
+
else {
|
|
598
|
+
body = (async () => {
|
|
599
|
+
let timeout;
|
|
600
|
+
try {
|
|
601
|
+
let text2 = await Promise.race([
|
|
602
|
+
boundedText(request.clone().body, 65536),
|
|
603
|
+
new Promise((_, reject) => {
|
|
604
|
+
timeout = setTimeout(() => reject(new Error("capture deadline")), 100);
|
|
605
|
+
})
|
|
606
|
+
]);
|
|
607
|
+
const removed = [];
|
|
608
|
+
if (event.contentType?.includes("json")) {
|
|
609
|
+
text2 = JSON.stringify(JSON.parse(text2), (key, value) => {
|
|
610
|
+
if (/(token|password|secret|credential|authorization|api.?key)/i.test(key)) {
|
|
611
|
+
removed.push("sensitive JSON body fields redacted");
|
|
612
|
+
return "[redacted]";
|
|
613
|
+
}
|
|
614
|
+
return value;
|
|
615
|
+
});
|
|
616
|
+
} else if (event.contentType?.includes("x-www-form-urlencoded")) {
|
|
617
|
+
const form = new URLSearchParams(text2);
|
|
618
|
+
for (const key of [...form.keys()])
|
|
619
|
+
if (/(token|password|secret|credential|api.?key)/i.test(key)) {
|
|
620
|
+
form.set(key, "[redacted]");
|
|
621
|
+
removed.push("sensitive form fields redacted");
|
|
622
|
+
}
|
|
623
|
+
text2 = form.toString();
|
|
624
|
+
}
|
|
625
|
+
return { text: text2, omitted: removed };
|
|
626
|
+
} catch {
|
|
627
|
+
return { omitted: ["request body capture truncated or unavailable"] };
|
|
628
|
+
} finally {
|
|
629
|
+
clearTimeout(timeout);
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { artifact: trace.artifact, event, body };
|
|
635
|
+
}
|
|
636
|
+
async finishHTTP(token, response) {
|
|
637
|
+
if (!token)
|
|
638
|
+
return;
|
|
639
|
+
const trace = this.traces.get(token.artifact.namespace);
|
|
640
|
+
if (!trace || trace.artifact !== token.artifact)
|
|
641
|
+
return;
|
|
642
|
+
const captured = await token.body;
|
|
643
|
+
token.event.status = response.status;
|
|
644
|
+
token.event.omitted.push(...captured.omitted);
|
|
645
|
+
if (captured.text !== void 0)
|
|
646
|
+
token.event.body = captured.text;
|
|
647
|
+
if (response.headers.get("content-type")?.includes("text/event-stream"))
|
|
648
|
+
token.event.omitted.push("streaming responses are unsupported");
|
|
649
|
+
else {
|
|
650
|
+
try {
|
|
651
|
+
const text2 = await boundedText(response.clone().body, 65536);
|
|
652
|
+
token.event.responseHash = hash(text2);
|
|
653
|
+
} catch {
|
|
654
|
+
token.event.omitted.push("response hash unavailable or truncated");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
trace.inFlight--;
|
|
658
|
+
for (const omitted of token.event.omitted)
|
|
659
|
+
this.limitation(trace.artifact, omitted);
|
|
660
|
+
this.append(trace, token.event);
|
|
661
|
+
}
|
|
662
|
+
append(trace, event) {
|
|
663
|
+
const bytes = Buffer.byteLength(JSON.stringify(event));
|
|
664
|
+
if (trace.artifact.events.length >= this.limits.traceEvents || trace.bytes + bytes > this.limits.traceBytes) {
|
|
665
|
+
trace.artifact.truncated = true;
|
|
666
|
+
trace.artifact.active = false;
|
|
667
|
+
this.limitation(trace.artifact, "recording limits reached");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
trace.bytes += bytes;
|
|
671
|
+
trace.artifact.events.push(event);
|
|
672
|
+
trace.artifact.events.sort((a, b) => a.sequence - b.sequence);
|
|
673
|
+
}
|
|
674
|
+
unsupportedControl(namespace, action) {
|
|
675
|
+
const trace = this.traces.get(namespace);
|
|
676
|
+
if (trace?.artifact.active)
|
|
677
|
+
this.limitation(trace.artifact, `control not replayable: ${action}`);
|
|
678
|
+
}
|
|
679
|
+
control(namespace, action, controls) {
|
|
680
|
+
const trace = this.traces.get(namespace);
|
|
681
|
+
if (trace?.artifact.active)
|
|
682
|
+
this.append(trace, {
|
|
683
|
+
sequence: ++trace.next,
|
|
684
|
+
kind: "control",
|
|
685
|
+
action,
|
|
686
|
+
controls: structuredClone(controls),
|
|
687
|
+
omitted: []
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
trace(namespace, stop = false) {
|
|
691
|
+
const trace = this.traces.get(namespace);
|
|
692
|
+
if (!trace)
|
|
693
|
+
throw new Error("trace not started");
|
|
694
|
+
if (stop)
|
|
695
|
+
trace.artifact.active = false;
|
|
696
|
+
if (trace.inFlight)
|
|
697
|
+
this.limitation(trace.artifact, "requests still in flight when inspected");
|
|
698
|
+
return structuredClone(trace.artifact);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// ../../adapters/node/dist/fleet-proxy.js
|
|
703
|
+
var ingressSupport = {
|
|
704
|
+
stripe: {
|
|
705
|
+
publicOrigin: true,
|
|
706
|
+
pathPrefix: true,
|
|
707
|
+
namespaces: "path-or-header",
|
|
708
|
+
streaming: true,
|
|
709
|
+
signing: "vendor request signing is not used; webhook signatures remain payload based"
|
|
710
|
+
},
|
|
711
|
+
other: {
|
|
712
|
+
publicOrigin: false,
|
|
713
|
+
reason: "host/path-sensitive signing and hosted URL rewriting are not certified; use a direct endpoint"
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function advertisedHttpBase(entry) {
|
|
717
|
+
if (entry.publicPath !== void 0 && !entry.publicOrigin)
|
|
718
|
+
throw new Error("publicPath requires publicOrigin");
|
|
719
|
+
if (!entry.publicOrigin)
|
|
720
|
+
return void 0;
|
|
721
|
+
const url = new URL(entry.publicOrigin);
|
|
722
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
723
|
+
throw new Error("publicOrigin must be an HTTP(S) origin without credentials or a path");
|
|
724
|
+
const prefix = entry.publicPath ?? "";
|
|
725
|
+
if (prefix && (!/^\/(?:[A-Za-z0-9_-]+\/?)*$/.test(prefix) || prefix.endsWith("/")))
|
|
726
|
+
throw new Error("publicPath must be a canonical path prefix without a trailing slash");
|
|
727
|
+
return `${url.origin}${prefix}`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ../../adapters/node/dist/fleet-time.js
|
|
731
|
+
var FleetTimeController = class {
|
|
732
|
+
context;
|
|
733
|
+
constructor(context) {
|
|
734
|
+
this.context = context;
|
|
735
|
+
}
|
|
736
|
+
async run(namespace, selected, body, single) {
|
|
737
|
+
const condition = body.condition ?? "no-events", maxEvents = single ? 1 : body.maxEvents ?? 100, maxAdvanceMs = body.maxAdvanceMs ?? 864e5, timeoutMs = body.timeoutMs ?? 5e3;
|
|
738
|
+
if (!["no-events", "idle", "checkpoint-ready"].includes(String(condition)) || typeof maxEvents !== "number" || !Number.isSafeInteger(maxEvents) || maxEvents < 1 || maxEvents > 1e4 || typeof maxAdvanceMs !== "number" || !Number.isFinite(maxAdvanceMs) || maxAdvanceMs < 0 || typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e4)
|
|
739
|
+
throw new Error("invalid bounded time-control condition or budgets");
|
|
740
|
+
const unsupported = [...selected].filter(([, child]) => !child.nextEvent).map(([name]) => name);
|
|
741
|
+
if (unsupported.length)
|
|
742
|
+
throw new Error(`next-event inspection unsupported for ${unsupported.join(", ")}`);
|
|
743
|
+
const clocks = [...selected.values()].map((child) => child.clock(namespace).state());
|
|
744
|
+
if (clocks.some((clock) => !clock.frozen || clock.now !== clocks[0]?.now))
|
|
745
|
+
throw new Error("freeze and align selected participant clocks before event stepping");
|
|
746
|
+
const start2 = clocks[0]?.now ?? this.context.clock(namespace).now(), deadline = Date.now() + timeoutMs;
|
|
747
|
+
const steps = [];
|
|
748
|
+
const diagnostics = () => Object.fromEntries([...selected].map(([name, child]) => [name, child.diagnostics(namespace)]));
|
|
749
|
+
const bounded = async (promise) => {
|
|
750
|
+
let timer;
|
|
751
|
+
try {
|
|
752
|
+
return await Promise.race([
|
|
753
|
+
promise,
|
|
754
|
+
new Promise((_, reject) => {
|
|
755
|
+
timer = setTimeout(() => reject(Object.assign(new Error("time-control host budget exceeded; recovery may be required"), { code: "MOCKINGBIRD_WORKLOAD_UNCERTAIN" })), Math.max(1, deadline - Date.now()));
|
|
756
|
+
})
|
|
757
|
+
]);
|
|
758
|
+
} finally {
|
|
759
|
+
clearTimeout(timer);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
for (; ; ) {
|
|
763
|
+
const due = await bounded(Promise.all([...selected].map(async ([name, child]) => [name, await child.nextEvent?.(namespace)])));
|
|
764
|
+
if (due.some(([, time]) => time !== void 0 && !Number.isFinite(time)))
|
|
765
|
+
throw new Error("participant returned an invalid event time");
|
|
766
|
+
const services = diagnostics(), states = Object.values(services);
|
|
767
|
+
const ready = condition === "checkpoint-ready" ? states.every((d) => d.activeRequests + d.blockedCommands + d.pendingWebhooks + (d.activeJobs ?? 0) === 0) : condition === "idle" ? states.every((d) => d.activeRequests + d.blockedCommands + d.pendingWebhooks + d.pendingJobs === 0) : due.every(([, time]) => time === void 0);
|
|
768
|
+
if (!single && ready)
|
|
769
|
+
return Response.json({ status: "settled", namespace, condition, steps, services });
|
|
770
|
+
const times = due.flatMap(([, time]) => time === void 0 ? [] : [time]);
|
|
771
|
+
if (!times.length)
|
|
772
|
+
return Response.json({
|
|
773
|
+
status: "no-events",
|
|
774
|
+
namespace,
|
|
775
|
+
condition,
|
|
776
|
+
settled: ready,
|
|
777
|
+
steps,
|
|
778
|
+
services
|
|
779
|
+
});
|
|
780
|
+
const current = [...selected.values()][0]?.clock(namespace).now() ?? start2, target = Math.max(current, Math.min(...times));
|
|
781
|
+
if (Date.now() >= deadline || steps.length >= maxEvents || target - start2 > maxAdvanceMs)
|
|
782
|
+
return Response.json({ status: "budget-exhausted", namespace, condition, steps, services }, { status: 409 });
|
|
783
|
+
const signal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
784
|
+
const response = await this.context.transaction(namespace, selected, async (_name, child) => {
|
|
785
|
+
child.clock(namespace).set(target);
|
|
786
|
+
if (child.settle)
|
|
787
|
+
await bounded(child.settle(namespace, signal));
|
|
788
|
+
});
|
|
789
|
+
if (!response.ok)
|
|
790
|
+
return response;
|
|
791
|
+
this.context.clock(namespace).freeze();
|
|
792
|
+
this.context.clock(namespace).set(target);
|
|
793
|
+
try {
|
|
794
|
+
if (this.context.synchronize)
|
|
795
|
+
await bounded(this.context.synchronize({
|
|
796
|
+
namespace,
|
|
797
|
+
services: [...selected.keys()],
|
|
798
|
+
now: target,
|
|
799
|
+
signal
|
|
800
|
+
}));
|
|
801
|
+
} catch (error) {
|
|
802
|
+
const fences = [];
|
|
803
|
+
for (const child of selected.values())
|
|
804
|
+
try {
|
|
805
|
+
fences.push(child.lock(namespace));
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
this.context.recovery.set(namespace, fences);
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
const processed = due.filter(([, time]) => time !== void 0 && time <= target).map(([name]) => name);
|
|
812
|
+
steps.push({ now: target, processed });
|
|
813
|
+
this.context.phase(namespace, "stepping", steps.length, steps.length);
|
|
814
|
+
if (single)
|
|
815
|
+
return Response.json({
|
|
816
|
+
status: "stepped",
|
|
817
|
+
namespace,
|
|
818
|
+
steps,
|
|
819
|
+
services: diagnostics(),
|
|
820
|
+
clientClockSynchronization: this.context.synchronize ? "hook-invoked" : "external worker clocks are unchanged"
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// ../../adapters/node/dist/fleet-management.js
|
|
827
|
+
var object2 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
828
|
+
var fleetError = (status, message, extra = {}) => Response.json({
|
|
829
|
+
...extra,
|
|
830
|
+
error: {
|
|
831
|
+
type: "mockingbird_admin",
|
|
832
|
+
code: {
|
|
833
|
+
400: "invalid_request",
|
|
834
|
+
401: "unauthorized",
|
|
835
|
+
403: "forbidden",
|
|
836
|
+
404: "not_found",
|
|
837
|
+
409: "conflict",
|
|
838
|
+
413: "too_large",
|
|
839
|
+
429: "quota_exceeded",
|
|
840
|
+
500: "operation_failed",
|
|
841
|
+
503: "unavailable"
|
|
842
|
+
}[status] ?? "request_failed",
|
|
843
|
+
message
|
|
844
|
+
}
|
|
845
|
+
}, { status });
|
|
846
|
+
var checked = (body, keys) => {
|
|
847
|
+
const unknown = Object.keys(body).filter((k) => !keys.includes(k));
|
|
848
|
+
if (unknown.length)
|
|
849
|
+
throw new Error(`unknown fields: ${unknown.join(", ")}`);
|
|
850
|
+
};
|
|
851
|
+
var namePattern = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
852
|
+
var FleetManagement = class {
|
|
853
|
+
ctx;
|
|
854
|
+
operationContext = new AsyncLocalStorage();
|
|
855
|
+
interrupted = /* @__PURE__ */ new Map();
|
|
856
|
+
durable = /* @__PURE__ */ new Map();
|
|
857
|
+
async recoverOperations() {
|
|
858
|
+
if (!this.ctx.options.journal)
|
|
859
|
+
return;
|
|
860
|
+
if (!this.ctx.options.repository)
|
|
861
|
+
throw new Error("durable journal requires a portable archive repository");
|
|
862
|
+
let records;
|
|
863
|
+
try {
|
|
864
|
+
records = await this.ctx.options.journal.load();
|
|
865
|
+
} catch {
|
|
866
|
+
throw new Error("durable operation journal unavailable");
|
|
867
|
+
}
|
|
868
|
+
for (const record of records) {
|
|
869
|
+
const decision = await reconcileOperation(record, this.ctx.options.repository);
|
|
870
|
+
if (decision.status === "terminal")
|
|
871
|
+
continue;
|
|
872
|
+
if (decision.status === "abandoned-before-commit" || decision.status === "verified-publication") {
|
|
873
|
+
if (decision.status === "verified-publication")
|
|
874
|
+
await this.restoreArchive(await this.ctx.options.repository.get(decision.archive));
|
|
875
|
+
await this.ctx.options.journal.write({
|
|
876
|
+
...record,
|
|
877
|
+
revision: record.revision + 1,
|
|
878
|
+
phase: "resolved",
|
|
879
|
+
resolution: decision.status,
|
|
880
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
881
|
+
});
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
this.interrupted.set(record.operationId, record);
|
|
885
|
+
for (const namespace of record.scope) {
|
|
886
|
+
if (!this.namespaces.has(namespace)) {
|
|
887
|
+
const result = await this.createNamespace(namespace);
|
|
888
|
+
if (!result.ok)
|
|
889
|
+
throw new Error("cannot prepare interrupted namespace for recovery");
|
|
890
|
+
}
|
|
891
|
+
if (this.ctx.recovery.has(namespace))
|
|
892
|
+
continue;
|
|
893
|
+
const releases = [];
|
|
894
|
+
for (const child of this.ctx.children.values())
|
|
895
|
+
try {
|
|
896
|
+
releases.push(child.lock(namespace));
|
|
897
|
+
} catch {
|
|
898
|
+
}
|
|
899
|
+
this.ctx.recovery.set(namespace, releases);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
fenceNamespaces(scope) {
|
|
904
|
+
for (const namespace of scope) {
|
|
905
|
+
const releases = this.ctx.recovery.get(namespace) ?? [];
|
|
906
|
+
for (const child of this.ctx.children.values())
|
|
907
|
+
try {
|
|
908
|
+
releases.push(child.lock(namespace));
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
this.ctx.recovery.set(namespace, releases);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async journalPhase(operationId, phase, extra = {}) {
|
|
915
|
+
const current = this.durable.get(operationId);
|
|
916
|
+
if (!current || !this.ctx.options.journal)
|
|
917
|
+
return;
|
|
918
|
+
const next = {
|
|
919
|
+
...current,
|
|
920
|
+
...extra,
|
|
921
|
+
revision: current.revision + 1,
|
|
922
|
+
phase,
|
|
923
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
924
|
+
};
|
|
925
|
+
await this.ctx.options.journal.write(next);
|
|
926
|
+
this.durable.set(operationId, next);
|
|
927
|
+
}
|
|
928
|
+
observations;
|
|
929
|
+
requestPath(service, request) {
|
|
930
|
+
const prefix = this.ctx.config.services[service]?.adminPrefix ?? this.ctx.config.adminPrefix ?? "/__admin";
|
|
931
|
+
let path = new URL(request.url).pathname;
|
|
932
|
+
if (path.startsWith(`${prefix}/ns/`)) {
|
|
933
|
+
const tail = path.slice(`${prefix}/ns/`.length), slash = tail.indexOf("/");
|
|
934
|
+
path = slash < 0 ? "/" : tail.slice(slash);
|
|
935
|
+
}
|
|
936
|
+
return { path, admin: path === prefix || path.startsWith(`${prefix}/`) };
|
|
937
|
+
}
|
|
938
|
+
beginRequest(service, namespace, request) {
|
|
939
|
+
const route = this.requestPath(service, request);
|
|
940
|
+
return route.admin ? void 0 : this.observations.beginHTTP(namespace, service, request, route.path);
|
|
941
|
+
}
|
|
942
|
+
async finishRequest(service, namespace, _request, response, log, token) {
|
|
943
|
+
if (this.requestPath(service, _request).admin)
|
|
944
|
+
return;
|
|
945
|
+
const pathFingerprint = createHash3("sha256").update(log.path).digest("hex");
|
|
946
|
+
this.observations.record({
|
|
947
|
+
namespace,
|
|
948
|
+
service,
|
|
949
|
+
kind: "http",
|
|
950
|
+
method: log.method,
|
|
951
|
+
pathFingerprint,
|
|
952
|
+
status: log.status,
|
|
953
|
+
unmatched: log.unmatched,
|
|
954
|
+
fault: log.faultId !== void 0,
|
|
955
|
+
...log.operationId ? { operationId: log.operationId } : {}
|
|
956
|
+
});
|
|
957
|
+
await this.observations.finishHTTP(token, response);
|
|
958
|
+
}
|
|
959
|
+
observeDelivery(service, event) {
|
|
960
|
+
this.observations.record({
|
|
961
|
+
namespace: event.namespace,
|
|
962
|
+
service,
|
|
963
|
+
kind: "webhook",
|
|
964
|
+
deliveryState: event.state
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
instrumentation;
|
|
968
|
+
initializationToken = randomBytes(32).toString("base64url");
|
|
969
|
+
environmentContext = new AsyncLocalStorage();
|
|
970
|
+
environment;
|
|
971
|
+
ownsEnvironment() {
|
|
972
|
+
return this.environment !== void 0 && this.environmentContext.getStore() === this.environment.id;
|
|
973
|
+
}
|
|
974
|
+
topologyBusy(namespace) {
|
|
975
|
+
return this.topology.has(namespace) || this.environment !== void 0 && !this.ownsEnvironment();
|
|
976
|
+
}
|
|
977
|
+
environmentFence(namespace, service) {
|
|
978
|
+
return this.ownsEnvironment() && this.environment?.fences.get(namespace)?.has(service) === true;
|
|
979
|
+
}
|
|
980
|
+
checkCancelled(namespace) {
|
|
981
|
+
const id = this.operationId(namespace);
|
|
982
|
+
if (id && this.aborts.get(id)?.signal.aborted)
|
|
983
|
+
throw new Error("operation cancelled before commit");
|
|
984
|
+
}
|
|
985
|
+
activeOperation(namespace) {
|
|
986
|
+
const current = this.operationContext.getStore();
|
|
987
|
+
return current?.status === "running" ? current : [...this.operations.values()].find((operation) => operation.namespace === namespace && operation.status === "running");
|
|
988
|
+
}
|
|
989
|
+
operationId(namespace) {
|
|
990
|
+
return this.activeOperation(namespace)?.id;
|
|
991
|
+
}
|
|
992
|
+
async withEnvironment(namespaces, selected, run) {
|
|
993
|
+
if (this.ownsEnvironment())
|
|
994
|
+
return run();
|
|
995
|
+
if (this.environment || this.ctx.controlling.size || this.topology.size)
|
|
996
|
+
throw new Error("environment operation conflicts with active namespace work");
|
|
997
|
+
const env = { id: randomUUID3(), fences: /* @__PURE__ */ new Map() };
|
|
998
|
+
this.environment = env;
|
|
999
|
+
return this.environmentContext.run(env.id, async () => {
|
|
1000
|
+
try {
|
|
1001
|
+
for (const ns of namespaces) {
|
|
1002
|
+
if (!this.namespaces.has(ns))
|
|
1003
|
+
continue;
|
|
1004
|
+
await this.hook("prepare", ns, selected);
|
|
1005
|
+
const fences = /* @__PURE__ */ new Map();
|
|
1006
|
+
env.fences.set(ns, fences);
|
|
1007
|
+
for (const [name, child] of selected) {
|
|
1008
|
+
if (child.protocol !== "http" && !Object.hasOwn(child.namespaces.endpoints ?? {}, ns))
|
|
1009
|
+
continue;
|
|
1010
|
+
if (this.ctx.recovery.has(ns))
|
|
1011
|
+
continue;
|
|
1012
|
+
fences.set(name, child.lock(ns));
|
|
1013
|
+
}
|
|
1014
|
+
if (this.busy(ns, selected).length)
|
|
1015
|
+
throw new Error(`namespace ${ns} is not quiescent`);
|
|
1016
|
+
}
|
|
1017
|
+
return await run();
|
|
1018
|
+
} finally {
|
|
1019
|
+
for (const [ns, fences] of env.fences) {
|
|
1020
|
+
if (this.ctx.recovery.has(ns))
|
|
1021
|
+
this.ctx.recovery.get(ns)?.push(...fences.values());
|
|
1022
|
+
else
|
|
1023
|
+
for (const unlock of [...fences.values()].reverse())
|
|
1024
|
+
unlock();
|
|
1025
|
+
}
|
|
1026
|
+
this.environment = void 0;
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
operations = /* @__PURE__ */ new Map();
|
|
1031
|
+
namespaces = /* @__PURE__ */ new Map();
|
|
1032
|
+
credentials = /* @__PURE__ */ new Map();
|
|
1033
|
+
retries = /* @__PURE__ */ new Map();
|
|
1034
|
+
events = [];
|
|
1035
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1036
|
+
aborts = /* @__PURE__ */ new Map();
|
|
1037
|
+
topology = /* @__PURE__ */ new Set();
|
|
1038
|
+
privateRequests = /* @__PURE__ */ new Map();
|
|
1039
|
+
privateRequest(namespace, request) {
|
|
1040
|
+
const token = request.headers.get("x-mockingbird-initializer");
|
|
1041
|
+
return token !== null && (this.privateRequests.get(namespace) === token || this.lifecycle === "initializing" && token === this.initializationToken);
|
|
1042
|
+
}
|
|
1043
|
+
sequence = 0;
|
|
1044
|
+
timer;
|
|
1045
|
+
expiryTimer;
|
|
1046
|
+
backupRunning = false;
|
|
1047
|
+
backupStatus = {};
|
|
1048
|
+
lifecycle = "starting";
|
|
1049
|
+
constructor(ctx) {
|
|
1050
|
+
this.ctx = ctx;
|
|
1051
|
+
this.instrumentation = new FleetInstrumentation(ctx.options);
|
|
1052
|
+
this.observations = new FleetObservations(ctx.id);
|
|
1053
|
+
this.remember("default");
|
|
1054
|
+
for (const child of ctx.children.values())
|
|
1055
|
+
for (const ns of Object.keys(child.namespaces.endpoints ?? {}))
|
|
1056
|
+
this.remember(ns);
|
|
1057
|
+
for (const value of Object.values(ctx.options.limits ?? {}))
|
|
1058
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1059
|
+
throw new Error("limits must be nonnegative integers");
|
|
1060
|
+
for (const child of ctx.children.values())
|
|
1061
|
+
child.setAdmission?.((namespace) => {
|
|
1062
|
+
const entry = this.namespaces.get(namespace);
|
|
1063
|
+
if (entry)
|
|
1064
|
+
entry.activityAt = Date.now();
|
|
1065
|
+
if (this.lifecycle === "initializing" || this.lifecycle === "draining" || this.lifecycle === "failed" || this.environment || this.topology.has(namespace) || this.ctx.recovery.has(namespace))
|
|
1066
|
+
return false;
|
|
1067
|
+
const limit = this.ctx.options.limits?.connections;
|
|
1068
|
+
if (limit === void 0)
|
|
1069
|
+
return true;
|
|
1070
|
+
let connections = 0;
|
|
1071
|
+
for (const c of ctx.children.values()) {
|
|
1072
|
+
if (c.protocol !== "http" && !Object.hasOwn(c.namespaces.endpoints ?? {}, namespace))
|
|
1073
|
+
continue;
|
|
1074
|
+
const d = c.diagnostics(namespace);
|
|
1075
|
+
connections += d.activeConnections + (c.protocol === "http" ? d.activeRequests : 0);
|
|
1076
|
+
}
|
|
1077
|
+
return connections < limit;
|
|
1078
|
+
});
|
|
1079
|
+
const retention = ctx.options.retention;
|
|
1080
|
+
if (retention?.maxCount !== void 0 && (!Number.isSafeInteger(retention.maxCount) || retention.maxCount < 1))
|
|
1081
|
+
throw new Error("retention maxCount must be positive");
|
|
1082
|
+
if (retention?.ttlMs !== void 0 && (!Number.isFinite(retention.ttlMs) || retention.ttlMs < 1))
|
|
1083
|
+
throw new Error("retention ttlMs must be positive");
|
|
1084
|
+
}
|
|
1085
|
+
remember(namespace) {
|
|
1086
|
+
if (!this.namespaces.has(namespace))
|
|
1087
|
+
this.namespaces.set(namespace, {
|
|
1088
|
+
generation: randomUUID3(),
|
|
1089
|
+
createdAt: Date.now(),
|
|
1090
|
+
activityAt: Date.now(),
|
|
1091
|
+
pinned: false
|
|
1092
|
+
});
|
|
1093
|
+
return this.namespaces.get(namespace);
|
|
1094
|
+
}
|
|
1095
|
+
generation(namespace) {
|
|
1096
|
+
return this.remember(namespace).generation;
|
|
1097
|
+
}
|
|
1098
|
+
admit(namespace, request) {
|
|
1099
|
+
if (this.privateRequest(namespace, request))
|
|
1100
|
+
return void 0;
|
|
1101
|
+
if (this.topology.has(namespace) || this.environment)
|
|
1102
|
+
return fleetError(409, "namespace lifecycle operation in progress", { namespace });
|
|
1103
|
+
if (this.lifecycle === "initializing" && request.headers.get("x-mockingbird-initializer") === this.initializationToken)
|
|
1104
|
+
return void 0;
|
|
1105
|
+
if (this.lifecycle !== "ready" && this.lifecycle !== "starting")
|
|
1106
|
+
return fleetError(503, `fleet is ${this.lifecycle}`);
|
|
1107
|
+
if (!this.namespaces.has(namespace) && this.ctx.options.limits?.maxNamespaces !== void 0 && this.namespaces.size + this.topology.size >= this.ctx.options.limits.maxNamespaces)
|
|
1108
|
+
return fleetError(429, "maximum namespaces reached");
|
|
1109
|
+
const entry = this.remember(namespace);
|
|
1110
|
+
entry.activityAt = Date.now();
|
|
1111
|
+
const expected = request.headers.get("x-mockingbird-generation");
|
|
1112
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && expected !== null && expected !== entry.generation)
|
|
1113
|
+
return fleetError(409, "stale namespace generation", { generation: entry.generation });
|
|
1114
|
+
const limits = this.ctx.options.limits;
|
|
1115
|
+
if (limits)
|
|
1116
|
+
for (const child of this.ctx.children.values()) {
|
|
1117
|
+
if (child.protocol !== "http" && !Object.hasOwn(child.namespaces.endpoints ?? {}, namespace))
|
|
1118
|
+
continue;
|
|
1119
|
+
const usage = child.diagnostics(namespace);
|
|
1120
|
+
if (limits.connections !== void 0 && usage.activeConnections + usage.activeRequests >= limits.connections)
|
|
1121
|
+
return fleetError(429, "namespace admission quota reached", { namespace });
|
|
1122
|
+
}
|
|
1123
|
+
return void 0;
|
|
1124
|
+
}
|
|
1125
|
+
emit(operation) {
|
|
1126
|
+
this.instrumentation.phase(operation, (operation.scope ?? [operation.namespace]).some((namespace) => this.ctx.recovery.has(namespace)));
|
|
1127
|
+
const event = {
|
|
1128
|
+
id: `${this.ctx.id}:${++this.sequence}`,
|
|
1129
|
+
namespace: operation.namespace,
|
|
1130
|
+
operation: structuredClone(operation)
|
|
1131
|
+
};
|
|
1132
|
+
this.events.push(event);
|
|
1133
|
+
if (this.events.length > 500)
|
|
1134
|
+
this.events.shift();
|
|
1135
|
+
for (const sub of this.subscribers)
|
|
1136
|
+
if (sub.namespace === operation.namespace) {
|
|
1137
|
+
const data = sub.member ? {
|
|
1138
|
+
id: event.id,
|
|
1139
|
+
namespace: event.namespace,
|
|
1140
|
+
operation: {
|
|
1141
|
+
id: operation.id,
|
|
1142
|
+
phase: operation.phase,
|
|
1143
|
+
status: operation.status,
|
|
1144
|
+
completed: operation.completed,
|
|
1145
|
+
total: operation.total
|
|
1146
|
+
}
|
|
1147
|
+
} : event;
|
|
1148
|
+
sub.push(`id: ${event.id}
|
|
1149
|
+
event: lifecycle
|
|
1150
|
+
data: ${JSON.stringify(data)}
|
|
1151
|
+
|
|
1152
|
+
`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
phase(namespace, phase, completed, total, participant) {
|
|
1156
|
+
const operation = this.activeOperation(namespace);
|
|
1157
|
+
if (operation) {
|
|
1158
|
+
operation.phase = phase;
|
|
1159
|
+
if (participant)
|
|
1160
|
+
operation.participant = participant;
|
|
1161
|
+
else
|
|
1162
|
+
delete operation.participant;
|
|
1163
|
+
if (completed !== void 0)
|
|
1164
|
+
operation.completed = completed;
|
|
1165
|
+
if (total !== void 0)
|
|
1166
|
+
operation.total = total;
|
|
1167
|
+
this.emit(operation);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
prune() {
|
|
1171
|
+
const { maxCount = 50, ttlMs = 24 * 36e5 } = this.ctx.options.retention ?? {};
|
|
1172
|
+
const entries = [...this.ctx.checkpoints];
|
|
1173
|
+
for (const [id, point] of entries)
|
|
1174
|
+
if (Date.now() - Date.parse(point.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()) > ttlMs)
|
|
1175
|
+
this.ctx.checkpoints.delete(id);
|
|
1176
|
+
while (this.ctx.checkpoints.size > maxCount)
|
|
1177
|
+
this.ctx.checkpoints.delete(this.ctx.checkpoints.keys().next().value);
|
|
1178
|
+
}
|
|
1179
|
+
select(body) {
|
|
1180
|
+
if (body.services === void 0)
|
|
1181
|
+
return this.ctx.children;
|
|
1182
|
+
if (!Array.isArray(body.services) || !body.services.length || body.services.some((n) => typeof n !== "string" || !this.ctx.children.has(n)))
|
|
1183
|
+
throw new Error("services must be a nonempty list of configured service names");
|
|
1184
|
+
return new Map(body.services.map((n) => [n, this.ctx.children.get(n)]));
|
|
1185
|
+
}
|
|
1186
|
+
scope(namespace, selected) {
|
|
1187
|
+
for (const [name, child] of selected)
|
|
1188
|
+
if (child.protocol !== "http" && !Object.hasOwn(child.namespaces.endpoints ?? {}, namespace))
|
|
1189
|
+
throw new Error(`namespace ${namespace} is not configured for ${name}`);
|
|
1190
|
+
}
|
|
1191
|
+
capability() {
|
|
1192
|
+
return {
|
|
1193
|
+
version: 1,
|
|
1194
|
+
archiveFormat: "mockingbird.fleet/v1",
|
|
1195
|
+
archiveProtection: this.ctx.options.archiveProtection?.policy ?? { format: "none" },
|
|
1196
|
+
processId: this.ctx.id,
|
|
1197
|
+
authentication: ["admin-key", "scoped-credential"],
|
|
1198
|
+
scopes: ["namespace", "services", "environment"],
|
|
1199
|
+
routes: [
|
|
1200
|
+
["GET", ""],
|
|
1201
|
+
["GET", "/namespaces"],
|
|
1202
|
+
["POST", "/namespaces/{namespace}/fork"],
|
|
1203
|
+
["GET", "/audit"],
|
|
1204
|
+
["GET", "/telemetry"],
|
|
1205
|
+
["GET", "/recovery"],
|
|
1206
|
+
["POST", "/recovery/{id}/rollback"],
|
|
1207
|
+
["POST", "/time/step-next"],
|
|
1208
|
+
["POST", "/time/run-until"],
|
|
1209
|
+
["POST", "/assertions/baseline"],
|
|
1210
|
+
["POST", "/assertions/{id}/check"],
|
|
1211
|
+
["POST", "/trace/start"],
|
|
1212
|
+
["POST", "/trace/stop"],
|
|
1213
|
+
["GET", "/trace"],
|
|
1214
|
+
["POST", "/archives/bindings"],
|
|
1215
|
+
["POST", "/namespaces/{namespace}"],
|
|
1216
|
+
["DELETE", "/namespaces/{namespace}"],
|
|
1217
|
+
["GET", "/namespaces/{namespace}/snapshots"],
|
|
1218
|
+
["POST", "/namespaces/{namespace}/snapshots"],
|
|
1219
|
+
["GET", "/namespaces/{namespace}/snapshots/{id}"],
|
|
1220
|
+
["DELETE", "/namespaces/{namespace}/snapshots/{id}"],
|
|
1221
|
+
["POST", "/namespaces/{namespace}/snapshots/{id}/restore"],
|
|
1222
|
+
["GET", "/metrics"],
|
|
1223
|
+
["GET", "/clock"],
|
|
1224
|
+
["POST", "/clock"],
|
|
1225
|
+
["POST", "/can-checkpoint"],
|
|
1226
|
+
["POST", "/wait-until-idle"],
|
|
1227
|
+
["POST", "/archives/export"],
|
|
1228
|
+
["POST", "/archives/import"],
|
|
1229
|
+
["POST", "/archives/inspect"],
|
|
1230
|
+
["POST", "/archives/diff"],
|
|
1231
|
+
["GET", "/operations"],
|
|
1232
|
+
["GET", "/operations/{id}"],
|
|
1233
|
+
["POST", "/operations/{id}/cancel"],
|
|
1234
|
+
["GET", "/events"],
|
|
1235
|
+
["POST", "/profiles/apply"],
|
|
1236
|
+
["POST", "/profiles/baseline"],
|
|
1237
|
+
["GET", "/scenarios"],
|
|
1238
|
+
["POST", "/scenarios/{name}/apply"],
|
|
1239
|
+
["GET", "/inspect"],
|
|
1240
|
+
["GET", "/diagnostics"],
|
|
1241
|
+
["POST", "/credentials"],
|
|
1242
|
+
["DELETE", "/credentials/{id}"],
|
|
1243
|
+
["POST", "/backups"],
|
|
1244
|
+
["GET", "/backups"],
|
|
1245
|
+
["GET", "/cleanup"],
|
|
1246
|
+
["POST", "/cleanup"],
|
|
1247
|
+
["POST", "/namespaces/{namespace}/policy"]
|
|
1248
|
+
].map(([method, path]) => ({ method, path })),
|
|
1249
|
+
services: Object.fromEntries([...this.ctx.children].map(([name, c]) => [
|
|
1250
|
+
name,
|
|
1251
|
+
{
|
|
1252
|
+
protocol: c.protocol,
|
|
1253
|
+
coverage: c.coverage,
|
|
1254
|
+
ingress: c.targetName === "stripe" ? ingressSupport.stripe : ingressSupport.other,
|
|
1255
|
+
target: c.targetName,
|
|
1256
|
+
outboundPolicy: c.outboundPolicy ?? "unsupported",
|
|
1257
|
+
version: 1,
|
|
1258
|
+
packageVersion: c.packageVersion,
|
|
1259
|
+
nextEvent: Boolean(c.nextEvent),
|
|
1260
|
+
externalWorkerClock: "requires explicit harness synchronization",
|
|
1261
|
+
checkpoint: true,
|
|
1262
|
+
portable: Boolean(c.exportCheckpoint && c.importCheckpoint),
|
|
1263
|
+
provisioning: Boolean(c.createNamespace && c.deleteNamespace),
|
|
1264
|
+
inspect: Boolean(c.inspect),
|
|
1265
|
+
controls: c.protocol === "http" ? ["clock", "faults", "latency", "seed"] : ["clock"],
|
|
1266
|
+
stalePrecondition: c.protocol === "http"
|
|
1267
|
+
}
|
|
1268
|
+
])),
|
|
1269
|
+
limits: {
|
|
1270
|
+
usage: {
|
|
1271
|
+
namespaces: this.namespaces.size,
|
|
1272
|
+
listeners: [...this.ctx.children.values()].reduce((n, c) => n + (c.protocol === "http" ? 1 : Object.keys(c.namespaces.endpoints ?? {}).length), 1)
|
|
1273
|
+
},
|
|
1274
|
+
headroom: {
|
|
1275
|
+
namespaces: this.ctx.options.limits?.maxNamespaces === void 0 ? null : this.ctx.options.limits.maxNamespaces - this.namespaces.size
|
|
1276
|
+
},
|
|
1277
|
+
checkpoints: this.ctx.options.retention?.maxCount ?? 50,
|
|
1278
|
+
eventHistory: 500,
|
|
1279
|
+
operationHistory: 200,
|
|
1280
|
+
idempotencyMs: 36e5,
|
|
1281
|
+
supportedBudgets: ["maxNamespaces", "maxListeners", "connections"],
|
|
1282
|
+
unsupportedBudgets: ["objectBytes", "records", "pendingJobs"],
|
|
1283
|
+
...this.ctx.options.limits
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
action(path, method) {
|
|
1288
|
+
if (["GET", "HEAD"].includes(method))
|
|
1289
|
+
return "read";
|
|
1290
|
+
if (/\/credentials/.test(path))
|
|
1291
|
+
return "credentials";
|
|
1292
|
+
if (/\/reset$/.test(path))
|
|
1293
|
+
return "reset";
|
|
1294
|
+
if (/\/restore$/.test(path) || path.endsWith("/archives/import"))
|
|
1295
|
+
return "restore";
|
|
1296
|
+
if (/\/snapshots/.test(path) || path.endsWith("/archives/export"))
|
|
1297
|
+
return "snapshot";
|
|
1298
|
+
if (/\/namespaces\/[^/]+$/.test(path))
|
|
1299
|
+
return "namespace";
|
|
1300
|
+
return "control";
|
|
1301
|
+
}
|
|
1302
|
+
auth(request, namespace, action, path) {
|
|
1303
|
+
const key = request.headers.get("x-mockingbird-admin-key");
|
|
1304
|
+
const adminKey = this.ctx.config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
1305
|
+
if (key !== null && (adminKey === void 0 || key !== adminKey)) {
|
|
1306
|
+
const credential = this.credentials.get(digest(key));
|
|
1307
|
+
if (credential && credential.expiresAt > Date.now()) {
|
|
1308
|
+
if (!credential.namespaces.includes(namespace) || !credential.actions.includes(action) || /\/(credentials|cleanup|backups)(\/|$)/.test(path) || ["/fleet", "/fleet/namespaces"].includes(path) || path === "/fleet/archives/import" || path === "/fleet/archives/export")
|
|
1309
|
+
return fleetError(403, "credential does not permit this scope or action");
|
|
1310
|
+
return credential.id;
|
|
1311
|
+
}
|
|
1312
|
+
return fleetError(401, "invalid or expired credential");
|
|
1313
|
+
}
|
|
1314
|
+
if (adminKey !== void 0 && key !== adminKey)
|
|
1315
|
+
return fleetError(401, "admin key required");
|
|
1316
|
+
return "admin";
|
|
1317
|
+
}
|
|
1318
|
+
async dispatch(request, fallback) {
|
|
1319
|
+
const cors = this.ctx.options.cors;
|
|
1320
|
+
const url = new URL(request.url), prefix = this.ctx.config.adminPrefix ?? "/__admin";
|
|
1321
|
+
const controlled = url.pathname === `${prefix}/fleet` || url.pathname.startsWith(`${prefix}/fleet/`) || url.pathname === `${prefix}/ui`;
|
|
1322
|
+
const origin = request.headers.get("origin");
|
|
1323
|
+
const allowed = controlled && cors !== void 0 && origin !== null && cors.origins.includes(origin);
|
|
1324
|
+
const methods = cors?.methods ?? ["GET", "POST", "DELETE", "OPTIONS"];
|
|
1325
|
+
const headers = cors?.headers ?? [
|
|
1326
|
+
"content-type",
|
|
1327
|
+
"x-mockingbird-admin-key",
|
|
1328
|
+
"x-mockingbird-namespace",
|
|
1329
|
+
"idempotency-key",
|
|
1330
|
+
"last-event-id"
|
|
1331
|
+
];
|
|
1332
|
+
if (controlled && cors && request.method === "OPTIONS" && origin !== null) {
|
|
1333
|
+
const method = request.headers.get("access-control-request-method");
|
|
1334
|
+
const requested = (request.headers.get("access-control-request-headers") ?? "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
1335
|
+
if (!allowed || !method || !methods.includes(method) || requested.some((h) => !headers.map((v) => v.toLowerCase()).includes(h)))
|
|
1336
|
+
return fleetError(403, "CORS preflight denied");
|
|
1337
|
+
return new Response(null, {
|
|
1338
|
+
status: 204,
|
|
1339
|
+
headers: {
|
|
1340
|
+
"access-control-allow-origin": origin,
|
|
1341
|
+
"access-control-allow-methods": methods.join(", "),
|
|
1342
|
+
"access-control-allow-headers": headers.join(", "),
|
|
1343
|
+
vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
const response = await this.dispatchControl(request, fallback);
|
|
1348
|
+
if (controlled && cors) {
|
|
1349
|
+
response.headers.append("vary", "Origin");
|
|
1350
|
+
if (allowed && origin)
|
|
1351
|
+
response.headers.set("access-control-allow-origin", origin);
|
|
1352
|
+
}
|
|
1353
|
+
return response;
|
|
1354
|
+
}
|
|
1355
|
+
async dispatchControl(request, fallback) {
|
|
1356
|
+
const url = new URL(request.url), prefix = this.ctx.config.adminPrefix ?? "/__admin";
|
|
1357
|
+
if (!url.pathname.startsWith(`${prefix}/`) && url.pathname !== prefix)
|
|
1358
|
+
return fallback();
|
|
1359
|
+
const path = url.pathname.slice(prefix.length).replace(/\/$/, "");
|
|
1360
|
+
if (path === "/live" && request.method === "GET")
|
|
1361
|
+
return Response.json({ status: this.lifecycle === "stopped" ? "stopped" : "alive", id: this.ctx.id }, { status: this.lifecycle === "stopped" ? 503 : 200 });
|
|
1362
|
+
if (path === "/health" && (this.lifecycle !== "ready" || this.ctx.controlling.size || this.ctx.recovery.size || this.environment))
|
|
1363
|
+
return Response.json({
|
|
1364
|
+
status: this.ctx.recovery.size ? "failed" : this.ctx.controlling.size || this.environment ? "maintenance" : this.lifecycle,
|
|
1365
|
+
alive: this.lifecycle !== "stopped",
|
|
1366
|
+
operations: [...this.ctx.controlling].map(([namespace2, operationId]) => ({
|
|
1367
|
+
namespace: namespace2,
|
|
1368
|
+
operationId
|
|
1369
|
+
})),
|
|
1370
|
+
fenced: [...this.ctx.recovery.keys()]
|
|
1371
|
+
}, { status: 503 });
|
|
1372
|
+
if (!path.startsWith("/fleet") && path !== "/ui")
|
|
1373
|
+
return fallback();
|
|
1374
|
+
let namespace;
|
|
1375
|
+
try {
|
|
1376
|
+
namespace = /^\/fleet\/namespaces\/([^/]+)/.test(path) ? decodeURIComponent(path.split("/")[3]) : url.searchParams.get("namespace") ?? request.headers.get("x-mockingbird-namespace") ?? this.ctx.config.namespace ?? "default";
|
|
1377
|
+
} catch {
|
|
1378
|
+
return fleetError(400, "invalid namespace encoding");
|
|
1379
|
+
}
|
|
1380
|
+
if (!namePattern.test(namespace))
|
|
1381
|
+
return fleetError(400, "invalid namespace");
|
|
1382
|
+
const action = this.action(path, request.method), caller = this.auth(request, namespace, action, path);
|
|
1383
|
+
const member = path === "/fleet/events" && request.method === "GET" && this.ctx.options.memberStatus === true;
|
|
1384
|
+
if (caller instanceof Response && !member) {
|
|
1385
|
+
const audited = await this.instrumentation.audit({
|
|
1386
|
+
actor: "unauthorized",
|
|
1387
|
+
namespace,
|
|
1388
|
+
action: `${request.method} ${this.action(path, request.method)}`,
|
|
1389
|
+
services: [],
|
|
1390
|
+
stage: "denied"
|
|
1391
|
+
});
|
|
1392
|
+
if (!audited)
|
|
1393
|
+
return fleetError(503, "required audit sink unavailable");
|
|
1394
|
+
return caller;
|
|
1395
|
+
}
|
|
1396
|
+
let body = {};
|
|
1397
|
+
if (!["GET", "HEAD", "DELETE"].includes(request.method)) {
|
|
1398
|
+
try {
|
|
1399
|
+
const text2 = await request.clone().text();
|
|
1400
|
+
const parsed = text2 ? JSON.parse(text2) : {};
|
|
1401
|
+
if (!object2(parsed))
|
|
1402
|
+
throw new Error();
|
|
1403
|
+
body = parsed;
|
|
1404
|
+
} catch {
|
|
1405
|
+
return fleetError(400, "expected JSON object");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
this.prune();
|
|
1409
|
+
const mutating = !["GET", "HEAD"].includes(request.method);
|
|
1410
|
+
const run = async () => {
|
|
1411
|
+
let operation;
|
|
1412
|
+
if (mutating && !path.includes("/operations/") && !path.endsWith("/can-checkpoint") && !path.endsWith("/wait-until-idle")) {
|
|
1413
|
+
operation = {
|
|
1414
|
+
id: randomUUID3(),
|
|
1415
|
+
scope: body.allNamespaces === true ? [...this.namespaces.keys()] : [namespace],
|
|
1416
|
+
actor: typeof caller === "string" ? caller : "member",
|
|
1417
|
+
services: Array.isArray(body.services) && body.services.every((s) => typeof s === "string" && this.ctx.children.has(s)) ? body.services : [...this.ctx.children.keys()],
|
|
1418
|
+
namespace,
|
|
1419
|
+
action: `${request.method} ${path}`,
|
|
1420
|
+
phase: "validating",
|
|
1421
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1422
|
+
status: "running",
|
|
1423
|
+
completed: 0,
|
|
1424
|
+
total: this.ctx.children.size
|
|
1425
|
+
};
|
|
1426
|
+
this.operations.set(operation.id, operation);
|
|
1427
|
+
this.aborts.set(operation.id, new AbortController());
|
|
1428
|
+
this.emit(operation);
|
|
1429
|
+
}
|
|
1430
|
+
const execute = async () => {
|
|
1431
|
+
try {
|
|
1432
|
+
if (operation && !await this.instrumentation.audit({
|
|
1433
|
+
actor: operation.actor ?? "admin",
|
|
1434
|
+
namespace,
|
|
1435
|
+
action,
|
|
1436
|
+
services: operation.services ?? [],
|
|
1437
|
+
operationId: operation.id,
|
|
1438
|
+
stage: "attempt"
|
|
1439
|
+
}))
|
|
1440
|
+
throw new Error("required audit sink unavailable before mutation");
|
|
1441
|
+
if (operation && this.ctx.options.journal && !path.startsWith("/fleet/recovery") && !path.includes("/assertions/") && !path.startsWith("/fleet/trace")) {
|
|
1442
|
+
if (!this.ctx.options.repository)
|
|
1443
|
+
throw new Error("durable journal requires a repository");
|
|
1444
|
+
let before;
|
|
1445
|
+
if (this.namespaces.has(namespace) && !this.ctx.recovery.has(namespace)) {
|
|
1446
|
+
const archive = await this.capture(body.allNamespaces === true ? [...this.namespaces.keys()] : [namespace], this.select(body));
|
|
1447
|
+
before = await this.ctx.options.repository.put(archive);
|
|
1448
|
+
await this.ctx.options.repository.get(before);
|
|
1449
|
+
}
|
|
1450
|
+
const record = {
|
|
1451
|
+
version: 1,
|
|
1452
|
+
revision: 1,
|
|
1453
|
+
operationId: operation.id,
|
|
1454
|
+
controller: this.ctx.id,
|
|
1455
|
+
namespace,
|
|
1456
|
+
scope: body.allNamespaces === true ? [...this.namespaces.keys()] : [namespace],
|
|
1457
|
+
action,
|
|
1458
|
+
phase: "prepared",
|
|
1459
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1460
|
+
requestFingerprint: digest({ path, body, method: request.method }),
|
|
1461
|
+
...before ? { before } : {}
|
|
1462
|
+
};
|
|
1463
|
+
await this.ctx.options.journal.write(record);
|
|
1464
|
+
this.durable.set(operation.id, record);
|
|
1465
|
+
await this.journalPhase(operation.id, "committing");
|
|
1466
|
+
}
|
|
1467
|
+
const result = await this.handle(request, path, namespace, body, caller === "admin" && (!member || request.headers.has("x-mockingbird-admin-key"))) ?? await fallback();
|
|
1468
|
+
if (operation) {
|
|
1469
|
+
operation.status = this.aborts.get(operation.id)?.signal.aborted ? "cancelled" : result.ok ? "succeeded" : "failed";
|
|
1470
|
+
operation.phase = operation.status;
|
|
1471
|
+
operation.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1472
|
+
const payload = await result.clone().json().catch(() => void 0);
|
|
1473
|
+
operation.result = object2(payload) ? Object.fromEntries(Object.entries(payload).filter(([key2]) => [
|
|
1474
|
+
"status",
|
|
1475
|
+
"id",
|
|
1476
|
+
"namespace",
|
|
1477
|
+
"failedService",
|
|
1478
|
+
"rollback",
|
|
1479
|
+
"rolledBack",
|
|
1480
|
+
"error",
|
|
1481
|
+
"sha256"
|
|
1482
|
+
].includes(key2))) : { status: result.status };
|
|
1483
|
+
if (result.ok)
|
|
1484
|
+
operation.completed = operation.total;
|
|
1485
|
+
await this.journalPhase(operation.id, result.ok ? "succeeded" : this.ctx.recovery.has(namespace) ? "recovery-required" : "failed");
|
|
1486
|
+
if (result.ok && (action === "reset" || action === "restore")) {
|
|
1487
|
+
for (const record of this.interrupted.values())
|
|
1488
|
+
if (record.scope.includes(namespace)) {
|
|
1489
|
+
const scope2 = record.scope.filter((ns) => ns !== namespace);
|
|
1490
|
+
const resolved = {
|
|
1491
|
+
...record,
|
|
1492
|
+
scope: scope2,
|
|
1493
|
+
revision: record.revision + 1,
|
|
1494
|
+
phase: scope2.length ? "recovery-required" : "resolved",
|
|
1495
|
+
resolution: `explicit-${action}`,
|
|
1496
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1497
|
+
};
|
|
1498
|
+
try {
|
|
1499
|
+
await this.ctx.options.journal?.write(resolved);
|
|
1500
|
+
} catch (error) {
|
|
1501
|
+
this.fenceNamespaces(record.scope);
|
|
1502
|
+
throw error;
|
|
1503
|
+
}
|
|
1504
|
+
if (scope2.length)
|
|
1505
|
+
this.interrupted.set(record.operationId, resolved);
|
|
1506
|
+
else
|
|
1507
|
+
this.interrupted.delete(record.operationId);
|
|
1508
|
+
}
|
|
1509
|
+
await this.ctx.publish();
|
|
1510
|
+
}
|
|
1511
|
+
if (!await this.instrumentation.audit({
|
|
1512
|
+
actor: operation.actor ?? "admin",
|
|
1513
|
+
namespace,
|
|
1514
|
+
action,
|
|
1515
|
+
services: operation.services ?? [],
|
|
1516
|
+
operationId: operation.id,
|
|
1517
|
+
stage: "terminal",
|
|
1518
|
+
result: operation.status === "succeeded" ? "succeeded" : operation.status === "cancelled" ? "cancelled" : "failed"
|
|
1519
|
+
})) {
|
|
1520
|
+
const fences = [];
|
|
1521
|
+
for (const name of operation.services ?? [])
|
|
1522
|
+
try {
|
|
1523
|
+
const release = this.ctx.children.get(name)?.lock(namespace);
|
|
1524
|
+
if (release)
|
|
1525
|
+
fences.push(release);
|
|
1526
|
+
} catch {
|
|
1527
|
+
}
|
|
1528
|
+
this.ctx.recovery.set(namespace, fences);
|
|
1529
|
+
await this.journalPhase(operation.id, "recovery-required");
|
|
1530
|
+
throw new Error("required terminal audit unavailable; recovery required");
|
|
1531
|
+
}
|
|
1532
|
+
result.headers.set("x-mockingbird-operation-id", operation.id);
|
|
1533
|
+
this.emit(operation);
|
|
1534
|
+
if (result.ok && request.method !== "DELETE" && (action === "restore" || action === "reset" || action === "namespace"))
|
|
1535
|
+
this.remember(namespace).generation = randomUUID3();
|
|
1536
|
+
}
|
|
1537
|
+
if (result.ok && request.method === "POST") {
|
|
1538
|
+
if (path === "/fleet/clock")
|
|
1539
|
+
this.observations.control(namespace, "clock", body);
|
|
1540
|
+
else if (/^\/fleet\/namespaces\/[^/]+\/reset$/.test(path))
|
|
1541
|
+
this.observations.control(namespace, "reset", body);
|
|
1542
|
+
else if (!path.startsWith("/fleet/trace") && !path.includes("/assertions/"))
|
|
1543
|
+
this.observations.unsupportedControl(namespace, action);
|
|
1544
|
+
}
|
|
1545
|
+
for (const point of this.ctx.checkpoints.values())
|
|
1546
|
+
if (!point.createdAt) {
|
|
1547
|
+
point.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1548
|
+
if (body.label !== void 0)
|
|
1549
|
+
point.label = body.label;
|
|
1550
|
+
}
|
|
1551
|
+
this.prune();
|
|
1552
|
+
if (this.ctx.recovery.size)
|
|
1553
|
+
await this.ctx.publish();
|
|
1554
|
+
return result;
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
if (operation && ["committing", "uploading", "publishing"].includes(this.durable.get(operation.id)?.phase ?? "")) {
|
|
1557
|
+
const releases = this.ctx.recovery.get(namespace) ?? [];
|
|
1558
|
+
for (const child of this.ctx.children.values())
|
|
1559
|
+
try {
|
|
1560
|
+
releases.push(child.lock(namespace));
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
this.ctx.recovery.set(namespace, releases);
|
|
1564
|
+
}
|
|
1565
|
+
if (operation) {
|
|
1566
|
+
operation.status = "failed";
|
|
1567
|
+
operation.phase = "failed";
|
|
1568
|
+
operation.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1569
|
+
operation.result = {
|
|
1570
|
+
message: error instanceof Error ? error.message : "operation failed"
|
|
1571
|
+
};
|
|
1572
|
+
this.emit(operation);
|
|
1573
|
+
}
|
|
1574
|
+
if (this.ctx.recovery.size)
|
|
1575
|
+
await this.ctx.publish().catch(() => {
|
|
1576
|
+
});
|
|
1577
|
+
return fleetError(400, error instanceof Error ? error.message : "invalid control");
|
|
1578
|
+
} finally {
|
|
1579
|
+
if (operation)
|
|
1580
|
+
this.aborts.delete(operation.id);
|
|
1581
|
+
while (this.operations.size > 200) {
|
|
1582
|
+
const entry = [...this.operations].find(([, o]) => o.status !== "running");
|
|
1583
|
+
if (!entry)
|
|
1584
|
+
break;
|
|
1585
|
+
this.operations.delete(entry[0]);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
return operation ? this.operationContext.run(operation, execute) : execute();
|
|
1590
|
+
};
|
|
1591
|
+
const key = request.headers.get("idempotency-key");
|
|
1592
|
+
if (!key || !mutating)
|
|
1593
|
+
return run();
|
|
1594
|
+
if (key.length > 256)
|
|
1595
|
+
return fleetError(400, "idempotency key too long");
|
|
1596
|
+
for (const [id, kept] of this.retries)
|
|
1597
|
+
if (kept.expires < Date.now())
|
|
1598
|
+
this.retries.delete(id);
|
|
1599
|
+
const scope = `${typeof caller === "string" ? caller : "member"}:${namespace}:${key}`;
|
|
1600
|
+
const fingerprint = digest({ path, method: request.method, body });
|
|
1601
|
+
const existing = this.retries.get(scope);
|
|
1602
|
+
if (existing)
|
|
1603
|
+
return existing.fingerprint === fingerprint ? (await existing.response).clone() : fleetError(409, "idempotency key reused with different request");
|
|
1604
|
+
if (this.retries.size >= 1e3)
|
|
1605
|
+
return fleetError(429, "idempotency retention capacity reached");
|
|
1606
|
+
const response = Promise.resolve().then(run);
|
|
1607
|
+
this.retries.set(scope, { fingerprint, expires: Date.now() + 36e5, response });
|
|
1608
|
+
return (await response).clone();
|
|
1609
|
+
}
|
|
1610
|
+
async handle(request, path, namespace, body, admin) {
|
|
1611
|
+
const route = path.slice("/fleet".length), method = request.method;
|
|
1612
|
+
if (path === "/ui")
|
|
1613
|
+
return new Response(fleetConsole(this.ctx.config.adminPrefix ?? "/__admin"), {
|
|
1614
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1615
|
+
});
|
|
1616
|
+
if (route === "" && method === "GET")
|
|
1617
|
+
return Response.json({
|
|
1618
|
+
...this.capability(),
|
|
1619
|
+
manifest: this.ctx.manifest(),
|
|
1620
|
+
namespaces: this.namespaceStatus()
|
|
1621
|
+
});
|
|
1622
|
+
if (route === "/recovery" && method === "GET")
|
|
1623
|
+
return admin ? Response.json({
|
|
1624
|
+
version: 1,
|
|
1625
|
+
interrupted: [...this.interrupted.values()],
|
|
1626
|
+
resumable: Boolean(this.ctx.options.journal)
|
|
1627
|
+
}) : fleetError(403, "recovery administration required");
|
|
1628
|
+
const recovery = /^\/recovery\/([^/]+)\/rollback$/.exec(route);
|
|
1629
|
+
if (recovery && method === "POST") {
|
|
1630
|
+
checked(body, []);
|
|
1631
|
+
if (!admin)
|
|
1632
|
+
return fleetError(403, "recovery administration required");
|
|
1633
|
+
const record = this.interrupted.get(recovery[1]);
|
|
1634
|
+
if (!record?.before || !this.ctx.options.repository || !this.ctx.options.journal)
|
|
1635
|
+
throw new Error("interrupted operation has no verified portable before-state; explicit namespace reset is required");
|
|
1636
|
+
const beforeArchive = await this.ctx.options.repository.get(record.before);
|
|
1637
|
+
const { sha256: _identity, ...content } = beforeArchive;
|
|
1638
|
+
await this.restoreArchive(sealArchive({
|
|
1639
|
+
...content,
|
|
1640
|
+
namespaces: Object.fromEntries(Object.entries(beforeArchive.namespaces).filter(([namespace2]) => record.scope.includes(namespace2)))
|
|
1641
|
+
}));
|
|
1642
|
+
try {
|
|
1643
|
+
await this.ctx.options.journal.write({
|
|
1644
|
+
...record,
|
|
1645
|
+
revision: record.revision + 1,
|
|
1646
|
+
phase: "resolved",
|
|
1647
|
+
resolution: "explicit-restore-before",
|
|
1648
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1649
|
+
});
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
this.fenceNamespaces(record.scope);
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
this.interrupted.delete(record.operationId);
|
|
1655
|
+
await this.ctx.publish();
|
|
1656
|
+
return Response.json({
|
|
1657
|
+
status: "resolved",
|
|
1658
|
+
operationId: record.operationId,
|
|
1659
|
+
resolution: "restored-before-state"
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
if (route === "/telemetry" && method === "GET")
|
|
1663
|
+
return admin ? Response.json(this.instrumentation.snapshot()) : fleetError(403, "operator telemetry requires administrator scope");
|
|
1664
|
+
if (route === "/audit" && method === "GET")
|
|
1665
|
+
return admin ? Response.json(this.instrumentation.auditTrail()) : fleetError(403, "audit trail requires administrator scope");
|
|
1666
|
+
if (route === "/namespaces" && method === "GET")
|
|
1667
|
+
return Response.json({ namespaces: this.namespaceStatus() });
|
|
1668
|
+
const fork = /^\/namespaces\/[^/]+\/fork$/.test(route);
|
|
1669
|
+
if (fork && method === "POST") {
|
|
1670
|
+
checked(body, ["destination", "bindings"]);
|
|
1671
|
+
if (!admin)
|
|
1672
|
+
return fleetError(403, "namespace fork requires administrator scope");
|
|
1673
|
+
if (typeof body.destination !== "string" || !namePattern.test(body.destination))
|
|
1674
|
+
throw new Error("destination namespace required");
|
|
1675
|
+
const destination2 = body.destination;
|
|
1676
|
+
if (this.namespaces.has(destination2))
|
|
1677
|
+
return fleetError(409, "destination namespace already exists");
|
|
1678
|
+
return this.withEnvironment([namespace, destination2], this.ctx.children, async () => {
|
|
1679
|
+
let archive = await this.capture([namespace]);
|
|
1680
|
+
let bindings = {
|
|
1681
|
+
changes: [],
|
|
1682
|
+
limitations: ["opaque namespace-bearing keys and application credentials are unchanged"]
|
|
1683
|
+
};
|
|
1684
|
+
if (body.bindings !== void 0) {
|
|
1685
|
+
const prepared = await planRestoreBindings(archive, body.bindings, this.ctx.options.resolveArchiveSecret);
|
|
1686
|
+
archive = prepared.archive;
|
|
1687
|
+
bindings = prepared.report;
|
|
1688
|
+
}
|
|
1689
|
+
await this.restoreArchive(rebaseArchiveNamespace(archive, namespace, destination2));
|
|
1690
|
+
return Response.json({
|
|
1691
|
+
namespace: destination2,
|
|
1692
|
+
generation: this.generation(destination2),
|
|
1693
|
+
manifest: this.ctx.manifest(),
|
|
1694
|
+
bindings,
|
|
1695
|
+
connections: Object.fromEntries([...this.ctx.children].map(([name, child]) => [
|
|
1696
|
+
name,
|
|
1697
|
+
child.connections?.[destination2] ?? `${child.url}${this.ctx.config.adminPrefix ?? "/__admin"}/ns/${destination2}`
|
|
1698
|
+
]))
|
|
1699
|
+
}, { status: 201 });
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
if (route === "/archives/bindings" && method === "POST") {
|
|
1703
|
+
if (!admin)
|
|
1704
|
+
return fleetError(403, "restore binding plans require administrator scope");
|
|
1705
|
+
checked(body, ["archive", "plan"]);
|
|
1706
|
+
const prepared = await planRestoreBindings(body.archive, body.plan, this.ctx.options.resolveArchiveSecret);
|
|
1707
|
+
return Response.json(prepared.report);
|
|
1708
|
+
}
|
|
1709
|
+
if (route === "/assertions/baseline" && method === "POST") {
|
|
1710
|
+
checked(body, ["services"]);
|
|
1711
|
+
const selected = this.select(body);
|
|
1712
|
+
this.scope(namespace, selected);
|
|
1713
|
+
return Response.json(this.observations.baseline(namespace, [...selected.keys()]));
|
|
1714
|
+
}
|
|
1715
|
+
const assertion = /^\/assertions\/([^/]+)\/check$/.exec(route);
|
|
1716
|
+
if (assertion && method === "POST") {
|
|
1717
|
+
checked(body, ["allowances", "allowCheckpointablePending"]);
|
|
1718
|
+
if (body.allowCheckpointablePending !== void 0 && typeof body.allowCheckpointablePending !== "boolean")
|
|
1719
|
+
throw new Error("allowCheckpointablePending must be boolean");
|
|
1720
|
+
const diagnostics = Object.fromEntries([...this.ctx.children].filter(([, child]) => child.protocol === "http" || Object.hasOwn(child.namespaces.endpoints ?? {}, namespace)).map(([name, child]) => [name, child.diagnostics(namespace)]));
|
|
1721
|
+
const report = this.observations.assertClean(assertion[1], namespace, diagnostics, body);
|
|
1722
|
+
return Response.json(report, { status: report.clean ? 200 : 409 });
|
|
1723
|
+
}
|
|
1724
|
+
if (route.startsWith("/trace")) {
|
|
1725
|
+
if (!admin)
|
|
1726
|
+
return fleetError(403, "trace capture requires administrator scope");
|
|
1727
|
+
if (!this.ctx.options.trace)
|
|
1728
|
+
return fleetError(400, "trace capture is not enabled");
|
|
1729
|
+
if (route === "/trace/start" && method === "POST") {
|
|
1730
|
+
checked(body, ["includeBodies"]);
|
|
1731
|
+
if (body.includeBodies !== void 0 && typeof body.includeBodies !== "boolean")
|
|
1732
|
+
throw new Error("includeBodies must be boolean");
|
|
1733
|
+
const baseline = await this.capture([namespace]);
|
|
1734
|
+
const limitations = [...this.ctx.children.values()].some((child) => child.protocol !== "http") ? ["protocol/external interactions are not captured"] : [];
|
|
1735
|
+
return Response.json(this.observations.startTrace(namespace, baseline, body.includeBodies === true, limitations));
|
|
1736
|
+
}
|
|
1737
|
+
if (route === "/trace" && method === "GET")
|
|
1738
|
+
return Response.json(this.observations.trace(namespace));
|
|
1739
|
+
if (route === "/trace/stop" && method === "POST") {
|
|
1740
|
+
checked(body, []);
|
|
1741
|
+
return Response.json(this.observations.trace(namespace, true));
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const nsRoute = /^\/namespaces\/([^/]+)$/.exec(route);
|
|
1745
|
+
if (nsRoute && method === "POST") {
|
|
1746
|
+
checked(body, ["baseline"]);
|
|
1747
|
+
if (body.baseline !== void 0 && typeof body.baseline !== "string")
|
|
1748
|
+
throw new Error("baseline must be a checkpoint id");
|
|
1749
|
+
return this.createNamespace(namespace, body.baseline);
|
|
1750
|
+
}
|
|
1751
|
+
if (nsRoute && method === "DELETE")
|
|
1752
|
+
return this.deleteNamespace(namespace);
|
|
1753
|
+
if (/^\/namespaces\/[^/]+\/policy$/.test(route) && method === "POST") {
|
|
1754
|
+
checked(body, ["pinned", "expiresInMs"]);
|
|
1755
|
+
if (body.pinned !== void 0 && typeof body.pinned !== "boolean")
|
|
1756
|
+
throw new Error("pinned must be boolean");
|
|
1757
|
+
if (body.expiresInMs !== void 0 && (typeof body.expiresInMs !== "number" || !Number.isSafeInteger(body.expiresInMs) || body.expiresInMs < 1 || body.expiresInMs > 864e5))
|
|
1758
|
+
throw new Error("expiry must be within one host day");
|
|
1759
|
+
if (body.pinned === void 0 && body.expiresInMs === void 0)
|
|
1760
|
+
throw new Error("namespace policy required");
|
|
1761
|
+
if (typeof body.pinned === "boolean")
|
|
1762
|
+
this.remember(namespace).pinned = body.pinned;
|
|
1763
|
+
if (typeof body.expiresInMs === "number") {
|
|
1764
|
+
this.remember(namespace).expiresAt = Date.now() + body.expiresInMs;
|
|
1765
|
+
if (!this.expiryTimer) {
|
|
1766
|
+
this.expiryTimer = setInterval(() => {
|
|
1767
|
+
for (const entry of this.cleanupPreview())
|
|
1768
|
+
if (entry.eligible)
|
|
1769
|
+
void this.deleteNamespace(entry.namespace).catch(() => {
|
|
1770
|
+
});
|
|
1771
|
+
}, Math.max(10, Math.min(6e4, body.expiresInMs)));
|
|
1772
|
+
this.expiryTimer.unref?.();
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
return Response.json({ namespace, ...this.remember(namespace) });
|
|
1776
|
+
}
|
|
1777
|
+
const snapshot = /^\/namespaces\/[^/]+\/snapshots(?:\/([^/]+))?$/.exec(route);
|
|
1778
|
+
if (snapshot && (method === "GET" || method === "DELETE")) {
|
|
1779
|
+
const info = (id, p) => ({
|
|
1780
|
+
id,
|
|
1781
|
+
namespace: p.namespace,
|
|
1782
|
+
label: p.label,
|
|
1783
|
+
createdAt: p.createdAt,
|
|
1784
|
+
services: [...p.values.keys()],
|
|
1785
|
+
portable: [...p.values.keys()].every((s) => Boolean(this.ctx.children.get(s)?.exportCheckpoint))
|
|
1786
|
+
});
|
|
1787
|
+
if (!snapshot[1]) {
|
|
1788
|
+
if (method !== "GET")
|
|
1789
|
+
return fleetError(405, "select a checkpoint to delete");
|
|
1790
|
+
return Response.json({
|
|
1791
|
+
snapshots: [...this.ctx.checkpoints].filter(([, p]) => p.namespace === namespace).map(([id, p]) => info(id, p))
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
const point = this.ctx.checkpoints.get(snapshot[1]);
|
|
1795
|
+
if (!point || point.namespace !== namespace)
|
|
1796
|
+
return fleetError(404, "no checkpoint in this namespace");
|
|
1797
|
+
if (method === "DELETE")
|
|
1798
|
+
this.ctx.checkpoints.delete(snapshot[1]);
|
|
1799
|
+
return Response.json(method === "DELETE" ? { status: "deleted", id: snapshot[1] } : info(snapshot[1], point));
|
|
1800
|
+
}
|
|
1801
|
+
if ((route === "/time/step-next" || route === "/time/run-until") && method === "POST") {
|
|
1802
|
+
checked(body, ["services", "condition", "maxEvents", "maxAdvanceMs", "timeoutMs"]);
|
|
1803
|
+
const selected = this.select(body);
|
|
1804
|
+
this.scope(namespace, selected);
|
|
1805
|
+
const controller = new FleetTimeController({
|
|
1806
|
+
clock: this.ctx.clock,
|
|
1807
|
+
transaction: this.ctx.transaction,
|
|
1808
|
+
phase: (ns, phase, done, total) => this.phase(ns, phase, done, total),
|
|
1809
|
+
recovery: this.ctx.recovery,
|
|
1810
|
+
...this.ctx.options.synchronizeClock ? { synchronize: this.ctx.options.synchronizeClock } : {}
|
|
1811
|
+
});
|
|
1812
|
+
return controller.run(namespace, selected, body, route === "/time/step-next");
|
|
1813
|
+
}
|
|
1814
|
+
if (route === "/can-checkpoint" && method === "POST") {
|
|
1815
|
+
checked(body, ["services", "timeoutMs"]);
|
|
1816
|
+
if (body.timeoutMs !== void 0 && (typeof body.timeoutMs !== "number" || body.timeoutMs < 0 || body.timeoutMs > 3e4))
|
|
1817
|
+
throw new Error("timeoutMs must be between 0 and 30000");
|
|
1818
|
+
const selected = this.select(body);
|
|
1819
|
+
this.scope(namespace, selected);
|
|
1820
|
+
const until = Date.now() + (body.timeoutMs ?? 0);
|
|
1821
|
+
for (; ; ) {
|
|
1822
|
+
const services = Object.fromEntries([...selected].map(([n, c]) => {
|
|
1823
|
+
const d = c.diagnostics(namespace);
|
|
1824
|
+
return [
|
|
1825
|
+
n,
|
|
1826
|
+
{
|
|
1827
|
+
...d,
|
|
1828
|
+
checkpointReady: d.activeRequests + d.blockedCommands + d.pendingWebhooks + (d.activeJobs ?? 0) === 0,
|
|
1829
|
+
idle: d.activeRequests + d.blockedCommands + d.pendingWebhooks + d.pendingJobs === 0
|
|
1830
|
+
}
|
|
1831
|
+
];
|
|
1832
|
+
}));
|
|
1833
|
+
const ready = !this.ctx.controlling.has(namespace) && !this.ctx.recovery.has(namespace) && Object.values(services).every((d) => d.checkpointReady);
|
|
1834
|
+
if (ready || Date.now() >= until)
|
|
1835
|
+
return Response.json({ namespace, checkpointReady: ready, services }, { status: ready ? 200 : 409 });
|
|
1836
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (route === "/operations" && method === "GET")
|
|
1840
|
+
return Response.json({
|
|
1841
|
+
operations: [...this.operations.values()].filter((o) => o.namespace === namespace)
|
|
1842
|
+
});
|
|
1843
|
+
const operationRoute = /^\/operations\/([^/]+)(\/cancel)?$/.exec(route);
|
|
1844
|
+
if (operationRoute) {
|
|
1845
|
+
const op = this.operations.get(operationRoute[1]);
|
|
1846
|
+
if (!op || op.namespace !== namespace)
|
|
1847
|
+
return fleetError(404, "operation not found");
|
|
1848
|
+
if (operationRoute[2] && method === "POST") {
|
|
1849
|
+
checked(body, []);
|
|
1850
|
+
if (op.status !== "running" || !["validating", "preparing", "checkpointing"].includes(op.phase))
|
|
1851
|
+
return fleetError(409, "operation has passed cancellable phase");
|
|
1852
|
+
this.aborts.get(op.id)?.abort();
|
|
1853
|
+
return Response.json({ id: op.id, cancellationRequested: true });
|
|
1854
|
+
}
|
|
1855
|
+
if (method === "GET")
|
|
1856
|
+
return Response.json(op);
|
|
1857
|
+
}
|
|
1858
|
+
if (route === "/events" && method === "GET") {
|
|
1859
|
+
const cursor = request.headers.get("last-event-id");
|
|
1860
|
+
const encode = new TextEncoder();
|
|
1861
|
+
let subscription;
|
|
1862
|
+
let heartbeat;
|
|
1863
|
+
const cleanup = () => {
|
|
1864
|
+
if (subscription)
|
|
1865
|
+
this.subscribers.delete(subscription);
|
|
1866
|
+
clearInterval(heartbeat);
|
|
1867
|
+
};
|
|
1868
|
+
const stream = new ReadableStream({
|
|
1869
|
+
start: (controller) => {
|
|
1870
|
+
subscription = {
|
|
1871
|
+
namespace,
|
|
1872
|
+
member: this.ctx.options.memberStatus === true && !admin,
|
|
1873
|
+
push: (event) => {
|
|
1874
|
+
if ((controller.desiredSize ?? 0) < -32) {
|
|
1875
|
+
cleanup();
|
|
1876
|
+
controller.close();
|
|
1877
|
+
} else
|
|
1878
|
+
controller.enqueue(encode.encode(event));
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
this.subscribers.add(subscription);
|
|
1882
|
+
const at = this.events.findIndex((e) => e.id === cursor);
|
|
1883
|
+
if (cursor === null || at < 0)
|
|
1884
|
+
subscription.push(`event: resync
|
|
1885
|
+
data: ${JSON.stringify({ processId: this.ctx.id, namespace, generation: this.generation(namespace), lifecycle: this.lifecycle })}
|
|
1886
|
+
|
|
1887
|
+
`);
|
|
1888
|
+
for (const event of this.events.slice(at < 0 ? 0 : at + 1))
|
|
1889
|
+
if (event.namespace === namespace) {
|
|
1890
|
+
const data = this.ctx.options.memberStatus && !admin ? {
|
|
1891
|
+
id: event.id,
|
|
1892
|
+
namespace,
|
|
1893
|
+
operation: {
|
|
1894
|
+
id: event.operation.id,
|
|
1895
|
+
phase: event.operation.phase,
|
|
1896
|
+
status: event.operation.status,
|
|
1897
|
+
completed: event.operation.completed,
|
|
1898
|
+
total: event.operation.total
|
|
1899
|
+
}
|
|
1900
|
+
} : event;
|
|
1901
|
+
subscription.push(`id: ${event.id}
|
|
1902
|
+
event: lifecycle
|
|
1903
|
+
data: ${JSON.stringify(data)}
|
|
1904
|
+
|
|
1905
|
+
`);
|
|
1906
|
+
}
|
|
1907
|
+
heartbeat = setInterval(() => subscription.push(": heartbeat\n\n"), 15e3);
|
|
1908
|
+
heartbeat.unref?.();
|
|
1909
|
+
request.signal.addEventListener("abort", () => {
|
|
1910
|
+
cleanup();
|
|
1911
|
+
try {
|
|
1912
|
+
controller.close();
|
|
1913
|
+
} catch {
|
|
1914
|
+
}
|
|
1915
|
+
}, { once: true });
|
|
1916
|
+
},
|
|
1917
|
+
cancel: cleanup
|
|
1918
|
+
});
|
|
1919
|
+
return new Response(stream, {
|
|
1920
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
if (route === "/archives/export" && method === "POST") {
|
|
1924
|
+
checked(body, ["services", "allNamespaces"]);
|
|
1925
|
+
if (body.allNamespaces !== void 0 && typeof body.allNamespaces !== "boolean")
|
|
1926
|
+
throw new Error("allNamespaces must be boolean");
|
|
1927
|
+
const archive = await this.capture(body.allNamespaces === true ? [...this.namespaces.keys()] : [namespace], this.select(body));
|
|
1928
|
+
return Response.json(this.ctx.options.archiveProtection ? await this.ctx.options.archiveProtection.seal(archive) : archive);
|
|
1929
|
+
}
|
|
1930
|
+
if (route === "/archives/import" && method === "POST") {
|
|
1931
|
+
checked(body, ["archive", "namespace", "bindings"]);
|
|
1932
|
+
let archive = this.ctx.options.archiveProtection ? await this.ctx.options.archiveProtection.open(body.archive) : validateArchive(body.archive);
|
|
1933
|
+
if (body.namespace !== void 0 && (typeof body.namespace !== "string" || !namePattern.test(body.namespace)))
|
|
1934
|
+
throw new Error("invalid selected archive namespace");
|
|
1935
|
+
if (body.bindings !== void 0)
|
|
1936
|
+
archive = (await planRestoreBindings(archive, body.bindings, this.ctx.options.resolveArchiveSecret)).archive;
|
|
1937
|
+
await this.restoreArchive(archive, body.namespace);
|
|
1938
|
+
return Response.json({
|
|
1939
|
+
status: "restored",
|
|
1940
|
+
sha256: archive.sha256,
|
|
1941
|
+
namespaces: body.namespace ? [body.namespace] : Object.keys(archive.namespaces)
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
if (route === "/archives/inspect" && method === "POST") {
|
|
1945
|
+
checked(body, ["archive"]);
|
|
1946
|
+
return Response.json(inspectArchive(body.archive));
|
|
1947
|
+
}
|
|
1948
|
+
if (route === "/archives/diff" && method === "POST") {
|
|
1949
|
+
checked(body, ["left", "right", "namespace", "service", "values", "limit"]);
|
|
1950
|
+
if (body.values !== void 0 && typeof body.values !== "boolean" || body.limit !== void 0 && (typeof body.limit !== "number" || !Number.isInteger(body.limit) || body.limit < 1 || body.limit > 1e3))
|
|
1951
|
+
throw new Error("invalid diff options");
|
|
1952
|
+
return Response.json(diffArchives(body.left, body.right, body));
|
|
1953
|
+
}
|
|
1954
|
+
if (route === "/profiles/apply" && method === "POST") {
|
|
1955
|
+
checked(body, ["name", "profile"]);
|
|
1956
|
+
const profile = typeof body.name === "string" ? this.ctx.options.profiles?.[body.name] : body.profile;
|
|
1957
|
+
return this.applyProfile(namespace, profile);
|
|
1958
|
+
}
|
|
1959
|
+
if (route === "/profiles/baseline" && method === "POST") {
|
|
1960
|
+
checked(body, ["services"]);
|
|
1961
|
+
return this.applyProfile(namespace, Object.fromEntries([...this.select(body)].map(([name, c]) => [
|
|
1962
|
+
name,
|
|
1963
|
+
c.protocol === "http" ? { faults: [], latency: 0 } : {}
|
|
1964
|
+
])));
|
|
1965
|
+
}
|
|
1966
|
+
if (route === "/scenarios" && method === "GET")
|
|
1967
|
+
return Response.json({
|
|
1968
|
+
scenarios: Object.entries(this.ctx.options.scenarios ?? {}).map(([name, b]) => ({
|
|
1969
|
+
name,
|
|
1970
|
+
format: b.format,
|
|
1971
|
+
version: b.version,
|
|
1972
|
+
description: b.description,
|
|
1973
|
+
services: Object.keys(b.services)
|
|
1974
|
+
}))
|
|
1975
|
+
});
|
|
1976
|
+
const scenario = /^\/scenarios\/([^/]+)\/apply$/.exec(route);
|
|
1977
|
+
if (scenario && method === "POST") {
|
|
1978
|
+
checked(body, []);
|
|
1979
|
+
const bundle = this.ctx.options.scenarios?.[scenario[1]];
|
|
1980
|
+
if (bundle?.format !== "mockingbird.scenario/v1" || !Number.isSafeInteger(bundle.version) || bundle.version < 1)
|
|
1981
|
+
throw new Error("unknown or incompatible scenario bundle");
|
|
1982
|
+
for (const [name, entry] of Object.entries(bundle.services))
|
|
1983
|
+
if (this.ctx.children.get(name)?.protocol !== entry.protocol)
|
|
1984
|
+
throw new Error(`incompatible scenario service ${name}`);
|
|
1985
|
+
for (const [name, entry] of Object.entries(bundle.services))
|
|
1986
|
+
if (entry.fixtures !== void 0) {
|
|
1987
|
+
const child = this.ctx.children.get(name);
|
|
1988
|
+
if (entry.protocol !== "http" || !child?.importCheckpoint)
|
|
1989
|
+
throw new Error(`scenario fixtures unsupported for ${name}`);
|
|
1990
|
+
await child.importCheckpoint(namespace, {
|
|
1991
|
+
format: "mockingbird.snapshot/v1",
|
|
1992
|
+
service: name,
|
|
1993
|
+
records: entry.fixtures
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
const profile = Object.fromEntries(Object.entries(bundle.services).map(([name, entry]) => [
|
|
1997
|
+
name,
|
|
1998
|
+
{
|
|
1999
|
+
...entry.controls,
|
|
2000
|
+
...bundle.seed !== void 0 && entry.protocol === "http" ? { seed: bundle.seed } : {},
|
|
2001
|
+
...bundle.clock ? { clock: bundle.clock } : {}
|
|
2002
|
+
}
|
|
2003
|
+
]));
|
|
2004
|
+
const response = await this.applyProfile(namespace, profile, async (name, child) => {
|
|
2005
|
+
const fixtures = bundle.services[name]?.fixtures;
|
|
2006
|
+
if (fixtures !== void 0) {
|
|
2007
|
+
if (!child.configure)
|
|
2008
|
+
throw new Error(`fixtures unsupported for ${name}`);
|
|
2009
|
+
const document = { format: "mockingbird.snapshot/v1", service: name, records: fixtures };
|
|
2010
|
+
await child.configure(namespace, { snapshot: document });
|
|
2011
|
+
}
|
|
2012
|
+
}, bundle.seedApplication);
|
|
2013
|
+
return response;
|
|
2014
|
+
}
|
|
2015
|
+
if (route === "/inspect" && method === "GET") {
|
|
2016
|
+
const url = new URL(request.url), service = url.searchParams.get("service");
|
|
2017
|
+
const child = service ? this.ctx.children.get(service) : void 0;
|
|
2018
|
+
if (!child?.inspect)
|
|
2019
|
+
return fleetError(400, "service inspection is unsupported");
|
|
2020
|
+
const offset = Number(url.searchParams.get("offset") ?? 0), limit = Number(url.searchParams.get("limit") ?? 100);
|
|
2021
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 1e3)
|
|
2022
|
+
throw new Error("invalid pagination");
|
|
2023
|
+
this.scope(namespace, /* @__PURE__ */ new Map([[service, child]]));
|
|
2024
|
+
const collection = url.searchParams.get("collection");
|
|
2025
|
+
return Response.json({
|
|
2026
|
+
namespace,
|
|
2027
|
+
service,
|
|
2028
|
+
result: await child.inspect(namespace, {
|
|
2029
|
+
offset,
|
|
2030
|
+
limit,
|
|
2031
|
+
...collection !== null ? { collection } : {}
|
|
2032
|
+
})
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
if (route === "/diagnostics" && method === "GET") {
|
|
2036
|
+
const selected = new URL(request.url).searchParams.getAll("service");
|
|
2037
|
+
const children = selected.length ? this.select({ services: selected }) : this.ctx.children;
|
|
2038
|
+
this.scope(namespace, children);
|
|
2039
|
+
return Response.json({
|
|
2040
|
+
format: "mockingbird.diagnostics/v1",
|
|
2041
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2042
|
+
processId: this.ctx.id,
|
|
2043
|
+
namespace,
|
|
2044
|
+
generation: this.generation(namespace),
|
|
2045
|
+
configurationFingerprint: digest(this.ctx.config),
|
|
2046
|
+
capabilities: this.capability(),
|
|
2047
|
+
services: Object.fromEntries([...children].map(([name, c]) => [
|
|
2048
|
+
name,
|
|
2049
|
+
{
|
|
2050
|
+
protocol: c.protocol,
|
|
2051
|
+
diagnostics: c.diagnostics(namespace),
|
|
2052
|
+
unmatched: c.unmatched?.(namespace, 50)
|
|
2053
|
+
}
|
|
2054
|
+
])),
|
|
2055
|
+
operations: [...this.operations.values()].filter((o) => o.namespace === namespace).slice(-50).map(({ id, phase, action, status, startedAt, finishedAt }) => ({
|
|
2056
|
+
id,
|
|
2057
|
+
phase,
|
|
2058
|
+
action,
|
|
2059
|
+
status,
|
|
2060
|
+
startedAt,
|
|
2061
|
+
finishedAt
|
|
2062
|
+
})),
|
|
2063
|
+
truncated: [...this.operations.values()].filter((o) => o.namespace === namespace).length > 50,
|
|
2064
|
+
excluded: ["credentials", "requestBodies", "records", "modelText", "operationPayloads"]
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
if (route === "/credentials" && method === "POST") {
|
|
2068
|
+
checked(body, ["namespaces", "actions", "ttlMs"]);
|
|
2069
|
+
if (!admin)
|
|
2070
|
+
return fleetError(403, "admin credential required");
|
|
2071
|
+
if (!Array.isArray(body.namespaces) || !body.namespaces.length || body.namespaces.some((n) => typeof n !== "string" || !namePattern.test(n)) || !Array.isArray(body.actions) || !body.actions.length || body.actions.some((a) => !["read", "reset", "restore", "snapshot", "control"].includes(String(a))) || typeof body.ttlMs !== "number" || body.ttlMs <= 0 || body.ttlMs > 864e5)
|
|
2072
|
+
throw new Error("expected namespaces, actions and ttlMs (at most one day)");
|
|
2073
|
+
const credential2 = randomBytes(32).toString("base64url"), id = randomUUID3(), expiresAt = Date.now() + body.ttlMs;
|
|
2074
|
+
this.credentials.set(digest(credential2), {
|
|
2075
|
+
id,
|
|
2076
|
+
namespaces: body.namespaces,
|
|
2077
|
+
actions: body.actions,
|
|
2078
|
+
expiresAt
|
|
2079
|
+
});
|
|
2080
|
+
return Response.json({ id, credential: credential2, expiresAt }, { status: 201 });
|
|
2081
|
+
}
|
|
2082
|
+
const credential = /^\/credentials\/([^/]+)$/.exec(route);
|
|
2083
|
+
if (credential && method === "DELETE") {
|
|
2084
|
+
if (!admin)
|
|
2085
|
+
return fleetError(403, "admin credential required");
|
|
2086
|
+
for (const [key, c] of this.credentials)
|
|
2087
|
+
if (c.id === credential[1]) {
|
|
2088
|
+
this.credentials.delete(key);
|
|
2089
|
+
return Response.json({ status: "revoked", id: c.id });
|
|
2090
|
+
}
|
|
2091
|
+
return fleetError(404, "credential not found");
|
|
2092
|
+
}
|
|
2093
|
+
if (route === "/backups" && method === "GET")
|
|
2094
|
+
return Response.json({
|
|
2095
|
+
...this.backupStatus,
|
|
2096
|
+
running: this.backupRunning,
|
|
2097
|
+
archives: await this.ctx.options.repository?.list() ?? []
|
|
2098
|
+
});
|
|
2099
|
+
if (route === "/backups" && method === "POST") {
|
|
2100
|
+
checked(body, ["allNamespaces"]);
|
|
2101
|
+
return Response.json(await this.backup(body.allNamespaces === true ? [...this.namespaces.keys()] : [namespace], [...this.operations.values()].reverse().find((o) => o.status === "running" && o.namespace === namespace)?.id));
|
|
2102
|
+
}
|
|
2103
|
+
if (route === "/cleanup" && method === "GET")
|
|
2104
|
+
return Response.json({ candidates: this.cleanupPreview() });
|
|
2105
|
+
if (route === "/cleanup" && method === "POST") {
|
|
2106
|
+
checked(body, []);
|
|
2107
|
+
const results = {};
|
|
2108
|
+
for (const entry of this.cleanupPreview())
|
|
2109
|
+
if (entry.eligible) {
|
|
2110
|
+
try {
|
|
2111
|
+
if (this.ctx.options.expiry?.finalBackup)
|
|
2112
|
+
await this.backup([entry.namespace]);
|
|
2113
|
+
const response = await this.deleteNamespace(entry.namespace);
|
|
2114
|
+
results[entry.namespace] = { status: response.status, result: await response.json() };
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
results[entry.namespace] = {
|
|
2117
|
+
status: "failed",
|
|
2118
|
+
message: error instanceof Error ? error.message : "cleanup failed"
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
return Response.json({ results });
|
|
2123
|
+
}
|
|
2124
|
+
return void 0;
|
|
2125
|
+
}
|
|
2126
|
+
namespaceStatus() {
|
|
2127
|
+
return [...this.namespaces].map(([namespace, state]) => ({
|
|
2128
|
+
namespace,
|
|
2129
|
+
...state,
|
|
2130
|
+
status: this.ctx.recovery.has(namespace) ? "failed" : this.ctx.controlling.has(namespace) || this.topology.has(namespace) ? "maintenance" : "ready",
|
|
2131
|
+
operationId: this.ctx.controlling.get(namespace),
|
|
2132
|
+
expiresAt: state.expiresAt ?? (this.ctx.options.expiry ? Math.max(state.activityAt, ...[...this.ctx.children.values()].map((c) => c.lastActivity?.(namespace) ?? 0)) + this.ctx.options.expiry.idleMs : void 0)
|
|
2133
|
+
}));
|
|
2134
|
+
}
|
|
2135
|
+
busy(namespace, selected = this.ctx.children) {
|
|
2136
|
+
return [...selected].filter(([, c]) => {
|
|
2137
|
+
const d = c.diagnostics(namespace);
|
|
2138
|
+
return d.activeRequests + d.blockedCommands + d.pendingWebhooks + (d.activeJobs ?? 0) > 0;
|
|
2139
|
+
}).map(([name]) => name);
|
|
2140
|
+
}
|
|
2141
|
+
async hook(phase, namespace, selected) {
|
|
2142
|
+
const hook = this.ctx.options.workload?.[phase];
|
|
2143
|
+
const op = this.activeOperation(namespace);
|
|
2144
|
+
this.phase(namespace, phase === "prepare" ? "preparing" : phase === "verify" ? "verifying" : "resuming");
|
|
2145
|
+
const controller = op ? this.aborts.get(op.id) : new AbortController();
|
|
2146
|
+
if (controller?.signal.aborted && phase !== "resume")
|
|
2147
|
+
throw new Error("operation cancelled before commit");
|
|
2148
|
+
if (!hook)
|
|
2149
|
+
return;
|
|
2150
|
+
const timeout = AbortSignal.timeout(this.ctx.options.hookTimeoutMs ?? 3e4);
|
|
2151
|
+
const signal = controller && !controller.signal.aborted ? AbortSignal.any([controller.signal, timeout]) : timeout;
|
|
2152
|
+
await Promise.race([
|
|
2153
|
+
hook({
|
|
2154
|
+
namespace,
|
|
2155
|
+
services: [...selected.keys()],
|
|
2156
|
+
signal,
|
|
2157
|
+
operationId: op?.id ?? randomUUID3()
|
|
2158
|
+
}),
|
|
2159
|
+
new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error(`${phase} hook aborted or timed out`)), { once: true }))
|
|
2160
|
+
]);
|
|
2161
|
+
}
|
|
2162
|
+
async createNamespace(namespace, baseline) {
|
|
2163
|
+
if (this.environment && !this.ownsEnvironment())
|
|
2164
|
+
return fleetError(409, "environment operation in progress");
|
|
2165
|
+
if (this.namespaces.has(namespace) || this.topology.has(namespace))
|
|
2166
|
+
return fleetError(409, "namespace already exists or is provisioning");
|
|
2167
|
+
if (this.ctx.controlling.has(namespace))
|
|
2168
|
+
return fleetError(409, "namespace operation in progress", {
|
|
2169
|
+
operationId: this.ctx.controlling.get(namespace)
|
|
2170
|
+
});
|
|
2171
|
+
const limits = this.ctx.options.limits;
|
|
2172
|
+
if (limits?.maxNamespaces !== void 0 && this.namespaces.size + this.topology.size >= limits.maxNamespaces)
|
|
2173
|
+
return fleetError(429, "maximum namespaces reached");
|
|
2174
|
+
const protocolCount = [...this.ctx.children.values()].filter((c) => c.protocol !== "http").length;
|
|
2175
|
+
const listenerCount = [...this.ctx.children.values()].reduce((n, c) => n + (c.protocol === "external" ? 0 : c.protocol === "http" ? 1 : Object.keys(c.namespaces.endpoints ?? {}).length), 1);
|
|
2176
|
+
if (limits?.maxListeners !== void 0 && listenerCount + (this.topology.size + 1) * protocolCount > limits.maxListeners)
|
|
2177
|
+
return fleetError(429, "maximum listeners reached");
|
|
2178
|
+
for (const [name, c] of this.ctx.children)
|
|
2179
|
+
if (!c.createNamespace || !c.deleteNamespace)
|
|
2180
|
+
throw new Error(`namespace provisioning unsupported for ${name}`);
|
|
2181
|
+
const point = baseline ? this.ctx.checkpoints.get(baseline) : void 0;
|
|
2182
|
+
if (baseline && !point)
|
|
2183
|
+
throw new Error("baseline checkpoint not found");
|
|
2184
|
+
this.topology.add(namespace);
|
|
2185
|
+
const created = [];
|
|
2186
|
+
try {
|
|
2187
|
+
this.phase(namespace, "provisioning");
|
|
2188
|
+
const docs = /* @__PURE__ */ new Map();
|
|
2189
|
+
if (point)
|
|
2190
|
+
for (const [name, value] of point.values) {
|
|
2191
|
+
const child = this.ctx.children.get(name);
|
|
2192
|
+
if (!child?.exportCheckpoint || !child.importCheckpoint)
|
|
2193
|
+
throw new Error(`portable baseline unsupported for ${name}`);
|
|
2194
|
+
docs.set(name, await child.exportCheckpoint(point.namespace, value));
|
|
2195
|
+
}
|
|
2196
|
+
for (const [name, child] of this.ctx.children) {
|
|
2197
|
+
await child.createNamespace?.(namespace);
|
|
2198
|
+
created.push(child);
|
|
2199
|
+
if (docs.has(name))
|
|
2200
|
+
await child.restore(namespace, await child.importCheckpoint?.(namespace, docs.get(name)));
|
|
2201
|
+
}
|
|
2202
|
+
if (point)
|
|
2203
|
+
restoreClock(this.ctx.clock(namespace), point.clock);
|
|
2204
|
+
this.remember(namespace);
|
|
2205
|
+
await this.ctx.publish();
|
|
2206
|
+
return Response.json({
|
|
2207
|
+
namespace,
|
|
2208
|
+
generation: this.generation(namespace),
|
|
2209
|
+
manifest: this.ctx.manifest(),
|
|
2210
|
+
connections: Object.fromEntries([...this.ctx.children].map(([n, c]) => [
|
|
2211
|
+
n,
|
|
2212
|
+
c.connections?.[namespace] ?? `${c.url}${this.ctx.config.adminPrefix ?? "/__admin"}/ns/${encodeURIComponent(namespace)}`
|
|
2213
|
+
]))
|
|
2214
|
+
}, { status: 201 });
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
const results = await Promise.allSettled(created.reverse().map((c) => c.deleteNamespace?.(namespace)));
|
|
2217
|
+
this.namespaces.delete(namespace);
|
|
2218
|
+
if (results.some((r) => r.status === "rejected"))
|
|
2219
|
+
return fleetError(500, "namespace provisioning cleanup failed", {
|
|
2220
|
+
namespace,
|
|
2221
|
+
recoveryRequired: true
|
|
2222
|
+
});
|
|
2223
|
+
throw error;
|
|
2224
|
+
} finally {
|
|
2225
|
+
this.topology.delete(namespace);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
async deleteNamespace(namespace) {
|
|
2229
|
+
if (this.environment && !this.ownsEnvironment())
|
|
2230
|
+
return fleetError(409, "environment operation in progress");
|
|
2231
|
+
if (namespace === "default")
|
|
2232
|
+
return fleetError(409, "default namespace cannot be deleted");
|
|
2233
|
+
if (!this.namespaces.has(namespace))
|
|
2234
|
+
return fleetError(404, "namespace not found");
|
|
2235
|
+
if (this.remember(namespace).pinned)
|
|
2236
|
+
return fleetError(409, "namespace is pinned");
|
|
2237
|
+
if (this.topology.has(namespace) || this.ctx.controlling.has(namespace) || this.ctx.recovery.has(namespace))
|
|
2238
|
+
return fleetError(409, "namespace is busy or requires recovery");
|
|
2239
|
+
this.scope(namespace, this.ctx.children);
|
|
2240
|
+
const busy = this.busy(namespace);
|
|
2241
|
+
for (const [name, c] of this.ctx.children)
|
|
2242
|
+
if (c.diagnostics(namespace).activeConnections)
|
|
2243
|
+
busy.push(name);
|
|
2244
|
+
if (busy.length)
|
|
2245
|
+
return fleetError(409, "namespace has active work or clients", {
|
|
2246
|
+
services: [...new Set(busy)]
|
|
2247
|
+
});
|
|
2248
|
+
const selected = this.ctx.children;
|
|
2249
|
+
for (const [name, child] of selected)
|
|
2250
|
+
if (!child.deleteNamespace || !child.createNamespace || !child.exportCheckpoint || !child.importCheckpoint)
|
|
2251
|
+
throw new Error(`transactional namespace deletion unsupported for ${name}`);
|
|
2252
|
+
this.topology.add(namespace);
|
|
2253
|
+
const release = [];
|
|
2254
|
+
const documents = /* @__PURE__ */ new Map();
|
|
2255
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
2256
|
+
const original = { ...this.remember(namespace) };
|
|
2257
|
+
try {
|
|
2258
|
+
for (const child of selected.values())
|
|
2259
|
+
release.push(child.lock(namespace));
|
|
2260
|
+
if (this.busy(namespace).length)
|
|
2261
|
+
return fleetError(409, "namespace acquired active work before deletion");
|
|
2262
|
+
for (const [name, child] of selected)
|
|
2263
|
+
documents.set(name, await child.exportCheckpoint?.(namespace, await child.checkpoint(namespace)));
|
|
2264
|
+
for (const [name, child] of selected) {
|
|
2265
|
+
await child.deleteNamespace?.(namespace);
|
|
2266
|
+
deleted.add(name);
|
|
2267
|
+
}
|
|
2268
|
+
this.namespaces.delete(namespace);
|
|
2269
|
+
await this.ctx.publish();
|
|
2270
|
+
for (const [id, p] of this.ctx.checkpoints)
|
|
2271
|
+
if (p.namespace === namespace)
|
|
2272
|
+
this.ctx.checkpoints.delete(id);
|
|
2273
|
+
return Response.json({ status: "deleted", namespace });
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
const rollback = {};
|
|
2276
|
+
for (const [name, child] of selected)
|
|
2277
|
+
if (documents.has(name)) {
|
|
2278
|
+
try {
|
|
2279
|
+
if (deleted.has(name) || child.protocol !== "http" && !Object.hasOwn(child.namespaces.endpoints ?? {}, namespace))
|
|
2280
|
+
await child.createNamespace?.(namespace);
|
|
2281
|
+
await child.restore(namespace, await child.importCheckpoint?.(namespace, documents.get(name)));
|
|
2282
|
+
rollback[name] = "restored";
|
|
2283
|
+
} catch {
|
|
2284
|
+
rollback[name] = "failed";
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
this.namespaces.set(namespace, original);
|
|
2288
|
+
if (Object.values(rollback).includes("failed")) {
|
|
2289
|
+
const fences = [];
|
|
2290
|
+
for (const child of selected.values())
|
|
2291
|
+
try {
|
|
2292
|
+
fences.push(child.lock(namespace));
|
|
2293
|
+
} catch {
|
|
2294
|
+
}
|
|
2295
|
+
this.ctx.recovery.set(namespace, fences);
|
|
2296
|
+
}
|
|
2297
|
+
await this.ctx.publish().catch(() => {
|
|
2298
|
+
});
|
|
2299
|
+
return fleetError(500, error instanceof Error ? error.message : "namespace deletion failed", {
|
|
2300
|
+
namespace,
|
|
2301
|
+
rollback,
|
|
2302
|
+
rolledBack: Object.values(rollback).every((v) => v === "restored")
|
|
2303
|
+
});
|
|
2304
|
+
} finally {
|
|
2305
|
+
for (const unlock of release.reverse())
|
|
2306
|
+
unlock();
|
|
2307
|
+
this.topology.delete(namespace);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
async capture(namespaces, selected = this.ctx.children) {
|
|
2311
|
+
if (namespaces.length > 1 && !this.ownsEnvironment())
|
|
2312
|
+
return this.withEnvironment(namespaces, selected, () => this.capture(namespaces, selected));
|
|
2313
|
+
const contents = {};
|
|
2314
|
+
for (const [name, child] of selected)
|
|
2315
|
+
if (!child.exportCheckpoint || !child.importCheckpoint)
|
|
2316
|
+
throw new Error(`portable snapshots unsupported for ${name}`);
|
|
2317
|
+
for (const namespace of namespaces) {
|
|
2318
|
+
this.scope(namespace, selected);
|
|
2319
|
+
const services = {};
|
|
2320
|
+
const response = await this.ctx.transaction(namespace, selected, async (name, child) => {
|
|
2321
|
+
const point = await child.checkpoint(namespace);
|
|
2322
|
+
services[name] = {
|
|
2323
|
+
protocol: child.protocol,
|
|
2324
|
+
target: child.targetName ?? name,
|
|
2325
|
+
version: 1,
|
|
2326
|
+
...child.packageVersion ? { packageVersion: child.packageVersion } : {},
|
|
2327
|
+
state: await child.exportCheckpoint?.(namespace, point)
|
|
2328
|
+
};
|
|
2329
|
+
});
|
|
2330
|
+
if (!response.ok)
|
|
2331
|
+
throw new Error(`capture ${namespace} refused: ${canonical(await response.json())}`);
|
|
2332
|
+
contents[namespace] = { clock: this.ctx.clock(namespace).state(), services };
|
|
2333
|
+
}
|
|
2334
|
+
return sealArchive({
|
|
2335
|
+
format: "mockingbird.fleet/v1",
|
|
2336
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2337
|
+
sourceId: this.ctx.id,
|
|
2338
|
+
namespaces: contents
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
async restoreArchive(input, selectedNamespace) {
|
|
2342
|
+
const archive = validateArchive(input);
|
|
2343
|
+
const entries = Object.entries(archive.namespaces).filter(([ns]) => selectedNamespace === void 0 || ns === selectedNamespace);
|
|
2344
|
+
if (!entries.length)
|
|
2345
|
+
throw new Error("selected namespace absent from archive");
|
|
2346
|
+
if (entries.length > 1 && !this.ownsEnvironment()) {
|
|
2347
|
+
const selected = new Map([...new Set(entries.flatMap(([, ns]) => Object.keys(ns.services)))].map((name) => [
|
|
2348
|
+
name,
|
|
2349
|
+
this.ctx.children.get(name)
|
|
2350
|
+
]));
|
|
2351
|
+
for (const [name, child] of selected)
|
|
2352
|
+
if (!child)
|
|
2353
|
+
throw new Error(`unsupported archive service ${name}`);
|
|
2354
|
+
return this.withEnvironment(entries.map(([ns]) => ns), selected, () => this.restoreArchive(archive, selectedNamespace));
|
|
2355
|
+
}
|
|
2356
|
+
for (const [namespace, ns] of entries)
|
|
2357
|
+
for (const [name, state] of Object.entries(ns.services)) {
|
|
2358
|
+
const child = this.ctx.children.get(name);
|
|
2359
|
+
if (!child || child.protocol !== state.protocol || !child.importCheckpoint || (state.target ?? name) !== (child.targetName ?? name))
|
|
2360
|
+
throw new Error(`unsupported archive service ${name}`);
|
|
2361
|
+
await child.importCheckpoint(namespace, state.state);
|
|
2362
|
+
}
|
|
2363
|
+
const rollback = [];
|
|
2364
|
+
const created = [];
|
|
2365
|
+
try {
|
|
2366
|
+
for (const [namespace, ns] of entries) {
|
|
2367
|
+
if (!this.namespaces.has(namespace)) {
|
|
2368
|
+
const response2 = await this.createNamespace(namespace);
|
|
2369
|
+
if (!response2.ok)
|
|
2370
|
+
throw new Error(`namespace creation refused: ${canonical(await response2.json())}`);
|
|
2371
|
+
created.push(namespace);
|
|
2372
|
+
}
|
|
2373
|
+
const selected = new Map(Object.keys(ns.services).map((name) => [name, this.ctx.children.get(name)]));
|
|
2374
|
+
const values = /* @__PURE__ */ new Map();
|
|
2375
|
+
const oldClock = this.ctx.clock(namespace).state();
|
|
2376
|
+
const response = await this.ctx.transaction(namespace, selected, async (name, child) => {
|
|
2377
|
+
values.set(name, await child.checkpoint(namespace));
|
|
2378
|
+
await child.restore(namespace, await child.importCheckpoint?.(namespace, ns.services[name]?.state));
|
|
2379
|
+
}, true);
|
|
2380
|
+
if (!response.ok)
|
|
2381
|
+
throw new Error(`restore ${namespace} failed: ${canonical(await response.json())}`);
|
|
2382
|
+
rollback.push({ namespace, selected, values, clock: oldClock });
|
|
2383
|
+
restoreClock(this.ctx.clock(namespace), ns.clock);
|
|
2384
|
+
this.remember(namespace).generation = randomUUID3();
|
|
2385
|
+
}
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
const failures = [];
|
|
2388
|
+
for (const point of rollback.reverse()) {
|
|
2389
|
+
const response = await this.ctx.transaction(point.namespace, point.selected, async (name, child) => child.restore(point.namespace, point.values.get(name)), true);
|
|
2390
|
+
if (!response.ok)
|
|
2391
|
+
failures.push(point.namespace);
|
|
2392
|
+
else
|
|
2393
|
+
restoreClock(this.ctx.clock(point.namespace), point.clock);
|
|
2394
|
+
}
|
|
2395
|
+
for (const namespace of created.reverse()) {
|
|
2396
|
+
const response = await this.deleteNamespace(namespace);
|
|
2397
|
+
if (!response.ok)
|
|
2398
|
+
failures.push(namespace);
|
|
2399
|
+
}
|
|
2400
|
+
if (failures.length)
|
|
2401
|
+
throw new Error(`archive rollback requires recovery for ${failures.join(", ")}`, {
|
|
2402
|
+
cause: error
|
|
2403
|
+
});
|
|
2404
|
+
throw error;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
async applyProfile(namespace, input, extra, seedApplication) {
|
|
2408
|
+
if (!object2(input) || !Object.keys(input).length)
|
|
2409
|
+
throw new Error("expected nonempty service control profile");
|
|
2410
|
+
const selected = this.select({ services: Object.keys(input) });
|
|
2411
|
+
for (const [name, child] of selected) {
|
|
2412
|
+
const controls = input[name];
|
|
2413
|
+
if (!object2(controls))
|
|
2414
|
+
throw new Error(`expected controls for ${name}`);
|
|
2415
|
+
normalizeControls(controls, `profile.${name}`);
|
|
2416
|
+
if (child.protocol !== "http" && Object.keys(controls).some((key) => key !== "clock"))
|
|
2417
|
+
throw new Error(`unsupported protocol controls for ${name}`);
|
|
2418
|
+
}
|
|
2419
|
+
return this.ctx.transaction(namespace, selected, async (name, child) => {
|
|
2420
|
+
const controls = input[name];
|
|
2421
|
+
if (child.configure)
|
|
2422
|
+
await child.configure(namespace, controls);
|
|
2423
|
+
else {
|
|
2424
|
+
const plan = normalizeControls(controls);
|
|
2425
|
+
if (plan.clock?.frozen === true)
|
|
2426
|
+
child.clock(namespace).freeze();
|
|
2427
|
+
if (plan.clock?.now !== void 0)
|
|
2428
|
+
child.clock(namespace).set(plan.clock.now);
|
|
2429
|
+
if (plan.clock?.frozen === false)
|
|
2430
|
+
child.clock(namespace).unfreeze();
|
|
2431
|
+
}
|
|
2432
|
+
await extra?.(name, child);
|
|
2433
|
+
if (name === [...selected.keys()].at(-1) && seedApplication) {
|
|
2434
|
+
const operation = [...this.operations.values()].find((o) => o.namespace === namespace && o.status === "running");
|
|
2435
|
+
const token = randomBytes(32).toString("base64url");
|
|
2436
|
+
this.privateRequests.set(namespace, token);
|
|
2437
|
+
const access = [];
|
|
2438
|
+
const connections = {};
|
|
2439
|
+
try {
|
|
2440
|
+
for (const [service, target] of selected) {
|
|
2441
|
+
if (target.initializationAccess) {
|
|
2442
|
+
const seedAccess = await target.initializationAccess();
|
|
2443
|
+
access.push(seedAccess);
|
|
2444
|
+
const url = seedAccess.connections[namespace];
|
|
2445
|
+
if (url)
|
|
2446
|
+
connections[service] = { url, namespaces: { [namespace]: url } };
|
|
2447
|
+
} else
|
|
2448
|
+
connections[service] = { url: target.url, namespaces: {} };
|
|
2449
|
+
}
|
|
2450
|
+
const signal = AbortSignal.timeout(this.ctx.options.hookTimeoutMs ?? 3e4);
|
|
2451
|
+
await Promise.race([
|
|
2452
|
+
seedApplication({
|
|
2453
|
+
namespace,
|
|
2454
|
+
services: [...selected.keys()],
|
|
2455
|
+
operationId: operation?.id ?? randomUUID3(),
|
|
2456
|
+
signal,
|
|
2457
|
+
manifest: this.ctx.manifest(),
|
|
2458
|
+
connections,
|
|
2459
|
+
headers: { "x-mockingbird-initializer": token, "x-mockingbird-namespace": namespace }
|
|
2460
|
+
}),
|
|
2461
|
+
new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("application seed hook timed out")), { once: true }))
|
|
2462
|
+
]);
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
throw Object.assign(new Error("application seed hook failed; recovery required", { cause: error }), { code: "MOCKINGBIRD_WORKLOAD_UNCERTAIN" });
|
|
2465
|
+
} finally {
|
|
2466
|
+
this.privateRequests.delete(namespace);
|
|
2467
|
+
await Promise.all(access.map((a) => a.close()));
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
async backup(namespaces, operationId) {
|
|
2473
|
+
if (!this.ctx.options.repository)
|
|
2474
|
+
throw new Error("archive repository is not configured");
|
|
2475
|
+
if (this.backupRunning)
|
|
2476
|
+
throw new Error("backup is already running; overlapping run skipped");
|
|
2477
|
+
this.backupRunning = true;
|
|
2478
|
+
this.backupStatus.lastAttempt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2479
|
+
try {
|
|
2480
|
+
const archive = await this.capture(namespaces);
|
|
2481
|
+
for (const namespace of namespaces)
|
|
2482
|
+
this.phase(namespace, "uploading");
|
|
2483
|
+
if (operationId)
|
|
2484
|
+
await this.journalPhase(operationId, "uploading", { after: archive.sha256 });
|
|
2485
|
+
const id = await this.ctx.options.repository.put(archive);
|
|
2486
|
+
for (const namespace of namespaces)
|
|
2487
|
+
this.phase(namespace, "verifying-upload");
|
|
2488
|
+
const verified = validateArchive(await this.ctx.options.repository.get(id));
|
|
2489
|
+
if (verified.sha256 !== archive.sha256)
|
|
2490
|
+
throw new Error("backup verification failed");
|
|
2491
|
+
if (operationId)
|
|
2492
|
+
await this.journalPhase(operationId, "publishing", this.ctx.options.repository.writerGeneration !== void 0 ? { writerGeneration: this.ctx.options.repository.writerGeneration } : {});
|
|
2493
|
+
for (const namespace of namespaces)
|
|
2494
|
+
this.phase(namespace, "publishing");
|
|
2495
|
+
await this.ctx.options.repository.publishLatest(id);
|
|
2496
|
+
const publication = await this.ctx.options.repository.publicationMetadata?.();
|
|
2497
|
+
if (operationId && publication)
|
|
2498
|
+
await this.journalPhase(operationId, "publishing", {
|
|
2499
|
+
writerGeneration: publication.generation
|
|
2500
|
+
});
|
|
2501
|
+
this.backupStatus.lastSuccess = (/* @__PURE__ */ new Date()).toISOString();
|
|
2502
|
+
this.backupStatus.archiveId = id;
|
|
2503
|
+
delete this.backupStatus.lastFailure;
|
|
2504
|
+
const keep = this.ctx.options.backupPolicy?.keep;
|
|
2505
|
+
if (keep !== void 0) {
|
|
2506
|
+
const archives = await this.ctx.options.repository.list();
|
|
2507
|
+
for (const old of archives.slice(0, Math.max(0, archives.length - keep)))
|
|
2508
|
+
if (old !== id)
|
|
2509
|
+
await this.ctx.options.repository.delete(old);
|
|
2510
|
+
}
|
|
2511
|
+
return { id, sha256: archive.sha256, verified: true };
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
this.backupStatus.lastFailure = error instanceof Error ? error.message : "backup failed";
|
|
2514
|
+
throw error;
|
|
2515
|
+
} finally {
|
|
2516
|
+
this.backupRunning = false;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
cleanupPreview() {
|
|
2520
|
+
return this.namespaceStatus().filter((entry) => entry.namespace !== "default").map((entry) => {
|
|
2521
|
+
let busy = true;
|
|
2522
|
+
try {
|
|
2523
|
+
busy = this.busy(entry.namespace).length > 0 || [...this.ctx.children.values()].some((c) => c.diagnostics(entry.namespace).activeConnections > 0);
|
|
2524
|
+
} catch {
|
|
2525
|
+
}
|
|
2526
|
+
return {
|
|
2527
|
+
namespace: entry.namespace,
|
|
2528
|
+
expiresAt: entry.expiresAt,
|
|
2529
|
+
pinned: entry.pinned,
|
|
2530
|
+
eligible: entry.expiresAt !== void 0 && !entry.pinned && !busy && entry.status === "ready" && !this.topology.has(entry.namespace) && Date.now() >= (entry.expiresAt ?? Infinity)
|
|
2531
|
+
};
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
async startPolicies() {
|
|
2535
|
+
if (this.ctx.options.repository && this.ctx.options.backupPolicy) {
|
|
2536
|
+
try {
|
|
2537
|
+
const id = await this.ctx.options.repository.latest();
|
|
2538
|
+
if (id) {
|
|
2539
|
+
await this.ctx.options.repository.get(id);
|
|
2540
|
+
this.backupStatus.archiveId = id;
|
|
2541
|
+
this.backupStatus.lastSuccess = (/* @__PURE__ */ new Date()).toISOString();
|
|
2542
|
+
}
|
|
2543
|
+
} catch (error) {
|
|
2544
|
+
this.backupStatus.lastFailure = error instanceof Error ? error.message : "repository recovery failed";
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
this.lifecycle = "ready";
|
|
2548
|
+
const policy = this.ctx.options.backupPolicy;
|
|
2549
|
+
if (policy) {
|
|
2550
|
+
if (!Number.isFinite(policy.intervalMs) || policy.intervalMs < 10)
|
|
2551
|
+
throw new Error("backup intervalMs must be at least 10");
|
|
2552
|
+
const schedule = () => {
|
|
2553
|
+
this.backupStatus.nextRun = new Date(Date.now() + policy.intervalMs).toISOString();
|
|
2554
|
+
if (!this.backupRunning)
|
|
2555
|
+
void this.backup(policy.namespaces ?? [...this.namespaces.keys()]).catch(() => {
|
|
2556
|
+
});
|
|
2557
|
+
};
|
|
2558
|
+
this.backupStatus.nextRun = new Date(Date.now() + policy.intervalMs).toISOString();
|
|
2559
|
+
this.timer = setInterval(schedule, policy.intervalMs);
|
|
2560
|
+
this.timer.unref?.();
|
|
2561
|
+
}
|
|
2562
|
+
const expiry = this.ctx.options.expiry;
|
|
2563
|
+
if (expiry) {
|
|
2564
|
+
if (!Number.isFinite(expiry.idleMs) || expiry.idleMs < 1)
|
|
2565
|
+
throw new Error("expiry idleMs must be positive");
|
|
2566
|
+
this.expiryTimer = setInterval(() => {
|
|
2567
|
+
for (const entry of this.cleanupPreview())
|
|
2568
|
+
if (entry.eligible)
|
|
2569
|
+
void (async () => {
|
|
2570
|
+
if (expiry.finalBackup)
|
|
2571
|
+
await this.backup([entry.namespace]);
|
|
2572
|
+
await this.deleteNamespace(entry.namespace);
|
|
2573
|
+
})().catch(() => {
|
|
2574
|
+
});
|
|
2575
|
+
}, expiry.intervalMs ?? 6e4);
|
|
2576
|
+
this.expiryTimer.unref?.();
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
stopPolicies() {
|
|
2580
|
+
clearInterval(this.timer);
|
|
2581
|
+
clearInterval(this.expiryTimer);
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
var fleetConsole = (prefix) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Mockingbird Fleet</title><style>body{font:16px system-ui;max-width:1000px;margin:3rem auto;padding:1rem;background:#10151b;color:#e0e9f2}input,select,button{padding:.7rem;margin:.3rem;background:#233142;color:inherit;border:1px solid #60768b;border-radius:5px}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#9fdbff}</style><h1>Mockingbird Fleet</h1><label>Admin key <input id="key" type="password" autocomplete="off"></label><label>Namespace <input id="ns" value="default"></label><button id="refresh">Refresh</button><section id="children"></section><label>Action <select id="action"><option value="snapshots">Capture checkpoint</option><option value="reset">Reset namespace</option></select></label><button id="run">Run</button><h2>Lifecycle and operations</h2><pre id="result"></pre><script type="module">const base=${JSON.stringify(`${prefix}/fleet`)};const key=document.querySelector('#key'),ns=document.querySelector('#ns'),result=document.querySelector('#result');async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'content-type':'application/json','x-mockingbird-admin-key':key.value},...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();result.textContent=JSON.stringify(data,null,2);return data}document.querySelector('#refresh').onclick=async()=>{const data=await api('');const host=document.querySelector('#children');host.replaceChildren();for(const [name,child]of Object.entries(data.manifest?.services??{})){const link=document.createElement('a');link.href=child.adminUrl;link.textContent=name+' ('+child.protocol+') ';host.append(link)}await api('/operations?namespace='+encodeURIComponent(ns.value))};document.querySelector('#run').onclick=()=>api('/namespaces/'+encodeURIComponent(ns.value)+'/'+document.querySelector('#action').value,'POST',{});</script></html>`;
|
|
2585
|
+
|
|
2586
|
+
// ../../adapters/node/dist/fleet-preflight.js
|
|
2587
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2588
|
+
import { createRequire } from "node:module";
|
|
2589
|
+
import { basename, dirname, resolve } from "node:path";
|
|
2590
|
+
|
|
2591
|
+
// ../../adapters/node/dist/fleet-config.schema.json
|
|
2592
|
+
var fleet_config_schema_default = {
|
|
2593
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
2594
|
+
$id: "https://mockingbird.chrisvouga.dev/schemas/fleet-v1.json",
|
|
2595
|
+
title: "Mockingbird fleet configuration v1",
|
|
2596
|
+
type: "object",
|
|
2597
|
+
required: ["services"],
|
|
2598
|
+
additionalProperties: false,
|
|
2599
|
+
properties: {
|
|
2600
|
+
$schema: {
|
|
2601
|
+
const: "https://mockingbird.chrisvouga.dev/schemas/fleet-v1.json"
|
|
2602
|
+
},
|
|
2603
|
+
schemaVersion: {
|
|
2604
|
+
const: 1
|
|
2605
|
+
},
|
|
2606
|
+
services: {
|
|
2607
|
+
type: "object",
|
|
2608
|
+
propertyNames: {
|
|
2609
|
+
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
2610
|
+
},
|
|
2611
|
+
additionalProperties: {
|
|
2612
|
+
$ref: "#/$defs/service"
|
|
2613
|
+
}
|
|
2614
|
+
},
|
|
2615
|
+
log: {
|
|
2616
|
+
enum: ["pretty", "json", "off"]
|
|
2617
|
+
},
|
|
2618
|
+
adminPrefix: {
|
|
2619
|
+
type: "string"
|
|
2620
|
+
},
|
|
2621
|
+
adminKey: {
|
|
2622
|
+
type: "string"
|
|
2623
|
+
},
|
|
2624
|
+
adminControls: {
|
|
2625
|
+
$ref: "#/$defs/controls"
|
|
2626
|
+
},
|
|
2627
|
+
namespace: {
|
|
2628
|
+
type: "string",
|
|
2629
|
+
pattern: "^[A-Za-z0-9_.-]{1,64}$"
|
|
2630
|
+
},
|
|
2631
|
+
control: {
|
|
2632
|
+
type: "object",
|
|
2633
|
+
additionalProperties: false,
|
|
2634
|
+
properties: {
|
|
2635
|
+
port: {
|
|
2636
|
+
type: "integer",
|
|
2637
|
+
minimum: 0,
|
|
2638
|
+
maximum: 65535
|
|
2639
|
+
},
|
|
2640
|
+
host: {
|
|
2641
|
+
type: "string"
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
},
|
|
2645
|
+
cors: {
|
|
2646
|
+
type: "object",
|
|
2647
|
+
required: ["origins"],
|
|
2648
|
+
additionalProperties: false,
|
|
2649
|
+
properties: {
|
|
2650
|
+
origins: {
|
|
2651
|
+
type: "array",
|
|
2652
|
+
items: {
|
|
2653
|
+
type: "string"
|
|
2654
|
+
}
|
|
2655
|
+
},
|
|
2656
|
+
methods: {
|
|
2657
|
+
type: "array",
|
|
2658
|
+
items: {
|
|
2659
|
+
enum: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"]
|
|
2660
|
+
}
|
|
2661
|
+
},
|
|
2662
|
+
headers: {
|
|
2663
|
+
type: "array",
|
|
2664
|
+
items: {
|
|
2665
|
+
type: "string"
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
extensions: {
|
|
2671
|
+
type: "object",
|
|
2672
|
+
propertyNames: {
|
|
2673
|
+
pattern: "^x-[a-z0-9-]+$"
|
|
2674
|
+
},
|
|
2675
|
+
additionalProperties: false
|
|
2676
|
+
}
|
|
2677
|
+
},
|
|
2678
|
+
$defs: {
|
|
2679
|
+
service: {
|
|
2680
|
+
type: "object",
|
|
2681
|
+
additionalProperties: false,
|
|
2682
|
+
properties: {
|
|
2683
|
+
target: {
|
|
2684
|
+
type: "string",
|
|
2685
|
+
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
2686
|
+
},
|
|
2687
|
+
dependsOn: {
|
|
2688
|
+
type: "array",
|
|
2689
|
+
items: {
|
|
2690
|
+
type: "string"
|
|
2691
|
+
}
|
|
2692
|
+
},
|
|
2693
|
+
protocol: {
|
|
2694
|
+
enum: ["http", "postgres", "redis"]
|
|
2695
|
+
},
|
|
2696
|
+
port: {
|
|
2697
|
+
type: "integer",
|
|
2698
|
+
minimum: 0,
|
|
2699
|
+
maximum: 65535
|
|
2700
|
+
},
|
|
2701
|
+
host: {
|
|
2702
|
+
type: "string"
|
|
2703
|
+
},
|
|
2704
|
+
adminPrefix: {
|
|
2705
|
+
type: "string"
|
|
2706
|
+
},
|
|
2707
|
+
adminKey: {
|
|
2708
|
+
type: "string"
|
|
2709
|
+
},
|
|
2710
|
+
seed: {
|
|
2711
|
+
type: "string"
|
|
2712
|
+
},
|
|
2713
|
+
adminControls: {
|
|
2714
|
+
$ref: "#/$defs/controls"
|
|
2715
|
+
},
|
|
2716
|
+
fixtures: {
|
|
2717
|
+
type: "object"
|
|
2718
|
+
},
|
|
2719
|
+
baseUrl: {
|
|
2720
|
+
type: "string"
|
|
2721
|
+
},
|
|
2722
|
+
password: {
|
|
2723
|
+
type: "string"
|
|
2724
|
+
},
|
|
2725
|
+
user: {
|
|
2726
|
+
type: "string"
|
|
2727
|
+
},
|
|
2728
|
+
database: {
|
|
2729
|
+
type: "string"
|
|
2730
|
+
},
|
|
2731
|
+
namespaces: {
|
|
2732
|
+
type: "object",
|
|
2733
|
+
propertyNames: {
|
|
2734
|
+
pattern: "^[A-Za-z0-9_.-]{1,64}$"
|
|
2735
|
+
},
|
|
2736
|
+
additionalProperties: {
|
|
2737
|
+
anyOf: [
|
|
2738
|
+
{
|
|
2739
|
+
type: "string"
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
type: "integer",
|
|
2743
|
+
minimum: 0,
|
|
2744
|
+
maximum: 15
|
|
2745
|
+
}
|
|
2746
|
+
]
|
|
2747
|
+
}
|
|
2748
|
+
},
|
|
2749
|
+
options: {
|
|
2750
|
+
type: "object",
|
|
2751
|
+
additionalProperties: false,
|
|
2752
|
+
description: "Compose the selected target option schema before supplying service-specific flags."
|
|
2753
|
+
},
|
|
2754
|
+
publicOrigin: {
|
|
2755
|
+
type: "string"
|
|
2756
|
+
},
|
|
2757
|
+
publicPath: {
|
|
2758
|
+
type: "string"
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
},
|
|
2762
|
+
controls: {
|
|
2763
|
+
type: "object",
|
|
2764
|
+
additionalProperties: false,
|
|
2765
|
+
properties: {
|
|
2766
|
+
seed: {},
|
|
2767
|
+
clock: {
|
|
2768
|
+
type: "object"
|
|
2769
|
+
},
|
|
2770
|
+
latency: {},
|
|
2771
|
+
faults: {
|
|
2772
|
+
type: "array"
|
|
2773
|
+
},
|
|
2774
|
+
snapshot: {
|
|
2775
|
+
type: "object"
|
|
2776
|
+
},
|
|
2777
|
+
virtualTime: {
|
|
2778
|
+
type: "boolean"
|
|
2779
|
+
},
|
|
2780
|
+
credentials: {
|
|
2781
|
+
type: "object"
|
|
2782
|
+
},
|
|
2783
|
+
namespaces: {
|
|
2784
|
+
type: "object"
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2791
|
+
// ../../adapters/node/dist/fleet-schema.js
|
|
2792
|
+
var object3 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2793
|
+
function validateFleetConfig(value, targetOptions = {}, extensions = {}) {
|
|
2794
|
+
const composed = composeFleetSchema(value, targetOptions, extensions), issues = [];
|
|
2795
|
+
const walk = (input, rule, path, output) => {
|
|
2796
|
+
if (rule.$ref) {
|
|
2797
|
+
const resolved = composed.$defs?.[rule.$ref.slice("#/$defs/".length)];
|
|
2798
|
+
if (!resolved)
|
|
2799
|
+
output.push({ path, message: "unresolved configuration schema" });
|
|
2800
|
+
else
|
|
2801
|
+
walk(input, resolved, path, output);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
if (rule.anyOf) {
|
|
2805
|
+
if (!rule.anyOf.some((branch) => {
|
|
2806
|
+
const found = [];
|
|
2807
|
+
walk(input, branch, path, found);
|
|
2808
|
+
return found.length === 0;
|
|
2809
|
+
}))
|
|
2810
|
+
output.push({ path, message: "value does not match an allowed shape" });
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if ("const" in rule && input !== rule.const) {
|
|
2814
|
+
output.push({
|
|
2815
|
+
path,
|
|
2816
|
+
message: path === "$.schemaVersion" || path === "$.$schema" ? "unsupported fleet schema version; upgrade the consumer/schema together" : "unexpected fixed value"
|
|
2817
|
+
});
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
if (rule.enum && !rule.enum.includes(input))
|
|
2821
|
+
output.push({ path, message: "unsupported enumerated value" });
|
|
2822
|
+
if (rule.type) {
|
|
2823
|
+
const valid = rule.type === "object" ? object3(input) : rule.type === "array" ? Array.isArray(input) : rule.type === "integer" ? typeof input === "number" && Number.isInteger(input) : typeof input === rule.type;
|
|
2824
|
+
if (!valid) {
|
|
2825
|
+
output.push({ path, message: `expected ${rule.type}` });
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
if (typeof input === "number" && (rule.minimum !== void 0 && input < rule.minimum || rule.maximum !== void 0 && input > rule.maximum))
|
|
2830
|
+
output.push({ path, message: "number outside supported bounds" });
|
|
2831
|
+
if (typeof input === "string" && rule.pattern && !new RegExp(rule.pattern).test(input))
|
|
2832
|
+
output.push({ path, message: "invalid identifier" });
|
|
2833
|
+
if (Array.isArray(input) && rule.items)
|
|
2834
|
+
input.forEach((entry, index) => {
|
|
2835
|
+
walk(entry, rule.items, `${path}[${index}]`, output);
|
|
2836
|
+
});
|
|
2837
|
+
if (object3(input)) {
|
|
2838
|
+
for (const name of rule.required ?? [])
|
|
2839
|
+
if (!Object.hasOwn(input, name))
|
|
2840
|
+
output.push({ path: `${path}.${name}`, message: "required field missing" });
|
|
2841
|
+
for (const [key, entry] of Object.entries(input)) {
|
|
2842
|
+
if (rule.propertyNames)
|
|
2843
|
+
walk(key, rule.propertyNames, `${path}.${key}`, output);
|
|
2844
|
+
const property = rule.properties?.[key];
|
|
2845
|
+
if (property)
|
|
2846
|
+
walk(entry, property, `${path}.${key}`, output);
|
|
2847
|
+
else if (rule.additionalProperties === false)
|
|
2848
|
+
output.push({ path: `${path}.${key}`, message: "unknown configuration field" });
|
|
2849
|
+
else if (object3(rule.additionalProperties))
|
|
2850
|
+
walk(entry, rule.additionalProperties, `${path}.${key}`, output);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
walk(value, composed, "$", issues);
|
|
2855
|
+
return issues;
|
|
2856
|
+
}
|
|
2857
|
+
function composeFleetSchema(value, targetOptions = {}, extensions = {}) {
|
|
2858
|
+
const composed = structuredClone(fleet_config_schema_default);
|
|
2859
|
+
const serviceRule = composed.$defs?.service;
|
|
2860
|
+
const servicesRule = composed.properties?.services;
|
|
2861
|
+
servicesRule.properties = {};
|
|
2862
|
+
if (object3(value) && object3(value.services))
|
|
2863
|
+
for (const [instance, entry] of Object.entries(value.services)) {
|
|
2864
|
+
if (!object3(entry))
|
|
2865
|
+
continue;
|
|
2866
|
+
const target = typeof entry.target === "string" ? entry.target : instance;
|
|
2867
|
+
const declared = targetOptions[target] ?? {};
|
|
2868
|
+
const configured = structuredClone(serviceRule);
|
|
2869
|
+
configured.properties = {
|
|
2870
|
+
...configured.properties,
|
|
2871
|
+
options: {
|
|
2872
|
+
type: "object",
|
|
2873
|
+
additionalProperties: false,
|
|
2874
|
+
properties: Object.fromEntries(Object.entries(declared).map(([key, option]) => [key, { type: option.type }]))
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
servicesRule.properties[instance] = configured;
|
|
2878
|
+
}
|
|
2879
|
+
if (composed.properties?.extensions)
|
|
2880
|
+
composed.properties.extensions.properties = extensions;
|
|
2881
|
+
return composed;
|
|
2882
|
+
}
|
|
2883
|
+
function assertFleetConfig(config, targetOptions = {}) {
|
|
2884
|
+
const issues = validateFleetConfig(config, targetOptions);
|
|
2885
|
+
if (issues.length)
|
|
2886
|
+
throw new Error(issues.slice(0, 20).map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// ../../adapters/node/dist/fleet-preflight.js
|
|
2890
|
+
var destination = async (path) => {
|
|
2891
|
+
const absolute = resolve(path);
|
|
2892
|
+
try {
|
|
2893
|
+
return await realpath(absolute);
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
if (error.code !== "ENOENT")
|
|
2896
|
+
throw error;
|
|
2897
|
+
const parent = dirname(absolute);
|
|
2898
|
+
return parent === absolute ? absolute : resolve(await destination(parent), basename(absolute));
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
async function preflightFleet(config, options) {
|
|
2902
|
+
if (options.readyFile && options.connectionsFile && await destination(options.readyFile) === await destination(options.connectionsFile))
|
|
2903
|
+
throw new Error("ready-file and connections-file destinations must differ");
|
|
2904
|
+
if (!config.services || typeof config.services !== "object" || !Object.keys(config.services).length && !Object.keys(options.participants ?? {}).length)
|
|
2905
|
+
throw new Error("config must contain at least one service");
|
|
2906
|
+
validateFleetPolicies(options);
|
|
2907
|
+
if (options.archiveProtection && options.repository && options.repository.protectionPolicy?.format !== options.archiveProtection.policy.format)
|
|
2908
|
+
throw new Error("protected archive policy requires a protected repository; configure its protector before startup");
|
|
2909
|
+
resolveAdminPrefix(config.adminPrefix);
|
|
2910
|
+
normalizeBootControls(config.adminControls);
|
|
2911
|
+
const targets = /* @__PURE__ */ new Map();
|
|
2912
|
+
const ports = /* @__PURE__ */ new Set();
|
|
2913
|
+
for (const [name, entry] of Object.entries(config.services)) {
|
|
2914
|
+
try {
|
|
2915
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
2916
|
+
throw new Error("invalid service name");
|
|
2917
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
2918
|
+
throw new Error("expected service object");
|
|
2919
|
+
resolveAdminPrefix(entry.adminPrefix ?? config.adminPrefix);
|
|
2920
|
+
normalizeBootControls({ ...config.adminControls, ...entry.adminControls }, `services.${name}.adminControls`);
|
|
2921
|
+
if (entry.port !== void 0 && (!Number.isInteger(entry.port) || entry.port < 0 || entry.port > 65535))
|
|
2922
|
+
throw new Error("invalid port");
|
|
2923
|
+
if (entry.target !== void 0 && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.target))
|
|
2924
|
+
throw new Error("target must name a supported service suffix");
|
|
2925
|
+
const service = entry.target ?? name;
|
|
2926
|
+
if (advertisedHttpBase(entry) && service !== "stripe")
|
|
2927
|
+
throw new Error(`public ingress unsupported for ${service}; use a direct endpoint to preserve signed targets`);
|
|
2928
|
+
const target = await options.load(service);
|
|
2929
|
+
const protocol = "protocol" in target ? target.protocol : "http";
|
|
2930
|
+
if (entry.protocol !== void 0 && entry.protocol !== protocol)
|
|
2931
|
+
throw new Error("configured protocol does not match target");
|
|
2932
|
+
if (protocol !== "http") {
|
|
2933
|
+
if (entry.seed !== void 0 && (!String(entry.seed).trim() || !Number.isFinite(Number(entry.seed))))
|
|
2934
|
+
throw new Error("protocol seed must be a finite number");
|
|
2935
|
+
for (const [namespace, selector] of Object.entries(entry.namespaces ?? {})) {
|
|
2936
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
|
|
2937
|
+
throw new Error("invalid namespace");
|
|
2938
|
+
if (protocol === "postgres" ? typeof selector !== "string" || !selector.length : typeof selector !== "number" || !Number.isInteger(selector) || selector < 0 || selector > 15)
|
|
2939
|
+
throw new Error("invalid protocol namespace selector");
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
const port = entry.port ?? target.defaultPort;
|
|
2943
|
+
if (port !== 0) {
|
|
2944
|
+
const key = `${entry.host ?? "127.0.0.1"}:${port}`;
|
|
2945
|
+
if (ports.has(key))
|
|
2946
|
+
throw new Error(`duplicate listener ${key}`);
|
|
2947
|
+
ports.add(key);
|
|
2948
|
+
}
|
|
2949
|
+
let packageVersion = target.packageVersion;
|
|
2950
|
+
if (!packageVersion) {
|
|
2951
|
+
for (const from of [
|
|
2952
|
+
createRequire(resolve(process.cwd(), "package.json")),
|
|
2953
|
+
createRequire(import.meta.url)
|
|
2954
|
+
]) {
|
|
2955
|
+
try {
|
|
2956
|
+
let directory = dirname(from.resolve(`@crvouga/mockingbird-service-${service}/server`));
|
|
2957
|
+
for (; ; ) {
|
|
2958
|
+
try {
|
|
2959
|
+
const manifest = JSON.parse(await readFile(resolve(directory, "package.json"), "utf8"));
|
|
2960
|
+
if (manifest.name === `@crvouga/mockingbird-service-${service}`) {
|
|
2961
|
+
packageVersion = manifest.version;
|
|
2962
|
+
break;
|
|
2963
|
+
}
|
|
2964
|
+
} catch {
|
|
2965
|
+
}
|
|
2966
|
+
const parent = dirname(directory);
|
|
2967
|
+
if (parent === directory)
|
|
2968
|
+
break;
|
|
2969
|
+
directory = parent;
|
|
2970
|
+
}
|
|
2971
|
+
} catch {
|
|
2972
|
+
}
|
|
2973
|
+
if (packageVersion)
|
|
2974
|
+
break;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
targets.set(name, { ...target, packageVersion: packageVersion ?? "3.4.0" });
|
|
2978
|
+
} catch (error) {
|
|
2979
|
+
throw new Error(`service ${name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
for (const [name, participant] of Object.entries(options.participants ?? {})) {
|
|
2983
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || targets.has(name))
|
|
2984
|
+
throw new Error(`external participant identity collision ${name}`);
|
|
2985
|
+
if (participant.protocol !== "external")
|
|
2986
|
+
throw new Error(`registered participant ${name} must declare external protocol`);
|
|
2987
|
+
targets.set(name, {
|
|
2988
|
+
name,
|
|
2989
|
+
protocol: "external",
|
|
2990
|
+
defaultPort: 0,
|
|
2991
|
+
start: async () => participant
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
assertFleetConfig(config, Object.fromEntries([...targets].map(([name, target]) => [
|
|
2995
|
+
config.services[name]?.target ?? name,
|
|
2996
|
+
"options" in target ? target.options ?? {} : {}
|
|
2997
|
+
])));
|
|
2998
|
+
const ordered = /* @__PURE__ */ new Map();
|
|
2999
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
3000
|
+
const visit = (name, chain) => {
|
|
3001
|
+
if (ordered.has(name))
|
|
3002
|
+
return;
|
|
3003
|
+
if (!targets.has(name))
|
|
3004
|
+
throw new Error(`unknown dependency ${[...chain, name].join(" -> ")}`);
|
|
3005
|
+
if (visiting.has(name))
|
|
3006
|
+
throw new Error(`dependency cycle ${[...chain, name].join(" -> ")}`);
|
|
3007
|
+
visiting.add(name);
|
|
3008
|
+
const dependencies = config.services[name]?.dependsOn ?? options.participants?.[name]?.dependencies ?? [];
|
|
3009
|
+
if (!Array.isArray(dependencies) || dependencies.some((d) => typeof d !== "string"))
|
|
3010
|
+
throw new Error(`services.${name}.dependsOn must be a list of instance names`);
|
|
3011
|
+
for (const dependency of dependencies)
|
|
3012
|
+
visit(dependency, [...chain, name]);
|
|
3013
|
+
visiting.delete(name);
|
|
3014
|
+
ordered.set(name, targets.get(name));
|
|
3015
|
+
};
|
|
3016
|
+
for (const name of targets.keys())
|
|
3017
|
+
visit(name, []);
|
|
3018
|
+
return ordered;
|
|
3019
|
+
}
|
|
3020
|
+
function validateFleetPolicies(options) {
|
|
3021
|
+
if (options.journal && !options.repository)
|
|
3022
|
+
throw new Error("durable operation journal requires an archive repository");
|
|
3023
|
+
if (options.cors) {
|
|
3024
|
+
if (!Array.isArray(options.cors.origins) || options.cors.origins.some((o) => {
|
|
3025
|
+
try {
|
|
3026
|
+
const u = new URL(o);
|
|
3027
|
+
return !["http:", "https:"].includes(u.protocol) || u.origin !== o;
|
|
3028
|
+
} catch {
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
3031
|
+
}))
|
|
3032
|
+
throw new Error("cors.origins must contain explicit HTTP origins");
|
|
3033
|
+
if (options.cors.methods?.some((m) => !["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"].includes(m)) || options.cors.headers?.some((h) => !/^[a-zA-Z0-9-]+$/.test(h)))
|
|
3034
|
+
throw new Error("invalid CORS methods/headers");
|
|
3035
|
+
}
|
|
3036
|
+
for (const [key, value] of Object.entries(options.limits ?? {})) {
|
|
3037
|
+
if (!["maxNamespaces", "maxListeners", "connections"].includes(key))
|
|
3038
|
+
throw new Error(`unsupported namespace budget ${key}`);
|
|
3039
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
3040
|
+
throw new Error(`invalid limit ${key}`);
|
|
3041
|
+
}
|
|
3042
|
+
for (const [key, value] of Object.entries(options.retention ?? {}))
|
|
3043
|
+
if (!["maxCount", "ttlMs"].includes(key) || !Number.isSafeInteger(value) || value < 1)
|
|
3044
|
+
throw new Error(`invalid retention ${key}`);
|
|
3045
|
+
if (options.backupPolicy && (!options.repository || !Number.isSafeInteger(options.backupPolicy.intervalMs) || options.backupPolicy.intervalMs < 10 || options.backupPolicy.keep !== void 0 && (!Number.isSafeInteger(options.backupPolicy.keep) || options.backupPolicy.keep < 1)))
|
|
3046
|
+
throw new Error("backup policy requires a repository, intervalMs >= 10 and positive retention");
|
|
3047
|
+
if (options.expiry && (!Number.isSafeInteger(options.expiry.idleMs) || options.expiry.idleMs < 1 || options.expiry.intervalMs !== void 0 && (!Number.isSafeInteger(options.expiry.intervalMs) || options.expiry.intervalMs < 10) || options.expiry.finalBackup && !options.repository))
|
|
3048
|
+
throw new Error("invalid expiry policy or missing final-backup repository");
|
|
3049
|
+
for (const duration of [
|
|
3050
|
+
options.hookTimeoutMs,
|
|
3051
|
+
options.initializationTimeoutMs,
|
|
3052
|
+
options.shutdownTimeoutMs
|
|
3053
|
+
])
|
|
3054
|
+
if (duration !== void 0 && (!Number.isSafeInteger(duration) || duration < 1))
|
|
3055
|
+
throw new Error("hook deadlines must be positive integer milliseconds");
|
|
3056
|
+
}
|
|
22
3057
|
|
|
23
3058
|
// ../../adapters/node/dist/serve.js
|
|
24
3059
|
import { createServer } from "node:http";
|
|
3060
|
+
import { Readable } from "node:stream";
|
|
25
3061
|
var serve = async (api, options = {}) => {
|
|
3062
|
+
const maxBytes = options.maxBodyBytes ?? 16 * 1024 * 1024;
|
|
3063
|
+
const timeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
3064
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
3065
|
+
throw new Error("invalid request limits");
|
|
26
3066
|
const server = createServer(async (req, res) => {
|
|
27
|
-
const chunks = [];
|
|
28
|
-
for await (const chunk of req) {
|
|
29
|
-
chunks.push(Buffer.from(chunk));
|
|
30
|
-
}
|
|
31
|
-
const body = Buffer.concat(chunks);
|
|
32
|
-
const address = server.address();
|
|
33
|
-
const port = typeof address === "object" && address !== null ? address.port : void 0;
|
|
34
|
-
const base = `http://${req.headers.host ?? `localhost:${port ?? 80}`}`;
|
|
35
|
-
const raw = req.url ?? "/";
|
|
36
|
-
const url = new URL(raw.replace(/^\/+/, "/"), base);
|
|
37
|
-
const method = req.method ?? "GET";
|
|
38
|
-
const init = { method, headers: req.headers };
|
|
39
|
-
if (method !== "GET" && method !== "HEAD" && body.length > 0) {
|
|
40
|
-
init.body = body;
|
|
41
|
-
}
|
|
42
3067
|
const aborted = new AbortController();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
3068
|
+
let reader;
|
|
3069
|
+
let timer;
|
|
3070
|
+
const abort = () => {
|
|
3071
|
+
aborted.abort();
|
|
3072
|
+
void reader?.cancel().catch(() => {
|
|
3073
|
+
});
|
|
3074
|
+
};
|
|
3075
|
+
const fail = (status, message) => {
|
|
3076
|
+
if (!res.headersSent && !res.destroyed) {
|
|
3077
|
+
res.writeHead(status, { "content-type": "application/json", connection: "close" });
|
|
3078
|
+
res.end(JSON.stringify({ error: { type: "mockingbird_transport", message } }));
|
|
3079
|
+
}
|
|
3080
|
+
abort();
|
|
3081
|
+
};
|
|
3082
|
+
req.once("aborted", abort);
|
|
3083
|
+
res.once("close", abort);
|
|
3084
|
+
res.once("error", abort);
|
|
50
3085
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
3086
|
+
const address = server.address();
|
|
3087
|
+
const port = typeof address === "object" && address !== null ? address.port : 80;
|
|
3088
|
+
const url = new URL((req.url ?? "/").replace(/^\/+/, "/"), `http://${req.headers.host ?? `localhost:${port}`}`);
|
|
3089
|
+
const headers = new Headers();
|
|
3090
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
3091
|
+
if (Array.isArray(value))
|
|
3092
|
+
for (const item of value)
|
|
3093
|
+
headers.append(name, item);
|
|
3094
|
+
else if (value !== void 0)
|
|
3095
|
+
headers.set(name, value);
|
|
56
3096
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
3097
|
+
const init = {
|
|
3098
|
+
method: req.method ?? "GET",
|
|
3099
|
+
headers,
|
|
3100
|
+
signal: aborted.signal
|
|
3101
|
+
};
|
|
3102
|
+
if (init.method !== "GET" && init.method !== "HEAD") {
|
|
3103
|
+
if (Number(req.headers["content-length"]) > maxBytes) {
|
|
3104
|
+
fail(413, "request body exceeds byte limit");
|
|
3105
|
+
return;
|
|
62
3106
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
3107
|
+
let bytes = 0;
|
|
3108
|
+
const source = Readable.toWeb(req);
|
|
3109
|
+
init.body = source.pipeThrough(new TransformStream({
|
|
3110
|
+
transform(chunk, controller) {
|
|
3111
|
+
bytes += chunk.byteLength;
|
|
3112
|
+
if (bytes > maxBytes) {
|
|
3113
|
+
fail(413, "request body exceeds byte limit");
|
|
3114
|
+
controller.error(new Error("request body exceeds byte limit"));
|
|
3115
|
+
} else
|
|
3116
|
+
controller.enqueue(chunk);
|
|
3117
|
+
}
|
|
3118
|
+
}));
|
|
3119
|
+
init.duplex = "half";
|
|
3120
|
+
timer = setTimeout(() => fail(408, "request body timed out"), timeoutMs);
|
|
3121
|
+
req.once("end", () => clearTimeout(timer));
|
|
3122
|
+
}
|
|
3123
|
+
const response = await api.fetch(new Request(url, init));
|
|
3124
|
+
if (aborted.signal.aborted || res.destroyed) {
|
|
3125
|
+
await response.body?.cancel();
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
const output = Object.fromEntries(response.headers);
|
|
3129
|
+
const cookies = response.headers.getSetCookie();
|
|
3130
|
+
if (cookies.length)
|
|
3131
|
+
output["set-cookie"] = cookies;
|
|
3132
|
+
res.writeHead(response.status, output);
|
|
3133
|
+
if (!response.body) {
|
|
3134
|
+
res.end();
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
res.flushHeaders();
|
|
3138
|
+
reader = response.body.getReader();
|
|
79
3139
|
for (; ; ) {
|
|
80
3140
|
const { done, value } = await reader.read();
|
|
81
|
-
if (done)
|
|
3141
|
+
if (done || aborted.signal.aborted)
|
|
82
3142
|
break;
|
|
83
|
-
if (!res.write(value))
|
|
84
|
-
await new Promise((
|
|
3143
|
+
if (!res.write(value)) {
|
|
3144
|
+
await new Promise((resolve4) => {
|
|
3145
|
+
const finish = () => {
|
|
3146
|
+
res.off("drain", finish);
|
|
3147
|
+
res.off("close", finish);
|
|
3148
|
+
res.off("error", finish);
|
|
3149
|
+
resolve4();
|
|
3150
|
+
};
|
|
3151
|
+
res.once("drain", finish);
|
|
3152
|
+
res.once("close", finish);
|
|
3153
|
+
res.once("error", finish);
|
|
3154
|
+
if (res.destroyed || aborted.signal.aborted)
|
|
3155
|
+
finish();
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
if (!aborted.signal.aborted)
|
|
3160
|
+
res.end();
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
if (error.code === "MOCKINGBIRD_DROP")
|
|
3163
|
+
req.socket.destroy();
|
|
3164
|
+
else if (!aborted.signal.aborted && !res.destroyed) {
|
|
3165
|
+
if (res.headersSent)
|
|
3166
|
+
res.destroy();
|
|
3167
|
+
else {
|
|
3168
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3169
|
+
res.end(JSON.stringify({
|
|
3170
|
+
error: {
|
|
3171
|
+
type: "mockingbird_internal",
|
|
3172
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3173
|
+
}
|
|
3174
|
+
}));
|
|
3175
|
+
}
|
|
85
3176
|
}
|
|
86
|
-
res.end();
|
|
87
|
-
} catch {
|
|
88
|
-
res.destroy();
|
|
89
3177
|
} finally {
|
|
90
|
-
|
|
3178
|
+
clearTimeout(timer);
|
|
3179
|
+
reader?.releaseLock();
|
|
3180
|
+
req.off("aborted", abort);
|
|
3181
|
+
res.off("close", abort);
|
|
3182
|
+
res.off("error", abort);
|
|
3183
|
+
if (!req.complete)
|
|
3184
|
+
req.resume();
|
|
91
3185
|
}
|
|
92
3186
|
});
|
|
93
|
-
|
|
3187
|
+
server.requestTimeout = timeoutMs;
|
|
3188
|
+
await new Promise((resolve4, reject) => {
|
|
94
3189
|
server.once("error", reject);
|
|
95
|
-
server.listen(options.port ?? 0, options.host,
|
|
3190
|
+
server.listen(options.port ?? 0, options.host, resolve4);
|
|
96
3191
|
});
|
|
97
3192
|
return server;
|
|
98
3193
|
};
|
|
@@ -109,12 +3204,12 @@ var listen = async (api, options = {}) => {
|
|
|
109
3204
|
port,
|
|
110
3205
|
host,
|
|
111
3206
|
server,
|
|
112
|
-
close: () => new Promise((
|
|
3207
|
+
close: () => new Promise((resolve4, reject) => {
|
|
113
3208
|
server.close((error) => {
|
|
114
3209
|
if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
|
|
115
3210
|
reject(error);
|
|
116
3211
|
else
|
|
117
|
-
|
|
3212
|
+
resolve4();
|
|
118
3213
|
});
|
|
119
3214
|
server.closeAllConnections?.();
|
|
120
3215
|
})
|
|
@@ -131,7 +3226,26 @@ var EMPTY = {
|
|
|
131
3226
|
unmatchedRequests: 0
|
|
132
3227
|
};
|
|
133
3228
|
var NS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
134
|
-
var json = (status, value) =>
|
|
3229
|
+
var json = (status, value) => {
|
|
3230
|
+
if (status >= 400 && isObject(value)) {
|
|
3231
|
+
const message = typeof value.error === "string" ? value.error : typeof value.message === "string" ? value.message : value.status === "failed" ? "fleet operation failed" : "fleet request rejected";
|
|
3232
|
+
return Response.json({
|
|
3233
|
+
...value,
|
|
3234
|
+
error: {
|
|
3235
|
+
type: "mockingbird_admin",
|
|
3236
|
+
code: {
|
|
3237
|
+
400: "invalid_request",
|
|
3238
|
+
401: "unauthorized",
|
|
3239
|
+
404: "not_found",
|
|
3240
|
+
409: "conflict",
|
|
3241
|
+
500: "operation_failed"
|
|
3242
|
+
}[status] ?? "request_failed",
|
|
3243
|
+
message
|
|
3244
|
+
}
|
|
3245
|
+
}, { status });
|
|
3246
|
+
}
|
|
3247
|
+
return Response.json(value, { status });
|
|
3248
|
+
};
|
|
135
3249
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
3250
|
var pending = (d) => d.activeRequests + d.blockedCommands + d.pendingJobs + d.pendingWebhooks;
|
|
137
3251
|
var publicUrl = (raw) => {
|
|
@@ -140,7 +3254,7 @@ var publicUrl = (raw) => {
|
|
|
140
3254
|
return url.href;
|
|
141
3255
|
};
|
|
142
3256
|
var atomicWrite = async (path, value) => {
|
|
143
|
-
const temp = `${path}.${
|
|
3257
|
+
const temp = `${path}.${randomUUID4()}.tmp`;
|
|
144
3258
|
try {
|
|
145
3259
|
await writeFile(temp, `${JSON.stringify(value, null, 2)}
|
|
146
3260
|
`, { mode: 384, flag: "wx" });
|
|
@@ -154,7 +3268,7 @@ var removeOwned = async (path, id) => {
|
|
|
154
3268
|
if (!path)
|
|
155
3269
|
return;
|
|
156
3270
|
try {
|
|
157
|
-
const value = JSON.parse(await
|
|
3271
|
+
const value = JSON.parse(await readFile2(path, "utf8"));
|
|
158
3272
|
if (isObject(value) && value.id === id)
|
|
159
3273
|
await rm(path, { force: true });
|
|
160
3274
|
} catch (error) {
|
|
@@ -162,29 +3276,114 @@ var removeOwned = async (path, id) => {
|
|
|
162
3276
|
throw error;
|
|
163
3277
|
}
|
|
164
3278
|
};
|
|
165
|
-
var httpChild = async (target, entry, options) => {
|
|
166
|
-
const
|
|
3279
|
+
var httpChild = async (target, entry, options, admission, generation, privateAccess, observer) => {
|
|
3280
|
+
const requestLog = new AsyncLocalStorage2();
|
|
3281
|
+
const created = await target.create(entry.options ?? {}, {
|
|
167
3282
|
adminPrefix: resolveAdminPrefix(entry.adminPrefix),
|
|
168
3283
|
adminKey: entry.adminKey,
|
|
169
3284
|
seed: entry.seed,
|
|
170
|
-
onLog:
|
|
3285
|
+
onLog: (entry2) => {
|
|
3286
|
+
const current = requestLog.getStore();
|
|
3287
|
+
if (current)
|
|
3288
|
+
current.entry = entry2;
|
|
3289
|
+
options.onLog?.(entry2);
|
|
3290
|
+
},
|
|
3291
|
+
...entry.adminControls !== void 0 ? { adminControls: entry.adminControls } : {},
|
|
171
3292
|
...entry.fixtures !== void 0 ? { fixtures: entry.fixtures } : {},
|
|
172
|
-
...entry.baseUrl !== void 0 ? { baseUrl: entry.baseUrl } : {}
|
|
3293
|
+
...(advertisedHttpBase(entry) ?? entry.baseUrl) !== void 0 ? { baseUrl: advertisedHttpBase(entry) ?? entry.baseUrl } : {}
|
|
173
3294
|
});
|
|
3295
|
+
if (!isObject(created) || [
|
|
3296
|
+
"fetch",
|
|
3297
|
+
"fleetSnapshot",
|
|
3298
|
+
"fleetRestore",
|
|
3299
|
+
"fleetExport",
|
|
3300
|
+
"fleetImport",
|
|
3301
|
+
"namespaceOf",
|
|
3302
|
+
"instance",
|
|
3303
|
+
"namespaceClock",
|
|
3304
|
+
"reset",
|
|
3305
|
+
"configure",
|
|
3306
|
+
"dropNamespace"
|
|
3307
|
+
].some((method) => typeof created[method] !== "function"))
|
|
3308
|
+
throw new Error("HTTP target does not implement the fleet runtime protocol; build/install matching supported service versions");
|
|
3309
|
+
const runtime = created;
|
|
3310
|
+
const unobserve = runtime.webhooks?.observeDeliveries(observer.delivery);
|
|
3311
|
+
if (options.outbound && runtime.webhooks)
|
|
3312
|
+
runtime.webhooks.setTransportPolicy(ownedTransport(options.outbound));
|
|
174
3313
|
const adminPrefix = resolveAdminPrefix(entry.adminPrefix);
|
|
175
|
-
runtime.isolateNamespaces();
|
|
176
3314
|
const locked = /* @__PURE__ */ new Set();
|
|
177
3315
|
const active = /* @__PURE__ */ new Map();
|
|
3316
|
+
const activity = /* @__PURE__ */ new Map();
|
|
178
3317
|
const api = {
|
|
179
3318
|
async fetch(request) {
|
|
180
3319
|
const namespace = runtime.namespaceOf(request);
|
|
181
|
-
if (locked.has(namespace))
|
|
3320
|
+
if (locked.has(namespace) && !privateAccess(namespace, request))
|
|
182
3321
|
return json(409, { error: "fleet control in progress", namespace });
|
|
3322
|
+
if (!new URL(request.url).pathname.endsWith(`${adminPrefix}/health`)) {
|
|
3323
|
+
const refused = admission(namespace, request);
|
|
3324
|
+
if (refused)
|
|
3325
|
+
return refused;
|
|
3326
|
+
}
|
|
3327
|
+
activity.set(namespace, Date.now());
|
|
183
3328
|
active.set(namespace, (active.get(namespace) ?? 0) + 1);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
3329
|
+
let complete = false;
|
|
3330
|
+
const finish = () => {
|
|
3331
|
+
if (complete)
|
|
3332
|
+
return;
|
|
3333
|
+
complete = true;
|
|
187
3334
|
active.set(namespace, (active.get(namespace) ?? 1) - 1);
|
|
3335
|
+
request.signal.removeEventListener("abort", finish);
|
|
3336
|
+
};
|
|
3337
|
+
request.signal.addEventListener("abort", finish, { once: true });
|
|
3338
|
+
try {
|
|
3339
|
+
const log = {}, traceToken = observer.begin(namespace, request), started = performance.now();
|
|
3340
|
+
const response = await requestLog.run(log, () => runtime.fetch(request));
|
|
3341
|
+
await observer.finish(namespace, request, response, log.entry ?? {
|
|
3342
|
+
service: target.name,
|
|
3343
|
+
namespace,
|
|
3344
|
+
operationId: void 0,
|
|
3345
|
+
method: request.method,
|
|
3346
|
+
path: new URL(request.url).pathname,
|
|
3347
|
+
status: response.status,
|
|
3348
|
+
durationMs: performance.now() - started,
|
|
3349
|
+
unmatched: response.status === 404
|
|
3350
|
+
}, traceToken);
|
|
3351
|
+
if (!response.body) {
|
|
3352
|
+
finish();
|
|
3353
|
+
return response;
|
|
3354
|
+
}
|
|
3355
|
+
const reader = response.body.getReader();
|
|
3356
|
+
const headers = new Headers(response.headers);
|
|
3357
|
+
const epoch = generation(namespace);
|
|
3358
|
+
if (epoch)
|
|
3359
|
+
headers.set("x-mockingbird-generation", epoch);
|
|
3360
|
+
return new Response(new ReadableStream({
|
|
3361
|
+
async pull(controller) {
|
|
3362
|
+
try {
|
|
3363
|
+
const next = await reader.read();
|
|
3364
|
+
if (next.done) {
|
|
3365
|
+
finish();
|
|
3366
|
+
controller.close();
|
|
3367
|
+
reader.releaseLock();
|
|
3368
|
+
} else
|
|
3369
|
+
controller.enqueue(next.value);
|
|
3370
|
+
} catch (error) {
|
|
3371
|
+
finish();
|
|
3372
|
+
controller.error(error);
|
|
3373
|
+
}
|
|
3374
|
+
},
|
|
3375
|
+
async cancel(reason) {
|
|
3376
|
+
try {
|
|
3377
|
+
await reader.cancel(reason);
|
|
3378
|
+
} finally {
|
|
3379
|
+
finish();
|
|
3380
|
+
reader.releaseLock();
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
}), { status: response.status, statusText: response.statusText, headers });
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
finish();
|
|
3386
|
+
throw error;
|
|
188
3387
|
}
|
|
189
3388
|
}
|
|
190
3389
|
};
|
|
@@ -196,6 +3395,7 @@ var httpChild = async (target, entry, options) => {
|
|
|
196
3395
|
});
|
|
197
3396
|
target.listening?.(server);
|
|
198
3397
|
} catch (error) {
|
|
3398
|
+
unobserve?.();
|
|
199
3399
|
runtime.stop?.();
|
|
200
3400
|
await server?.close();
|
|
201
3401
|
runtime.webhooks?.clear();
|
|
@@ -204,6 +3404,7 @@ var httpChild = async (target, entry, options) => {
|
|
|
204
3404
|
}
|
|
205
3405
|
return {
|
|
206
3406
|
protocol: "http",
|
|
3407
|
+
outboundPolicy: runtime.webhooks ? "webhook-hub" : "unsupported",
|
|
207
3408
|
url: server.url,
|
|
208
3409
|
healthUrl: `${server.url}${adminPrefix}/health`,
|
|
209
3410
|
adminUrl: `${server.url}${adminPrefix}`,
|
|
@@ -216,6 +3417,7 @@ var httpChild = async (target, entry, options) => {
|
|
|
216
3417
|
}
|
|
217
3418
|
},
|
|
218
3419
|
async close() {
|
|
3420
|
+
unobserve?.();
|
|
219
3421
|
runtime.stop?.();
|
|
220
3422
|
runtime.webhooks?.clear();
|
|
221
3423
|
await server.close();
|
|
@@ -243,30 +3445,141 @@ var httpChild = async (target, entry, options) => {
|
|
|
243
3445
|
},
|
|
244
3446
|
reset: (namespace) => runtime.reset(namespace),
|
|
245
3447
|
clock: (namespace) => runtime.namespaceClock(namespace),
|
|
3448
|
+
unmatched(namespace, limit) {
|
|
3449
|
+
const entries = runtime.metrics.report(namespace).unmatched;
|
|
3450
|
+
return {
|
|
3451
|
+
entries: entries.slice(0, limit).map(({ method, path, count }) => ({
|
|
3452
|
+
method,
|
|
3453
|
+
pathFingerprint: createHash4("sha256").update(path).digest("hex"),
|
|
3454
|
+
count
|
|
3455
|
+
})),
|
|
3456
|
+
truncated: entries.length > limit
|
|
3457
|
+
};
|
|
3458
|
+
},
|
|
3459
|
+
lastActivity: (namespace) => activity.get(namespace) ?? 0,
|
|
3460
|
+
async exportCheckpoint(_namespace, point) {
|
|
3461
|
+
return runtime.fleetExport(point);
|
|
3462
|
+
},
|
|
3463
|
+
async importCheckpoint(namespace, document) {
|
|
3464
|
+
return runtime.fleetImport(namespace, document);
|
|
3465
|
+
},
|
|
3466
|
+
async createNamespace(namespace) {
|
|
3467
|
+
runtime.instance(namespace);
|
|
3468
|
+
},
|
|
3469
|
+
deleteNamespace: (namespace) => runtime.dropNamespace(namespace),
|
|
3470
|
+
async configure(namespace, controls) {
|
|
3471
|
+
return runtime.configure(controls, namespace);
|
|
3472
|
+
},
|
|
3473
|
+
async inspect(namespace, { offset, limit, collection }) {
|
|
3474
|
+
const doc = runtime.fleetExport(runtime.fleetSnapshot(namespace));
|
|
3475
|
+
const rows = collection ? doc.records.filter((row) => row.collection === collection) : doc.records;
|
|
3476
|
+
return {
|
|
3477
|
+
total: rows.length,
|
|
3478
|
+
offset,
|
|
3479
|
+
records: rows.slice(offset, offset + limit),
|
|
3480
|
+
collections: [...new Set(doc.records.map((row) => row.collection))]
|
|
3481
|
+
};
|
|
3482
|
+
},
|
|
3483
|
+
...typeof runtime.instance().nextEvent === "function" ? {
|
|
3484
|
+
nextEvent: (namespace) => runtime.instance(namespace).nextEvent()
|
|
3485
|
+
} : {},
|
|
246
3486
|
async settle(namespace) {
|
|
247
3487
|
const instance = runtime.instance(namespace);
|
|
248
|
-
instance.tick?.();
|
|
249
|
-
await runtime.webhooks?.idle();
|
|
3488
|
+
await instance.tick?.();
|
|
3489
|
+
await runtime.webhooks?.idle(namespace);
|
|
250
3490
|
}
|
|
251
3491
|
};
|
|
252
3492
|
};
|
|
253
3493
|
async function startFleet(config, options) {
|
|
254
|
-
|
|
3494
|
+
if (config.cors && options.cors === void 0)
|
|
3495
|
+
options = { ...options, cors: config.cors };
|
|
3496
|
+
const targets = await preflightFleet(config, options);
|
|
3497
|
+
const id = randomUUID4();
|
|
255
3498
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
256
3499
|
const children = /* @__PURE__ */ new Map();
|
|
257
3500
|
const clocks = /* @__PURE__ */ new Map();
|
|
258
3501
|
const checkpoints = /* @__PURE__ */ new Map();
|
|
259
3502
|
let supervisor;
|
|
260
|
-
let
|
|
261
|
-
let
|
|
262
|
-
const
|
|
263
|
-
|
|
3503
|
+
let management;
|
|
3504
|
+
let manifest;
|
|
3505
|
+
const privateConnections = () => Object.fromEntries([...children].map(([name, child]) => [
|
|
3506
|
+
name,
|
|
3507
|
+
{ url: child.connection ?? child.url, namespaces: child.connections }
|
|
3508
|
+
]));
|
|
3509
|
+
const publish = async () => {
|
|
3510
|
+
if (!manifest || management && management.lifecycle !== "ready")
|
|
264
3511
|
return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
3512
|
+
manifest.state = recovery.size ? "recovery-required" : "ready";
|
|
3513
|
+
for (const [name, child] of children)
|
|
3514
|
+
if (manifest.services[name])
|
|
3515
|
+
manifest.services[name].namespaces = child.namespaces;
|
|
3516
|
+
if (options.connectionsFile)
|
|
3517
|
+
await atomicWrite(options.connectionsFile, {
|
|
3518
|
+
version: 1,
|
|
3519
|
+
id,
|
|
3520
|
+
pid: process.pid,
|
|
3521
|
+
services: privateConnections()
|
|
3522
|
+
});
|
|
3523
|
+
if (options.readyFile)
|
|
3524
|
+
await atomicWrite(options.readyFile, manifest);
|
|
3525
|
+
};
|
|
3526
|
+
let stopped = false;
|
|
3527
|
+
const controlling = /* @__PURE__ */ new Map();
|
|
3528
|
+
const recovery = /* @__PURE__ */ new Map();
|
|
3529
|
+
let closing;
|
|
3530
|
+
const close = () => {
|
|
3531
|
+
if (closing)
|
|
3532
|
+
return closing;
|
|
3533
|
+
closing = (async () => {
|
|
3534
|
+
management?.stopPolicies();
|
|
3535
|
+
let gateFailure;
|
|
3536
|
+
if (options.beforeClose && management && ["ready", "failed"].includes(management.lifecycle)) {
|
|
3537
|
+
management.lifecycle = "draining";
|
|
3538
|
+
const signal = AbortSignal.timeout(options.shutdownTimeoutMs ?? 3e4);
|
|
3539
|
+
try {
|
|
3540
|
+
await Promise.race([
|
|
3541
|
+
options.beforeClose({
|
|
3542
|
+
manifest,
|
|
3543
|
+
signal,
|
|
3544
|
+
capture: (namespaces) => management.capture(namespaces)
|
|
3545
|
+
}),
|
|
3546
|
+
new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("pre-shutdown hook timed out")), { once: true }))
|
|
3547
|
+
]);
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
management.lifecycle = "failed";
|
|
3550
|
+
if (options.shutdownFailure !== "close") {
|
|
3551
|
+
closing = void 0;
|
|
3552
|
+
throw error;
|
|
3553
|
+
}
|
|
3554
|
+
gateFailure = error;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
stopped = true;
|
|
3558
|
+
if (management)
|
|
3559
|
+
management.lifecycle = "stopped";
|
|
3560
|
+
const entries = [...children];
|
|
3561
|
+
const results = [];
|
|
3562
|
+
for (const [, child] of [...entries].reverse()) {
|
|
3563
|
+
try {
|
|
3564
|
+
await child.close();
|
|
3565
|
+
results.unshift({ status: "fulfilled", value: void 0 });
|
|
3566
|
+
} catch (reason) {
|
|
3567
|
+
results.unshift({ status: "rejected", reason });
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
const cleanup = await Promise.allSettled([
|
|
3571
|
+
supervisor?.close(),
|
|
3572
|
+
removeOwned(options.readyFile, id),
|
|
3573
|
+
removeOwned(options.connectionsFile, id)
|
|
3574
|
+
]);
|
|
3575
|
+
const failures = results.flatMap((r, i) => r.status === "rejected" ? [{ service: entries[i]?.[0], cause: r.reason }] : []);
|
|
3576
|
+
if (gateFailure)
|
|
3577
|
+
failures.push({ service: "pre-shutdown", cause: gateFailure });
|
|
3578
|
+
failures.push(...cleanup.flatMap((r) => r.status === "rejected" ? [{ service: "controller", cause: r.reason }] : []));
|
|
3579
|
+
if (failures.length)
|
|
3580
|
+
throw new AggregateError(failures.map((f) => f.cause), `fleet shutdown failed: ${failures.map((f) => f.service).join(", ")}`);
|
|
3581
|
+
})();
|
|
3582
|
+
return closing;
|
|
270
3583
|
};
|
|
271
3584
|
if (options.readyFile)
|
|
272
3585
|
await rm(options.readyFile, { force: true });
|
|
@@ -276,6 +3589,12 @@ async function startFleet(config, options) {
|
|
|
276
3589
|
let value = clocks.get(namespace);
|
|
277
3590
|
if (!value) {
|
|
278
3591
|
value = createClock();
|
|
3592
|
+
const plan = normalizeBootControls(config.adminControls);
|
|
3593
|
+
const boot = { ...plan.shared.clock, ...plan.namespaces[namespace]?.clock };
|
|
3594
|
+
if (boot.frozen)
|
|
3595
|
+
value.freeze();
|
|
3596
|
+
if (boot.now !== void 0)
|
|
3597
|
+
value.set(boot.now);
|
|
279
3598
|
clocks.set(namespace, value);
|
|
280
3599
|
}
|
|
281
3600
|
return value;
|
|
@@ -293,19 +3612,47 @@ async function startFleet(config, options) {
|
|
|
293
3612
|
}
|
|
294
3613
|
return selected;
|
|
295
3614
|
};
|
|
296
|
-
const diagnostics = (namespace) => Object.fromEntries([...
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
3615
|
+
const diagnostics = (namespace, selected = children) => Object.fromEntries([...selected].map(([name, child]) => [name, child.diagnostics(namespace)]));
|
|
3616
|
+
const missingNamespace = (namespace, selected) => {
|
|
3617
|
+
const missing = [...selected].filter(([, c]) => c.protocol !== "http" && !Object.hasOwn(c.namespaces.endpoints ?? {}, namespace)).map(([name]) => name);
|
|
3618
|
+
return missing.length ? json(404, { error: "namespace is not configured", namespace, services: missing }) : void 0;
|
|
3619
|
+
};
|
|
3620
|
+
const transaction = async (namespace, selected, run, recover = false) => {
|
|
3621
|
+
const unavailable = missingNamespace(namespace, selected);
|
|
3622
|
+
if (unavailable)
|
|
3623
|
+
return unavailable;
|
|
3624
|
+
if (management?.topologyBusy(namespace))
|
|
3625
|
+
return json(409, { error: "namespace topology operation in progress" });
|
|
3626
|
+
if (controlling.has(namespace))
|
|
3627
|
+
return json(409, {
|
|
3628
|
+
error: "another fleet operation is in progress",
|
|
3629
|
+
operationId: controlling.get(namespace)
|
|
3630
|
+
});
|
|
3631
|
+
if (recovery.has(namespace) && !recover)
|
|
3632
|
+
return json(409, {
|
|
3633
|
+
error: "namespace requires explicit reset or restore recovery",
|
|
3634
|
+
namespace
|
|
3635
|
+
});
|
|
3636
|
+
const held = recovery.get(namespace);
|
|
3637
|
+
if (held) {
|
|
3638
|
+
for (const unlock of held.reverse())
|
|
3639
|
+
unlock();
|
|
3640
|
+
recovery.delete(namespace);
|
|
3641
|
+
}
|
|
3642
|
+
controlling.set(namespace, management?.operationId(namespace) ?? randomUUID4());
|
|
301
3643
|
const release = [];
|
|
3644
|
+
let keepFence = false;
|
|
302
3645
|
const before = /* @__PURE__ */ new Map();
|
|
303
3646
|
const results = {};
|
|
304
3647
|
let failure;
|
|
305
3648
|
try {
|
|
3649
|
+
if (![...selected.keys()].every((name) => management?.environmentFence(namespace, name)))
|
|
3650
|
+
await management?.hook("prepare", namespace, selected);
|
|
3651
|
+
management?.phase(namespace, "checkpointing");
|
|
306
3652
|
for (const [name, child] of selected) {
|
|
307
3653
|
failure = name;
|
|
308
|
-
|
|
3654
|
+
if (!management?.environmentFence(namespace, name))
|
|
3655
|
+
release.push(child.lock(namespace));
|
|
309
3656
|
}
|
|
310
3657
|
const busy = Object.fromEntries([...selected].filter(([, child]) => {
|
|
311
3658
|
const state = child.diagnostics(namespace);
|
|
@@ -315,24 +3662,54 @@ async function startFleet(config, options) {
|
|
|
315
3662
|
return json(409, { error: "namespace is not quiescent", services: busy });
|
|
316
3663
|
for (const [name, child] of selected) {
|
|
317
3664
|
failure = name;
|
|
3665
|
+
management?.phase(namespace, "checkpointing", before.size, selected.size, name);
|
|
318
3666
|
before.set(name, await child.checkpoint(namespace));
|
|
319
3667
|
}
|
|
3668
|
+
management?.checkCancelled(namespace);
|
|
3669
|
+
management?.phase(namespace, "committing");
|
|
320
3670
|
for (const [name, child] of selected) {
|
|
321
3671
|
failure = name;
|
|
3672
|
+
management?.phase(namespace, "committing", Object.keys(results).length, selected.size, name);
|
|
322
3673
|
results[name] = { status: "ok", result: await run(name, child) };
|
|
3674
|
+
management?.phase(namespace, "committing", Object.keys(results).length, selected.size);
|
|
3675
|
+
}
|
|
3676
|
+
await management?.hook("verify", namespace, selected);
|
|
3677
|
+
try {
|
|
3678
|
+
await management?.hook("resume", namespace, selected);
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
keepFence = true;
|
|
3681
|
+
throw error;
|
|
323
3682
|
}
|
|
324
3683
|
return json(200, { status: "ok", namespace, services: results });
|
|
325
|
-
} catch {
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
if (error.code === "MOCKINGBIRD_WORKLOAD_UNCERTAIN")
|
|
3686
|
+
keepFence = true;
|
|
326
3687
|
const rollback = {};
|
|
327
3688
|
for (const [name, point] of before) {
|
|
328
3689
|
try {
|
|
3690
|
+
management?.phase(namespace, "rolling-back", void 0, void 0, name);
|
|
329
3691
|
await selected.get(name)?.restore(namespace, point);
|
|
330
3692
|
rollback[name] = "restored";
|
|
331
3693
|
} catch {
|
|
332
3694
|
rollback[name] = "failed";
|
|
3695
|
+
management?.phase(namespace, "rollback-failed", void 0, void 0, name);
|
|
333
3696
|
}
|
|
334
3697
|
}
|
|
3698
|
+
keepFence = keepFence || Object.values(rollback).includes("failed") || held !== void 0;
|
|
3699
|
+
if (!keepFence)
|
|
3700
|
+
try {
|
|
3701
|
+
await management?.hook("resume", namespace, selected);
|
|
3702
|
+
} catch {
|
|
3703
|
+
keepFence = true;
|
|
3704
|
+
}
|
|
3705
|
+
if (keepFence && release.length === 0)
|
|
3706
|
+
for (const child of selected.values())
|
|
3707
|
+
try {
|
|
3708
|
+
release.push(child.lock(namespace));
|
|
3709
|
+
} catch {
|
|
3710
|
+
}
|
|
335
3711
|
return json(500, {
|
|
3712
|
+
error: error instanceof Error ? error.message : "fleet operation failed",
|
|
336
3713
|
status: "failed",
|
|
337
3714
|
namespace,
|
|
338
3715
|
failedService: failure,
|
|
@@ -341,12 +3718,15 @@ async function startFleet(config, options) {
|
|
|
341
3718
|
rolledBack: before.size > 0 && Object.values(rollback).every((value) => value === "restored")
|
|
342
3719
|
});
|
|
343
3720
|
} finally {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
3721
|
+
if (keepFence)
|
|
3722
|
+
recovery.set(namespace, release);
|
|
3723
|
+
else
|
|
3724
|
+
for (const unlock of release.reverse())
|
|
3725
|
+
unlock();
|
|
3726
|
+
controlling.delete(namespace);
|
|
347
3727
|
}
|
|
348
3728
|
};
|
|
349
|
-
const
|
|
3729
|
+
const coreApi = {
|
|
350
3730
|
async fetch(request) {
|
|
351
3731
|
const url = new URL(request.url);
|
|
352
3732
|
const adminPrefix = resolveAdminPrefix(config.adminPrefix);
|
|
@@ -377,13 +3757,20 @@ async function startFleet(config, options) {
|
|
|
377
3757
|
}
|
|
378
3758
|
if (!path.startsWith("/fleet/"))
|
|
379
3759
|
return json(404, { error: "not found" });
|
|
380
|
-
const adminKey = config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
381
|
-
if (adminKey !== void 0 && request.headers.get("x-mockingbird-admin-key") !== adminKey)
|
|
382
|
-
return json(401, { error: "admin key required" });
|
|
383
3760
|
const route = /^\/fleet\/namespaces\/([^/]+)\/(reset|snapshots)(?:\/([^/]+)\/restore)?$/.exec(path);
|
|
384
|
-
|
|
3761
|
+
let namespace;
|
|
3762
|
+
try {
|
|
3763
|
+
namespace = route ? decodeURIComponent(route[1]) : url.searchParams.get("namespace") ?? request.headers.get("x-mockingbird-namespace") ?? config.namespace ?? "default";
|
|
3764
|
+
} catch {
|
|
3765
|
+
return json(400, { error: "invalid namespace encoding" });
|
|
3766
|
+
}
|
|
385
3767
|
if (!NS.test(namespace))
|
|
386
3768
|
return json(400, { error: "invalid namespace" });
|
|
3769
|
+
if (request.method === "GET") {
|
|
3770
|
+
const unavailable2 = missingNamespace(namespace, children);
|
|
3771
|
+
if (unavailable2)
|
|
3772
|
+
return unavailable2;
|
|
3773
|
+
}
|
|
387
3774
|
if (request.method === "GET" && path === "/fleet/metrics")
|
|
388
3775
|
return json(200, { namespace, services: diagnostics(namespace) });
|
|
389
3776
|
if (request.method === "GET" && path === "/fleet/clock")
|
|
@@ -410,13 +3797,27 @@ async function startFleet(config, options) {
|
|
|
410
3797
|
} catch (error) {
|
|
411
3798
|
return json(400, { error: error instanceof Error ? error.message : "bad selection" });
|
|
412
3799
|
}
|
|
3800
|
+
const allowed = route?.[3] ? [] : path === "/fleet/clock" ? ["services", "set", "advance", "freeze"] : route?.[2] === "snapshots" ? ["services", "label"] : ["services"];
|
|
3801
|
+
if (Object.keys(body).some((key) => !allowed.includes(key)))
|
|
3802
|
+
return json(400, { error: "unknown control field" });
|
|
3803
|
+
if (body.label !== void 0 && (typeof body.label !== "string" || body.label.length > 256))
|
|
3804
|
+
return json(400, { error: "label must be a string of at most 256 characters" });
|
|
3805
|
+
const unavailable = missingNamespace(namespace, selected);
|
|
3806
|
+
if (unavailable)
|
|
3807
|
+
return unavailable;
|
|
413
3808
|
if (path === "/fleet/wait-until-idle") {
|
|
414
|
-
const services = diagnostics(namespace);
|
|
3809
|
+
const services = diagnostics(namespace, selected);
|
|
415
3810
|
const idle = Object.values(services).every((d) => pending(d) === 0);
|
|
416
3811
|
return json(idle ? 200 : 409, { status: idle ? "idle" : "busy", namespace, services });
|
|
417
3812
|
}
|
|
418
3813
|
if (path === "/fleet/clock") {
|
|
419
3814
|
const current = clock(namespace);
|
|
3815
|
+
if (body.set === void 0 && body.advance === void 0 && body.freeze === void 0)
|
|
3816
|
+
return json(200, {
|
|
3817
|
+
namespace,
|
|
3818
|
+
...current.state(),
|
|
3819
|
+
services: Object.fromEntries([...selected].map(([name, child]) => [name, child.clock(namespace).state()]))
|
|
3820
|
+
});
|
|
420
3821
|
let target = current.now();
|
|
421
3822
|
if (body.set !== void 0) {
|
|
422
3823
|
target = typeof body.set === "number" ? body.set : typeof body.set === "string" ? Date.parse(body.set) : NaN;
|
|
@@ -434,19 +3835,15 @@ async function startFleet(config, options) {
|
|
|
434
3835
|
return json(400, { error: "freeze must be boolean" });
|
|
435
3836
|
const response2 = await transaction(namespace, selected, async (_name, child) => {
|
|
436
3837
|
const childClock = child.clock(namespace);
|
|
437
|
-
childClock
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
3838
|
+
restoreClock(childClock, {
|
|
3839
|
+
now: body.set !== void 0 ? target : childClock.now() + (body.advance !== void 0 ? parseDuration(body.advance) ?? 0 : 0),
|
|
3840
|
+
frozen: body.freeze === void 0 ? childClock.state().frozen : frozen
|
|
3841
|
+
});
|
|
441
3842
|
await child.settle?.(namespace);
|
|
442
3843
|
return childClock.state();
|
|
443
3844
|
});
|
|
444
|
-
if (response2.ok)
|
|
445
|
-
current
|
|
446
|
-
current.set(target);
|
|
447
|
-
if (!frozen)
|
|
448
|
-
current.unfreeze();
|
|
449
|
-
}
|
|
3845
|
+
if (response2.ok)
|
|
3846
|
+
restoreClock(current, { now: target, frozen });
|
|
450
3847
|
return response2;
|
|
451
3848
|
}
|
|
452
3849
|
if (!route)
|
|
@@ -454,9 +3851,11 @@ async function startFleet(config, options) {
|
|
|
454
3851
|
if (route[2] === "reset") {
|
|
455
3852
|
if (route[3])
|
|
456
3853
|
return json(404, { error: "not found" });
|
|
457
|
-
const response2 = await transaction(namespace, selected, async (_name, child) => child.reset(namespace));
|
|
458
|
-
if (response2.ok && selected.size === children.size)
|
|
459
|
-
|
|
3854
|
+
const response2 = await transaction(namespace, selected, async (_name, child) => child.reset(namespace), true);
|
|
3855
|
+
if (response2.ok && selected.size === children.size) {
|
|
3856
|
+
clocks.delete(namespace);
|
|
3857
|
+
clock(namespace);
|
|
3858
|
+
}
|
|
460
3859
|
return response2;
|
|
461
3860
|
}
|
|
462
3861
|
if (route[3]) {
|
|
@@ -464,17 +3863,12 @@ async function startFleet(config, options) {
|
|
|
464
3863
|
if (!point || point.namespace !== namespace)
|
|
465
3864
|
return json(404, { error: "no snapshot in this namespace" });
|
|
466
3865
|
const restored = new Map([...point.values].map(([name]) => [name, children.get(name)]));
|
|
467
|
-
const response2 = await transaction(namespace, restored, async (name, child) => child.restore(namespace, point.values.get(name)));
|
|
468
|
-
if (response2.ok)
|
|
469
|
-
|
|
470
|
-
current.freeze();
|
|
471
|
-
current.set(point.clock.now);
|
|
472
|
-
if (!point.clock.frozen)
|
|
473
|
-
current.unfreeze();
|
|
474
|
-
}
|
|
3866
|
+
const response2 = await transaction(namespace, restored, async (name, child) => child.restore(namespace, point.values.get(name)), true);
|
|
3867
|
+
if (response2.ok)
|
|
3868
|
+
restoreClock(clock(namespace), point.clock);
|
|
475
3869
|
return response2;
|
|
476
3870
|
}
|
|
477
|
-
const snapshotId =
|
|
3871
|
+
const snapshotId = randomUUID4();
|
|
478
3872
|
const values = /* @__PURE__ */ new Map();
|
|
479
3873
|
const response = await transaction(namespace, selected, async (name, child) => {
|
|
480
3874
|
values.set(name, await child.checkpoint(namespace));
|
|
@@ -492,34 +3886,67 @@ async function startFleet(config, options) {
|
|
|
492
3886
|
};
|
|
493
3887
|
try {
|
|
494
3888
|
const adminPrefix = resolveAdminPrefix(config.adminPrefix);
|
|
495
|
-
if (!isObject(config.services) ||
|
|
3889
|
+
if (!isObject(config.services) || targets.size === 0)
|
|
496
3890
|
throw new Error("config must contain at least one service");
|
|
497
|
-
for (const [name,
|
|
3891
|
+
for (const [name, target] of targets) {
|
|
3892
|
+
const entry = config.services[name] ?? {};
|
|
498
3893
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
499
3894
|
throw new Error("invalid service name");
|
|
3895
|
+
if (config.adminControls !== void 0 || entry.adminControls !== void 0)
|
|
3896
|
+
normalizeBootControls({ ...config.adminControls, ...entry.adminControls }, `services.${name}.adminControls`);
|
|
500
3897
|
try {
|
|
501
|
-
const target = await options.load(name);
|
|
502
3898
|
if (entry.protocol && entry.protocol !== ("protocol" in target ? target.protocol : "http"))
|
|
503
3899
|
throw new Error("configured protocol does not match target");
|
|
504
3900
|
const adminKey = entry.adminKey ?? config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
505
|
-
const child = "start" in target ? await target.start(
|
|
3901
|
+
const child = "start" in target ? await target.start({
|
|
3902
|
+
...entry,
|
|
3903
|
+
...config.adminControls !== void 0 || entry.adminControls !== void 0 ? { adminControls: { ...config.adminControls, ...entry.adminControls } } : {}
|
|
3904
|
+
}) : await httpChild(target, {
|
|
506
3905
|
...entry,
|
|
507
3906
|
adminPrefix: resolveAdminPrefix(entry.adminPrefix ?? adminPrefix),
|
|
508
|
-
...adminKey !== void 0 ? { adminKey } : {}
|
|
509
|
-
|
|
3907
|
+
...adminKey !== void 0 ? { adminKey } : {},
|
|
3908
|
+
// Fleet-wide controls, with the service's own keys replacing them.
|
|
3909
|
+
...config.adminControls !== void 0 || entry.adminControls !== void 0 ? { adminControls: { ...config.adminControls, ...entry.adminControls } } : {}
|
|
3910
|
+
}, options, (namespace, request) => management?.admit(namespace, request), (namespace) => management?.generation(namespace), (namespace, request) => management?.privateRequest(namespace, request) ?? false, {
|
|
3911
|
+
begin: (namespace, request) => management?.beginRequest(name, namespace, request),
|
|
3912
|
+
finish: async (namespace, request, response, log, token) => {
|
|
3913
|
+
await management?.finishRequest(name, namespace, request, response, log, token);
|
|
3914
|
+
},
|
|
3915
|
+
delivery: (event) => management?.observeDelivery(name, event)
|
|
3916
|
+
});
|
|
3917
|
+
child.targetName = entry.target ?? name;
|
|
3918
|
+
if (target.packageVersion)
|
|
3919
|
+
child.packageVersion = target.packageVersion;
|
|
510
3920
|
children.set(name, child);
|
|
511
3921
|
if (!await child.ready())
|
|
512
3922
|
throw new Error("protocol readiness failed");
|
|
513
|
-
} catch {
|
|
514
|
-
throw new Error(`service ${name} failed to start; all started listeners were closed
|
|
3923
|
+
} catch (error) {
|
|
3924
|
+
throw new Error(`service ${name} failed to start; all started listeners were closed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
515
3925
|
}
|
|
516
3926
|
}
|
|
3927
|
+
management = new FleetManagement({
|
|
3928
|
+
id,
|
|
3929
|
+
config: { ...config, adminPrefix },
|
|
3930
|
+
options,
|
|
3931
|
+
children,
|
|
3932
|
+
checkpoints,
|
|
3933
|
+
clock,
|
|
3934
|
+
transaction,
|
|
3935
|
+
manifest: () => manifest,
|
|
3936
|
+
publish,
|
|
3937
|
+
recovery,
|
|
3938
|
+
controlling
|
|
3939
|
+
});
|
|
3940
|
+
const api = {
|
|
3941
|
+
fetch: (request) => management.dispatch(request, () => coreApi.fetch(request))
|
|
3942
|
+
};
|
|
517
3943
|
supervisor = await listen(api, {
|
|
518
3944
|
port: config.control?.port ?? 0,
|
|
519
3945
|
host: config.control?.host ?? "127.0.0.1"
|
|
520
3946
|
});
|
|
521
|
-
|
|
3947
|
+
manifest = {
|
|
522
3948
|
version: 1,
|
|
3949
|
+
capabilityVersion: 1,
|
|
523
3950
|
state: "ready",
|
|
524
3951
|
pid: process.pid,
|
|
525
3952
|
id,
|
|
@@ -530,7 +3957,7 @@ async function startFleet(config, options) {
|
|
|
530
3957
|
name,
|
|
531
3958
|
{
|
|
532
3959
|
protocol: child.protocol,
|
|
533
|
-
url: publicUrl(child.url),
|
|
3960
|
+
url: advertisedHttpBase(config.services[name] ?? {}) ?? publicUrl(child.url),
|
|
534
3961
|
healthUrl: child.healthUrl ?? `${supervisor?.url}${adminPrefix}/health`,
|
|
535
3962
|
adminUrl: child.adminUrl ?? `${supervisor?.url}${adminPrefix}/fleet`,
|
|
536
3963
|
namespaces: child.namespaces,
|
|
@@ -539,26 +3966,395 @@ async function startFleet(config, options) {
|
|
|
539
3966
|
}
|
|
540
3967
|
]))
|
|
541
3968
|
};
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
3969
|
+
await management.recoverOperations();
|
|
3970
|
+
if (options.initialize) {
|
|
3971
|
+
management.lifecycle = "initializing";
|
|
3972
|
+
const access = [];
|
|
3973
|
+
const connections = privateConnections();
|
|
3974
|
+
try {
|
|
3975
|
+
for (const [name, child] of children)
|
|
3976
|
+
if (child.initializationAccess) {
|
|
3977
|
+
const privateAccess = await child.initializationAccess();
|
|
3978
|
+
access.push(privateAccess);
|
|
3979
|
+
connections[name] = {
|
|
3980
|
+
url: privateAccess.connections.default ?? child.connection ?? child.url,
|
|
3981
|
+
namespaces: privateAccess.connections
|
|
3982
|
+
};
|
|
3983
|
+
}
|
|
3984
|
+
const signal = AbortSignal.timeout(options.initializationTimeoutMs ?? 3e4);
|
|
3985
|
+
await Promise.race([
|
|
3986
|
+
options.initialize({
|
|
3987
|
+
manifest,
|
|
3988
|
+
connections,
|
|
3989
|
+
headers: { "x-mockingbird-initializer": management.initializationToken },
|
|
3990
|
+
signal
|
|
3991
|
+
}),
|
|
3992
|
+
new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("fleet initialization timed out")), { once: true }))
|
|
3993
|
+
]);
|
|
3994
|
+
} finally {
|
|
3995
|
+
await Promise.all(access.map((entry) => entry.close()));
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
await management.startPolicies();
|
|
3999
|
+
await publish();
|
|
4000
|
+
if (manifest.state === "ready")
|
|
4001
|
+
options.onReady?.(manifest);
|
|
555
4002
|
return { manifest, children, fetch: api.fetch, close };
|
|
556
4003
|
} catch (error) {
|
|
557
|
-
|
|
4004
|
+
try {
|
|
4005
|
+
await close();
|
|
4006
|
+
} catch (cleanup) {
|
|
4007
|
+
throw new AggregateError([error, cleanup], `fleet startup and cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4008
|
+
}
|
|
558
4009
|
throw error;
|
|
559
4010
|
}
|
|
560
4011
|
}
|
|
561
4012
|
|
|
4013
|
+
// ../../adapters/node/dist/fleet-tools.js
|
|
4014
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
4015
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
4016
|
+
import { resolve as resolve2 } from "node:path";
|
|
4017
|
+
|
|
4018
|
+
// ../../adapters/node/dist/fleet-client.js
|
|
4019
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
4020
|
+
import { chmod as chmod2, readFile as readFile3, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
4021
|
+
var FleetClientError = class extends Error {
|
|
4022
|
+
status;
|
|
4023
|
+
details;
|
|
4024
|
+
constructor(status, details) {
|
|
4025
|
+
super(`fleet control HTTP ${status}: ${canonical(details)}`);
|
|
4026
|
+
this.status = status;
|
|
4027
|
+
this.details = details;
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
var FleetClient = class {
|
|
4031
|
+
base;
|
|
4032
|
+
options;
|
|
4033
|
+
constructor(base, options = {}) {
|
|
4034
|
+
this.base = base;
|
|
4035
|
+
this.options = options;
|
|
4036
|
+
}
|
|
4037
|
+
async request(path, options = {}) {
|
|
4038
|
+
const url = new URL(`${this.base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`);
|
|
4039
|
+
if (url.username || url.password)
|
|
4040
|
+
throw new Error("fleet client URL must not contain credentials");
|
|
4041
|
+
url.searchParams.set("namespace", options.namespace ?? this.options.namespace ?? "default");
|
|
4042
|
+
const response = await (this.options.fetch ?? fetch)(url, {
|
|
4043
|
+
method: options.method ?? (options.body === void 0 ? "GET" : "POST"),
|
|
4044
|
+
headers: {
|
|
4045
|
+
"content-type": "application/json",
|
|
4046
|
+
...this.options.adminKey ? { "x-mockingbird-admin-key": this.options.adminKey } : {},
|
|
4047
|
+
...options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}
|
|
4048
|
+
},
|
|
4049
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {},
|
|
4050
|
+
signal: options.signal ?? AbortSignal.timeout(this.options.timeoutMs ?? 3e4)
|
|
4051
|
+
});
|
|
4052
|
+
const data = await response.json();
|
|
4053
|
+
if (!response.ok)
|
|
4054
|
+
throw new FleetClientError(response.status, data);
|
|
4055
|
+
return data;
|
|
4056
|
+
}
|
|
4057
|
+
discover() {
|
|
4058
|
+
return this.request("");
|
|
4059
|
+
}
|
|
4060
|
+
namespaces() {
|
|
4061
|
+
return this.request("namespaces");
|
|
4062
|
+
}
|
|
4063
|
+
createNamespace(namespace, baseline) {
|
|
4064
|
+
return this.request(`namespaces/${encodeURIComponent(namespace)}`, {
|
|
4065
|
+
body: baseline ? { baseline } : {}
|
|
4066
|
+
});
|
|
4067
|
+
}
|
|
4068
|
+
deleteNamespace(namespace) {
|
|
4069
|
+
return this.request(`namespaces/${encodeURIComponent(namespace)}`, { method: "DELETE" });
|
|
4070
|
+
}
|
|
4071
|
+
reset(namespace, options = {}) {
|
|
4072
|
+
return this.request(`namespaces/${encodeURIComponent(namespace)}/reset`, {
|
|
4073
|
+
body: options.services ? { services: options.services } : {},
|
|
4074
|
+
...options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}
|
|
4075
|
+
});
|
|
4076
|
+
}
|
|
4077
|
+
snapshot(namespace, options = {}) {
|
|
4078
|
+
const { idempotencyKey, ...body } = options;
|
|
4079
|
+
return this.request(`namespaces/${encodeURIComponent(namespace)}/snapshots`, {
|
|
4080
|
+
body,
|
|
4081
|
+
...idempotencyKey ? { idempotencyKey } : {}
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
restore(namespace, id, idempotencyKey) {
|
|
4085
|
+
return this.request(`namespaces/${encodeURIComponent(namespace)}/snapshots/${encodeURIComponent(id)}/restore`, { body: {}, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
4086
|
+
}
|
|
4087
|
+
exportArchive(namespace, allNamespaces = false) {
|
|
4088
|
+
return this.request("archives/export", {
|
|
4089
|
+
body: { allNamespaces },
|
|
4090
|
+
...namespace ? { namespace } : {}
|
|
4091
|
+
});
|
|
4092
|
+
}
|
|
4093
|
+
exportProtectedArchive(namespace, allNamespaces = false) {
|
|
4094
|
+
return this.request("archives/export", {
|
|
4095
|
+
body: { allNamespaces },
|
|
4096
|
+
...namespace ? { namespace } : {}
|
|
4097
|
+
});
|
|
4098
|
+
}
|
|
4099
|
+
importArchive(archive, namespace) {
|
|
4100
|
+
if (archive.format !== "mockingbird.jwe/v1")
|
|
4101
|
+
validateArchive(archive);
|
|
4102
|
+
return this.request("archives/import", {
|
|
4103
|
+
body: { archive, ...namespace ? { namespace } : {} }
|
|
4104
|
+
});
|
|
4105
|
+
}
|
|
4106
|
+
operation(id) {
|
|
4107
|
+
return this.request(`operations/${encodeURIComponent(id)}`);
|
|
4108
|
+
}
|
|
4109
|
+
};
|
|
4110
|
+
function renderConnections(manifest, privateManifest, namespace, mapping) {
|
|
4111
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
|
|
4112
|
+
throw new Error("invalid namespace");
|
|
4113
|
+
const result = {};
|
|
4114
|
+
for (const [variable, entry] of Object.entries(mapping)) {
|
|
4115
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable))
|
|
4116
|
+
throw new Error(`invalid variable name ${variable}`);
|
|
4117
|
+
const child = manifest.services[entry.service], secret = privateManifest.services[entry.service];
|
|
4118
|
+
if (!child || !secret)
|
|
4119
|
+
throw new Error(`missing service mapping ${entry.service}`);
|
|
4120
|
+
if (child.protocol === "http") {
|
|
4121
|
+
const template = child.namespaces.path;
|
|
4122
|
+
if (typeof template !== "string")
|
|
4123
|
+
throw new Error(`missing HTTP namespace mapping ${entry.service}`);
|
|
4124
|
+
result[variable] = new URL(template.replace("{name}", encodeURIComponent(namespace)), secret.url).href;
|
|
4125
|
+
} else {
|
|
4126
|
+
const endpoint = secret.namespaces?.[namespace];
|
|
4127
|
+
if (!endpoint)
|
|
4128
|
+
throw new Error(`missing namespace mapping ${namespace}/${entry.service}`);
|
|
4129
|
+
result[variable] = endpoint;
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
return result;
|
|
4133
|
+
}
|
|
4134
|
+
async function writeConnections(path, values, format = "json") {
|
|
4135
|
+
const temp = `${path}.${randomUUID5()}.tmp`;
|
|
4136
|
+
const text2 = format === "json" ? `${JSON.stringify(values, null, 2)}
|
|
4137
|
+
` : `${Object.entries(values).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join("\n")}
|
|
4138
|
+
`;
|
|
4139
|
+
try {
|
|
4140
|
+
await writeFile2(temp, text2, { mode: 384, flag: "wx" });
|
|
4141
|
+
await chmod2(temp, 384);
|
|
4142
|
+
await rename2(temp, path);
|
|
4143
|
+
} finally {
|
|
4144
|
+
await rm2(temp, { force: true });
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
// ../../adapters/node/dist/fleet-tools.js
|
|
4149
|
+
async function doctorFleet(config, options, probes = []) {
|
|
4150
|
+
const findings = [];
|
|
4151
|
+
const plan = [];
|
|
4152
|
+
try {
|
|
4153
|
+
const targets = await preflightFleet(config, options);
|
|
4154
|
+
const require2 = createRequire2(resolve2(process.cwd(), "package.json"));
|
|
4155
|
+
for (const [service, target] of targets) {
|
|
4156
|
+
let version = "development";
|
|
4157
|
+
try {
|
|
4158
|
+
version = require2(`@crvouga/mockingbird-service-${service}/package.json`).version;
|
|
4159
|
+
} catch {
|
|
4160
|
+
}
|
|
4161
|
+
plan.push({
|
|
4162
|
+
service,
|
|
4163
|
+
protocol: "protocol" in target ? target.protocol : "http",
|
|
4164
|
+
port: config.services[service]?.port ?? target.defaultPort,
|
|
4165
|
+
version
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
findings.push({
|
|
4169
|
+
check: "configuration",
|
|
4170
|
+
ok: true,
|
|
4171
|
+
message: "all targets and control documents validated; no listeners opened"
|
|
4172
|
+
});
|
|
4173
|
+
} catch (error) {
|
|
4174
|
+
findings.push({
|
|
4175
|
+
check: "configuration",
|
|
4176
|
+
ok: false,
|
|
4177
|
+
message: error instanceof Error ? error.message : "configuration failed"
|
|
4178
|
+
});
|
|
4179
|
+
}
|
|
4180
|
+
for (const probe of probes) {
|
|
4181
|
+
try {
|
|
4182
|
+
const url = new URL(probe.url);
|
|
4183
|
+
if (url.username || url.password)
|
|
4184
|
+
throw new Error("probe URLs must not contain credentials");
|
|
4185
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(probe.timeoutMs ?? 2e3) });
|
|
4186
|
+
await response.body?.cancel();
|
|
4187
|
+
findings.push({
|
|
4188
|
+
check: "reachability",
|
|
4189
|
+
ok: response.ok,
|
|
4190
|
+
message: `endpoint returned HTTP ${response.status}`
|
|
4191
|
+
});
|
|
4192
|
+
} catch {
|
|
4193
|
+
findings.push({
|
|
4194
|
+
check: "reachability",
|
|
4195
|
+
ok: false,
|
|
4196
|
+
message: "endpoint unreachable; verify the listener host, port and readiness"
|
|
4197
|
+
});
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
return {
|
|
4201
|
+
version: 1,
|
|
4202
|
+
ok: findings.every((f) => f.ok),
|
|
4203
|
+
findings,
|
|
4204
|
+
plan,
|
|
4205
|
+
storageWritesPerformed: false
|
|
4206
|
+
};
|
|
4207
|
+
}
|
|
4208
|
+
async function rehearseFleet(config, options, input, probes = [], timeoutMs = 3e4) {
|
|
4209
|
+
const archive = validateArchive(input);
|
|
4210
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4211
|
+
const isolated = {
|
|
4212
|
+
...config,
|
|
4213
|
+
control: { host: "127.0.0.1", port: 0 },
|
|
4214
|
+
services: Object.fromEntries(Object.entries(config.services).map(([name, entry]) => [
|
|
4215
|
+
name,
|
|
4216
|
+
{ ...entry, host: "127.0.0.1", port: 0 }
|
|
4217
|
+
]))
|
|
4218
|
+
};
|
|
4219
|
+
let fleet;
|
|
4220
|
+
try {
|
|
4221
|
+
fleet = await startFleet(isolated, { load: options.load });
|
|
4222
|
+
const client = new FleetClient(fleet.manifest.adminBase, {
|
|
4223
|
+
...config.adminKey ? { adminKey: config.adminKey } : {}
|
|
4224
|
+
});
|
|
4225
|
+
await client.importArchive(archive);
|
|
4226
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
4227
|
+
for (const probe of probes)
|
|
4228
|
+
await Promise.race([
|
|
4229
|
+
probe(fleet, signal),
|
|
4230
|
+
new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("rehearsal probe timed out")), {
|
|
4231
|
+
once: true
|
|
4232
|
+
}))
|
|
4233
|
+
]);
|
|
4234
|
+
return {
|
|
4235
|
+
version: 1,
|
|
4236
|
+
ok: true,
|
|
4237
|
+
startedAt,
|
|
4238
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4239
|
+
sourceArchive: archive.sha256,
|
|
4240
|
+
rehearsalProcess: fleet.manifest.id,
|
|
4241
|
+
probes: probes.length
|
|
4242
|
+
};
|
|
4243
|
+
} finally {
|
|
4244
|
+
await fleet?.close();
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
function fleetCommands(load) {
|
|
4248
|
+
const parse = async (path) => {
|
|
4249
|
+
if (typeof path !== "string")
|
|
4250
|
+
throw new Error("file path required");
|
|
4251
|
+
return JSON.parse(await readFile4(path, "utf8"));
|
|
4252
|
+
};
|
|
4253
|
+
return {
|
|
4254
|
+
"fleet control": {
|
|
4255
|
+
summary: "Call authenticated fleet controls using discovered adminBase",
|
|
4256
|
+
options: {
|
|
4257
|
+
base: { type: "string", description: "Discovered adminBase URL" },
|
|
4258
|
+
action: { type: "string", description: "Relative control route" },
|
|
4259
|
+
method: { type: "string", description: "HTTP method" },
|
|
4260
|
+
body: { type: "string", description: "JSON request file" },
|
|
4261
|
+
namespace: { type: "string", description: "Selected namespace" },
|
|
4262
|
+
"key-env": {
|
|
4263
|
+
type: "string",
|
|
4264
|
+
description: "Admin-key environment variable name",
|
|
4265
|
+
default: "MOCKINGBIRD_ADMIN_KEY"
|
|
4266
|
+
},
|
|
4267
|
+
"idempotency-key": { type: "string", description: "Retry identity" }
|
|
4268
|
+
},
|
|
4269
|
+
async run(values) {
|
|
4270
|
+
try {
|
|
4271
|
+
if (typeof values.base !== "string" || typeof values.action !== "string")
|
|
4272
|
+
throw new Error("--base and --action required");
|
|
4273
|
+
const key = process.env[String(values["key-env"] ?? "MOCKINGBIRD_ADMIN_KEY")];
|
|
4274
|
+
const client = new FleetClient(values.base, {
|
|
4275
|
+
...key ? { adminKey: key } : {},
|
|
4276
|
+
...typeof values.namespace === "string" ? { namespace: values.namespace } : {}
|
|
4277
|
+
});
|
|
4278
|
+
console.log(JSON.stringify(await client.request(values.action, {
|
|
4279
|
+
...typeof values.method === "string" ? { method: values.method } : {},
|
|
4280
|
+
...values.body ? { body: await parse(values.body) } : {},
|
|
4281
|
+
...typeof values["idempotency-key"] === "string" ? { idempotencyKey: values["idempotency-key"] } : {}
|
|
4282
|
+
})));
|
|
4283
|
+
return 0;
|
|
4284
|
+
} catch (error) {
|
|
4285
|
+
console.error(error instanceof Error ? error.message : "fleet control failed");
|
|
4286
|
+
return 1;
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
},
|
|
4290
|
+
"fleet doctor": {
|
|
4291
|
+
summary: "Validate fleet configuration without starting or resetting services",
|
|
4292
|
+
options: {
|
|
4293
|
+
config: { type: "string", description: "Fleet configuration file" },
|
|
4294
|
+
json: { type: "boolean", description: "Machine-readable report" },
|
|
4295
|
+
endpoint: { type: "string", description: "Optional read-only reachability probe" },
|
|
4296
|
+
"ready-file": { type: "string", description: "Validate readiness destination" },
|
|
4297
|
+
"connections-file": { type: "string", description: "Validate private destination" }
|
|
4298
|
+
},
|
|
4299
|
+
async run(values) {
|
|
4300
|
+
try {
|
|
4301
|
+
const report = await doctorFleet(await parse(values.config), {
|
|
4302
|
+
load,
|
|
4303
|
+
...typeof values["ready-file"] === "string" ? { readyFile: values["ready-file"] } : {},
|
|
4304
|
+
...typeof values["connections-file"] === "string" ? { connectionsFile: values["connections-file"] } : {}
|
|
4305
|
+
}, typeof values.endpoint === "string" ? [{ url: values.endpoint }] : []);
|
|
4306
|
+
console.log(values.json ? JSON.stringify(report) : report.findings.map((f) => `${f.ok ? "PASS" : "FAIL"} ${f.check}: ${f.message}`).join("\n"));
|
|
4307
|
+
return report.ok ? 0 : 1;
|
|
4308
|
+
} catch (error) {
|
|
4309
|
+
console.error(error instanceof Error ? error.message : "doctor failed");
|
|
4310
|
+
return 1;
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
},
|
|
4314
|
+
"fleet render": {
|
|
4315
|
+
summary: "Render explicit application variables from private namespace discovery",
|
|
4316
|
+
options: {
|
|
4317
|
+
manifest: { type: "string", description: "Public ready manifest file" },
|
|
4318
|
+
connections: { type: "string", description: "Private connection manifest file" },
|
|
4319
|
+
mapping: { type: "string", description: "JSON variable-to-service mapping" },
|
|
4320
|
+
namespace: { type: "string", description: "Explicit namespace" },
|
|
4321
|
+
output: { type: "string", description: "Private destination file" },
|
|
4322
|
+
format: { type: "string", description: "json or dotenv", default: "json" }
|
|
4323
|
+
},
|
|
4324
|
+
async run(values) {
|
|
4325
|
+
try {
|
|
4326
|
+
if (typeof values.namespace !== "string" || typeof values.output !== "string" || !["json", "dotenv"].includes(String(values.format)))
|
|
4327
|
+
throw new Error("--namespace, --output and valid --format required");
|
|
4328
|
+
const manifest = await parse(values.manifest), connections = await parse(values.connections);
|
|
4329
|
+
const output = renderConnections(manifest, connections, values.namespace, await parse(values.mapping));
|
|
4330
|
+
await writeConnections(values.output, output, values.format);
|
|
4331
|
+
console.log(JSON.stringify({ written: values.output, variables: Object.keys(output) }));
|
|
4332
|
+
return 0;
|
|
4333
|
+
} catch (error) {
|
|
4334
|
+
console.error(error instanceof Error ? error.message : "render failed");
|
|
4335
|
+
return 1;
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
},
|
|
4339
|
+
"fleet rehearse": {
|
|
4340
|
+
summary: "Restore an archive in an isolated ephemeral fleet, then close it",
|
|
4341
|
+
options: {
|
|
4342
|
+
config: { type: "string", description: "Fleet configuration file" },
|
|
4343
|
+
archive: { type: "string", description: "Saved fleet archive file" }
|
|
4344
|
+
},
|
|
4345
|
+
async run(values) {
|
|
4346
|
+
try {
|
|
4347
|
+
console.log(JSON.stringify(await rehearseFleet(await parse(values.config), { load }, await parse(values.archive))));
|
|
4348
|
+
return 0;
|
|
4349
|
+
} catch (error) {
|
|
4350
|
+
console.error(error instanceof Error ? error.message : "rehearsal failed");
|
|
4351
|
+
return 1;
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
};
|
|
4356
|
+
}
|
|
4357
|
+
|
|
562
4358
|
// ../../adapters/node/dist/cli.js
|
|
563
4359
|
var optionHelp = (options) => Object.entries(options).map(([name, option]) => {
|
|
564
4360
|
const flag = `--${name}${option.type === "string" ? ` ${option.value ?? "<value>"}` : ""}`;
|
|
@@ -584,6 +4380,12 @@ var commandHelp = (spec, name, command) => [
|
|
|
584
4380
|
...command.options ? ["", "Options:", ...optionHelp(command.options)] : []
|
|
585
4381
|
].join("\n");
|
|
586
4382
|
var runCli = async (spec, argv) => {
|
|
4383
|
+
const own = spec.commands.serve?.fleetTarget;
|
|
4384
|
+
if (own)
|
|
4385
|
+
spec = {
|
|
4386
|
+
...spec,
|
|
4387
|
+
commands: { ...fleetCommands((name2) => loadTarget(name2, own)), ...spec.commands }
|
|
4388
|
+
};
|
|
587
4389
|
const pair = argv.length >= 2 ? `${argv[0]} ${argv[1]}` : void 0;
|
|
588
4390
|
const words = pair !== void 0 && spec.commands[pair] ? 2 : 1;
|
|
589
4391
|
const name = words === 2 ? pair : argv[0];
|
|
@@ -638,7 +4440,51 @@ var COMMON_SERVE_OPTIONS = {
|
|
|
638
4440
|
value: "<key>",
|
|
639
4441
|
description: "Require x-mockingbird-admin-key on /__admin/* (env MOCKINGBIRD_ADMIN_KEY)"
|
|
640
4442
|
},
|
|
641
|
-
seed: {
|
|
4443
|
+
seed: {
|
|
4444
|
+
type: "string",
|
|
4445
|
+
value: "<seed>",
|
|
4446
|
+
description: "Seed for every random choice (env MOCKINGBIRD_SEED)"
|
|
4447
|
+
},
|
|
4448
|
+
"admin-controls": {
|
|
4449
|
+
type: "string",
|
|
4450
|
+
value: "<json|file>",
|
|
4451
|
+
description: "Admin controls to boot with, as JSON or a JSON file: clock, latency, faults, snapshot, namespaces (env MOCKINGBIRD_ADMIN_CONTROLS)"
|
|
4452
|
+
},
|
|
4453
|
+
clock: {
|
|
4454
|
+
type: "string",
|
|
4455
|
+
value: "<instant>",
|
|
4456
|
+
description: "Start the clock at this instant (epoch ms or ISO-8601)"
|
|
4457
|
+
},
|
|
4458
|
+
freeze: { type: "boolean", description: "Freeze the clock: it moves only when told to" },
|
|
4459
|
+
"auto-advance": {
|
|
4460
|
+
type: "string",
|
|
4461
|
+
value: "<duration>",
|
|
4462
|
+
description: "Step the clock this much before every request (e.g. 1s)"
|
|
4463
|
+
},
|
|
4464
|
+
latency: {
|
|
4465
|
+
type: "string",
|
|
4466
|
+
value: "<duration>",
|
|
4467
|
+
description: "Baseline response time for every request (e.g. 250ms)"
|
|
4468
|
+
},
|
|
4469
|
+
jitter: {
|
|
4470
|
+
type: "string",
|
|
4471
|
+
value: "<duration>",
|
|
4472
|
+
description: "Seeded extra latency between 0 and this"
|
|
4473
|
+
},
|
|
4474
|
+
"virtual-time": {
|
|
4475
|
+
type: "boolean",
|
|
4476
|
+
description: "Spend latency, delays and timeouts on the mock clock instead of really waiting"
|
|
4477
|
+
},
|
|
4478
|
+
fault: {
|
|
4479
|
+
type: "string",
|
|
4480
|
+
value: "<fault>",
|
|
4481
|
+
description: 'A fault to boot with, as in x-mockingbird-fault: 503, rate_limited, "status=503; delay=2s"'
|
|
4482
|
+
},
|
|
4483
|
+
snapshot: {
|
|
4484
|
+
type: "string",
|
|
4485
|
+
value: "<file>",
|
|
4486
|
+
description: "Start from a snapshot document (GET /__admin/snapshots/:id)"
|
|
4487
|
+
},
|
|
642
4488
|
log: {
|
|
643
4489
|
type: "string",
|
|
644
4490
|
value: "<pretty|json|off>",
|
|
@@ -685,6 +4531,68 @@ var formatLog = (format) => {
|
|
|
685
4531
|
};
|
|
686
4532
|
};
|
|
687
4533
|
var asString = (value) => typeof value === "string" ? value : void 0;
|
|
4534
|
+
var readJsonFile = async (path, label) => {
|
|
4535
|
+
try {
|
|
4536
|
+
return JSON.parse(await readFile5(path, "utf8"));
|
|
4537
|
+
} catch (error) {
|
|
4538
|
+
throw new Error(`${label}: cannot read ${path}: ${error instanceof Error ? error.message : error}`);
|
|
4539
|
+
}
|
|
4540
|
+
};
|
|
4541
|
+
var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4542
|
+
var resolveServeControls = async (values, env = process.env, base) => {
|
|
4543
|
+
const source = asString(values["admin-controls"]) ?? env.MOCKINGBIRD_ADMIN_CONTROLS;
|
|
4544
|
+
let document = isObject2(base) ? { ...base } : {};
|
|
4545
|
+
if (source !== void 0 && source.trim() !== "") {
|
|
4546
|
+
let parsed;
|
|
4547
|
+
if (source.trim().startsWith("{")) {
|
|
4548
|
+
try {
|
|
4549
|
+
parsed = JSON.parse(source);
|
|
4550
|
+
} catch {
|
|
4551
|
+
throw new Error("--admin-controls: not valid JSON");
|
|
4552
|
+
}
|
|
4553
|
+
} else
|
|
4554
|
+
parsed = await readJsonFile(source, "--admin-controls");
|
|
4555
|
+
if (!isObject2(parsed))
|
|
4556
|
+
throw new Error("--admin-controls: expected a JSON object");
|
|
4557
|
+
document = { ...document, ...parsed };
|
|
4558
|
+
}
|
|
4559
|
+
const clock = isObject2(document.clock) ? { ...document.clock } : {};
|
|
4560
|
+
const now = asString(values.clock);
|
|
4561
|
+
if (now !== void 0)
|
|
4562
|
+
clock.now = now;
|
|
4563
|
+
if (values.freeze === true)
|
|
4564
|
+
clock.frozen = true;
|
|
4565
|
+
const autoAdvance = asString(values["auto-advance"]);
|
|
4566
|
+
if (autoAdvance !== void 0)
|
|
4567
|
+
clock.autoAdvance = autoAdvance;
|
|
4568
|
+
if (Object.keys(clock).length > 0)
|
|
4569
|
+
document.clock = clock;
|
|
4570
|
+
const delay = asString(values.latency);
|
|
4571
|
+
const jitter = asString(values.jitter);
|
|
4572
|
+
if (delay !== void 0 || jitter !== void 0) {
|
|
4573
|
+
const latency = isObject2(document.latency) ? { ...document.latency } : document.latency !== void 0 ? { delay: document.latency } : {};
|
|
4574
|
+
if (delay !== void 0)
|
|
4575
|
+
latency.delay = delay;
|
|
4576
|
+
if (jitter !== void 0)
|
|
4577
|
+
latency.jitter = jitter;
|
|
4578
|
+
document.latency = latency;
|
|
4579
|
+
}
|
|
4580
|
+
if (values["virtual-time"] === true)
|
|
4581
|
+
document.virtualTime = true;
|
|
4582
|
+
const fault = asString(values.fault);
|
|
4583
|
+
if (fault !== void 0)
|
|
4584
|
+
document.faults = [
|
|
4585
|
+
...Array.isArray(document.faults) ? document.faults : [],
|
|
4586
|
+
parseFaultHeader(fault, "--fault")
|
|
4587
|
+
];
|
|
4588
|
+
const snapshot = asString(values.snapshot);
|
|
4589
|
+
if (snapshot !== void 0)
|
|
4590
|
+
document.snapshot = await readJsonFile(snapshot, "--snapshot");
|
|
4591
|
+
if (Object.keys(document).length === 0)
|
|
4592
|
+
return void 0;
|
|
4593
|
+
normalizeBootControls(document, "adminControls");
|
|
4594
|
+
return document;
|
|
4595
|
+
};
|
|
688
4596
|
var loadTarget = async (name, own) => {
|
|
689
4597
|
if (name === own.name)
|
|
690
4598
|
return own;
|
|
@@ -694,7 +4602,7 @@ var loadTarget = async (name, own) => {
|
|
|
694
4602
|
try {
|
|
695
4603
|
mod = await import(specifier);
|
|
696
4604
|
} catch {
|
|
697
|
-
const fromProject =
|
|
4605
|
+
const fromProject = createRequire3(resolve3(process.cwd(), "package.json"));
|
|
698
4606
|
mod = await import(pathToFileURL(fromProject.resolve(specifier)).href);
|
|
699
4607
|
}
|
|
700
4608
|
if (!mod.serveTarget)
|
|
@@ -711,7 +4619,8 @@ var start = async (target, values, config) => {
|
|
|
711
4619
|
adminPrefix,
|
|
712
4620
|
adminKey: config.adminKey,
|
|
713
4621
|
seed: config.seed,
|
|
714
|
-
onLog: formatLog(config.log)
|
|
4622
|
+
onLog: formatLog(config.log),
|
|
4623
|
+
...config.adminControls !== void 0 ? { adminControls: config.adminControls } : {}
|
|
715
4624
|
});
|
|
716
4625
|
const listening = await listen(runtime, { port: config.port, host: config.host });
|
|
717
4626
|
target.listening?.(listening);
|
|
@@ -723,18 +4632,22 @@ var start = async (target, values, config) => {
|
|
|
723
4632
|
console.log(`${target.name} ${line.replaceAll("/__admin", adminPrefix)}`);
|
|
724
4633
|
return listening;
|
|
725
4634
|
};
|
|
726
|
-
var untilSignal = async (servers) => new Promise((
|
|
4635
|
+
var untilSignal = async (servers) => new Promise((resolve4) => {
|
|
727
4636
|
const stop = async () => {
|
|
728
4637
|
;
|
|
729
4638
|
process.removeListener("SIGINT", stop);
|
|
730
4639
|
process.removeListener("SIGTERM", stop);
|
|
731
|
-
await Promise.allSettled(servers.map((s) => s.close()));
|
|
732
|
-
|
|
4640
|
+
const results = await Promise.allSettled(servers.map((s) => s.close()));
|
|
4641
|
+
for (const result of results)
|
|
4642
|
+
if (result.status === "rejected")
|
|
4643
|
+
console.error(result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
4644
|
+
resolve4(results.some((r) => r.status === "rejected") ? 1 : 0);
|
|
733
4645
|
};
|
|
734
4646
|
process.once("SIGINT", stop);
|
|
735
4647
|
process.once("SIGTERM", stop);
|
|
736
4648
|
});
|
|
737
4649
|
var serveCommand = (target) => ({
|
|
4650
|
+
fleetTarget: target,
|
|
738
4651
|
summary: `Serve the ${target.name} emulator or a configured fleet`,
|
|
739
4652
|
options: { ...COMMON_SERVE_OPTIONS, ..."options" in target ? target.options : {} },
|
|
740
4653
|
async run(values) {
|
|
@@ -746,11 +4659,14 @@ var serveCommand = (target) => ({
|
|
|
746
4659
|
const configPath = asString(values.config);
|
|
747
4660
|
if (configPath !== void 0) {
|
|
748
4661
|
try {
|
|
749
|
-
const config = JSON.parse(await
|
|
4662
|
+
const config = JSON.parse(await readFile5(configPath, "utf8"));
|
|
750
4663
|
config.adminPrefix = resolveAdminPrefix(config.adminPrefix ?? asString(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX);
|
|
751
4664
|
const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
752
4665
|
if (adminKey2 !== void 0)
|
|
753
4666
|
config.adminKey = adminKey2;
|
|
4667
|
+
const adminControls = await resolveServeControls(values, process.env, config.adminControls);
|
|
4668
|
+
if (adminControls !== void 0)
|
|
4669
|
+
config.adminControls = adminControls;
|
|
754
4670
|
const fleet = await startFleet(config, {
|
|
755
4671
|
load: (name) => loadTarget(name, target),
|
|
756
4672
|
onLog: formatLog(config.log ?? log),
|
|
@@ -776,10 +4692,12 @@ var serveCommand = (target) => ({
|
|
|
776
4692
|
}
|
|
777
4693
|
const port = asString(values.port);
|
|
778
4694
|
const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
779
|
-
const seed = asString(values.seed);
|
|
4695
|
+
const seed = asString(values.seed) ?? process.env.MOCKINGBIRD_SEED;
|
|
780
4696
|
let listening;
|
|
781
4697
|
try {
|
|
4698
|
+
const adminControls = await resolveServeControls(values);
|
|
782
4699
|
listening = await start(target, values, {
|
|
4700
|
+
...adminControls !== void 0 ? { adminControls } : {},
|
|
783
4701
|
adminPrefix: resolveAdminPrefix(asString(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX),
|
|
784
4702
|
port: port === void 0 ? target.defaultPort : Number.parseInt(port, 10),
|
|
785
4703
|
host: asString(values.host) ?? "127.0.0.1",
|
|
@@ -823,14 +4741,16 @@ var serve2 = {
|
|
|
823
4741
|
text(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX
|
|
824
4742
|
);
|
|
825
4743
|
const adminKey = text(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
826
|
-
const seed = text(values.seed);
|
|
4744
|
+
const seed = text(values.seed) ?? process.env.MOCKINGBIRD_SEED;
|
|
827
4745
|
const port = text(values.port);
|
|
828
4746
|
try {
|
|
4747
|
+
const adminControls = await resolveServeControls(values);
|
|
829
4748
|
const runtime = await serveTarget.create(values, {
|
|
830
4749
|
adminPrefix,
|
|
831
4750
|
adminKey,
|
|
832
4751
|
seed,
|
|
833
|
-
onLog: logger(format)
|
|
4752
|
+
onLog: logger(format),
|
|
4753
|
+
...adminControls !== void 0 ? { adminControls } : {}
|
|
834
4754
|
});
|
|
835
4755
|
const listening = await listenH2c(runtime, {
|
|
836
4756
|
port: port === void 0 ? serveTarget.defaultPort : Number.parseInt(port, 10),
|
|
@@ -844,10 +4764,10 @@ var serve2 = {
|
|
|
844
4764
|
console.log(`aws-speech admin ui: ${listening.url}${adminPrefix}/ui`);
|
|
845
4765
|
for (const line of serveTarget.banner?.(runtime) ?? [])
|
|
846
4766
|
console.log(`aws-speech ${line.replaceAll("/__admin", adminPrefix)}`);
|
|
847
|
-
return await new Promise((
|
|
4767
|
+
return await new Promise((resolve4) => {
|
|
848
4768
|
const stop = async () => {
|
|
849
4769
|
await listening.close();
|
|
850
|
-
|
|
4770
|
+
resolve4(0);
|
|
851
4771
|
};
|
|
852
4772
|
process.once("SIGINT", stop);
|
|
853
4773
|
process.once("SIGTERM", stop);
|