@substrat-run/control-plane-api 0.122.1 → 0.124.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/dist/api.d.ts.map +1 -1
- package/dist/api.js +201 -58
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +375 -6
- package/dist/cf-observability.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +194 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +13 -0
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +6 -3
- package/dist/platform-drain.js.map +1 -1
- package/dist/reconcile.d.ts +32 -9
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +26 -8
- package/dist/reconcile.js.map +1 -1
- package/dist/tenant-request-query.d.ts +52 -0
- package/dist/tenant-request-query.d.ts.map +1 -0
- package/dist/tenant-request-query.js +107 -0
- package/dist/tenant-request-query.js.map +1 -0
- package/dist/vertical-client.d.ts +20 -2
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +21 -6
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EA0EL,CAAC,EAQF,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAGV,UAAU,EACV,kBAAkB,EAElB,wBAAwB,EACxB,oBAAoB,EAEpB,eAAe,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/api.js
CHANGED
|
@@ -9,7 +9,7 @@ import { connectionGrantsForScope } from './vertical-client.js';
|
|
|
9
9
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
10
10
|
import { versionReachedAt } from './scope-deployment.js';
|
|
11
11
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
12
|
-
import { reconcileThenReassert } from './reconcile.js';
|
|
12
|
+
import { reconcileThenReassert, switchCarryFor } from './reconcile.js';
|
|
13
13
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
14
14
|
import { ControlPlaneError } from './client.js';
|
|
15
15
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
@@ -33,7 +33,8 @@ function problem(c, answer) {
|
|
|
33
33
|
}
|
|
34
34
|
import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
|
|
35
35
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
36
|
-
import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
|
|
36
|
+
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
|
|
37
|
+
import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow } from './tenant-request-query.js';
|
|
37
38
|
import { namespacesForScript } from './do-namespaces.js';
|
|
38
39
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
39
40
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
@@ -73,6 +74,38 @@ class ExportBreakRefused extends ControlPlaneError {
|
|
|
73
74
|
function exportBreakBody(e) {
|
|
74
75
|
return { error: e.message, ...(e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {}) };
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* A delegated facet answer, normalized — and refused when the vertical that produced it
|
|
79
|
+
* predates #1762.
|
|
80
|
+
*
|
|
81
|
+
* The facet runs inside the vertical holding the scope, on the kernel that vertical was
|
|
82
|
+
* pushed with, and a kernel from before the rule groups a payload field over personal
|
|
83
|
+
* data too — one bucket per email address. It says so by omitting `withheldPersonal`.
|
|
84
|
+
* Such a PAYLOAD grouping is not relayed: no buckets, every event that was not erased
|
|
85
|
+
* counted as withheld, and `withheldReason` saying why. An envelope grouping is a
|
|
86
|
+
* kernel-stamped column, which the old kernel answered correctly, so it passes with the
|
|
87
|
+
* count it could not have withheld anything under — 0 — filled in, as the schema
|
|
88
|
+
* requires.
|
|
89
|
+
*
|
|
90
|
+
* A numeric `withheldPersonal` is trusted as it comes. This guard is for version skew,
|
|
91
|
+
* not a defence against a dishonest vertical: a vertical is tenant code that can already
|
|
92
|
+
* read its own scope, so one that lied here would be disclosing nothing it could not
|
|
93
|
+
* show its own users some other way.
|
|
94
|
+
*/
|
|
95
|
+
function withholdIfPredatesRule(input, answer) {
|
|
96
|
+
if (input.groupBy.kind !== 'payload')
|
|
97
|
+
return { ...answer, withheldPersonal: answer.withheldPersonal ?? 0 };
|
|
98
|
+
if (typeof answer.withheldPersonal === 'number')
|
|
99
|
+
return answer;
|
|
100
|
+
return {
|
|
101
|
+
buckets: [],
|
|
102
|
+
erased: answer.erased,
|
|
103
|
+
withheldPersonal: Math.max(0, answer.total - answer.erased),
|
|
104
|
+
withheldReason: 'vertical-predates-rule',
|
|
105
|
+
total: answer.total,
|
|
106
|
+
truncated: false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
76
109
|
/**
|
|
77
110
|
* A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
|
|
78
111
|
* tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
|
|
@@ -492,6 +525,12 @@ const TENANT_ROUTES = [
|
|
|
492
525
|
{ method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
|
|
493
526
|
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
|
|
494
527
|
{ method: 'GET', re: /\/observability\/tenant-logs$/, pin: 'query' },
|
|
528
|
+
// #1746: the request record's histogram, facets and list — same grain, same forced tenant.
|
|
529
|
+
{ method: 'GET', re: /\/observability\/tenant-request-volume$/, pin: 'query' },
|
|
530
|
+
{ method: 'GET', re: /\/observability\/tenant-request-facets$/, pin: 'query' },
|
|
531
|
+
{ method: 'GET', re: /\/observability\/tenant-requests$/, pin: 'query' },
|
|
532
|
+
// #1747: the tenant's `ctx.log` lines grouped by template — same grain, same forced tenant.
|
|
533
|
+
{ method: 'GET', re: /\/observability\/tenant-log-patterns$/, pin: 'query' },
|
|
495
534
|
{ method: 'GET', re: /\/hostnames$/, pin: 'query' },
|
|
496
535
|
{ method: 'POST', re: /\/hostnames$/, pin: 'body' },
|
|
497
536
|
// The registry + install catalog.
|
|
@@ -732,6 +771,12 @@ export function createControlPlaneApi(options) {
|
|
|
732
771
|
{ method: 'GET', re: /\/observability\/tenant-metrics$/ },
|
|
733
772
|
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/ },
|
|
734
773
|
{ method: 'GET', re: /\/observability\/tenant-logs$/ },
|
|
774
|
+
// #1746: the same lines, counted and listed. Tenant-keyed, so no fleet-wide fallback.
|
|
775
|
+
{ method: 'GET', re: /\/observability\/tenant-request-volume$/ },
|
|
776
|
+
{ method: 'GET', re: /\/observability\/tenant-request-facets$/ },
|
|
777
|
+
{ method: 'GET', re: /\/observability\/tenant-requests$/ },
|
|
778
|
+
// #1747: the same tenant's `ctx.log` lines, grouped by template.
|
|
779
|
+
{ method: 'GET', re: /\/observability\/tenant-log-patterns$/ },
|
|
735
780
|
];
|
|
736
781
|
app.use('*', async (c, next) => {
|
|
737
782
|
if (c.get('principal').kind === 'builder') {
|
|
@@ -1253,10 +1298,13 @@ export function createControlPlaneApi(options) {
|
|
|
1253
1298
|
// `/connections/health` — absent from BUILDER_ROUTES and TENANT_ROUTES, so both
|
|
1254
1299
|
// credentials default-deny it.
|
|
1255
1300
|
//
|
|
1256
|
-
//
|
|
1301
|
+
// `total` is a count of terminal FAILURES, never a queue depth (see `platformRequestBacklog`'s
|
|
1257
1302
|
// doc): a still-`pending` intent lives in the vertical's own scope DO and nothing here
|
|
1258
1303
|
// walks the fleet to find it. A `0` means "nothing has given up lately", not "nothing is
|
|
1259
1304
|
// waiting" — the console tile is worded to keep that distinction, not just this comment.
|
|
1305
|
+
// What IS waiting is `pending` (#1840): the newest `platform-request` sweep row, which the
|
|
1306
|
+
// platform sweep writes once per pass from the fan-out it already pays. As fresh as that
|
|
1307
|
+
// pass, and null — never 0 — when no pass is on record.
|
|
1260
1308
|
app.get('/platform-requests/backlog', async (c) => {
|
|
1261
1309
|
const now = new Date();
|
|
1262
1310
|
const since = new Date(now.getTime() - PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS * 86_400_000).toISOString();
|
|
@@ -1274,22 +1322,31 @@ export function createControlPlaneApi(options) {
|
|
|
1274
1322
|
SWEEP_RUNS_KIND,
|
|
1275
1323
|
...providers.map(connectorDispatchKind),
|
|
1276
1324
|
];
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1325
|
+
// The drain's newest pass is independent of the failure counts, so it rides alongside them.
|
|
1326
|
+
const [counts, [lastPass]] = await Promise.all([
|
|
1327
|
+
Promise.all(kinds.map(async (kind) => {
|
|
1328
|
+
const found = await admin.listOpsFailures(c.get('actor'), {
|
|
1329
|
+
operation: `intent.${kind}`,
|
|
1330
|
+
since,
|
|
1331
|
+
limit: PLATFORM_REQUEST_BACKLOG_CAP + 1,
|
|
1332
|
+
});
|
|
1333
|
+
return {
|
|
1334
|
+
count: Math.min(found.length, PLATFORM_REQUEST_BACKLOG_CAP),
|
|
1335
|
+
capped: found.length > PLATFORM_REQUEST_BACKLOG_CAP,
|
|
1336
|
+
};
|
|
1337
|
+
})),
|
|
1338
|
+
admin.listSweepRuns(c.get('actor'), { kind: 'platform-request', unit: 'fleet', limit: 1 }),
|
|
1339
|
+
]);
|
|
1288
1340
|
const body = {
|
|
1289
1341
|
total: counts.reduce((sum, k) => sum + k.count, 0),
|
|
1290
1342
|
capped: counts.some((k) => k.capped),
|
|
1291
1343
|
since,
|
|
1292
1344
|
windowDays: PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS,
|
|
1345
|
+
// A row without totals is not a pass this route can read a count from; it answers
|
|
1346
|
+
// null like no row at all rather than inventing a zero.
|
|
1347
|
+
pending: lastPass?.platformRequests
|
|
1348
|
+
? { count: lastPass.platformRequests.pending, asOf: lastPass.at, floor: lastPass.outcome !== 'ok' }
|
|
1349
|
+
: null,
|
|
1293
1350
|
};
|
|
1294
1351
|
return c.json(body);
|
|
1295
1352
|
});
|
|
@@ -1655,6 +1712,16 @@ export function createControlPlaneApi(options) {
|
|
|
1655
1712
|
* installs short-circuit.
|
|
1656
1713
|
* Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
|
|
1657
1714
|
*/
|
|
1715
|
+
/**
|
|
1716
|
+
* #1742: restore a dump into a deployment carrying the scope's recorded-off modules, so the
|
|
1717
|
+
* deployment switches them off in the replay's own event. The record is read on EVERY
|
|
1718
|
+
* attempt, inside the retry, so a retried restore carries the record as it stands then, not
|
|
1719
|
+
* as it stood before the failed attempt. Every caller still re-asserts after it (the
|
|
1720
|
+
* fallback), passing what this reports.
|
|
1721
|
+
*/
|
|
1722
|
+
const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables) => {
|
|
1723
|
+
return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, await switchCarryFor(admin, actor, { tenantId, scopeId })));
|
|
1724
|
+
};
|
|
1658
1725
|
const adoptScopeOntoServing = async (c, tenantId, scopeId, opts = {}) => {
|
|
1659
1726
|
const actor = c.get('actor');
|
|
1660
1727
|
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
@@ -1690,7 +1757,8 @@ export function createControlPlaneApi(options) {
|
|
|
1690
1757
|
throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
|
|
1691
1758
|
}
|
|
1692
1759
|
const dump = await source.exportScope(scopeId);
|
|
1693
|
-
|
|
1760
|
+
// #1742: the recorded OFF positions ride the restore, applied in the replay's own event.
|
|
1761
|
+
const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump);
|
|
1694
1762
|
// Data landed — only now flip routing and move the version pointer.
|
|
1695
1763
|
await admin
|
|
1696
1764
|
.setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
|
|
@@ -1699,8 +1767,8 @@ export function createControlPlaneApi(options) {
|
|
|
1699
1767
|
.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, { acknowledge: opts.acknowledge })
|
|
1700
1768
|
.catch(relayHostRefusal);
|
|
1701
1769
|
// #1674: the scope now routes to a different store, so put the directory's recorded
|
|
1702
|
-
// OFF positions back there — cheap and idempotent when the dump
|
|
1703
|
-
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1770
|
+
// OFF positions back there — cheap and idempotent when the dump or the restore did.
|
|
1771
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
|
|
1704
1772
|
return { servingRef: serving.ref, tables: restored.tables };
|
|
1705
1773
|
};
|
|
1706
1774
|
/**
|
|
@@ -1834,7 +1902,7 @@ export function createControlPlaneApi(options) {
|
|
|
1834
1902
|
throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
|
|
1835
1903
|
}
|
|
1836
1904
|
const dump = await source.exportScope(scopeId);
|
|
1837
|
-
const restored = await
|
|
1905
|
+
const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump); // #1742, as adopt
|
|
1838
1906
|
// Data landed on the target script — only now flip routing and cross the pointer.
|
|
1839
1907
|
// `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
|
|
1840
1908
|
// extra snapshot here (adopt-serving's precedent): the source script's copy is the
|
|
@@ -1842,7 +1910,7 @@ export function createControlPlaneApi(options) {
|
|
|
1842
1910
|
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
|
|
1843
1911
|
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
|
|
1844
1912
|
// #1674: re-assert the recorded OFF positions in the store the scope now routes to.
|
|
1845
|
-
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1913
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
|
|
1846
1914
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
1847
1915
|
};
|
|
1848
1916
|
/**
|
|
@@ -1920,8 +1988,9 @@ export function createControlPlaneApi(options) {
|
|
|
1920
1988
|
`so the version was not bound (#1710)`);
|
|
1921
1989
|
}
|
|
1922
1990
|
const dump = await retryTransient(() => source.exportScope(scope.id));
|
|
1923
|
-
|
|
1924
|
-
|
|
1991
|
+
// #1742: the recorded OFF positions ride the restore, as on adopt and rebind.
|
|
1992
|
+
const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump);
|
|
1993
|
+
return { from, to, tables: restored.tables, switchedOff: restored.switchedOff };
|
|
1925
1994
|
};
|
|
1926
1995
|
app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
|
|
1927
1996
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
@@ -2163,13 +2232,14 @@ export function createControlPlaneApi(options) {
|
|
|
2163
2232
|
const ranAs = versionReachedAt(reached.via, scope, serving);
|
|
2164
2233
|
try {
|
|
2165
2234
|
// #1674: the recorded OFF positions go back after the reconcile, before the receipt.
|
|
2166
|
-
const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, () => vertical.reconcileInstance({
|
|
2235
|
+
const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
|
|
2167
2236
|
tenantId,
|
|
2168
2237
|
scopeId,
|
|
2169
2238
|
entitlements,
|
|
2170
2239
|
identityLinks,
|
|
2171
2240
|
connectionGrants,
|
|
2172
2241
|
connectionKeys,
|
|
2242
|
+
...carry,
|
|
2173
2243
|
// Handed over exactly as at provision: a store minted HERE has never been migrated
|
|
2174
2244
|
// by the vertical, so the reconcile must carry it into the same ready-gate — a bound
|
|
2175
2245
|
// but unmigrated database fails as loudly as an absent one.
|
|
@@ -2219,7 +2289,9 @@ export function createControlPlaneApi(options) {
|
|
|
2219
2289
|
if (!scope)
|
|
2220
2290
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
2221
2291
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
|
|
2222
|
-
|
|
2292
|
+
// #1762: only the vertical's answer needs the guard. The co-located read runs
|
|
2293
|
+
// this platform's own kernel, which withholds by construction.
|
|
2294
|
+
viaVertical: async (v) => withholdIfPredatesRule(input, await v.facetEvents(scopeId, input)),
|
|
2223
2295
|
colocated: () => admin.facetEvents(c.get('actor'), tenantId, scopeId, input),
|
|
2224
2296
|
},
|
|
2225
2297
|
// The buckets RETURNED, not `total`: the row says how much this read handed
|
|
@@ -2265,7 +2337,9 @@ export function createControlPlaneApi(options) {
|
|
|
2265
2337
|
method,
|
|
2266
2338
|
tenantId,
|
|
2267
2339
|
scopeId,
|
|
2268
|
-
|
|
2340
|
+
// The answer rides along for the one method whose row describes it (a facet's
|
|
2341
|
+
// withheld counts, #1762); every other projection ignores it.
|
|
2342
|
+
params: delegatedReadParams[method](input, answer),
|
|
2269
2343
|
resultCount: count(answer),
|
|
2270
2344
|
});
|
|
2271
2345
|
return answer;
|
|
@@ -3217,11 +3291,13 @@ export function createControlPlaneApi(options) {
|
|
|
3217
3291
|
const landing = { ...dump, tables };
|
|
3218
3292
|
await host.restoreScope(actor, tenantId, scopeId, landing);
|
|
3219
3293
|
const vertical = await verticalForScope(c, scope);
|
|
3220
|
-
|
|
3221
|
-
|
|
3294
|
+
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
3295
|
+
// off in the replay's own event — its own sweeper cannot land in between.
|
|
3296
|
+
const restored = vertical ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables) : undefined;
|
|
3222
3297
|
// #1674: a backup from before a module was switched off brings it back on; the
|
|
3223
|
-
// directory's record puts it back off now, not at the next reconcile.
|
|
3224
|
-
|
|
3298
|
+
// directory's record puts it back off now, not at the next reconcile. Where the
|
|
3299
|
+
// deployment already did (#1742), this finds it done and audits what it reported.
|
|
3300
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored?.switchedOff });
|
|
3225
3301
|
return c.json({ restored: scopeId, tables: tables.length });
|
|
3226
3302
|
}
|
|
3227
3303
|
catch (e) {
|
|
@@ -3297,6 +3373,9 @@ export function createControlPlaneApi(options) {
|
|
|
3297
3373
|
// through, makes the next sweep reconcile the scope, and that reconcile re-asserts
|
|
3298
3374
|
// what the directory records as off. Left to the sweep rather than done here: the
|
|
3299
3375
|
// scope's store restarts to finish the restore, and a re-assert now would race it.
|
|
3376
|
+
// Meanwhile, the deployment that rewound it holds those modules off (#1819). A hold is
|
|
3377
|
+
// released by that reconcile's re-assert, or earlier by a switch move known to land on the
|
|
3378
|
+
// rewound storage, such as an operator's ON.
|
|
3300
3379
|
await admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
3301
3380
|
return c.json(answer);
|
|
3302
3381
|
}
|
|
@@ -3430,15 +3509,18 @@ export function createControlPlaneApi(options) {
|
|
|
3430
3509
|
// A refusal throws a ControlPlaneError, which the app's error boundary relays with its
|
|
3431
3510
|
// status and records as an ops failure. An unknown scope carries nothing, and the bind
|
|
3432
3511
|
// below refuses it as it always has.
|
|
3512
|
+
let carried = null;
|
|
3433
3513
|
const carry = async () => {
|
|
3434
3514
|
if (scope)
|
|
3435
|
-
await carryOntoVersion(c, scope, versionId);
|
|
3515
|
+
carried = await carryOntoVersion(c, scope, versionId);
|
|
3436
3516
|
};
|
|
3437
3517
|
// #1674: after the bind, the scope may route to a different store (the carry's
|
|
3438
|
-
// destination); put the directory's recorded OFF positions back there.
|
|
3518
|
+
// destination); put the directory's recorded OFF positions back there. #1742: the carry
|
|
3519
|
+
// already did so in the restore's own event; what it reports is audited here.
|
|
3439
3520
|
const reassert = async () => {
|
|
3440
|
-
if (scope)
|
|
3441
|
-
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
3521
|
+
if (scope) {
|
|
3522
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: carried?.switchedOff });
|
|
3523
|
+
}
|
|
3442
3524
|
};
|
|
3443
3525
|
// The host's own refusal, should the impact have changed since it was read above (a
|
|
3444
3526
|
// promote landing in between): relayed as the same 409, never as a 500.
|
|
@@ -3558,6 +3640,10 @@ export function createControlPlaneApi(options) {
|
|
|
3558
3640
|
tenantId: input.tenantId,
|
|
3559
3641
|
patchBindings: options.patchScriptBindings,
|
|
3560
3642
|
});
|
|
3643
|
+
// #1742: the recorded OFF positions ride the provision into the seat's own unit. Read
|
|
3644
|
+
// from the directory whether or not the row exists yet: a brand-new install has no
|
|
3645
|
+
// record, so nothing is carried and the body is unchanged.
|
|
3646
|
+
const switches = await switchCarryFor(admin, c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
|
|
3561
3647
|
try {
|
|
3562
3648
|
// #424 case 2: the binding attach above races Cloudflare script-settings
|
|
3563
3649
|
// propagation, so the vertical's FIRST answer can be a transient 5xx that a
|
|
@@ -3570,6 +3656,7 @@ export function createControlPlaneApi(options) {
|
|
|
3570
3656
|
connectionGrants,
|
|
3571
3657
|
connectionKeys,
|
|
3572
3658
|
...(tenantStores.length ? { tenantStores } : {}),
|
|
3659
|
+
...switches,
|
|
3573
3660
|
}));
|
|
3574
3661
|
// #1172: the hook has just run, so record what it ran against — WHEN the scope is
|
|
3575
3662
|
// already bound to a version. Often it is not: this route does not bind, and the
|
|
@@ -3585,7 +3672,7 @@ export function createControlPlaneApi(options) {
|
|
|
3585
3672
|
// directory row exists: a brand-new install's is written after this call (see above),
|
|
3586
3673
|
// has no record to re-assert, and `reassertSystemSwitches` refuses an unknown scope.
|
|
3587
3674
|
if (provisioned) {
|
|
3588
|
-
await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
|
|
3675
|
+
await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: instance.switchedOff });
|
|
3589
3676
|
}
|
|
3590
3677
|
if (provisioned?.verticalVersionId) {
|
|
3591
3678
|
await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
|
|
@@ -5005,6 +5092,9 @@ export function createControlPlaneApi(options) {
|
|
|
5005
5092
|
// 0–7, so `Z000…` is not a ULID however it looks. The contracts package exports
|
|
5006
5093
|
// no generic ULID schema to reuse — each id brands its own private copy.
|
|
5007
5094
|
invocationId: z.string().regex(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/, 'invocationId must be a ULID').optional(),
|
|
5095
|
+
// #1747: one pattern's lines — a `ctx.log` template, verbatim. The line bounds a
|
|
5096
|
+
// template at 500 characters, so a longer one can match nothing and is refused.
|
|
5097
|
+
template: z.string().min(1).max(500).optional(),
|
|
5008
5098
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
5009
5099
|
// The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
|
|
5010
5100
|
// cannot spell — it always ends at now.
|
|
@@ -5012,30 +5102,8 @@ export function createControlPlaneApi(options) {
|
|
|
5012
5102
|
until: z.string().datetime({ offset: true }).optional(),
|
|
5013
5103
|
limit: z.coerce.number().int().min(1).max(200).default(100),
|
|
5014
5104
|
})
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
const to = v.until ? Date.parse(v.until) : now;
|
|
5018
|
-
const from = v.since ? Date.parse(v.since) : to - v.hours * 3_600_000;
|
|
5019
|
-
// On the resolved bounds rather than on the pair, so a lone `since` in the future
|
|
5020
|
-
// is refused by the same line: with no `until` it is measured against now, and
|
|
5021
|
-
// without this it would reach the backend as a window that cannot contain
|
|
5022
|
-
// anything and come back as an empty page nobody could explain.
|
|
5023
|
-
if (from >= to) {
|
|
5024
|
-
ctx.addIssue({ code: 'custom', path: ['since'], message: 'since must be before until' });
|
|
5025
|
-
}
|
|
5026
|
-
// The SAME ceiling `hours` carries, applied to the window however it was spelled.
|
|
5027
|
-
// Otherwise the pair is a second door onto a read the first one caps at three
|
|
5028
|
-
// days, and the two spellings together would read past what either allows.
|
|
5029
|
-
if (to - from > 72 * 3_600_000) {
|
|
5030
|
-
ctx.addIssue({ code: 'custom', path: ['since'], message: 'the window may not exceed 72 hours' });
|
|
5031
|
-
}
|
|
5032
|
-
// A window ending in the future returns nothing and says nothing about why, which
|
|
5033
|
-
// a reader takes for "my app was quiet". Five minutes of slack, because the
|
|
5034
|
-
// instant is stamped by a browser's clock rather than by ours.
|
|
5035
|
-
if (to > now + 5 * 60_000) {
|
|
5036
|
-
ctx.addIssue({ code: 'custom', path: ['until'], message: 'until may not be more than 5 minutes in the future' });
|
|
5037
|
-
}
|
|
5038
|
-
})
|
|
5105
|
+
// The window rules every tenant-grain read shares (`refineTenantWindow` says why each).
|
|
5106
|
+
.superRefine((v, ctx) => refineTenantWindow(v, ctx))
|
|
5039
5107
|
.parse({
|
|
5040
5108
|
tenantId,
|
|
5041
5109
|
scopeId: c.req.query('scopeId') || undefined,
|
|
@@ -5045,6 +5113,9 @@ export function createControlPlaneApi(options) {
|
|
|
5045
5113
|
// Raw, not `|| undefined`: an EMPTY id is a caller bug, and folding it into
|
|
5046
5114
|
// "absent" would answer it with the tenant's whole log.
|
|
5047
5115
|
invocationId: c.req.query('invocationId'),
|
|
5116
|
+
// Raw, like the invocation id: an EMPTY template is a caller bug, and folding it into
|
|
5117
|
+
// "absent" would answer it with the app's whole log instead of the schema's 400.
|
|
5118
|
+
template: c.req.query('template'),
|
|
5048
5119
|
hours: c.req.query('hours'),
|
|
5049
5120
|
since: c.req.query('since') || undefined,
|
|
5050
5121
|
until: c.req.query('until') || undefined,
|
|
@@ -5052,6 +5123,78 @@ export function createControlPlaneApi(options) {
|
|
|
5052
5123
|
});
|
|
5053
5124
|
return c.json(await options.observability.tenantLogs(input));
|
|
5054
5125
|
});
|
|
5126
|
+
// #1746: the per-request record, read at the tenant grain. The three routes take ONE
|
|
5127
|
+
// query — tenant, window, facet filters (`tenant-request-query.ts`) — so the histogram,
|
|
5128
|
+
// the facet counts and the list beside them always describe the same set of requests.
|
|
5129
|
+
// Each 501s when the reader cannot aggregate, never answers an empty set that would read
|
|
5130
|
+
// as "no traffic".
|
|
5131
|
+
const requestQueryOf = (c) => ({
|
|
5132
|
+
one: (k) => c.req.query(k),
|
|
5133
|
+
all: (k) => c.req.queries(k) ?? [],
|
|
5134
|
+
});
|
|
5135
|
+
const requestTenantOf = (c) => {
|
|
5136
|
+
const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
|
|
5137
|
+
if (!tenantId)
|
|
5138
|
+
throw new ControlPlaneError(400, 'tenantId is required');
|
|
5139
|
+
return tenantId;
|
|
5140
|
+
};
|
|
5141
|
+
app.get('/observability/tenant-request-volume', async (c) => {
|
|
5142
|
+
if (!options.observability?.tenantRequestVolume) {
|
|
5143
|
+
return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
|
|
5144
|
+
}
|
|
5145
|
+
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
5146
|
+
// A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
|
|
5147
|
+
// 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
|
|
5148
|
+
const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
|
|
5149
|
+
return c.json(await options.observability.tenantRequestVolume({ ...scope, buckets }));
|
|
5150
|
+
});
|
|
5151
|
+
app.get('/observability/tenant-request-facets', async (c) => {
|
|
5152
|
+
if (!options.observability?.tenantRequestFacets) {
|
|
5153
|
+
return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
|
|
5154
|
+
}
|
|
5155
|
+
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
5156
|
+
// `facet` repeats; absent means every facet. Each one is a backend query, so a panel
|
|
5157
|
+
// that shows three asks for three.
|
|
5158
|
+
const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
|
|
5159
|
+
const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
|
|
5160
|
+
return c.json(await options.observability.tenantRequestFacets({ ...scope, ...(keys ? { keys } : {}) }));
|
|
5161
|
+
});
|
|
5162
|
+
// #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
|
|
5163
|
+
app.get('/observability/tenant-log-patterns', async (c) => {
|
|
5164
|
+
if (!options.observability?.tenantLogPatterns) {
|
|
5165
|
+
return c.json({ error: 'log patterns are not configured on this control plane' }, 501);
|
|
5166
|
+
}
|
|
5167
|
+
const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
|
|
5168
|
+
const narrow = z
|
|
5169
|
+
.object({
|
|
5170
|
+
// Each pattern's mini histogram. Few buckets: it is drawn 180px wide.
|
|
5171
|
+
buckets: z.coerce.number().int().min(10).max(120).default(30),
|
|
5172
|
+
level: z.array(z.enum(['debug', 'info', 'warn', 'error'])).max(4),
|
|
5173
|
+
operation: z.array(z.string().min(1).max(200)).max(20),
|
|
5174
|
+
})
|
|
5175
|
+
.parse({
|
|
5176
|
+
buckets: c.req.query('buckets'),
|
|
5177
|
+
level: [...new Set((c.req.queries('level') ?? []).filter((v) => v.length > 0))],
|
|
5178
|
+
operation: [...new Set((c.req.queries('operation') ?? []).filter((v) => v.length > 0))],
|
|
5179
|
+
});
|
|
5180
|
+
return c.json(await options.observability.tenantLogPatterns({
|
|
5181
|
+
tenantId: scope.tenantId,
|
|
5182
|
+
...(scope.scopeId ? { scopeId: scope.scopeId } : {}),
|
|
5183
|
+
from: scope.from,
|
|
5184
|
+
to: scope.to,
|
|
5185
|
+
buckets: narrow.buckets,
|
|
5186
|
+
...(narrow.level.length ? { level: narrow.level } : {}),
|
|
5187
|
+
...(narrow.operation.length ? { operation: narrow.operation } : {}),
|
|
5188
|
+
}));
|
|
5189
|
+
});
|
|
5190
|
+
app.get('/observability/tenant-requests', async (c) => {
|
|
5191
|
+
if (!options.observability?.tenantRequests) {
|
|
5192
|
+
return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
|
|
5193
|
+
}
|
|
5194
|
+
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
5195
|
+
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
|
|
5196
|
+
return c.json(await options.observability.tenantRequests({ ...scope, limit }));
|
|
5197
|
+
});
|
|
5055
5198
|
/**
|
|
5056
5199
|
* Observed-vs-declared outbound egress for a vertical's deployed versions (#859, D-46).
|
|
5057
5200
|
*
|
|
@@ -5599,7 +5742,7 @@ export function createControlPlaneApi(options) {
|
|
|
5599
5742
|
await admin.setScopeServingRef(actor, tenantId, existing.id, null);
|
|
5600
5743
|
await admin.bindScopeVersion(actor, tenantId, existing.id, opts.versionId);
|
|
5601
5744
|
// #1674: re-assert any recorded OFF in the script the preview now routes to.
|
|
5602
|
-
await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id });
|
|
5745
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id }, { appliedInUnit: carried?.switchedOff });
|
|
5603
5746
|
// Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
|
|
5604
5747
|
await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
|
|
5605
5748
|
const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
|