@substrat-run/control-plane-api 0.148.0 → 0.149.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 +19 -11
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +321 -93
- package/dist/api.js.map +1 -1
- package/dist/attachment-sweep.d.ts +29 -0
- package/dist/attachment-sweep.d.ts.map +1 -0
- package/dist/attachment-sweep.js +66 -0
- package/dist/attachment-sweep.js.map +1 -0
- package/dist/browser.d.ts +3 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +3 -0
- package/dist/browser.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +3 -1
- package/dist/cf-observability.js.map +1 -1
- package/dist/deploy.d.ts +25 -11
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +32 -17
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +28 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +35 -0
- package/dist/observability.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +1 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +2 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +12 -12
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +21 -21
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/scope-copy-cleanup.d.ts +1 -1
- package/dist/scope-copy-cleanup.d.ts.map +1 -1
- package/dist/scope-copy-cleanup.js.map +1 -1
- package/dist/staff-client.d.ts +10 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +6 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/store-entry.d.ts +19 -19
- package/dist/store-entry.d.ts.map +1 -1
- package/dist/store-entry.generated.d.ts +16 -16
- package/dist/store-entry.generated.d.ts.map +1 -1
- package/dist/store-entry.generated.js +19 -19
- package/dist/store-entry.generated.js.map +1 -1
- package/dist/store-entry.js +43 -43
- package/dist/store-entry.js.map +1 -1
- package/dist/store-rebuild.d.ts +142 -0
- package/dist/store-rebuild.d.ts.map +1 -0
- package/dist/store-rebuild.js +223 -0
- package/dist/store-rebuild.js.map +1 -0
- package/dist/store-releases.d.ts +60 -49
- package/dist/store-releases.d.ts.map +1 -1
- package/dist/store-releases.js +134 -100
- package/dist/store-releases.js.map +1 -1
- package/dist/store-script.d.ts +37 -19
- package/dist/store-script.d.ts.map +1 -1
- package/dist/store-script.js +38 -23
- package/dist/store-script.js.map +1 -1
- package/dist/vertical-client.d.ts +29 -2
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +48 -2
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
package/dist/api.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
2
2
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
-
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash,
|
|
5
|
-
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, replayStoreErasures } from '@substrat-run/kernel';
|
|
4
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, 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, 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, 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, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
|
|
5
|
+
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
6
6
|
import { attributeFailure } from './failure-attribution.js';
|
|
7
7
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
8
|
-
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
8
|
+
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
9
9
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
10
10
|
import { mintTenantToken } from './tenant-token.js';
|
|
11
11
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
12
12
|
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
13
|
-
import {
|
|
13
|
+
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
14
|
+
import { PUSH_HOLD_TTL_MS, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
|
|
14
15
|
import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
|
|
15
16
|
import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
|
|
16
17
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
@@ -24,7 +25,7 @@ import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
|
|
|
24
25
|
import { maskDump, maskRecords } from './mask.js';
|
|
25
26
|
import { createPseudonymizer } from './pseudonymize.js';
|
|
26
27
|
import { openDump, sealDump } from './seal.js';
|
|
27
|
-
import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
28
|
+
import { assertSandboxContract, verticalUploadBindings, migrationsDigestBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
28
29
|
/**
|
|
29
30
|
* Answer with a problem document (#113 phase 4).
|
|
30
31
|
*
|
|
@@ -297,6 +298,8 @@ const rebindScopeVerticalBody = z.object({
|
|
|
297
298
|
// version carry the SAME migration digest — or the operator acknowledges having
|
|
298
299
|
// read both migration surfaces. Same discipline as a promote's `--ack-migrations`.
|
|
299
300
|
ackMigrations: z.boolean().optional(),
|
|
301
|
+
// (#2347: the acknowledgement names the review it answers, `acknowledge.review`, as a
|
|
302
|
+
// promote's does; an `ackMigrations` without one is refused where the digests differ.)
|
|
300
303
|
// Rebind the DIRECTORY only — no export/restore. For a scope whose source script
|
|
301
304
|
// predates the `/internal/export` surface (#236) and so cannot be dumped at all.
|
|
302
305
|
// The data is not deleted: it stays on the source script, which remains the
|
|
@@ -304,7 +307,8 @@ const rebindScopeVerticalBody = z.object({
|
|
|
304
307
|
// on the target afterwards (`/verticals/:slug/instances` is idempotent, K-31).
|
|
305
308
|
abandonData: z.boolean().optional(),
|
|
306
309
|
// #1756: within one lineage the rebind is an adopt, refused when it breaks an app in the tenant.
|
|
307
|
-
|
|
310
|
+
// #2347: `review`, the rebind review `ackMigrations` answers (`GET …/rebind-review`).
|
|
311
|
+
acknowledge: rebindAcknowledgement.optional(),
|
|
308
312
|
});
|
|
309
313
|
// #1756: an adopt moves the scope onto what its vertical serves, and is refused when that breaks
|
|
310
314
|
// an app in the tenant. The body is optional, as it always was.
|
|
@@ -694,6 +698,8 @@ const TENANT_ROUTES = [
|
|
|
694
698
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/query$/, pin: 'path' },
|
|
695
699
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/reap$/, pin: 'path' },
|
|
696
700
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-vertical$/, pin: 'path' },
|
|
701
|
+
// #2347: the review a rebind's migration acknowledgement answers; visible exactly as the rebind is.
|
|
702
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-review$/, pin: 'path' },
|
|
697
703
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/restore$/, pin: 'path' },
|
|
698
704
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rewind$/, pin: 'path' },
|
|
699
705
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/snapshots$/, pin: 'path' },
|
|
@@ -845,7 +851,7 @@ export function createControlPlaneApi(options) {
|
|
|
845
851
|
const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
|
|
846
852
|
const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
|
|
847
853
|
/** The stores of the version the scope's deployment runs (`scopeStoresOf`, #2207). */
|
|
848
|
-
const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.
|
|
854
|
+
const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers, running);
|
|
849
855
|
/**
|
|
850
856
|
* The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
|
|
851
857
|
* own export, restore, wipe and erasure carry its stores, and a scope that declares none has
|
|
@@ -887,7 +893,7 @@ export function createControlPlaneApi(options) {
|
|
|
887
893
|
const caller = options.platformScopeStores;
|
|
888
894
|
if (!caller || !scope.vertical)
|
|
889
895
|
return undefined;
|
|
890
|
-
const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.
|
|
896
|
+
const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
|
|
891
897
|
if (all.length === 0)
|
|
892
898
|
return undefined;
|
|
893
899
|
const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
|
|
@@ -897,7 +903,7 @@ export function createControlPlaneApi(options) {
|
|
|
897
903
|
if (held) {
|
|
898
904
|
throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
|
|
899
905
|
`still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
|
|
900
|
-
`restored. Declare '${held.binding}' as a '${held.
|
|
906
|
+
`restored. Declare '${held.binding}' as a '${held.driver}' again in a version the scope runs, then export; ` +
|
|
901
907
|
`or reap the scope without a backup, which wipes it`);
|
|
902
908
|
}
|
|
903
909
|
return exported.filter((store) => current.has(store.binding));
|
|
@@ -948,7 +954,7 @@ export function createControlPlaneApi(options) {
|
|
|
948
954
|
};
|
|
949
955
|
/**
|
|
950
956
|
* A dump's stores, checked against what the destination's version declares (#2207), before
|
|
951
|
-
* any write: a binding it does not declare, or one of another
|
|
957
|
+
* any write: a binding it does not declare, or one of another driver, would be imported into an
|
|
952
958
|
* object nothing reads, or into one of the wrong shape.
|
|
953
959
|
*/
|
|
954
960
|
const refuseUndeclaredDumpStores = async (c, scope, stores) => {
|
|
@@ -958,15 +964,15 @@ export function createControlPlaneApi(options) {
|
|
|
958
964
|
throw new ControlPlaneError(422, 'restore refused: the dump names stores and the scope names no vertical');
|
|
959
965
|
// The destination's running version only (L8): a binding only an earlier version declared
|
|
960
966
|
// would be imported into an object nothing in the code the scope runs reads.
|
|
961
|
-
const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.
|
|
962
|
-
.map((d) => [d.binding, d.
|
|
967
|
+
const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers))
|
|
968
|
+
.map((d) => [d.binding, d.driver]));
|
|
963
969
|
for (const store of stores) {
|
|
964
|
-
const
|
|
965
|
-
if (
|
|
970
|
+
const driver = declared.get(store.binding);
|
|
971
|
+
if (driver === undefined) {
|
|
966
972
|
throw new ControlPlaneError(422, `restore refused: the dump names store '${store.binding}', which this scope's version does not declare`);
|
|
967
973
|
}
|
|
968
|
-
if (
|
|
969
|
-
throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.
|
|
974
|
+
if (driver !== store.driver) {
|
|
975
|
+
throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.driver}', and this scope's version declares a '${driver}'`);
|
|
970
976
|
}
|
|
971
977
|
}
|
|
972
978
|
};
|
|
@@ -2044,7 +2050,11 @@ export function createControlPlaneApi(options) {
|
|
|
2044
2050
|
.refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
|
|
2045
2051
|
horizonYears: z.number().int().min(0).optional(),
|
|
2046
2052
|
}).strict()
|
|
2047
|
-
:
|
|
2053
|
+
: row.provider === 'microsoft365'
|
|
2054
|
+
// #2101: the channel folder, by its drive path. The connector resolves it and holds the
|
|
2055
|
+
// fence to it; this only bounds the input.
|
|
2056
|
+
? z.object({ ...fields, folder: z.string().trim().min(1).max(400) }).strict()
|
|
2057
|
+
: z.object(fields).strict();
|
|
2048
2058
|
const input = schema.parse(await c.req.json());
|
|
2049
2059
|
const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
|
|
2050
2060
|
if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
|
|
@@ -2488,6 +2498,27 @@ export function createControlPlaneApi(options) {
|
|
|
2488
2498
|
}
|
|
2489
2499
|
return { refused, ...(refusal ? { refusal } : {}) };
|
|
2490
2500
|
};
|
|
2501
|
+
/**
|
|
2502
|
+
* The two sides of a rebind review (#2347), as the host stores them: the version the scope is
|
|
2503
|
+
* bound to (null: none, or one its lineage no longer holds) and the target lineage's serving
|
|
2504
|
+
* version, each with its stored migrations. The read route and the rebind gate both read
|
|
2505
|
+
* through here, so the digest a review hands out and the one the rebind recomputes are taken
|
|
2506
|
+
* from the same rows the same way.
|
|
2507
|
+
*/
|
|
2508
|
+
const rebindSidesOf = async (c, scope, target, servingVersionId) => {
|
|
2509
|
+
const actor = c.get('actor');
|
|
2510
|
+
const side = async (slug, id) => {
|
|
2511
|
+
const v = await c.var.admin.getVersion(actor, id, slug);
|
|
2512
|
+
return v ? { ...v, migrations: await c.var.admin.versionMigrations(actor, slug, id) } : null;
|
|
2513
|
+
};
|
|
2514
|
+
const [base, incoming] = await Promise.all([
|
|
2515
|
+
scope.vertical && scope.verticalVersionId ? side(scope.vertical, scope.verticalVersionId) : null,
|
|
2516
|
+
side(target, servingVersionId),
|
|
2517
|
+
]);
|
|
2518
|
+
if (!incoming)
|
|
2519
|
+
throw substratError('not_found', `unknown serving version for vertical '${target}'`);
|
|
2520
|
+
return { base, target: incoming };
|
|
2521
|
+
};
|
|
2491
2522
|
/**
|
|
2492
2523
|
* Move ONE scope onto a DIFFERENT vertical lineage's serving script (#389) — the
|
|
2493
2524
|
* update-rebind behind retiring a platform-owned lineage in favour of a tenant-owned
|
|
@@ -2525,13 +2556,15 @@ export function createControlPlaneApi(options) {
|
|
|
2525
2556
|
await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
2526
2557
|
return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
|
|
2527
2558
|
}
|
|
2559
|
+
// #2347: the review binding is the rebind's own; what the host's bind takes is the rest.
|
|
2560
|
+
const { review, ...bindAck } = opts.acknowledge ?? {};
|
|
2528
2561
|
// #1756: within one lineage this is an adopt, and judged as one before anything moves. A
|
|
2529
2562
|
// crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
|
|
2530
2563
|
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
2531
2564
|
servingRef: serving.ref,
|
|
2532
|
-
acknowledge:
|
|
2565
|
+
acknowledge: bindAck,
|
|
2533
2566
|
});
|
|
2534
|
-
const move = { acknowledge:
|
|
2567
|
+
const move = { acknowledge: bindAck };
|
|
2535
2568
|
if (opts.abandonData) {
|
|
2536
2569
|
// Directory-only crossing: no bytes move, so the frontier gate has nothing to
|
|
2537
2570
|
// protect — the target provisions its own schema from scratch. The source script's
|
|
@@ -2560,15 +2593,22 @@ export function createControlPlaneApi(options) {
|
|
|
2560
2593
|
// what this DO has already applied plus whatever a same-lineage update would have
|
|
2561
2594
|
// brought — the crossing is then no riskier than an ordinary version bind. Anything
|
|
2562
2595
|
// else (differing digests, or a scope with no bound version to compare) needs the
|
|
2563
|
-
// operator's explicit acknowledgement.
|
|
2564
|
-
const
|
|
2565
|
-
const
|
|
2566
|
-
|
|
2567
|
-
:
|
|
2568
|
-
|
|
2596
|
+
// operator's explicit acknowledgement, bound to the review it answers (#2347).
|
|
2597
|
+
const sides = await rebindSidesOf(c, scope, target, serving.versionId);
|
|
2598
|
+
const digestsMatch = Boolean(sides.base && sides.base.migrationDigest === sides.target.migrationDigest);
|
|
2599
|
+
await assertRebindReviewCurrent({
|
|
2600
|
+
ackMigrations: opts.ackMigrations === true,
|
|
2601
|
+
review,
|
|
2602
|
+
target,
|
|
2603
|
+
baseId: scope.verticalVersionId ?? null,
|
|
2604
|
+
migrationsChange: !digestsMatch,
|
|
2605
|
+
read: async () => ({ base: sides.base, baseVertical: scope.vertical, target: sides.target }),
|
|
2606
|
+
});
|
|
2569
2607
|
if (!digestsMatch && !opts.ackMigrations) {
|
|
2570
|
-
throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${
|
|
2571
|
-
`'${target}' ${
|
|
2608
|
+
throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${sides.base?.migrationDigest ?? '(unbound)'} → ` +
|
|
2609
|
+
`'${target}' ${sides.target.migrationDigest}) — read the rebind review (GET ` +
|
|
2610
|
+
`/tenants/:tenantId/scopes/:scopeId/rebind-review?vertical=<target>), then re-run with ackMigrations and the ` +
|
|
2611
|
+
'acknowledge.review it returned');
|
|
2572
2612
|
}
|
|
2573
2613
|
const source = await verticalForScope(c, scope);
|
|
2574
2614
|
const dest = await options.resolveVerticalRef?.(serving.ref);
|
|
@@ -2579,8 +2619,17 @@ export function createControlPlaneApi(options) {
|
|
|
2579
2619
|
// pointer cross. `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited.
|
|
2580
2620
|
// No snapshot is taken here: the old copy is swept once the move is confirmed, so an
|
|
2581
2621
|
// operator who wants a way back takes a snapshot or backup first.
|
|
2582
|
-
|
|
2583
|
-
|
|
2622
|
+
// #2347: the route flips and the pointer crosses in ONE write, and only if the scope is still
|
|
2623
|
+
// bound to the version the gate judged and the target still serves the version it judged: a
|
|
2624
|
+
// promote on either lineage since then is refused as stale, and the move settles `retained`.
|
|
2625
|
+
const restored = await moveOntoServing(c, scope, source, dest, serving.ref, {
|
|
2626
|
+
...move,
|
|
2627
|
+
bind: {
|
|
2628
|
+
versionId: serving.versionId,
|
|
2629
|
+
expectedVersionId: scope.verticalVersionId ?? null,
|
|
2630
|
+
expectedServing: { vertical: target, ref: serving.ref, versionId: serving.versionId },
|
|
2631
|
+
},
|
|
2632
|
+
});
|
|
2584
2633
|
// #1674: re-assert the recorded OFF positions in the store the scope now routes to.
|
|
2585
2634
|
await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
|
|
2586
2635
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
@@ -2620,12 +2669,18 @@ export function createControlPlaneApi(options) {
|
|
|
2620
2669
|
scopeId, exact: true, loadStamp: restoredStamp,
|
|
2621
2670
|
...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
|
|
2622
2671
|
});
|
|
2623
|
-
|
|
2624
|
-
|
|
2672
|
+
const flip = {
|
|
2673
|
+
acknowledge: routeOpts.acknowledge, expectedErasureEpoch: erasureEpoch,
|
|
2625
2674
|
...(moveId ? { confirmMove: sourceStamp
|
|
2626
2675
|
? { moveId, source: 'eligible', sourceMarker: { loadStamp: sourceStamp, revision: sourceRevision } }
|
|
2627
2676
|
: { moveId, source: 'retained' } } : {}),
|
|
2628
|
-
}
|
|
2677
|
+
};
|
|
2678
|
+
const { bind } = routeOpts;
|
|
2679
|
+
await (bind
|
|
2680
|
+
? c.var.admin.bindScopeVersion(actor, tenantId, scopeId, bind.versionId, {
|
|
2681
|
+
...flip, servingRef, expectedVersionId: bind.expectedVersionId, expectedServing: bind.expectedServing,
|
|
2682
|
+
})
|
|
2683
|
+
: c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, flip)).catch(relayHostRefusal);
|
|
2629
2684
|
return restored;
|
|
2630
2685
|
}
|
|
2631
2686
|
catch (error) {
|
|
@@ -4464,7 +4519,7 @@ export function createControlPlaneApi(options) {
|
|
|
4464
4519
|
// and the claim before the key goes requires it, and the serving pointer as read, to still hold.
|
|
4465
4520
|
const running = await runningVersionOf(c.var.admin, c.get('actor'), scope);
|
|
4466
4521
|
// #2207: the stores of the version the deployment runs, resolved before anything is erased. A
|
|
4467
|
-
// version that declares none is not asked. One that cannot be resolved while a
|
|
4522
|
+
// version that declares none is not asked. One that cannot be resolved while a driver is
|
|
4468
4523
|
// registered refuses (503): the key must not be destroyed while a store may hold the subject.
|
|
4469
4524
|
const reach = await platformStoresOf(c, scope, running);
|
|
4470
4525
|
/**
|
|
@@ -5353,7 +5408,7 @@ export function createControlPlaneApi(options) {
|
|
|
5353
5408
|
force,
|
|
5354
5409
|
localApply: !vertical,
|
|
5355
5410
|
});
|
|
5356
|
-
const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force }) : result;
|
|
5411
|
+
const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force, tenantId }) : result;
|
|
5357
5412
|
// #1674: a rewind takes the scope's grants back to the bookmark, and with them any
|
|
5358
5413
|
// schedule kill switch pulled since — a tenant owner could otherwise undo a staff
|
|
5359
5414
|
// switch this way. Clearing the provisioned receipt, only once the rewind has gone
|
|
@@ -5434,10 +5489,10 @@ export function createControlPlaneApi(options) {
|
|
|
5434
5489
|
return c.json({ vertical: slug, adopted, alreadyAdopted });
|
|
5435
5490
|
});
|
|
5436
5491
|
// Rebind ONE scope onto a different vertical lineage (#389) — the update-rebind that
|
|
5437
|
-
// retires a platform-owned lineage in favour of a tenant-owned one.
|
|
5438
|
-
//
|
|
5439
|
-
//
|
|
5440
|
-
//
|
|
5492
|
+
// retires a platform-owned lineage in favour of a tenant-owned one. Callers: staff, and a
|
|
5493
|
+
// tenant credential pinned to the tenant in the path (TENANT_ROUTES); the crossing is judged
|
|
5494
|
+
// by `crossingHidden`, the rule the review read shares. Deliberately NOT in BUILDER_ROUTES:
|
|
5495
|
+
// a builder's push token is default-denied it.
|
|
5441
5496
|
app.post('/tenants/:tenantId/scopes/:scopeId/rebind-vertical', async (c) => {
|
|
5442
5497
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5443
5498
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -5445,10 +5500,11 @@ export function createControlPlaneApi(options) {
|
|
|
5445
5500
|
// #977: the TARGET must be a vertical this principal can read — its own tenant's, or a
|
|
5446
5501
|
// listed one. The path pin confines whose scope moves; without this, it could move onto
|
|
5447
5502
|
// another tenant's private vertical, which a confined caller must not even learn exists
|
|
5448
|
-
// (404, as `GET /verticals/:slug` answers it).
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5503
|
+
// (404, as `GET /verticals/:slug` answers it). #2347: the one rule its review read shares.
|
|
5504
|
+
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
5505
|
+
// An unknown scope answers the same bytes as a hidden crossing, so the two cannot be told apart.
|
|
5506
|
+
if (!scope || (await crossingHidden(c.get('principal'), scope.vertical ?? null, vertical)))
|
|
5507
|
+
return crossingNotFound(c);
|
|
5452
5508
|
try {
|
|
5453
5509
|
const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
|
|
5454
5510
|
ackMigrations,
|
|
@@ -5464,6 +5520,55 @@ export function createControlPlaneApi(options) {
|
|
|
5464
5520
|
throw e;
|
|
5465
5521
|
}
|
|
5466
5522
|
});
|
|
5523
|
+
// #2347: the review a rebind's migration acknowledgement answers — what the target lineage's
|
|
5524
|
+
// serving version adds over the version the scope is bound to, with the binding
|
|
5525
|
+
// (`acknowledge.review`) computed from the very rows this answer shows. The server picks both
|
|
5526
|
+
// sides, so a caller cannot bind a review of a base the scope is not on.
|
|
5527
|
+
//
|
|
5528
|
+
// Who may read it is exactly who may make the rebind it answers (`crossingHidden`): a confined
|
|
5529
|
+
// caller must be able to read both verticals, its own tenant's or a listed one. An unknown
|
|
5530
|
+
// scope, a scope with no vertical, an unknown target and a vertical the caller cannot read all
|
|
5531
|
+
// answer the same 404, so the read is no oracle for another tenant's verticals.
|
|
5532
|
+
// Callers: staff, and a tenant credential pinned to the tenant in the path, as the rebind.
|
|
5533
|
+
//
|
|
5534
|
+
// The migration SQL itself stays owner-only, as `/versions/:id/migrations` keeps it: it describes
|
|
5535
|
+
// a schema. A caller that does not own both verticals gets `migrations: null` and
|
|
5536
|
+
// `migrationsWithheld: true`, with everything else: both sides, whether the digests differ, and
|
|
5537
|
+
// the binding, so it can still acknowledge the review and rebind onto a listed vertical.
|
|
5538
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/rebind-review', async (c) => {
|
|
5539
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5540
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5541
|
+
const target = z.string().min(1).parse(c.req.query('vertical'));
|
|
5542
|
+
const actor = c.get('actor');
|
|
5543
|
+
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5544
|
+
if (!scope?.vertical || (await crossingHidden(c.get('principal'), scope.vertical, target)))
|
|
5545
|
+
return crossingNotFound(c);
|
|
5546
|
+
const serving = await c.var.admin.verticalServing(actor, target).catch((e) => {
|
|
5547
|
+
if (errorCodeOf(e) === 'not_found')
|
|
5548
|
+
return undefined;
|
|
5549
|
+
throw e;
|
|
5550
|
+
});
|
|
5551
|
+
if (serving === undefined)
|
|
5552
|
+
return crossingNotFound(c);
|
|
5553
|
+
if (serving === null) {
|
|
5554
|
+
return refuseStatus(c, 409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
|
|
5555
|
+
}
|
|
5556
|
+
const { base, target: incoming } = await rebindSidesOf(c, scope, target, serving.versionId);
|
|
5557
|
+
const p = c.get('principal');
|
|
5558
|
+
const withheld = (await notOwned(p, scope.vertical)) || (await notOwned(p, target));
|
|
5559
|
+
const review = {
|
|
5560
|
+
base: scope.verticalVersionId ?? null,
|
|
5561
|
+
digest: await rebindReviewDigest(base && { vertical: scope.vertical, version: base }, { vertical: target, version: incoming }),
|
|
5562
|
+
};
|
|
5563
|
+
return c.json({
|
|
5564
|
+
base: scope.verticalVersionId ? { vertical: scope.vertical, versionId: scope.verticalVersionId } : null,
|
|
5565
|
+
target: { vertical: target, versionId: incoming.id },
|
|
5566
|
+
digestsMatch: Boolean(base && base.migrationDigest === incoming.migrationDigest),
|
|
5567
|
+
migrations: withheld || incoming.migrations === null ? null : migrationsOnTop(incoming.migrations, base ? base.migrations : undefined),
|
|
5568
|
+
migrationsWithheld: withheld,
|
|
5569
|
+
review,
|
|
5570
|
+
});
|
|
5571
|
+
});
|
|
5467
5572
|
// #1756: which apps in this tenant binding `versionId` onto the scope would break — the
|
|
5468
5573
|
// question the bind below refuses on, as a read, so a client can ask it before it records
|
|
5469
5574
|
// anything of its own (the dashboard's Activity line) and show what an acknowledgement would
|
|
@@ -6022,6 +6127,16 @@ export function createControlPlaneApi(options) {
|
|
|
6022
6127
|
const v = await verticalOf(p.actor, slug);
|
|
6023
6128
|
return !v || (v.ownerTenant !== pin && !v.listed);
|
|
6024
6129
|
};
|
|
6130
|
+
/**
|
|
6131
|
+
* #2347: the one visibility rule for a rebind across verticals and for the review it answers.
|
|
6132
|
+
* A confined caller sees a crossing only when it can read both ends (`notReadable`: its own
|
|
6133
|
+
* tenant's vertical, or a listed one), so whoever may make a rebind may read what it applies, and
|
|
6134
|
+
* nobody reads the migrations of a vertical it could not see. A scope with no vertical (null)
|
|
6135
|
+
* hides nothing here: the rebind refuses it on its own terms.
|
|
6136
|
+
*/
|
|
6137
|
+
const crossingHidden = async (p, source, target) => (await notReadable(p, target)) || (source !== null && (await notReadable(p, source)));
|
|
6138
|
+
/** What a hidden crossing answers, the same bytes whichever end is hidden. */
|
|
6139
|
+
const crossingNotFound = (c) => refuse(c, 'not_found', 'not found');
|
|
6025
6140
|
// The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
|
|
6026
6141
|
// (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
|
|
6027
6142
|
// from their authenticated tenant — so two builders can each own a `helpdesk` with no
|
|
@@ -6282,12 +6397,28 @@ export function createControlPlaneApi(options) {
|
|
|
6282
6397
|
}
|
|
6283
6398
|
// `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
|
|
6284
6399
|
const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
|
|
6400
|
+
const versionOf = async (id) => {
|
|
6401
|
+
const v = await c.var.admin.getVersion(c.get('actor'), id, slug);
|
|
6402
|
+
if (!v)
|
|
6403
|
+
throw substratError('not_found', `unknown version ${id} for vertical '${slug}'`);
|
|
6404
|
+
return v;
|
|
6405
|
+
};
|
|
6285
6406
|
const base = c.req.query('base');
|
|
6286
|
-
const
|
|
6287
|
-
|
|
6407
|
+
const incomingId = c.req.param('id');
|
|
6408
|
+
const [incoming, baseline, incomingVersion, baseVersion] = await Promise.all([
|
|
6409
|
+
migrationsOf(incomingId),
|
|
6288
6410
|
base === undefined ? undefined : migrationsOf(base),
|
|
6411
|
+
versionOf(incomingId),
|
|
6412
|
+
base === undefined ? null : versionOf(base),
|
|
6289
6413
|
]);
|
|
6290
|
-
|
|
6414
|
+
// #2333: the binding a promote's acknowledgement carries, computed from the very rows this
|
|
6415
|
+
// answer shows, in this answer — so a client cannot show one diff and bind another. The
|
|
6416
|
+
// promote recomputes it from its own stored rows and refuses when it no longer matches.
|
|
6417
|
+
const review = {
|
|
6418
|
+
base: base ?? null,
|
|
6419
|
+
digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
|
|
6420
|
+
};
|
|
6421
|
+
return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review });
|
|
6291
6422
|
});
|
|
6292
6423
|
// The emitted entity model (#1214) one version ships: entities, field schemas, parent
|
|
6293
6424
|
// edges, declared lifecycles — read out of the same retained manifest as the registry
|
|
@@ -6495,33 +6626,33 @@ export function createControlPlaneApi(options) {
|
|
|
6495
6626
|
if (stores.length === 0)
|
|
6496
6627
|
return;
|
|
6497
6628
|
const holder = ulid();
|
|
6498
|
-
const
|
|
6629
|
+
const drivers = [...new Set(stores.map((store) => store.driver))].sort();
|
|
6499
6630
|
const held = [];
|
|
6500
6631
|
try {
|
|
6501
6632
|
if (!options.ensureScopeStores) {
|
|
6502
6633
|
throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
|
|
6503
6634
|
}
|
|
6504
|
-
// A shared hold on each
|
|
6635
|
+
// A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
6505
6636
|
// a script's release before a rollout moved it would upload the old release back over it.
|
|
6506
6637
|
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
6507
|
-
for (const
|
|
6508
|
-
if (!(await host.admin.
|
|
6509
|
-
throw new ControlPlaneError(409, `${operation} refused: scope store
|
|
6638
|
+
for (const driver of drivers) {
|
|
6639
|
+
if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
6640
|
+
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
6510
6641
|
}
|
|
6511
|
-
held.push(
|
|
6642
|
+
held.push(driver);
|
|
6512
6643
|
}
|
|
6513
6644
|
const loadRelease = options.storeReleases
|
|
6514
6645
|
? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
|
|
6515
6646
|
: async () => undefined;
|
|
6516
|
-
// One
|
|
6517
|
-
// stops (409) rather than upload after a rollout could have taken the
|
|
6518
|
-
for (const
|
|
6647
|
+
// One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
|
|
6648
|
+
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
6649
|
+
for (const driver of drivers) {
|
|
6519
6650
|
for (const each of held) {
|
|
6520
|
-
if (!(await host.admin.
|
|
6521
|
-
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store
|
|
6651
|
+
if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
6652
|
+
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
|
|
6522
6653
|
}
|
|
6523
6654
|
}
|
|
6524
|
-
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.
|
|
6655
|
+
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease });
|
|
6525
6656
|
}
|
|
6526
6657
|
}
|
|
6527
6658
|
catch (e) {
|
|
@@ -6537,8 +6668,8 @@ export function createControlPlaneApi(options) {
|
|
|
6537
6668
|
throw e;
|
|
6538
6669
|
}
|
|
6539
6670
|
finally {
|
|
6540
|
-
for (const
|
|
6541
|
-
await host.admin.
|
|
6671
|
+
for (const driver of held)
|
|
6672
|
+
await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
|
|
6542
6673
|
}
|
|
6543
6674
|
};
|
|
6544
6675
|
/**
|
|
@@ -6602,8 +6733,17 @@ export function createControlPlaneApi(options) {
|
|
|
6602
6733
|
modules,
|
|
6603
6734
|
doClasses,
|
|
6604
6735
|
// #2210: a declared scope store adds no binding to either upload (L1), held at both call
|
|
6605
|
-
// sites with a
|
|
6606
|
-
bindings: verticalUploadBindings({
|
|
6736
|
+
// sites with a driver registered by upload-scope-store-bindings.test.ts.
|
|
6737
|
+
bindings: verticalUploadBindings({
|
|
6738
|
+
vertical: slug,
|
|
6739
|
+
manifest,
|
|
6740
|
+
// #2349: the digest of the served version's stored migrations, which its scopes hold
|
|
6741
|
+
// the SQL they apply to.
|
|
6742
|
+
platformBindings: [
|
|
6743
|
+
...storeBindings,
|
|
6744
|
+
...(await migrationsDigestBindings(await admin.versionMigrations(actor, slug, versionId))),
|
|
6745
|
+
],
|
|
6746
|
+
}),
|
|
6607
6747
|
// #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
|
|
6608
6748
|
// scope host stamps the signals `version` dimension. The serving script is one
|
|
6609
6749
|
// stable script updated in place, so this refresh is what keeps the binding
|
|
@@ -6923,6 +7063,12 @@ export function createControlPlaneApi(options) {
|
|
|
6923
7063
|
const manifest = deployManifest.parse(JSON.parse(raw));
|
|
6924
7064
|
// §4 sandbox contract, before anything reaches the namespace.
|
|
6925
7065
|
assertSandboxContract(manifest);
|
|
7066
|
+
// #2339: a push whose migration digest is not the one its migrations give is refused here,
|
|
7067
|
+
// before the upload, by the derivation `publishVersion` stores (and refuses with) again.
|
|
7068
|
+
await publishedMigrationDigest({
|
|
7069
|
+
id: 'push', migrationDigest: manifest.digests.migration, manifestJson: JSON.stringify(manifest),
|
|
7070
|
+
migrations: manifest.migrations ?? null,
|
|
7071
|
+
});
|
|
6926
7072
|
// The workspace this push is FOR — the project's pin (package.json `substrat.tenant`),
|
|
6927
7073
|
// sent by the CLI alongside the bundle. The pin is intent, and intent is honored or
|
|
6928
7074
|
// refused, never silently reinterpreted: a BUILDER's workspace is already fixed by
|
|
@@ -7118,7 +7264,12 @@ export function createControlPlaneApi(options) {
|
|
|
7118
7264
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
7119
7265
|
modules,
|
|
7120
7266
|
doClasses: manifest.doClasses,
|
|
7121
|
-
bindings: verticalUploadBindings({
|
|
7267
|
+
bindings: verticalUploadBindings({
|
|
7268
|
+
vertical: slug,
|
|
7269
|
+
manifest,
|
|
7270
|
+
// #2349: the migrations this push declares, which are the ones the version stores.
|
|
7271
|
+
platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
|
|
7272
|
+
}),
|
|
7122
7273
|
// #1242: the freshly minted version id — this archive script IS that version,
|
|
7123
7274
|
// and a preview routed at it stamps the signals `version` dimension honestly.
|
|
7124
7275
|
versionId: id,
|
|
@@ -9010,19 +9161,19 @@ export function createControlPlaneApi(options) {
|
|
|
9010
9161
|
const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
|
|
9011
9162
|
return c.json(summary);
|
|
9012
9163
|
});
|
|
9013
|
-
// -- store-
|
|
9164
|
+
// -- store-driver releases (D-63, #2300) ----------------------------------------
|
|
9014
9165
|
// Staff only: none of these is on the builder or the tenant allowlist. A write is also made by a
|
|
9015
9166
|
// person: `humanStaffActor` must name the same actor from a human staff sign-in, so the
|
|
9016
9167
|
// automation token, which resolves to a staff actor as well, publishes nothing.
|
|
9017
|
-
const
|
|
9018
|
-
const registration = (options.
|
|
9168
|
+
const registeredDriver = (driver) => {
|
|
9169
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
9019
9170
|
if (!registration)
|
|
9020
|
-
throw new ControlPlaneError(403, `scope store
|
|
9171
|
+
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
9021
9172
|
return registration;
|
|
9022
9173
|
};
|
|
9023
9174
|
const releaseFlow = () => {
|
|
9024
9175
|
if (!options.storeReleases || !options.ensureScopeStores) {
|
|
9025
|
-
throw new ControlPlaneError(501, 'this control plane keeps no store
|
|
9176
|
+
throw new ControlPlaneError(501, 'this control plane keeps no store driver releases');
|
|
9026
9177
|
}
|
|
9027
9178
|
return { flow: options.storeReleases, ensureScopeStores: options.ensureScopeStores };
|
|
9028
9179
|
};
|
|
@@ -9034,28 +9185,28 @@ export function createControlPlaneApi(options) {
|
|
|
9034
9185
|
*/
|
|
9035
9186
|
const byAPerson = async (c) => {
|
|
9036
9187
|
if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
|
|
9037
|
-
throw new ControlPlaneError(415, 'a store
|
|
9188
|
+
throw new ControlPlaneError(415, 'a store driver release write takes application/json');
|
|
9038
9189
|
}
|
|
9039
9190
|
const origin = c.req.header('origin');
|
|
9040
9191
|
const site = c.req.header('sec-fetch-site');
|
|
9041
9192
|
if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
|
|
9042
|
-
throw new ControlPlaneError(403, 'a store
|
|
9193
|
+
throw new ControlPlaneError(403, 'a store driver release write is not taken from another origin');
|
|
9043
9194
|
}
|
|
9044
9195
|
const actor = c.get('actor');
|
|
9045
9196
|
const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
|
|
9046
9197
|
if (!person || person !== actor) {
|
|
9047
|
-
throw new ControlPlaneError(403, 'a store
|
|
9198
|
+
throw new ControlPlaneError(403, 'a store driver release is published by a person: this takes a staff sign-in, not a service token');
|
|
9048
9199
|
}
|
|
9049
9200
|
return actor;
|
|
9050
9201
|
};
|
|
9051
9202
|
/** The pin a release is run under, or a refusal: the platform runs only what this repo pins. */
|
|
9052
9203
|
const pinOf = async (c, registration, sha256) => {
|
|
9053
|
-
const stored = await c.var.admin.
|
|
9204
|
+
const stored = await c.var.admin.getStoreDriverRelease(c.get('actor'), registration.driver, sha256);
|
|
9054
9205
|
if (!stored)
|
|
9055
|
-
throw new ControlPlaneError(404, `release ${sha256} of '${registration.
|
|
9206
|
+
throw new ControlPlaneError(404, `release ${sha256} of '${registration.driver}' is not stored`);
|
|
9056
9207
|
const pin = registration.releases.find((p) => p.sha256 === sha256);
|
|
9057
9208
|
if (!pin) {
|
|
9058
|
-
throw new ControlPlaneError(422, `release ${sha256} of '${registration.
|
|
9209
|
+
throw new ControlPlaneError(422, `release ${sha256} of '${registration.driver}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
|
|
9059
9210
|
}
|
|
9060
9211
|
if (pin.sourceCommit !== stored.sourceCommit) {
|
|
9061
9212
|
throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
|
|
@@ -9066,7 +9217,7 @@ export function createControlPlaneApi(options) {
|
|
|
9066
9217
|
const { flow, ensureScopeStores } = releaseFlow();
|
|
9067
9218
|
const actor = c.get('actor');
|
|
9068
9219
|
const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
|
|
9069
|
-
const result = await
|
|
9220
|
+
const result = await runStoreDriverRollout({
|
|
9070
9221
|
admin: c.var.admin,
|
|
9071
9222
|
actor,
|
|
9072
9223
|
registration,
|
|
@@ -9076,61 +9227,138 @@ export function createControlPlaneApi(options) {
|
|
|
9076
9227
|
const result = await ensureScopeStores({ ...input, loadRelease });
|
|
9077
9228
|
return result ? result.uploaded > 0 : true;
|
|
9078
9229
|
},
|
|
9079
|
-
ensureCanary: (to) => flow.ensureCanary({
|
|
9230
|
+
ensureCanary: (to) => flow.ensureCanary({ driver: registration.driver, to, loadRelease }),
|
|
9080
9231
|
callCanary: flow.callCanary,
|
|
9081
9232
|
});
|
|
9082
9233
|
return c.json(result, result.complete ? 200 : 202);
|
|
9083
9234
|
};
|
|
9084
|
-
/** Neither a rollout nor a rollback lowers the stored schema: a
|
|
9235
|
+
/** Neither a rollout nor a rollback lowers the stored schema: a driver migrates forward only. */
|
|
9085
9236
|
const assertSchemaKept = (registration, from, to) => {
|
|
9086
9237
|
if (from === null || from === to.sha256)
|
|
9087
9238
|
return;
|
|
9088
9239
|
const leaving = registration.releases.find((p) => p.sha256 === from);
|
|
9089
9240
|
if (!leaving)
|
|
9090
|
-
throw new ControlPlaneError(409, `the current release ${from} of '${registration.
|
|
9241
|
+
throw new ControlPlaneError(409, `the current release ${from} of '${registration.driver}' is not pinned, so its schema cannot be judged`);
|
|
9091
9242
|
if (to.schema < leaving.schema) {
|
|
9092
9243
|
throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
|
|
9093
9244
|
}
|
|
9094
9245
|
};
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
const
|
|
9098
|
-
|
|
9246
|
+
/** Where the platform's rebuild of every script of the driver stands (#2309), if it rebuilds them. */
|
|
9247
|
+
const rebuildView = async (admin, actor, driver, known) => {
|
|
9248
|
+
const fingerprint = known ?? (await options.storeReleases?.fingerprint?.());
|
|
9249
|
+
if (!fingerprint)
|
|
9250
|
+
return undefined;
|
|
9251
|
+
const [walk, declared] = await Promise.all([admin.storeDriverRebuild(actor, driver), admin.storeDriverDeclarations(actor, driver)]);
|
|
9252
|
+
const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
|
|
9253
|
+
return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
|
|
9254
|
+
};
|
|
9255
|
+
app.get('/store-drivers/:driver', async (c) => {
|
|
9256
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
9257
|
+
const state = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
|
|
9258
|
+
const rebuild = await rebuildView(c.var.admin, c.get('actor'), registration.driver);
|
|
9259
|
+
return c.json({ driver: registration.driver, pins: registration.releases, ...state, ...(rebuild ? { rebuild } : {}) });
|
|
9099
9260
|
});
|
|
9100
9261
|
// Store a release's bytes, before or after its pin lands (D-63). Idempotent per bundle.
|
|
9101
|
-
app.put('/store-
|
|
9102
|
-
const registration =
|
|
9262
|
+
app.put('/store-drivers/:driver/releases', async (c) => {
|
|
9263
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
9103
9264
|
const actor = await byAPerson(c);
|
|
9104
9265
|
const { flow } = releaseFlow();
|
|
9105
9266
|
const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
|
|
9106
|
-
const release = await
|
|
9267
|
+
const release = await storeDriverRelease({ admin: c.var.admin, actor, bytes: flow.bytes, driver: registration.driver, body });
|
|
9107
9268
|
return c.json({ release, pinned: registration.releases.some((p) => p.sha256 === release.sha256 && p.sourceCommit === release.sourceCommit) }, 201);
|
|
9108
9269
|
});
|
|
9109
9270
|
// Roll a stored, pinned release out: the canary, then every vertical that ever declared the
|
|
9110
|
-
//
|
|
9111
|
-
app.post('/store-
|
|
9112
|
-
const registration =
|
|
9271
|
+
// driver. Posting the same release again converges a rollout that stopped.
|
|
9272
|
+
app.post('/store-drivers/:driver/rollouts', async (c) => {
|
|
9273
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
9113
9274
|
await byAPerson(c);
|
|
9114
9275
|
const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
|
|
9115
9276
|
const pin = await pinOf(c, registration, sha256);
|
|
9116
|
-
const { current } = await c.var.admin.
|
|
9277
|
+
const { current } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
|
|
9117
9278
|
assertSchemaKept(registration, current?.sha256 ?? null, pin);
|
|
9118
9279
|
return rollOut(c, registration, sha256, 'rollout');
|
|
9119
9280
|
});
|
|
9120
9281
|
// Return to the immediately previous good release (D-63), never further and never a guess. After
|
|
9121
9282
|
// a rollout that ended diverged, that is the current good release itself: undoing the bad
|
|
9122
9283
|
// rollout converges every script back on the release the fleet was healthy on (kernel
|
|
9123
|
-
// `
|
|
9124
|
-
app.post('/store-
|
|
9125
|
-
const registration =
|
|
9284
|
+
// `storeDriverStateIn`, `rollbackTarget`).
|
|
9285
|
+
app.post('/store-drivers/:driver/rollback', async (c) => {
|
|
9286
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
9126
9287
|
await byAPerson(c);
|
|
9127
|
-
const { current, rollbackTarget } = await c.var.admin.
|
|
9288
|
+
const { current, rollbackTarget } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
|
|
9128
9289
|
if (!current || !rollbackTarget)
|
|
9129
|
-
throw new ControlPlaneError(409, `
|
|
9290
|
+
throw new ControlPlaneError(409, `driver '${registration.driver}' has no previous good release to roll back to`);
|
|
9130
9291
|
const pin = await pinOf(c, registration, rollbackTarget);
|
|
9131
9292
|
assertSchemaKept(registration, current.sha256, pin);
|
|
9132
9293
|
return rollOut(c, registration, rollbackTarget, 'rollback');
|
|
9133
9294
|
});
|
|
9295
|
+
// Run a pass of the platform's rebuild of every script of the driver now (#2309): the scheduled
|
|
9296
|
+
// pass does the same every quarter hour, and this is for when that is too slow, after a key is
|
|
9297
|
+
// revoked. Same lease and hold as the scheduled pass, so the two never walk the driver at once,
|
|
9298
|
+
// and a rollout or rollback excludes both. Rebuilds at the release each script runs; moves none.
|
|
9299
|
+
app.post('/store-drivers/:driver/rebuild', async (c) => {
|
|
9300
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
9301
|
+
const actor = await byAPerson(c);
|
|
9302
|
+
// `deployFinished`: the person attests that no control-plane deploy is rolling out, so the walk
|
|
9303
|
+
// this run starts proves every script current once done, without waiting out the settle window.
|
|
9304
|
+
const text = await c.req.text();
|
|
9305
|
+
let raw = {};
|
|
9306
|
+
if (text.trim() !== '') {
|
|
9307
|
+
try {
|
|
9308
|
+
raw = JSON.parse(text);
|
|
9309
|
+
}
|
|
9310
|
+
catch {
|
|
9311
|
+
throw new ControlPlaneError(422, 'the body is not JSON');
|
|
9312
|
+
}
|
|
9313
|
+
}
|
|
9314
|
+
const body = z.object({ deployFinished: z.boolean().optional() }).strict().parse(raw);
|
|
9315
|
+
const { flow, ensureScopeStores } = releaseFlow();
|
|
9316
|
+
if (!flow.fingerprint)
|
|
9317
|
+
throw new ControlPlaneError(501, 'this control plane does not rebuild store scripts');
|
|
9318
|
+
let fingerprint;
|
|
9319
|
+
let ran = {
|
|
9320
|
+
driver: registration.driver,
|
|
9321
|
+
outcome: 'error',
|
|
9322
|
+
walked: [],
|
|
9323
|
+
uploaded: [],
|
|
9324
|
+
...(body.deployFinished ? { deployFinished: true } : {}),
|
|
9325
|
+
};
|
|
9326
|
+
try {
|
|
9327
|
+
// Inside the audited path: lists that cannot be read refuse the run, and that refusal is audited too.
|
|
9328
|
+
fingerprint = await flow.fingerprint();
|
|
9329
|
+
const [pass] = await runStoreFleetRebuild({
|
|
9330
|
+
admin: c.var.admin,
|
|
9331
|
+
actor,
|
|
9332
|
+
drivers: [registration],
|
|
9333
|
+
fingerprint,
|
|
9334
|
+
bytes: flow.bytes,
|
|
9335
|
+
ensure: ensureScopeStores,
|
|
9336
|
+
ensureCanary: flow.ensureCanary,
|
|
9337
|
+
budget: storeRebuildBudget(STORE_REBUILD_REQUEST_SUBREQUESTS),
|
|
9338
|
+
// A person asking re-walks a driver that is done: after a control plane deploy has finished
|
|
9339
|
+
// rolling out, that walk is the proof no script kept the older lists.
|
|
9340
|
+
reverify: 'always',
|
|
9341
|
+
...(flow.settleMs === undefined ? {} : { settleMs: flow.settleMs }),
|
|
9342
|
+
...(body.deployFinished ? { attestedBy: actor } : {}),
|
|
9343
|
+
});
|
|
9344
|
+
const { rebuild: _row, ...result } = pass;
|
|
9345
|
+
ran = { ...ran, ...result };
|
|
9346
|
+
}
|
|
9347
|
+
catch (e) {
|
|
9348
|
+
ran = { ...ran, error: e instanceof Error ? e.message : String(e) };
|
|
9349
|
+
throw e;
|
|
9350
|
+
}
|
|
9351
|
+
finally {
|
|
9352
|
+
// Written however the pass ended, a refused one included: a staff request reached the fleet.
|
|
9353
|
+
await c.var.admin.recordStoreDriverRebuildRun(actor, ran);
|
|
9354
|
+
}
|
|
9355
|
+
if (ran.outcome === 'error')
|
|
9356
|
+
throw new ControlPlaneError(502, `the rebuild of '${registration.driver}' failed: ${ran.error}`);
|
|
9357
|
+
const rebuild = (await rebuildView(c.var.admin, actor, registration.driver, fingerprint));
|
|
9358
|
+
// Scripts remain, or another holder has the driver: post again.
|
|
9359
|
+
const again = ran.outcome === 'paused' || ran.outcome === 'held' || ran.outcome === 'busy';
|
|
9360
|
+
return c.json({ pass: ran, rebuild }, again ? 202 : 200);
|
|
9361
|
+
});
|
|
9134
9362
|
return app;
|
|
9135
9363
|
}
|
|
9136
9364
|
/**
|