@substrat-run/control-plane-api 0.175.0 → 0.177.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 +11 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +408 -59
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +51 -9
- package/dist/backups.d.ts.map +1 -1
- package/dist/backups.js +3 -0
- package/dist/backups.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/builder-turns.d.ts +16 -0
- package/dist/builder-turns.d.ts.map +1 -0
- package/dist/builder-turns.js +43 -0
- package/dist/builder-turns.js.map +1 -0
- package/dist/connection-binding-options.d.ts +14 -0
- package/dist/connection-binding-options.d.ts.map +1 -1
- package/dist/connection-binding-options.js +37 -1
- package/dist/connection-binding-options.js.map +1 -1
- package/dist/connection-binding-relay.d.ts +28 -1
- package/dist/connection-binding-relay.d.ts.map +1 -1
- package/dist/connection-binding-relay.js +92 -4
- package/dist/connection-binding-relay.js.map +1 -1
- package/dist/connection-relay.d.ts +2 -2
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +3 -1
- package/dist/mask.js.map +1 -1
- package/dist/migration-extract.d.ts +92 -0
- package/dist/migration-extract.d.ts.map +1 -0
- package/dist/migration-extract.js +217 -0
- package/dist/migration-extract.js.map +1 -0
- package/dist/platform-entry.d.ts +2 -0
- package/dist/platform-entry.d.ts.map +1 -1
- package/dist/platform-entry.js +2 -1
- package/dist/platform-entry.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +9 -11
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +34 -19
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/r2-backups.d.ts +8 -5
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +109 -36
- package/dist/r2-backups.js.map +1 -1
- package/dist/seal.d.ts +5 -2
- package/dist/seal.d.ts.map +1 -1
- package/dist/seal.js +3 -0
- package/dist/seal.js.map +1 -1
- package/dist/staff-client.d.ts +10 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +4 -2
- package/dist/staff-client.js.map +1 -1
- package/dist/store-entry.generated.d.ts +7 -2
- package/dist/store-entry.generated.d.ts.map +1 -1
- package/dist/store-entry.generated.js +10 -5
- package/dist/store-entry.generated.js.map +1 -1
- package/dist/vertical-client.d.ts +8 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +21 -3
- package/dist/vertical-client.js.map +1 -1
- package/package.json +9 -9
package/dist/api.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { sourceBuildRequest } from '@substrat-run/contracts';
|
|
2
2
|
import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
3
|
-
import { bindingChangeRefusal, connectionBindingOptions, refusesBindingChange } from './connection-binding-options.js';
|
|
3
|
+
import { bindingChangeRefusal, bindingOptionsRefusal, connectionBindingOptions, refusesBindingChange } from './connection-binding-options.js';
|
|
4
4
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
5
5
|
import { Hono } from 'hono';
|
|
6
|
-
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
|
|
6
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeDriverErasesSubjects, storeDriverServesVertical, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, mergeDumpPages, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
|
|
7
7
|
import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
|
|
8
8
|
import { ENGINE_ENTITLEMENT_KEYS, bareSlug, declarableEntitlementKey, entitlementNamespaceRefusal, grantableEntitlements } from '@substrat-run/contracts';
|
|
9
9
|
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
@@ -70,6 +70,7 @@ import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, re
|
|
|
70
70
|
import { namespacesForScript } from './do-namespaces.js';
|
|
71
71
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
72
72
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
73
|
+
import { extractedMigrationsDigest, extractedMigrationsProblem, extractedMigrationsRefusal, MigrationExtractionError, MigrationLoaderUnavailable, } from './migration-extract.js';
|
|
73
74
|
import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
|
|
74
75
|
import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
|
|
75
76
|
import { wireKeyDropSince, wireReadinessReport, WIRE_READINESS_LIMIT_DEFAULT, WIRE_READINESS_LIMIT_MAX } from './wire-readiness.js';
|
|
@@ -441,13 +442,62 @@ const restoreDirectoryBody = z.object({
|
|
|
441
442
|
overwrite: z.boolean().optional(),
|
|
442
443
|
});
|
|
443
444
|
/**
|
|
444
|
-
* How a stored backup is named in the admin log and to callers: the route that
|
|
445
|
-
* it
|
|
446
|
-
*
|
|
447
|
-
*
|
|
445
|
+
* How a stored backup is named in the admin log and to callers: the route that restores
|
|
446
|
+
* it (#2839), which every backup can take, whereas only one stored in a single entry can be
|
|
447
|
+
* read whole. Store-neutral by construction — the R2 key scheme stays private to the store —
|
|
448
|
+
* so an operator reading a reap entry gets an address they can use, rather than a bucket
|
|
449
|
+
* path they would have to know the platform's internals to use.
|
|
448
450
|
*/
|
|
449
451
|
function backupRefOf(b) {
|
|
450
|
-
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}`;
|
|
452
|
+
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}/restore`;
|
|
453
|
+
}
|
|
454
|
+
/** The tables whose rows a host judges a load's roles by (#2555): the role definitions and their seats. */
|
|
455
|
+
const ROLES_TABLE = '_substrat_roles';
|
|
456
|
+
const TUPLES_TABLE = '_substrat_tuples';
|
|
457
|
+
/**
|
|
458
|
+
* What a host judges a backup restored from its entries by (#2839): every table's schema, with the
|
|
459
|
+
* rows of the role definitions and of the role seats only, as the entries leave them in order (a
|
|
460
|
+
* change batch replaces a row by its rowid, deletes by rowid, or replaces a table read whole). The
|
|
461
|
+
* scope's data stays at the deployment; this holds no more than the roles a load would apply.
|
|
462
|
+
*/
|
|
463
|
+
function rolesJudgedOf() {
|
|
464
|
+
const schemas = new Map();
|
|
465
|
+
const rows = new Map();
|
|
466
|
+
let unkeyed = 0;
|
|
467
|
+
const judged = (t, row) => t.name === ROLES_TABLE || (t.name === TUPLES_TABLE && String(row[t.columns.indexOf('relation')]).startsWith('role:'));
|
|
468
|
+
const take = (t, replace) => {
|
|
469
|
+
if (t.name !== ROLES_TABLE && t.name !== TUPLES_TABLE)
|
|
470
|
+
return;
|
|
471
|
+
const held = replace ? new Map() : (rows.get(t.name) ?? new Map());
|
|
472
|
+
t.rows.forEach((row, i) => {
|
|
473
|
+
const key = t.rowids?.[i] ?? `u${unkeyed++}`;
|
|
474
|
+
if (judged(t, row))
|
|
475
|
+
held.set(key, row);
|
|
476
|
+
else
|
|
477
|
+
held.delete(key);
|
|
478
|
+
});
|
|
479
|
+
rows.set(t.name, held);
|
|
480
|
+
};
|
|
481
|
+
return {
|
|
482
|
+
page(tables) {
|
|
483
|
+
for (const { rows: _, rowids: __, ...schema } of tables) {
|
|
484
|
+
if (!schemas.has(schema.name))
|
|
485
|
+
schemas.set(schema.name, { ...schema, rows: [] });
|
|
486
|
+
}
|
|
487
|
+
for (const t of tables)
|
|
488
|
+
take(t, false);
|
|
489
|
+
},
|
|
490
|
+
changes(tables) {
|
|
491
|
+
for (const { deleted, ...t } of tables) {
|
|
492
|
+
for (const id of deleted)
|
|
493
|
+
rows.get(t.name)?.delete(id);
|
|
494
|
+
take(t, !t.rowids);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
tables() {
|
|
498
|
+
return [...schemas.values()].map((t) => ({ ...t, rows: [...(rows.get(t.name)?.values() ?? [])] }));
|
|
499
|
+
},
|
|
500
|
+
};
|
|
451
501
|
}
|
|
452
502
|
// A per-PR preview request (preview-and-snapshots.md §2/§9 — the "run a new version
|
|
453
503
|
// against a fork of prod" slice). `tag` is a short DNS-safe label (`pr-123`): it names
|
|
@@ -2541,6 +2591,9 @@ export function createControlPlaneApi(options) {
|
|
|
2541
2591
|
permission: permissionKey,
|
|
2542
2592
|
});
|
|
2543
2593
|
const input = schema.parse(await c.req.json());
|
|
2594
|
+
const optionsRefused = bindingOptionsRefusal(row.provider, input);
|
|
2595
|
+
if (optionsRefused)
|
|
2596
|
+
return refuseStatus(c, 422, optionsRefused);
|
|
2544
2597
|
const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
|
|
2545
2598
|
if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
|
|
2546
2599
|
return refuseStatus(c, 409, `connection does not hold '${input.permission}' on scope ${scopeId}; grant it through the tenant's authorized connection flow, then bind`);
|
|
@@ -5343,20 +5396,67 @@ export function createControlPlaneApi(options) {
|
|
|
5343
5396
|
`store is global, so a backup would move its data out of that jurisdiction ` +
|
|
5344
5397
|
`(K-32); refused until a per-jurisdiction store exists`);
|
|
5345
5398
|
}
|
|
5346
|
-
const
|
|
5399
|
+
const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
5347
5400
|
const vertical = await verticalForScope(c, scope);
|
|
5348
|
-
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
5349
5401
|
// Store tables are opaque and stay unsealed. The platform reads them from the store script
|
|
5350
5402
|
// (#2207); a store it cannot name or reach refuses the backup rather than omitting it.
|
|
5351
|
-
const stores = (await exportPlatformStores(c, scope)) ??
|
|
5352
|
-
|
|
5353
|
-
//
|
|
5354
|
-
//
|
|
5355
|
-
//
|
|
5356
|
-
//
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5403
|
+
const stores = (await exportPlatformStores(c, scope)) ?? header.stores;
|
|
5404
|
+
const backup = backupStage(store.begin({ vertical: scope.vertical, header: { ...header, stores } }), sealerFor(c, tenantId, scope.id));
|
|
5405
|
+
// #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
|
|
5406
|
+
// moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
|
|
5407
|
+
// copied as it was at one moment, and never held whole here. A deployment that predates
|
|
5408
|
+
// paging answers whole, and that is stored as one entry, as the co-located host's tables are.
|
|
5409
|
+
const moved = vertical
|
|
5410
|
+
? (await transferScope(vertical, backup.dest, { from: scope.id }, { retry: retryTransient, stamp: false, alwaysStage: true })).dump
|
|
5411
|
+
: hostTables;
|
|
5412
|
+
if (Array.isArray(moved))
|
|
5413
|
+
await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
|
|
5414
|
+
try {
|
|
5415
|
+
return await backup.commit();
|
|
5416
|
+
}
|
|
5417
|
+
catch (e) {
|
|
5418
|
+
await backup.discard();
|
|
5419
|
+
throw e;
|
|
5420
|
+
}
|
|
5421
|
+
};
|
|
5422
|
+
/**
|
|
5423
|
+
* A backup being written, as the destination of a paged copy (#2839): each page and each batch of
|
|
5424
|
+
* captured changes the copy stages becomes one entry, sealed per subject on the way in (#37).
|
|
5425
|
+
* This copy is full-fidelity and the platform keeps it, which is precisely why an erasure could
|
|
5426
|
+
* never reach it: a redaction fixes the live scope and does nothing to the object already in R2.
|
|
5427
|
+
* Sealed here, destroying one subject's key reaches backwards into every copy already taken.
|
|
5428
|
+
* Restores open it again, except for subjects shredded in the meantime, which is the mechanism
|
|
5429
|
+
* working rather than the copy being damaged.
|
|
5430
|
+
*
|
|
5431
|
+
* A copy that starts again discards its stage and stages anew, so the backup is the stage written
|
|
5432
|
+
* last.
|
|
5433
|
+
*/
|
|
5434
|
+
const backupStage = (writer, sealer) => {
|
|
5435
|
+
let last = { stageId: '', entries: 0, tables: new Set() };
|
|
5436
|
+
const took = (stageId, seq, tables) => {
|
|
5437
|
+
if (stageId !== last.stageId)
|
|
5438
|
+
last = { stageId, entries: 0, tables: new Set() };
|
|
5439
|
+
last.entries = Math.max(last.entries, seq + 1);
|
|
5440
|
+
for (const t of tables)
|
|
5441
|
+
last.tables.add(t.name);
|
|
5442
|
+
};
|
|
5443
|
+
return {
|
|
5444
|
+
dest: {
|
|
5445
|
+
stageScopePage: async (_, stageId, seq, tables) => {
|
|
5446
|
+
await writer.put(stageId, seq, { kind: 'page', tables: await sealDump(tables, sealer) });
|
|
5447
|
+
took(stageId, seq, tables);
|
|
5448
|
+
return { staged: true };
|
|
5449
|
+
},
|
|
5450
|
+
stageScopeChanges: async (_, stageId, seq, tables) => {
|
|
5451
|
+
await writer.put(stageId, seq, { kind: 'changes', tables: await sealDump(tables, sealer) });
|
|
5452
|
+
took(stageId, seq, tables);
|
|
5453
|
+
return { staged: true };
|
|
5454
|
+
},
|
|
5455
|
+
discardStagedScope: (_, stageId) => writer.discard(stageId),
|
|
5456
|
+
},
|
|
5457
|
+
commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
|
|
5458
|
+
discard: () => writer.discard(last.stageId),
|
|
5459
|
+
};
|
|
5360
5460
|
};
|
|
5361
5461
|
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
5362
5462
|
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
@@ -5374,9 +5474,11 @@ export function createControlPlaneApi(options) {
|
|
|
5374
5474
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5375
5475
|
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
5376
5476
|
});
|
|
5377
|
-
// One backup's DUMP
|
|
5378
|
-
//
|
|
5379
|
-
//
|
|
5477
|
+
// One backup's DUMP. Staff-only like the export it came from (not in BUILDER_ROUTES), and
|
|
5478
|
+
// full-fidelity, so it is the same governed-pull posture: K-3 cross-checked above. Only a
|
|
5479
|
+
// backup stored in one entry is served whole (#2839). One stored in several (more pages, or
|
|
5480
|
+
// writes caught while it was taken) is not a dump until a deployment applies them in order,
|
|
5481
|
+
// so it is answered 413 and `POST …/backups/:capturedAt/restore` loads it back.
|
|
5380
5482
|
app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
|
|
5381
5483
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5382
5484
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -5386,16 +5488,20 @@ export function createControlPlaneApi(options) {
|
|
|
5386
5488
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
5387
5489
|
if (!scope)
|
|
5388
5490
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5389
|
-
const
|
|
5390
|
-
if (!
|
|
5491
|
+
const held = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
5492
|
+
if (!held)
|
|
5391
5493
|
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
5494
|
+
if (held.entries > 1) {
|
|
5495
|
+
return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} is stored in ${held.entries} entries (its pages, and any writes caught while it was taken), which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
|
|
5496
|
+
}
|
|
5497
|
+
const { tables } = await held.entry(0);
|
|
5392
5498
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
5393
5499
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
5394
5500
|
// since the copy was taken opens to a null payload, which is exactly the erasure
|
|
5395
5501
|
// working: the ciphertext never left the object, and nothing turns it back into a
|
|
5396
5502
|
// person. A dump taken before sealing existed passes through untouched.
|
|
5397
|
-
const opened = await openDump(
|
|
5398
|
-
return c.json({ ...
|
|
5503
|
+
const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
|
|
5504
|
+
return c.json({ ...held.header, tables: opened });
|
|
5399
5505
|
});
|
|
5400
5506
|
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
5401
5507
|
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
@@ -5455,6 +5561,15 @@ export function createControlPlaneApi(options) {
|
|
|
5455
5561
|
// version that declares none is not asked. One that cannot be resolved while a driver is
|
|
5456
5562
|
// registered refuses (503): the key must not be destroyed while a store may hold the subject.
|
|
5457
5563
|
const reach = await platformStoresOf(c, scope, running);
|
|
5564
|
+
// A store whose driver cannot erase a subject yet would answer `{ erased: 0 }` while it still
|
|
5565
|
+
// holds them. Refused before anything is erased, so the key is kept and a retry can converge.
|
|
5566
|
+
const unerasable = reach?.stores.find((store) => {
|
|
5567
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
|
|
5568
|
+
return registration !== undefined && !storeDriverErasesSubjects(registration);
|
|
5569
|
+
});
|
|
5570
|
+
if (unerasable) {
|
|
5571
|
+
throw new ControlPlaneError(501, `scope ${scopeId}'s store '${unerasable.binding}' runs driver '${unerasable.driver}', which cannot erase a subject yet; nothing was erased`);
|
|
5572
|
+
}
|
|
5458
5573
|
/**
|
|
5459
5574
|
* L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
|
|
5460
5575
|
* would run a version whose stores this erasure did not reach. Both arms take one atomic claim
|
|
@@ -6229,32 +6344,135 @@ export function createControlPlaneApi(options) {
|
|
|
6229
6344
|
// keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
|
|
6230
6345
|
// a different scope, a world loaded sideways — must be opened with A's keys. Using
|
|
6231
6346
|
// the destination's would find no key, null every payload, and call it a restore.
|
|
6232
|
-
const
|
|
6233
|
-
|
|
6234
|
-
|
|
6347
|
+
const tables = await openDump(dump.tables, sealerFor(c, tenantIdSchema.parse(dump.tenantId), scopeIdSchema.parse(dump.scopeId)));
|
|
6348
|
+
return c.json(await loadIntoScope(c, scope, { header: dump, tables, sourceHint }));
|
|
6349
|
+
}
|
|
6350
|
+
catch (e) {
|
|
6351
|
+
return restoreFailed(c, scope, e);
|
|
6352
|
+
}
|
|
6353
|
+
});
|
|
6354
|
+
// Restore a held backup into its scope, read from the store here rather than handed in (#2839):
|
|
6355
|
+
// a backup stored in parts is staged at the deployment one entry at a time, opened with the
|
|
6356
|
+
// backup's own provenance, and loaded from the stage, so no call holds the whole backup. One
|
|
6357
|
+
// stored whole loads as `POST …/restore` loads a dump, through the same path. Staff-only, as the
|
|
6358
|
+
// backup routes are (not in TENANT_ROUTES or BUILDER_ROUTES).
|
|
6359
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt/restore', async (c) => {
|
|
6360
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
6361
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
6362
|
+
const capturedAt = c.req.param('capturedAt');
|
|
6363
|
+
const store = options.scopeBackups;
|
|
6364
|
+
if (!store)
|
|
6365
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
6366
|
+
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
6367
|
+
if (!scope)
|
|
6368
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
6369
|
+
const held = await store.get({ tenantId, scopeId, capturedAt });
|
|
6370
|
+
if (!held)
|
|
6371
|
+
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
6372
|
+
const { header } = held;
|
|
6373
|
+
if (options.platformScopeStores) {
|
|
6374
|
+
try {
|
|
6375
|
+
await refuseUndeclaredDumpStores(c, scope, header.stores);
|
|
6376
|
+
}
|
|
6377
|
+
catch (e) {
|
|
6378
|
+
if (e instanceof ControlPlaneError)
|
|
6379
|
+
return relayRefusal(c, e);
|
|
6380
|
+
throw e;
|
|
6381
|
+
}
|
|
6382
|
+
}
|
|
6383
|
+
const sealer = sealerFor(c, tenantIdSchema.parse(header.tenantId), scopeIdSchema.parse(header.scopeId));
|
|
6384
|
+
try {
|
|
6385
|
+
if (held.entries === 1) {
|
|
6386
|
+
const { tables } = await held.entry(0);
|
|
6387
|
+
if (tables.length === 0) {
|
|
6388
|
+
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
6389
|
+
}
|
|
6390
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
|
|
6391
|
+
}
|
|
6392
|
+
// The host judges the roles the backup seats or defines before anything is replaced (#2555), and
|
|
6393
|
+
// records the tables it restores. So it is handed every table's schema and the role tables' rows,
|
|
6394
|
+
// gathered while the entries are staged: its whole part of a load into a deployment, whose
|
|
6395
|
+
// placeholder is not the scope's store.
|
|
6396
|
+
const stage = async (vertical) => {
|
|
6397
|
+
const stageId = ulid();
|
|
6398
|
+
const judged = rolesJudgedOf();
|
|
6399
|
+
try {
|
|
6400
|
+
for (let index = 0; index < held.entries; index++) {
|
|
6401
|
+
const entry = await held.entry(index);
|
|
6402
|
+
let staged;
|
|
6403
|
+
if (entry.kind === 'page') {
|
|
6404
|
+
const tables = await openDump(entry.tables, sealer);
|
|
6405
|
+
judged.page(tables);
|
|
6406
|
+
staged = await retryTransient(() => vertical.stageScopePage(scopeId, stageId, index, tables));
|
|
6407
|
+
}
|
|
6408
|
+
else {
|
|
6409
|
+
const tables = await openDump(entry.tables, sealer);
|
|
6410
|
+
judged.changes(tables);
|
|
6411
|
+
staged = await retryTransient(() => vertical.stageScopeChanges(scopeId, stageId, index, tables));
|
|
6412
|
+
}
|
|
6413
|
+
if (staged === 'predates') {
|
|
6414
|
+
throw new ControlPlaneError(409, `backup ${capturedAt} is stored in ${held.entries} parts, and the deployment scope ${scopeId} runs cannot load one in parts; push a newer version of the vertical and restore again`);
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
catch (e) {
|
|
6419
|
+
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId });
|
|
6420
|
+
throw e;
|
|
6421
|
+
}
|
|
6422
|
+
return { dump: { stageId }, tables: judged.tables() };
|
|
6423
|
+
};
|
|
6424
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
|
|
6425
|
+
}
|
|
6426
|
+
catch (e) {
|
|
6427
|
+
return restoreFailed(c, scope, e);
|
|
6428
|
+
}
|
|
6429
|
+
});
|
|
6430
|
+
/**
|
|
6431
|
+
* Load a dump into an existing scope: the one path `POST …/restore` and a backup's restore share,
|
|
6432
|
+
* so both write the same audit row, judge the same roles and stores, replay the same erasures and
|
|
6433
|
+
* clear the same receipt. `tables` are opened (#37). `stage`, for a backup stored in several
|
|
6434
|
+
* entries (#2839), stages it at the scope's deployment, which then loads the stage, and answers
|
|
6435
|
+
* the tables the host's audited restore judges and records in place of `tables`. Those are every
|
|
6436
|
+
* table's schema with the role rows only, so after a staged restore the host's placeholder holds
|
|
6437
|
+
* no data, and its `_substrat_migrations` and `_substrat_meta` journals are empty, where a whole
|
|
6438
|
+
* dump's restore leaves them full. The scope's own journals are the deployment's.
|
|
6439
|
+
*/
|
|
6440
|
+
const loadIntoScope = async (c, scope, input) => {
|
|
6441
|
+
const actor = c.get('actor');
|
|
6442
|
+
const tenantId = tenantIdSchema.parse(scope.tenantId);
|
|
6443
|
+
const scopeId = scopeIdSchema.parse(scope.id);
|
|
6444
|
+
const { header, sourceHint } = input;
|
|
6445
|
+
const origin = scopeIdSchema.parse(header.scopeId);
|
|
6446
|
+
const vertical = await verticalForScope(c, scope);
|
|
6447
|
+
if (input.stage && !vertical) {
|
|
6448
|
+
throw new ControlPlaneError(409, `scope ${scopeId} has no deployment to load a backup stored in parts into`);
|
|
6449
|
+
}
|
|
6450
|
+
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6451
|
+
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6452
|
+
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6453
|
+
const staged = input.stage && vertical ? await input.stage(vertical) : undefined;
|
|
6454
|
+
const tables = staged?.tables ?? input.tables;
|
|
6455
|
+
try {
|
|
6235
6456
|
const storesCaller = options.platformScopeStores;
|
|
6236
|
-
const landing = { ...
|
|
6237
|
-
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6238
|
-
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6239
|
-
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6457
|
+
const landing = { ...header, tables, stores: storesCaller && vertical ? [] : header.stores };
|
|
6240
6458
|
const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
6241
|
-
if (storesCaller && vertical && scope.vertical &&
|
|
6459
|
+
if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
|
|
6242
6460
|
await storesCaller.importStores({
|
|
6243
6461
|
tenantId,
|
|
6244
6462
|
scopeId,
|
|
6245
6463
|
vertical: scope.vertical,
|
|
6246
|
-
stores:
|
|
6464
|
+
stores: header.stores,
|
|
6247
6465
|
});
|
|
6248
6466
|
}
|
|
6249
6467
|
const platformReplay = storesCaller && scope.vertical
|
|
6250
|
-
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin
|
|
6468
|
+
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
|
|
6251
6469
|
: undefined;
|
|
6252
6470
|
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
6253
6471
|
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
6254
6472
|
// off in the replay's own event — its own sweeper cannot land in between.
|
|
6255
6473
|
const restored = vertical
|
|
6256
|
-
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables, {
|
|
6257
|
-
scopeId: sourceHint ?? origin
|
|
6474
|
+
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6475
|
+
scopeId: sourceHint ?? origin,
|
|
6258
6476
|
exact: false,
|
|
6259
6477
|
})
|
|
6260
6478
|
: undefined;
|
|
@@ -6268,29 +6486,37 @@ export function createControlPlaneApi(options) {
|
|
|
6268
6486
|
// finished: the install reads as not reconciled, and the sweep's next pass reconciles it.
|
|
6269
6487
|
// Again here, after the load, for a sweep reconcile that wrote the receipt while it ran.
|
|
6270
6488
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6271
|
-
return
|
|
6489
|
+
return {
|
|
6272
6490
|
restored: scopeId,
|
|
6273
6491
|
tables: tables.length,
|
|
6492
|
+
// #2820: what the deployment serving the scope did with the dump's own indexes and triggers.
|
|
6493
|
+
...(restored ? { moduleObjects: restored.moduleObjects } : {}),
|
|
6274
6494
|
...storeReplayFields(replays),
|
|
6275
|
-
}
|
|
6495
|
+
};
|
|
6276
6496
|
}
|
|
6277
6497
|
catch (e) {
|
|
6278
|
-
//
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
if (e instanceof ControlPlaneError) {
|
|
6283
|
-
return relayRefusal(c, e);
|
|
6284
|
-
}
|
|
6285
|
-
// A restore throw is driven by the caller-supplied dump (a shape the target cannot
|
|
6286
|
-
// load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
|
|
6287
|
-
// collapsing to the generic 500 `internal error` mapError would produce (#321,
|
|
6288
|
-
// secondary obs #2). The route is staff- or own-tenant-gated and the detail is about the
|
|
6289
|
-
// dump the caller sent, not another tenant's state.
|
|
6290
|
-
const detail = e instanceof Error ? e.message : String(e);
|
|
6291
|
-
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
6498
|
+
// A stage the refused load left at the deployment goes now rather than waiting for a wipe.
|
|
6499
|
+
if (staged && vertical)
|
|
6500
|
+
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
|
|
6501
|
+
throw e;
|
|
6292
6502
|
}
|
|
6293
|
-
}
|
|
6503
|
+
};
|
|
6504
|
+
/** How a refused or failed restore answers, on either route. */
|
|
6505
|
+
const restoreFailed = async (c, scope, e) => {
|
|
6506
|
+
// #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
|
|
6507
|
+
// receipt between the clear before the load and the throw. Clearing is always safe (at worst
|
|
6508
|
+
// one more reconcile); a clear that fails here must not hide the restore's own error.
|
|
6509
|
+
await c.var.admin.markScopeProvisioned(c.get('actor'), tenantIdSchema.parse(scope.tenantId), scopeIdSchema.parse(scope.id), null).catch(() => undefined);
|
|
6510
|
+
if (e instanceof ControlPlaneError) {
|
|
6511
|
+
return relayRefusal(c, e);
|
|
6512
|
+
}
|
|
6513
|
+
// A restore throw is driven by the dump (a shape the target cannot load, a DDL the engine
|
|
6514
|
+
// rejects), so DISCLOSE it as an actionable 422 rather than collapsing to the generic 500
|
|
6515
|
+
// `internal error` mapError would produce (#321, secondary obs #2). The routes are staff- or
|
|
6516
|
+
// own-tenant-gated and the detail is about the dump, not another tenant's state.
|
|
6517
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
6518
|
+
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
6519
|
+
};
|
|
6294
6520
|
// #1236: when this scope's migrations actually ran — the schema-change history
|
|
6295
6521
|
// whose instants `_substrat_migrations.applied_at` has held all along with no
|
|
6296
6522
|
// reader. Read through the vertical that holds the scope's data when one
|
|
@@ -6698,11 +6924,14 @@ export function createControlPlaneApi(options) {
|
|
|
6698
6924
|
if ((await isCarriedAway(live, scope)) || (await live.keptCopy(scopeId))) {
|
|
6699
6925
|
return refuse(c, 'conflict', `the store scope ${scopeId} routes to in '${liveScript}' is not a live store (wiped or kept); nothing was resolved`);
|
|
6700
6926
|
}
|
|
6701
|
-
|
|
6702
|
-
//
|
|
6703
|
-
//
|
|
6704
|
-
await
|
|
6927
|
+
// #2450: a page at a time into the live store's stage, so a kept copy of any size goes forward.
|
|
6928
|
+
// Unstamped: the kept copy is only read. A write it takes meanwhile is caught up on through the
|
|
6929
|
+
// copy's capture, and one after the copy moves its revision, which the discard below is fenced on.
|
|
6930
|
+
const { dump } = await transferScope(holder, live, { from: scopeId }, { retry: retryTransient, stamp: false });
|
|
6705
6931
|
try {
|
|
6932
|
+
// #2185: before the load as well as after it, so a throw once the kept copy has landed (the
|
|
6933
|
+
// re-assert) cannot leave the receipt set over older data.
|
|
6934
|
+
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6706
6935
|
const restored = await restoreCarryingSwitches(actor, live, tenantId, scopeId, dump, {
|
|
6707
6936
|
scopeId,
|
|
6708
6937
|
exact: true,
|
|
@@ -6714,6 +6943,9 @@ export function createControlPlaneApi(options) {
|
|
|
6714
6943
|
catch (e) {
|
|
6715
6944
|
// And on a failure, for a sweep reconcile that wrote the receipt after the clear above.
|
|
6716
6945
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
|
|
6946
|
+
// A stage the refused load left at the live store goes now rather than waiting for a wipe.
|
|
6947
|
+
if (!Array.isArray(dump))
|
|
6948
|
+
await abandonCopy({ dest: live }, { from: scopeId, into: scopeId, stageId: dump.stageId });
|
|
6717
6949
|
throw e;
|
|
6718
6950
|
}
|
|
6719
6951
|
// #2185: the kept copy is an older store's data, which may predate a reconcile the live store
|
|
@@ -7311,6 +7543,7 @@ export function createControlPlaneApi(options) {
|
|
|
7311
7543
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7312
7544
|
}
|
|
7313
7545
|
input.verticalSlug = slug;
|
|
7546
|
+
let scopeStores = [];
|
|
7314
7547
|
// `manifestJson` is a string the schema cannot look inside, and every reader parses it
|
|
7315
7548
|
// with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
|
|
7316
7549
|
// (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
|
|
@@ -7356,7 +7589,12 @@ export function createControlPlaneApi(options) {
|
|
|
7356
7589
|
const connectorProblems = pushedConnectorProblems(checked.data);
|
|
7357
7590
|
if (connectorProblems.length > 0)
|
|
7358
7591
|
return refuseManifest({ path: ['connectors'], message: connectorProblems.join('; ') });
|
|
7592
|
+
scopeStores = checked.data.scopeStores;
|
|
7359
7593
|
}
|
|
7594
|
+
// #2210: publishing records the version's store bindings for the whole vertical's lifecycle
|
|
7595
|
+
// (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
|
|
7596
|
+
// does: a driver with no approved release refuses here, and nothing is published.
|
|
7597
|
+
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null);
|
|
7360
7598
|
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
7361
7599
|
const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
|
|
7362
7600
|
return c.json(version, 201);
|
|
@@ -7414,7 +7652,11 @@ export function createControlPlaneApi(options) {
|
|
|
7414
7652
|
base: base ?? null,
|
|
7415
7653
|
digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
|
|
7416
7654
|
};
|
|
7417
|
-
|
|
7655
|
+
// #2351: whether the incoming set was read from the version's bundle, or only declared by its
|
|
7656
|
+
// push (pushed before the read, or a bundle that exposes no registrations). A version storing none was
|
|
7657
|
+
// compared with nothing, whatever it recorded.
|
|
7658
|
+
const readFromBundle = incoming !== null && !!(await c.var.admin.versionMigrationsExtracted?.(c.get('actor'), slug, incomingId));
|
|
7659
|
+
return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review, readFromBundle });
|
|
7418
7660
|
});
|
|
7419
7661
|
// The emitted entity model (#1214) one version ships: entities, field schemas, parent
|
|
7420
7662
|
// edges, declared lifecycles — read out of the same retained manifest as the registry
|
|
@@ -7654,6 +7896,14 @@ export function createControlPlaneApi(options) {
|
|
|
7654
7896
|
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
|
|
7655
7897
|
if (stores.length === 0)
|
|
7656
7898
|
return;
|
|
7899
|
+
// D-67: a driver whose registration names its verticals serves those only, refused before any
|
|
7900
|
+
// hold is taken or anything is uploaded.
|
|
7901
|
+
for (const store of stores) {
|
|
7902
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
|
|
7903
|
+
if (registration && !storeDriverServesVertical(registration, slug)) {
|
|
7904
|
+
throw new ControlPlaneError(403, `${operation} refused: scope store driver '${store.driver}' does not serve vertical '${slug}'`);
|
|
7905
|
+
}
|
|
7906
|
+
}
|
|
7657
7907
|
const holder = ulid();
|
|
7658
7908
|
const drivers = [...new Set(stores.map((store) => store.driver))].sort();
|
|
7659
7909
|
const held = [];
|
|
@@ -7741,6 +7991,59 @@ export function createControlPlaneApi(options) {
|
|
|
7741
7991
|
recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
|
|
7742
7992
|
}
|
|
7743
7993
|
};
|
|
7994
|
+
const readBundleMigrations = async (bundle, declared) => {
|
|
7995
|
+
if (!options.extractMigrations)
|
|
7996
|
+
return { kind: 'unmarked' };
|
|
7997
|
+
let registered;
|
|
7998
|
+
try {
|
|
7999
|
+
registered = await options.extractMigrations(bundle);
|
|
8000
|
+
}
|
|
8001
|
+
catch (e) {
|
|
8002
|
+
if (e instanceof MigrationLoaderUnavailable)
|
|
8003
|
+
return { kind: 'unavailable', message: e.message };
|
|
8004
|
+
if (!(e instanceof MigrationExtractionError))
|
|
8005
|
+
throw e;
|
|
8006
|
+
return { kind: 'failed', message: e.message };
|
|
8007
|
+
}
|
|
8008
|
+
if (!registered)
|
|
8009
|
+
return { kind: 'unmarked' };
|
|
8010
|
+
const problem = declared && extractedMigrationsProblem(declared, registered);
|
|
8011
|
+
if (problem)
|
|
8012
|
+
return { kind: 'differs', message: extractedMigrationsRefusal(problem) };
|
|
8013
|
+
return { kind: 'read', digest: await extractedMigrationsDigest(registered) };
|
|
8014
|
+
};
|
|
8015
|
+
/**
|
|
8016
|
+
* #2351: a version pushed before its bundle was read is read once, at its first promote, from
|
|
8017
|
+
* its archive script. Same set: recorded, and the promote goes on. Another set: the refusal,
|
|
8018
|
+
* before anything moves. No registrations, or a bundle that cannot be read here (it was
|
|
8019
|
+
* accepted at push, before this read existed): the promote goes on as it did, unrecorded, so such
|
|
8020
|
+
* a version is read again at every promote. A version storing no migrations has nothing to
|
|
8021
|
+
* compare, and one that is not admitted is refused by the promote without a read.
|
|
8022
|
+
*/
|
|
8023
|
+
const bundleMigrationsRefusal = async (admin, actor, slug, versionId) => {
|
|
8024
|
+
if (!options.extractMigrations || !options.fetchVerticalModules || !admin.versionMigrationsExtracted || !admin.recordMigrationsExtracted) {
|
|
8025
|
+
return null;
|
|
8026
|
+
}
|
|
8027
|
+
const [extracted, declared, version, manifestJson] = await Promise.all([
|
|
8028
|
+
admin.versionMigrationsExtracted(actor, slug, versionId),
|
|
8029
|
+
admin.versionMigrations(actor, slug, versionId),
|
|
8030
|
+
admin.getVersion(actor, versionId, slug),
|
|
8031
|
+
admin.versionManifest(actor, slug, versionId),
|
|
8032
|
+
]);
|
|
8033
|
+
if (extracted !== null || declared === null || version?.admission !== 'admitted' || !version.deploymentRef || !manifestJson)
|
|
8034
|
+
return null;
|
|
8035
|
+
const manifest = storedDeployManifest.parse(JSON.parse(manifestJson));
|
|
8036
|
+
// An archive that cannot be read back is the serve's to report, after the promote, as it is today.
|
|
8037
|
+
const modules = await options.fetchVerticalModules(version.deploymentRef).catch(() => null);
|
|
8038
|
+
if (!modules)
|
|
8039
|
+
return null;
|
|
8040
|
+
const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, declared);
|
|
8041
|
+
if (read.kind === 'differs')
|
|
8042
|
+
return read.message;
|
|
8043
|
+
if (read.kind === 'read')
|
|
8044
|
+
await admin.recordMigrationsExtracted(actor, slug, versionId, read.digest);
|
|
8045
|
+
return null;
|
|
8046
|
+
};
|
|
7744
8047
|
/**
|
|
7745
8048
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
7746
8049
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -8133,6 +8436,10 @@ export function createControlPlaneApi(options) {
|
|
|
8133
8436
|
const answer = roleStateAnswer(roles, confinedTenant(p));
|
|
8134
8437
|
if (answer)
|
|
8135
8438
|
return refuseStatus(c, answer.status, answer.message, answer.extra);
|
|
8439
|
+
// #2351: a version whose bundle was never read is read now, before anything moves.
|
|
8440
|
+
const bundleRefusal = await bundleMigrationsRefusal(c.var.admin, c.get('actor'), slug, versionId);
|
|
8441
|
+
if (bundleRefusal)
|
|
8442
|
+
return refuseStatus(c, 422, bundleRefusal);
|
|
8136
8443
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
8137
8444
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
8138
8445
|
// surface as a 4xx through mapError, not a 500.
|
|
@@ -8581,6 +8888,20 @@ export function createControlPlaneApi(options) {
|
|
|
8581
8888
|
const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
|
|
8582
8889
|
if ('refuse' in sweeper)
|
|
8583
8890
|
return refuseStatus(c, 422, sweeper.refuse);
|
|
8891
|
+
// #2351: the migrations the bundle registers, read before anything is uploaded. Where the
|
|
8892
|
+
// manifest carries migrations they must be the registered ones, or the push is refused, and
|
|
8893
|
+
// so is a bundle that cannot be evaluated or does not settle: the stored set is then provably
|
|
8894
|
+
// what the version runs. A declared `[]` is a set like any other: the bundle must register
|
|
8895
|
+
// none. A loader that fails is the platform's, so that push answers 503 and may be retried. A
|
|
8896
|
+
// manifest that leaves the field off (over the caps a manifest carries) stores none, so nothing
|
|
8897
|
+
// is gated: its push is accepted however the read went, and records the digest of what was
|
|
8898
|
+
// read, if anything was.
|
|
8899
|
+
const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, manifest.migrations ?? null);
|
|
8900
|
+
if (read.kind === 'differs' || (read.kind === 'failed' && manifest.migrations))
|
|
8901
|
+
return refuseStatus(c, 422, read.message);
|
|
8902
|
+
if (read.kind === 'unavailable' && manifest.migrations)
|
|
8903
|
+
return refuseStatus(c, 503, read.message);
|
|
8904
|
+
const migrationsExtracted = read.kind === 'read' ? read.digest : null;
|
|
8584
8905
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
8585
8906
|
// on it, so it is CF-valid and unique per version.
|
|
8586
8907
|
const id = ulid();
|
|
@@ -8698,6 +9019,8 @@ export function createControlPlaneApi(options) {
|
|
|
8698
9019
|
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
8699
9020
|
});
|
|
8700
9021
|
await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
|
|
9022
|
+
if (migrationsExtracted)
|
|
9023
|
+
await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
|
|
8701
9024
|
// Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
|
|
8702
9025
|
// surfaces, name any surface that hostnames are still bound to but the declaration
|
|
8703
9026
|
// dropped — the URL keeps resolving (routing never keys on the declaration), it just
|
|
@@ -10858,6 +11181,32 @@ export function createControlPlaneApi(options) {
|
|
|
10858
11181
|
});
|
|
10859
11182
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
10860
11183
|
});
|
|
11184
|
+
// -- builder turns: one summary per builder-studio turn, every team -------------
|
|
11185
|
+
// Staff only, and stated here rather than left to the allowlists' default-deny: a
|
|
11186
|
+
// summary names a team and a project, and the turn behind it carries what someone
|
|
11187
|
+
// typed into the studio. The builder answers both; this relays and re-parses.
|
|
11188
|
+
app.get('/builder-turns', async (c) => {
|
|
11189
|
+
if (!staffCaller(c))
|
|
11190
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
11191
|
+
if (!options.builderTurns)
|
|
11192
|
+
return refuseStatus(c, 501, 'the builder studio is not configured');
|
|
11193
|
+
const url = new URL(c.req.url);
|
|
11194
|
+
const forwarded = new URLSearchParams();
|
|
11195
|
+
for (const key of ['tenantId', 'projectId', 'outcome', 'redGate', 'troubled', 'since', 'limit', 'cursor']) {
|
|
11196
|
+
const value = url.searchParams.get(key);
|
|
11197
|
+
if (value)
|
|
11198
|
+
forwarded.set(key, value);
|
|
11199
|
+
}
|
|
11200
|
+
return c.json(await options.builderTurns.list(forwarded));
|
|
11201
|
+
});
|
|
11202
|
+
app.get('/builder-turns/:turnId', async (c) => {
|
|
11203
|
+
if (!staffCaller(c))
|
|
11204
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
11205
|
+
if (!options.builderTurns)
|
|
11206
|
+
return refuseStatus(c, 501, 'the builder studio is not configured');
|
|
11207
|
+
const turn = await options.builderTurns.get(c.req.param('turnId'));
|
|
11208
|
+
return turn ? c.json(turn) : refuse(c, 'not_found', 'no such turn');
|
|
11209
|
+
});
|
|
10861
11210
|
// -- sweep runs (#1232): the durable record of what a sweep pass touched -------
|
|
10862
11211
|
// Staff read fleet-wide (the console's later view); a builder's tenant is forced
|
|
10863
11212
|
// from the principal, the /ops-failures posture exactly.
|