@substrat-run/control-plane-api 0.159.0 → 0.161.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 +33 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +674 -63
- package/dist/api.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/browser.js.map +1 -1
- package/dist/cf-observability.d.ts +30 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +304 -99
- package/dist/cf-observability.js.map +1 -1
- package/dist/custom-hostnames.d.ts +30 -0
- package/dist/custom-hostnames.d.ts.map +1 -1
- package/dist/custom-hostnames.js +25 -2
- package/dist/custom-hostnames.js.map +1 -1
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- 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 +29 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +8 -0
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts +26 -2
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +297 -119
- package/dist/platform-drain.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +2 -2
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +5 -5
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-wire.d.ts +51 -0
- package/dist/platform-wire.d.ts.map +1 -0
- package/dist/platform-wire.js +163 -0
- package/dist/platform-wire.js.map +1 -0
- package/dist/scope-copy-backfill.d.ts.map +1 -1
- package/dist/scope-copy-backfill.js +22 -9
- package/dist/scope-copy-backfill.js.map +1 -1
- package/dist/scope-copy-cleanup.js +1 -1
- package/dist/scope-copy-cleanup.js.map +1 -1
- package/dist/staff-client.d.ts +38 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +24 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +116 -4
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +366 -25
- package/dist/vertical-client.js.map +1 -1
- package/dist/wire-settings.d.ts +77 -0
- package/dist/wire-settings.d.ts.map +1 -0
- package/dist/wire-settings.js +155 -0
- package/dist/wire-settings.js.map +1 -0
- package/dist/wire-upload.d.ts +100 -0
- package/dist/wire-upload.d.ts.map +1 -0
- package/dist/wire-upload.js +141 -0
- package/dist/wire-upload.js.map +1 -0
- package/dist/wire-walk.d.ts +140 -0
- package/dist/wire-walk.d.ts.map +1 -0
- package/dist/wire-walk.js +339 -0
- package/dist/wire-walk.js.map +1 -0
- package/package.json +10 -10
package/dist/api.js
CHANGED
|
@@ -2,14 +2,15 @@ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
|
2
2
|
import { connectionBindingOptions } from './connection-binding-options.js';
|
|
3
3
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
-
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, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
|
|
5
|
+
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, 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, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
|
|
6
|
+
import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
|
|
6
7
|
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
7
8
|
import { attributeFailure } from './failure-attribution.js';
|
|
8
9
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
9
|
-
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';
|
|
10
|
+
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isExportBreakUnjudged, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, REQUEST_ROLLUP_LIMIT_DEFAULT, REQUEST_ROLLUP_LIMIT_MAX, METER_DAYS_LIMIT_DEFAULT, METER_DAYS_LIMIT_MAX, releaseRolesAskScopes, releaseRoleSetRefusal, withheldOpsFailure, } from '@substrat-run/kernel';
|
|
10
11
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
11
12
|
import { mintTenantToken } from './tenant-token.js';
|
|
12
|
-
import { beginTransfer, connectionGrantsForScope, transferScope } from './vertical-client.js';
|
|
13
|
+
import { beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
|
|
13
14
|
import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
|
|
14
15
|
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
15
16
|
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
@@ -61,11 +62,13 @@ function refuseStatus(c, status, detail, carried) {
|
|
|
61
62
|
}
|
|
62
63
|
import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
|
|
63
64
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
64
|
-
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
|
|
65
|
+
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS, LOGS_INCOMPLETE_HEADER } from './observability.js';
|
|
65
66
|
import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, scriptFamiliesOfScopes } from './tenant-request-query.js';
|
|
66
67
|
import { namespacesForScript } from './do-namespaces.js';
|
|
67
68
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
68
69
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
70
|
+
import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
|
|
71
|
+
import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
|
|
69
72
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
70
73
|
import { queryLakeEvents } from './lake-gateway.js';
|
|
71
74
|
import { LakeQueryError } from './r2-sql-engine.js';
|
|
@@ -166,6 +169,46 @@ function withholdIfPredatesRule(input, answer) {
|
|
|
166
169
|
truncated: false,
|
|
167
170
|
};
|
|
168
171
|
}
|
|
172
|
+
/** #2446: the most scopes one promote reads the role state of (`roleStateScopeCap`). */
|
|
173
|
+
export const ROLE_STATE_SCOPE_CAP = 500;
|
|
174
|
+
/** #2446: how many of those reads are in flight at once. */
|
|
175
|
+
const ROLE_STATE_CONCURRENCY = 8;
|
|
176
|
+
/** #2446: what a promote answers about the scopes it could not ask for their roles. */
|
|
177
|
+
export const ROLE_STATE_UNREAD_NOTE = "their deployment predates the role-state read, so this version's roles were not judged against them before the " +
|
|
178
|
+
'promote; their reconcile still refuses a role set it cannot define';
|
|
179
|
+
/**
|
|
180
|
+
* #2446: the promote's refusal for a role-state judgement, or null when it passes, as `pin` may
|
|
181
|
+
* read it. A confined caller sees only its own tenant's scopes by id and every other tenant as a
|
|
182
|
+
* count, and never what a deployment answered (that is logged where it was read); staff see all of
|
|
183
|
+
* it. A refusal found (409) is answered ahead of a read that failed (502), since it stands either way.
|
|
184
|
+
*/
|
|
185
|
+
function roleStateAnswer(roles, pin) {
|
|
186
|
+
if (roles.refused.length > 0) {
|
|
187
|
+
const { visible, otherTenants } = narrowToCaller(roles.refused, pin);
|
|
188
|
+
const why = visible[0] ? `: ${visible[0].refusal}` : ', none of them yours';
|
|
189
|
+
return {
|
|
190
|
+
status: 409,
|
|
191
|
+
message: `promotion refused: this version's roles cannot be defined in ${roles.refused.length} of the scopes it would move, ` +
|
|
192
|
+
`so their reconcile would be refused there${why}. Nothing was promoted`,
|
|
193
|
+
extra: { roleRefusals: { affected: visible, ...(otherTenants ? { otherTenants } : {}) } },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (roles.failed.length > 0) {
|
|
197
|
+
const { visible, otherTenants } = narrowToCaller(roles.failed, pin);
|
|
198
|
+
return {
|
|
199
|
+
status: 502,
|
|
200
|
+
message: `the roles of ${roles.failed.length} of the scopes this version would move could not be read, and its roles are ` +
|
|
201
|
+
`judged against them before it moves them. Nothing was promoted; retry the promote`,
|
|
202
|
+
extra: {
|
|
203
|
+
roleStateUnreadable: {
|
|
204
|
+
affected: pin === null ? visible : visible.map(({ tenantId, scopeId }) => ({ tenantId, scopeId })),
|
|
205
|
+
...(otherTenants ? { otherTenants } : {}),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
169
212
|
/**
|
|
170
213
|
* A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
|
|
171
214
|
* tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
|
|
@@ -190,9 +233,11 @@ const provisionScopeBody = z.object({
|
|
|
190
233
|
tenantId: tenantIdSchema,
|
|
191
234
|
scopeId: scopeIdSchema,
|
|
192
235
|
slug: z.string().optional(),
|
|
193
|
-
kind:
|
|
194
|
-
name:
|
|
195
|
-
|
|
236
|
+
kind: scopeText.optional(),
|
|
237
|
+
name: scopeText.optional(),
|
|
238
|
+
// The registry's grammar, as `resolveScopeRecord` holds it too: null or absent is "no vertical",
|
|
239
|
+
// and an empty string is refused here rather than stored.
|
|
240
|
+
vertical: verticalSlug.nullable().optional(),
|
|
196
241
|
storageShape: storageShape.optional(),
|
|
197
242
|
// The gate: only `global` is accepted until `eu`/`us` enforcement exists (K-32).
|
|
198
243
|
// A request naming a jurisdiction we cannot yet honour is refused at the Zod
|
|
@@ -614,9 +659,6 @@ const modelUsageSummaryQuery = z.object({
|
|
|
614
659
|
until: z.string().optional(),
|
|
615
660
|
});
|
|
616
661
|
export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
|
|
617
|
-
/** The upstream provider's trace handle, when a failure message carries one —
|
|
618
|
-
* Cloudflare's `internal error; reference = <id>` shape (#559). */
|
|
619
|
-
const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
|
|
620
662
|
const TENANT_ROUTES = [
|
|
621
663
|
{ method: 'GET', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
|
|
622
664
|
{ method: 'POST', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
|
|
@@ -1037,16 +1079,34 @@ export function createControlPlaneApi(options) {
|
|
|
1037
1079
|
const cause = c.get('failure');
|
|
1038
1080
|
const marked = typeof cause === 'object' && cause !== null && recordedCauses.has(cause);
|
|
1039
1081
|
if (!marked) {
|
|
1040
|
-
const { vertical, tenantId } = await failureSubject(c);
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1082
|
+
const { vertical, tenantId } = c.get('failureSubjectOf') ?? (await failureSubject(c));
|
|
1083
|
+
const scopeId = c.req.param('scopeId') ?? null;
|
|
1084
|
+
const base = { actor, operation: `${c.req.method} ${c.req.routePath}`, vertical, scopeId, status };
|
|
1085
|
+
const answered = await answeredMessage(c.res);
|
|
1086
|
+
const raw = cause !== undefined ? messageOfThrow(cause) : answered;
|
|
1087
|
+
if (tenantId !== null && raw !== answered) {
|
|
1088
|
+
// #2540: a row attributed to a tenant is read back by that tenant, so it says what this
|
|
1089
|
+
// request was answered, whoever made it. The throw's own text is unreviewed: it goes on a
|
|
1090
|
+
// row with no tenant, which only staff read, and to the log line, under the same reference.
|
|
1091
|
+
let reference = c.get('failureReference');
|
|
1092
|
+
if (!reference) {
|
|
1093
|
+
reference = ulid();
|
|
1094
|
+
console.error('control-plane.unhandled', { method: c.req.method, path: c.req.path, reference, tenantId, scopeId, detail: raw });
|
|
1095
|
+
}
|
|
1096
|
+
recordWithheld({ ...base, tenantId, reference }, raw, answered, cause);
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
recordFailure({
|
|
1100
|
+
...base,
|
|
1101
|
+
tenantId,
|
|
1102
|
+
message: raw,
|
|
1103
|
+
// The one the log line carries. `recordFailure` keeps an upstream handle in the text
|
|
1104
|
+
// off a tenant's row, on a staff row beside it, and takes it on a row with no tenant.
|
|
1105
|
+
reference: tenantId
|
|
1106
|
+
? (c.get('failureReference') ?? null)
|
|
1107
|
+
: (upstreamHandleIn(raw) ?? c.get('failureReference') ?? null),
|
|
1108
|
+
}, cause === undefined ? NO_CAUSE : cause);
|
|
1109
|
+
}
|
|
1050
1110
|
}
|
|
1051
1111
|
}
|
|
1052
1112
|
if (inflightRecords.size) {
|
|
@@ -1282,6 +1342,13 @@ export function createControlPlaneApi(options) {
|
|
|
1282
1342
|
// `undefined` (`throw undefined`, a bare rejection), and that throw deserves an
|
|
1283
1343
|
// `unknown` attribution, not a skipped one. Only true omission skips.
|
|
1284
1344
|
const NO_CAUSE = Symbol('no-cause');
|
|
1345
|
+
/**
|
|
1346
|
+
* #2540: the one kind of tenant row whose reference is an upstream trace handle. A failed upload
|
|
1347
|
+
* of a builder's own bundle answers the builder the upload's text verbatim, handle included, and
|
|
1348
|
+
* the CLI prints that handle for its lookup (#559), so the row keeps it as before. Every other
|
|
1349
|
+
* row filed under a tenant carries the platform's own reference. Named, never inferred from text.
|
|
1350
|
+
*/
|
|
1351
|
+
const UPSTREAM_REFERENCE_KEPT = new Set(['deploy.upload']);
|
|
1285
1352
|
// The throws a step already recorded, so the 5xx backstop below does not record them twice.
|
|
1286
1353
|
const recordedCauses = new WeakSet();
|
|
1287
1354
|
// The writes still in flight. A Worker may end once its response is sent, and a write nobody
|
|
@@ -1295,17 +1362,80 @@ export function createControlPlaneApi(options) {
|
|
|
1295
1362
|
// regex a message. A caller with no throw in hand leaves the columns null,
|
|
1296
1363
|
// which reads as "nobody classified this" rather than a guess nobody made.
|
|
1297
1364
|
const attributed = cause === NO_CAUSE ? undefined : attributeFailure(cause);
|
|
1365
|
+
// #2540: a row filed under a tenant is read back by it, so its reference is always the
|
|
1366
|
+
// platform's own. When its text carries an upstream trace handle (#559), that handle is kept
|
|
1367
|
+
// on a staff row beside it (`detail`, under the same reference), where staff's search finds it.
|
|
1368
|
+
// A row with no tenant is staff's: its text is written as a staff row's always is
|
|
1369
|
+
// (`withUpstreamReference`: any marker spelling neutralised, a genuine handle's marker first),
|
|
1370
|
+
// and it takes that handle as its reference, as before.
|
|
1371
|
+
const handle = upstreamHandleIn(entry.message);
|
|
1372
|
+
const staffMessage = entry.tenantId ? entry.message : withUpstreamReference(entry.message);
|
|
1373
|
+
const shaped = !entry.tenantId
|
|
1374
|
+
? { ...entry, message: staffMessage, reference: entry.reference ?? upstreamReferenceOf(staffMessage) }
|
|
1375
|
+
: UPSTREAM_REFERENCE_KEPT.has(entry.operation)
|
|
1376
|
+
? { ...entry, reference: entry.reference ?? handle }
|
|
1377
|
+
: entry.detail == null && handle
|
|
1378
|
+
? {
|
|
1379
|
+
...entry,
|
|
1380
|
+
reference: entry.reference && entry.reference !== handle ? entry.reference : ulid(),
|
|
1381
|
+
detail: withUpstreamReference(entry.message),
|
|
1382
|
+
}
|
|
1383
|
+
: { ...entry, reference: entry.reference ?? null };
|
|
1298
1384
|
const write = host.admin
|
|
1299
1385
|
.recordOpsFailure({
|
|
1300
|
-
...
|
|
1386
|
+
...shaped,
|
|
1301
1387
|
origin: entry.origin ?? attributed?.origin ?? null,
|
|
1302
1388
|
code: entry.code ?? attributed?.code ?? null,
|
|
1303
|
-
reference:
|
|
1389
|
+
reference: shaped.reference ?? null,
|
|
1304
1390
|
})
|
|
1305
1391
|
.catch(() => undefined)
|
|
1306
1392
|
.finally(() => inflightRecords.delete(write));
|
|
1307
1393
|
inflightRecords.add(write);
|
|
1308
1394
|
};
|
|
1395
|
+
/** #2540: whether this request's principal is staff. Anything else, or no principal yet, is confined. */
|
|
1396
|
+
const staffCaller = (c) => c.get('principal')?.kind === 'staff';
|
|
1397
|
+
/**
|
|
1398
|
+
* #2540: one failure whose text is not ours to hand a tenant. Filed under a tenant, it is one
|
|
1399
|
+
* write with a `detail`: the host puts what was answered on the tenant's row, which the tenant
|
|
1400
|
+
* reads back, and the text on a row with no tenant under the same reference, which only staff
|
|
1401
|
+
* read, and counts the failure once. With no tenant, the text is the row's message.
|
|
1402
|
+
*/
|
|
1403
|
+
const recordWithheld = (entry, raw, answered, cause) => {
|
|
1404
|
+
// The pair is the one the host-level writers file too (`withheldOpsFailure`, #2554): an upstream
|
|
1405
|
+
// trace handle the text carries is named on staff's row only, first, so either id finds the pair.
|
|
1406
|
+
// With no tenant, `recordFailure` writes the text as a staff row's.
|
|
1407
|
+
const { tenantId } = entry;
|
|
1408
|
+
recordFailure(tenantId ? withheldOpsFailure({ ...entry, tenantId }, raw, () => answered, entry.reference) : { ...entry, tenantId: null, message: raw }, cause);
|
|
1409
|
+
};
|
|
1410
|
+
/**
|
|
1411
|
+
* #2540: a failure whose text is not ours to hand a confined caller: what an upstream or a store
|
|
1412
|
+
* answered, often while the request reached scopes that are not the caller's (a promote's serve
|
|
1413
|
+
* and store backfill run across every install). The text goes to the `control-plane.unhandled`
|
|
1414
|
+
* log line and to an ops-failure row with no tenant on it, which only staff read, both under one
|
|
1415
|
+
* reference (`recordWithheld`). A `tenantId` the failure belongs to gets a row too, saying only
|
|
1416
|
+
* `answered(reference)`. The answer carries the reference, and the text too when the caller is
|
|
1417
|
+
* staff. Who the caller is is decided by the request's principal alone (`staffCaller`), never by a
|
|
1418
|
+
* scope or tenant the request names. The cause is marked recorded, so the 5xx backstop writes no
|
|
1419
|
+
* second row for it; a route that answers the failure as a 5xx of its own also sets `failureRecorded`.
|
|
1420
|
+
*/
|
|
1421
|
+
const withheldFailure = (c, entry, cause, answered) => {
|
|
1422
|
+
const reference = ulid();
|
|
1423
|
+
console.error('control-plane.unhandled', {
|
|
1424
|
+
method: c.req.method,
|
|
1425
|
+
path: c.req.path,
|
|
1426
|
+
reference,
|
|
1427
|
+
operation: entry.operation,
|
|
1428
|
+
stage: entry.stage ?? null,
|
|
1429
|
+
vertical: entry.vertical ?? null,
|
|
1430
|
+
version: entry.version ?? null,
|
|
1431
|
+
tenantId: entry.tenantId ?? null,
|
|
1432
|
+
scopeId: entry.scopeId ?? null,
|
|
1433
|
+
detail: entry.message,
|
|
1434
|
+
});
|
|
1435
|
+
const { message, ...rest } = entry;
|
|
1436
|
+
recordWithheld({ ...rest, actor: c.get('actor'), tenantId: answered ? (entry.tenantId ?? null) : null, reference }, message, answered?.(reference) ?? '', cause);
|
|
1437
|
+
return staffCaller(c) ? { reference, detail: message } : { reference };
|
|
1438
|
+
};
|
|
1309
1439
|
/**
|
|
1310
1440
|
* Whose failure the backstop is recording, so the dashboard's tenant-pinned failure view lists
|
|
1311
1441
|
* it. The path's tenant when it names one; otherwise the credential's own (a CI push token, a
|
|
@@ -1369,7 +1499,7 @@ export function createControlPlaneApi(options) {
|
|
|
1369
1499
|
// One error boundary for every route: adapters throw plain Errors, and each
|
|
1370
1500
|
// one is a fail-closed refusal that must reach the caller as a status, not a
|
|
1371
1501
|
// stack trace.
|
|
1372
|
-
app.onError((err, c) => {
|
|
1502
|
+
app.onError(async (err, c) => {
|
|
1373
1503
|
// A parse failure goes through the same builder as everything else (#113 phase 4):
|
|
1374
1504
|
// `toProblem` maps zod's `issues` onto the declared `errors: [{ path, message }]`
|
|
1375
1505
|
// extension, which is the shape the model documents and a client can act on. The raw
|
|
@@ -1382,6 +1512,31 @@ export function createControlPlaneApi(options) {
|
|
|
1382
1512
|
if (err instanceof CarrySourceChanged) {
|
|
1383
1513
|
return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
|
|
1384
1514
|
}
|
|
1515
|
+
// Whose failure this is, as the backstop files it, so a log line names the same tenant as its row.
|
|
1516
|
+
// Read only for an answer that is logged or filed: a 4xx refusal pays nothing.
|
|
1517
|
+
const subjectOf = async () => {
|
|
1518
|
+
const subject = await failureSubject(c).catch(() => ({ vertical: null, tenantId: null }));
|
|
1519
|
+
c.set('failureSubjectOf', subject);
|
|
1520
|
+
return subject;
|
|
1521
|
+
};
|
|
1522
|
+
// #2540: a refusal to judge what a promote or bind breaks names what could not be read, which may
|
|
1523
|
+
// be another tenant's app, and that read's own error. Every door that reaches it answers here.
|
|
1524
|
+
if (isExportBreakUnjudged(err)) {
|
|
1525
|
+
const subject = await subjectOf();
|
|
1526
|
+
const sentence = (reference) => `cannot say whom this change breaks: what an installed app imports could not be read, so nothing was changed. Retry (reference ${reference})`;
|
|
1527
|
+
const withheld = withheldFailure(c, {
|
|
1528
|
+
operation: `${c.req.method} ${c.req.routePath}`,
|
|
1529
|
+
stage: 'export-breaks',
|
|
1530
|
+
vertical: subject.vertical,
|
|
1531
|
+
tenantId: subject.tenantId,
|
|
1532
|
+
scopeId: c.req.param('scopeId') ?? null,
|
|
1533
|
+
status: 503,
|
|
1534
|
+
code: 'unavailable',
|
|
1535
|
+
message: messageOfThrow(err),
|
|
1536
|
+
}, err, sentence);
|
|
1537
|
+
c.set('failureRecorded', true);
|
|
1538
|
+
return refuse(c, 'unavailable', sentence(withheld.reference), withheld);
|
|
1539
|
+
}
|
|
1385
1540
|
const { status, body } = mapError(err);
|
|
1386
1541
|
// The 5xx backstop (registered first, above) records it, with this throw as the cause.
|
|
1387
1542
|
c.set('failure', err);
|
|
@@ -1394,9 +1549,16 @@ export function createControlPlaneApi(options) {
|
|
|
1394
1549
|
// (the 503 for a plane with no seal key, #603) passes through here too: its body already
|
|
1395
1550
|
// says what is wrong, and the tail line is what ties it to the request that hit it.
|
|
1396
1551
|
if (status >= 500) {
|
|
1552
|
+
const subject = await subjectOf();
|
|
1553
|
+
const reference = ulid();
|
|
1554
|
+
c.set('failureReference', reference);
|
|
1397
1555
|
console.error('control-plane.unhandled', {
|
|
1398
1556
|
method: c.req.method,
|
|
1399
1557
|
path: c.req.path,
|
|
1558
|
+
reference,
|
|
1559
|
+
tenantId: subject.tenantId,
|
|
1560
|
+
vertical: subject.vertical,
|
|
1561
|
+
scopeId: c.req.param('scopeId') ?? null,
|
|
1400
1562
|
detail: err instanceof Error ? err.message : String(err),
|
|
1401
1563
|
stack: err instanceof Error ? err.stack : undefined,
|
|
1402
1564
|
});
|
|
@@ -1531,11 +1693,13 @@ export function createControlPlaneApi(options) {
|
|
|
1531
1693
|
return c.json(await c.var.admin.listEntitlements(c.get('actor'), tenantId));
|
|
1532
1694
|
});
|
|
1533
1695
|
// -- the meters (§5, #38) --------------------------------------------------
|
|
1534
|
-
// Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`.
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1696
|
+
// Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`. Meter 3's parts are
|
|
1697
|
+
// read at their own routes, not here (`/meters/storage`, `/model-usage`, and the estimated
|
|
1698
|
+
// daily request rollup at `/meters/requests`); lake bytes have no reader yet, and exact API counts are not computable, since
|
|
1699
|
+
// reads emit nothing on the spine. Meter 4 has no route because it has no number
|
|
1700
|
+
// (it needs the cross-tenant order flow). See control-plane.md §5. Staff-only (absent
|
|
1701
|
+
// from BUILDER_ROUTES) — a fleet-wide revenue aggregate is the platform's own book, not
|
|
1702
|
+
// a builder's view of their installs.
|
|
1539
1703
|
//
|
|
1540
1704
|
// GET, and nothing is stored: D-30 is meter, do not bill, so a reading is recomputed
|
|
1541
1705
|
// per call and stamped with the instant it was taken.
|
|
@@ -1580,14 +1744,123 @@ export function createControlPlaneApi(options) {
|
|
|
1580
1744
|
if (!scope.vertical)
|
|
1581
1745
|
return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
1582
1746
|
const vertical = await verticalForScope(c, scope);
|
|
1583
|
-
if (vertical)
|
|
1584
|
-
|
|
1747
|
+
if (vertical) {
|
|
1748
|
+
try {
|
|
1749
|
+
return await vertical.databaseSize(scope.id);
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
// #2541: a deployment that holds nothing for a scope the directory has reaped or archived
|
|
1753
|
+
// since the listing (or does not have) reads 0; any other status holds a store and stays a
|
|
1754
|
+
// failure here, as does a directory read that fails.
|
|
1755
|
+
if (!isScopeNotHeld(err, scope.id))
|
|
1756
|
+
throw err;
|
|
1757
|
+
if (!(await notHeldIsBenign(() => c.var.admin.getScopeRecord(actor, q.tenantId, scope.id))))
|
|
1758
|
+
throw err;
|
|
1759
|
+
return 0;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1585
1762
|
if (delegatesToVerticals)
|
|
1586
1763
|
throw new Error(`no deployment resolves for vertical ${scope.vertical}`);
|
|
1587
1764
|
return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
1588
1765
|
},
|
|
1589
1766
|
}));
|
|
1590
1767
|
});
|
|
1768
|
+
// The daily request rollup (#2501): the stored per-(day, tenant, vertical, scope) request
|
|
1769
|
+
// counts the platform sweep reads from the router's Analytics Engine datapoints before they
|
|
1770
|
+
// age out, and the rollup's record of each day in the window. Staff-only like `/meters`. Every
|
|
1771
|
+
// count is an estimate (Analytics Engine samples), and the answer says so twice: `estimated`
|
|
1772
|
+
// on the reading and on every row. A directory read; nothing is queried live. `days` is what
|
|
1773
|
+
// tells a day without traffic from a day not yet rolled up.
|
|
1774
|
+
//
|
|
1775
|
+
// A page is at most half the directory's own read bound, so the one row past the page that
|
|
1776
|
+
// says the window holds more always fits under it.
|
|
1777
|
+
const REQUEST_ROLLUP_PAGE_MAX = REQUEST_ROLLUP_LIMIT_MAX / 2;
|
|
1778
|
+
// Unless a window is named, the 31 days ending at `until`, both ends included.
|
|
1779
|
+
const windowSince = (until) => new Date(Date.parse(`${until}T00:00:00Z`) - 30 * 86_400_000).toISOString().slice(0, 10);
|
|
1780
|
+
app.get('/meters/requests', async (c) => {
|
|
1781
|
+
const admin = c.var.admin;
|
|
1782
|
+
if (!admin.listRequestDays || !admin.listRequestRollupDays) {
|
|
1783
|
+
return refuseStatus(c, 501, 'this control plane keeps no request rollup');
|
|
1784
|
+
}
|
|
1785
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1786
|
+
const q = z
|
|
1787
|
+
.object({
|
|
1788
|
+
tenantId: tenantIdSchema.optional(),
|
|
1789
|
+
scopeId: scopeIdSchema.optional(),
|
|
1790
|
+
vertical: z.string().min(1).optional(),
|
|
1791
|
+
since: utcDay.optional(),
|
|
1792
|
+
until: utcDay.default(today),
|
|
1793
|
+
limit: z.coerce.number().int().min(1).max(REQUEST_ROLLUP_PAGE_MAX).default(REQUEST_ROLLUP_LIMIT_DEFAULT),
|
|
1794
|
+
})
|
|
1795
|
+
.parse({
|
|
1796
|
+
tenantId: c.req.query('tenantId') || undefined,
|
|
1797
|
+
scopeId: c.req.query('scopeId') || undefined,
|
|
1798
|
+
vertical: c.req.query('vertical') || undefined,
|
|
1799
|
+
since: c.req.query('since') || undefined,
|
|
1800
|
+
until: c.req.query('until') || undefined,
|
|
1801
|
+
limit: c.req.query('limit') || undefined,
|
|
1802
|
+
});
|
|
1803
|
+
const since = q.since ?? windowSince(q.until);
|
|
1804
|
+
if (since > q.until)
|
|
1805
|
+
return refuse(c, 'validation_failed', '`since` is after `until`');
|
|
1806
|
+
const actor = c.get('actor');
|
|
1807
|
+
// One row past the page says whether the window holds more than it returned.
|
|
1808
|
+
const rows = await admin.listRequestDays(actor, {
|
|
1809
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1810
|
+
...(q.scopeId ? { scopeId: q.scopeId } : {}),
|
|
1811
|
+
...(q.vertical ? { vertical: q.vertical } : {}),
|
|
1812
|
+
since,
|
|
1813
|
+
until: q.until,
|
|
1814
|
+
limit: q.limit + 1,
|
|
1815
|
+
});
|
|
1816
|
+
const days = await admin.listRequestRollupDays(actor, { since, until: q.until });
|
|
1817
|
+
return c.json(requestRollupReading.parse({
|
|
1818
|
+
estimated: true,
|
|
1819
|
+
since,
|
|
1820
|
+
until: q.until,
|
|
1821
|
+
rows: rows.slice(0, q.limit),
|
|
1822
|
+
truncated: rows.length > q.limit,
|
|
1823
|
+
days,
|
|
1824
|
+
}));
|
|
1825
|
+
});
|
|
1826
|
+
// The daily meter snapshot (#2501): meters 1 and 2 as the first scheduled pass of each UTC day
|
|
1827
|
+
// found them, one row per (day, tenant), and the record of each day in the window. Staff-only
|
|
1828
|
+
// like `/meters`. A directory read; nothing is recomputed, so a past day reads as it was then.
|
|
1829
|
+
// `days` is what tells a day no pass took from a day whose snapshot met no tenant. The page bound
|
|
1830
|
+
// leaves room under the directory's own for the one row past it.
|
|
1831
|
+
const METER_DAYS_PAGE_MAX = METER_DAYS_LIMIT_MAX / 2;
|
|
1832
|
+
app.get('/meters/days', async (c) => {
|
|
1833
|
+
const admin = c.var.admin;
|
|
1834
|
+
if (!admin.listMeterDays || !admin.listMeterDayTakes) {
|
|
1835
|
+
return refuseStatus(c, 501, 'this control plane keeps no meter snapshots');
|
|
1836
|
+
}
|
|
1837
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1838
|
+
const q = z
|
|
1839
|
+
.object({
|
|
1840
|
+
tenantId: tenantIdSchema.optional(),
|
|
1841
|
+
since: utcDay.optional(),
|
|
1842
|
+
until: utcDay.default(today),
|
|
1843
|
+
limit: z.coerce.number().int().min(1).max(METER_DAYS_PAGE_MAX).default(METER_DAYS_LIMIT_DEFAULT),
|
|
1844
|
+
})
|
|
1845
|
+
.parse({
|
|
1846
|
+
tenantId: c.req.query('tenantId') || undefined,
|
|
1847
|
+
since: c.req.query('since') || undefined,
|
|
1848
|
+
until: c.req.query('until') || undefined,
|
|
1849
|
+
limit: c.req.query('limit') || undefined,
|
|
1850
|
+
});
|
|
1851
|
+
const since = q.since ?? windowSince(q.until);
|
|
1852
|
+
if (since > q.until)
|
|
1853
|
+
return refuse(c, 'validation_failed', '`since` is after `until`');
|
|
1854
|
+
const actor = c.get('actor');
|
|
1855
|
+
const rows = await admin.listMeterDays(actor, {
|
|
1856
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1857
|
+
since,
|
|
1858
|
+
until: q.until,
|
|
1859
|
+
limit: q.limit + 1,
|
|
1860
|
+
});
|
|
1861
|
+
const days = await admin.listMeterDayTakes(actor, { since, until: q.until });
|
|
1862
|
+
return c.json(meterDaysReading.parse({ since, until: q.until, rows: rows.slice(0, q.limit), truncated: rows.length > q.limit, days }));
|
|
1863
|
+
});
|
|
1591
1864
|
// -- per-tenant stores (#301, #473) ----------------------------------------
|
|
1592
1865
|
// The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
|
|
1593
1866
|
// meant to answer for a staff surface: which database and which bucket hold this
|
|
@@ -3196,7 +3469,7 @@ export function createControlPlaneApi(options) {
|
|
|
3196
3469
|
};
|
|
3197
3470
|
/** Whether the scope's store in `holder` is a wiped copy: it holds the `carried_away` tombstone. */
|
|
3198
3471
|
const isCarriedAway = async (holder, scope) => {
|
|
3199
|
-
const page = await holder.
|
|
3472
|
+
const page = await holder.readTombstoneTable(scope.id, { table: '_substrat_meta', limit: 100, offset: 0 });
|
|
3200
3473
|
return metaValueIn(page.rows, page.columns, CARRIED_AWAY_KEY) !== null;
|
|
3201
3474
|
};
|
|
3202
3475
|
const recordCarryCleanup = (c, scope, versionId, stage, e) => {
|
|
@@ -3565,12 +3838,24 @@ export function createControlPlaneApi(options) {
|
|
|
3565
3838
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3566
3839
|
// Health reads the scope the same way the Data view does, so it leaves the same
|
|
3567
3840
|
// row: an auditor should not be able to tell which route reached the tables.
|
|
3841
|
+
// #2544: a deployment that does not hold the scope refuses the table read. Health is the one
|
|
3842
|
+
// reader that wants the answer "nothing stored" for it: the scope has no role rows, which is
|
|
3843
|
+
// what `roleProjectionEmpty` reports for an active one, rather than a refusal of the whole route.
|
|
3844
|
+
let notHeld = false;
|
|
3568
3845
|
const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
|
|
3569
3846
|
viaVertical: (v) => v.listScopeTables(scopeId),
|
|
3570
3847
|
colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
|
|
3571
|
-
}, (r) => r.length)
|
|
3848
|
+
}, (r) => r.length).catch((err) => {
|
|
3849
|
+
if (!isScopeNotHeld(err, scopeId))
|
|
3850
|
+
throw err;
|
|
3851
|
+
notHeld = true;
|
|
3852
|
+
return [];
|
|
3853
|
+
});
|
|
3572
3854
|
const roles = tables.find((t) => t.name === '_substrat_roles');
|
|
3573
|
-
|
|
3855
|
+
// #2531: the roles in force. A dropped role is kept as a tombstone, so the table's own row count
|
|
3856
|
+
// would read a projection that defines nothing as populated. A host from before tombstones
|
|
3857
|
+
// answers no `liveRowCount`, and kept none, so its `rowCount` is that count.
|
|
3858
|
+
const roleCount = notHeld ? 0 : roles ? (roles.liveRowCount ?? roles.rowCount) : null;
|
|
3574
3859
|
const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
|
|
3575
3860
|
const missingStores = scope.vertical
|
|
3576
3861
|
? await missingStoresForTenant({
|
|
@@ -3707,15 +3992,18 @@ export function createControlPlaneApi(options) {
|
|
|
3707
3992
|
// ready-gate and a fresh install without it is simply broken, so a failed mint must
|
|
3708
3993
|
// fail the provision rather than hand back a half-built scope.
|
|
3709
3994
|
const storeErrors = [];
|
|
3995
|
+
// #2540: the same failures as the scope's tenant may read them: the sentences, never the text.
|
|
3996
|
+
const storeSentences = [];
|
|
3710
3997
|
const mintBestEffort = async (stage, mint) => {
|
|
3711
3998
|
try {
|
|
3712
3999
|
return await mint();
|
|
3713
4000
|
}
|
|
3714
4001
|
catch (e) {
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
4002
|
+
// #2540: what the store answered is staff's. A confined caller (a builder may repair any
|
|
4003
|
+
// install of its own vertical, whoever's) reads that the mint failed and the reference, and
|
|
4004
|
+
// the scope's tenant reads the same on its failure row, whoever made the request.
|
|
4005
|
+
const sentence = (reference) => `a declared store could not be minted (${stage}, reference ${reference})`;
|
|
4006
|
+
const withheld = withheldFailure(c, {
|
|
3719
4007
|
operation: 'scope.provision.stores',
|
|
3720
4008
|
stage,
|
|
3721
4009
|
tenantId,
|
|
@@ -3723,9 +4011,10 @@ export function createControlPlaneApi(options) {
|
|
|
3723
4011
|
vertical: scope.vertical,
|
|
3724
4012
|
version: scope.verticalVersionId ?? null,
|
|
3725
4013
|
status: e instanceof ControlPlaneError ? e.status : null,
|
|
3726
|
-
message,
|
|
3727
|
-
}, e);
|
|
3728
|
-
storeErrors.push(
|
|
4014
|
+
message: messageOfThrow(e),
|
|
4015
|
+
}, e, sentence);
|
|
4016
|
+
storeErrors.push(withheld.detail ? `${withheld.detail} (reference ${withheld.reference})` : sentence(withheld.reference));
|
|
4017
|
+
storeSentences.push(sentence(withheld.reference));
|
|
3729
4018
|
return undefined;
|
|
3730
4019
|
}
|
|
3731
4020
|
};
|
|
@@ -3803,8 +4092,60 @@ export function createControlPlaneApi(options) {
|
|
|
3803
4092
|
// Both halves failed. The store fault is usually the CAUSE (an unmigrated or
|
|
3804
4093
|
// unbound store is exactly what makes a vertical refuse its own reconcile), so
|
|
3805
4094
|
// it must not be the half that gets dropped.
|
|
3806
|
-
const
|
|
3807
|
-
|
|
4095
|
+
const stores = storeErrors.length ? ` (stores: ${storeErrors.join('; ')})` : '';
|
|
4096
|
+
// #2540: the vertical's answer about a scope is its tenant's own text. Staff, and a confined
|
|
4097
|
+
// caller repairing an install of its own tenant, read it as before.
|
|
4098
|
+
if (staffCaller(c) || confinedTenant(c.get('principal')) === tenantId) {
|
|
4099
|
+
const answer = `${e.message}${stores}`;
|
|
4100
|
+
// Staff are answered the store text too, which the tenant's row must not say: a 5xx the
|
|
4101
|
+
// backstop would file is filed here as a pair, the tenant's row with the sentences only.
|
|
4102
|
+
const tenantStores = storeSentences.length ? ` (stores: ${storeSentences.join('; ')})` : '';
|
|
4103
|
+
if (e.status >= 500 && e.status !== 501 && tenantStores !== stores) {
|
|
4104
|
+
const reference = ulid();
|
|
4105
|
+
recordWithheld({
|
|
4106
|
+
actor,
|
|
4107
|
+
operation: `${c.req.method} ${c.req.routePath}`,
|
|
4108
|
+
tenantId,
|
|
4109
|
+
scopeId,
|
|
4110
|
+
vertical: scope.vertical,
|
|
4111
|
+
version: scope.verticalVersionId ?? null,
|
|
4112
|
+
status: e.status,
|
|
4113
|
+
reference,
|
|
4114
|
+
}, answer, `${e.message}${tenantStores}`, e);
|
|
4115
|
+
c.set('failureRecorded', true);
|
|
4116
|
+
return refuseStatus(c, failedWith(c, e), answer, { reference });
|
|
4117
|
+
}
|
|
4118
|
+
return refuseStatus(c, failedWith(c, e), answer);
|
|
4119
|
+
}
|
|
4120
|
+
// A builder repairing another tenant's install of its vertical reads the status, the store
|
|
4121
|
+
// sentences and a reference; what that deployment answered is staff's.
|
|
4122
|
+
const sentence = (reference) => `the reconcile of this scope failed (${e.status})${stores} (reference ${reference})`;
|
|
4123
|
+
if (e.status < 500) {
|
|
4124
|
+
// A refusal, which the 5xx backstop leaves unrecorded for staff too: logged, no row.
|
|
4125
|
+
const reference = ulid();
|
|
4126
|
+
console.warn('control-plane.withheld', {
|
|
4127
|
+
method: c.req.method,
|
|
4128
|
+
path: c.req.path,
|
|
4129
|
+
reference,
|
|
4130
|
+
operation: 'scope.provision.reconcile',
|
|
4131
|
+
tenantId,
|
|
4132
|
+
scopeId,
|
|
4133
|
+
status: e.status,
|
|
4134
|
+
detail: e.message,
|
|
4135
|
+
});
|
|
4136
|
+
return refuseStatus(c, e.status, sentence(reference), { reference });
|
|
4137
|
+
}
|
|
4138
|
+
const withheld = withheldFailure(c, {
|
|
4139
|
+
operation: 'scope.provision.reconcile',
|
|
4140
|
+
tenantId,
|
|
4141
|
+
scopeId,
|
|
4142
|
+
vertical: scope.vertical,
|
|
4143
|
+
version: scope.verticalVersionId ?? null,
|
|
4144
|
+
status: e.status,
|
|
4145
|
+
message: e.message,
|
|
4146
|
+
}, e, sentence);
|
|
4147
|
+
c.set('failureRecorded', true);
|
|
4148
|
+
return refuseStatus(c, e.status, sentence(withheld.reference), withheld);
|
|
3808
4149
|
}
|
|
3809
4150
|
throw e;
|
|
3810
4151
|
}
|
|
@@ -4507,6 +4848,7 @@ export function createControlPlaneApi(options) {
|
|
|
4507
4848
|
scopeId: snapId,
|
|
4508
4849
|
kind: opts.kind ?? 'archive',
|
|
4509
4850
|
vertical: scope.vertical,
|
|
4851
|
+
verticalSource: 'copy',
|
|
4510
4852
|
jurisdiction: scope.jurisdiction,
|
|
4511
4853
|
forkedFrom: scope.id,
|
|
4512
4854
|
forkedAt: new Date().toISOString(),
|
|
@@ -6901,6 +7243,20 @@ export function createControlPlaneApi(options) {
|
|
|
6901
7243
|
await c.var.admin.deleteVertical(c.get('actor'), slug);
|
|
6902
7244
|
return c.json({ slug, deleted: true });
|
|
6903
7245
|
});
|
|
7246
|
+
// Who owned a vertical slug when (#2501): every period, opened when the slug was registered and
|
|
7247
|
+
// closed when it was deleted, so a past period bills the builder who owned the vertical then.
|
|
7248
|
+
// Staff-only, like `/meters`: absent from BUILDER_ROUTES and from the tenant credential's pins.
|
|
7249
|
+
// Served for a deleted slug too, which is the point; a slug never registered has no period.
|
|
7250
|
+
// The FULL slug, read as given: unlike the routes behind `resolveVerticalId`, the tenant pin is
|
|
7251
|
+
// ignored, since it resolves a bare name through the registry row a deleted slug no longer has.
|
|
7252
|
+
// A bare name is read as a slug of its own; one never registered answers with no periods.
|
|
7253
|
+
app.get('/verticals/:slug/owners', async (c) => {
|
|
7254
|
+
const admin = c.var.admin;
|
|
7255
|
+
if (!admin.listVerticalOwners)
|
|
7256
|
+
return refuseStatus(c, 501, 'this control plane keeps no ownership history');
|
|
7257
|
+
const slug = verticalSlug.parse(c.req.param('slug'));
|
|
7258
|
+
return c.json(verticalOwnersReading.parse({ slug, owners: await admin.listVerticalOwners(c.get('actor'), slug) }));
|
|
7259
|
+
});
|
|
6904
7260
|
app.get('/verticals/:slug/channels', async (c) => {
|
|
6905
7261
|
const p = c.get('principal');
|
|
6906
7262
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
@@ -6987,6 +7343,40 @@ export function createControlPlaneApi(options) {
|
|
|
6987
7343
|
await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
|
|
6988
7344
|
}
|
|
6989
7345
|
};
|
|
7346
|
+
/**
|
|
7347
|
+
* Upload `bundle` to the script `ref`, a deployment of `slug`, with what the control plane's
|
|
7348
|
+
* settings give it for the envelope's verifier (RFC wire-signing step 2, follow-on 3:
|
|
7349
|
+
* `wireUploadFor`) added to its bindings. Both uploads of a vertical's script go through here, so
|
|
7350
|
+
* no upload path can leave the lists out. A refused setting or refused lists give nothing, and are
|
|
7351
|
+
* logged and recorded as an operational failure by reason, never with a key. Answers what the
|
|
7352
|
+
* upload gave, for its record (`recordWireUpload`).
|
|
7353
|
+
*/
|
|
7354
|
+
const deployVerticalScript = async (deploy, actor, slug, ref, bundle, inPlace) => {
|
|
7355
|
+
const wire = await wireUploadFor(options.wireUpload, { vertical: slug, deploymentRef: ref, bundle });
|
|
7356
|
+
if (wire.refusal !== null) {
|
|
7357
|
+
console.error(JSON.stringify({ substrat: 'wire-upload', vertical: slug, deploymentRef: ref, withheld: wire.record.withheld, reason: wire.refusal }));
|
|
7358
|
+
recordFailure({ actor, operation: 'deploy.wire-upload', stage: wire.record.withheld, vertical: slug, message: `no key lists given to ${ref}: ${wire.refusal}` });
|
|
7359
|
+
}
|
|
7360
|
+
await deploy(ref, { ...bundle, bindings: [...bundle.bindings, ...wire.bindings] }, inPlace);
|
|
7361
|
+
return wire;
|
|
7362
|
+
};
|
|
7363
|
+
/**
|
|
7364
|
+
* The capability record of an upload that succeeded (RFC wire-signing §7, Q8). A write that fails
|
|
7365
|
+
* never fails the upload, which is done: it is logged and recorded as an operational failure, and
|
|
7366
|
+
* the readiness report reads a deployment with no record as unknown, never as ready.
|
|
7367
|
+
*/
|
|
7368
|
+
const recordWireUpload = async (admin, actor, versionId, wire) => {
|
|
7369
|
+
if (!admin.recordWireAuth)
|
|
7370
|
+
return;
|
|
7371
|
+
const { vertical, deploymentRef, record } = wire;
|
|
7372
|
+
try {
|
|
7373
|
+
await admin.recordWireAuth(actor, { deploymentRef, verticalSlug: vertical, versionId, record, recordedAt: new Date().toISOString() });
|
|
7374
|
+
}
|
|
7375
|
+
catch (error) {
|
|
7376
|
+
console.error(JSON.stringify({ substrat: 'wire-upload-record-failed', vertical, deploymentRef, error: error instanceof Error ? error.name : 'error' }));
|
|
7377
|
+
recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
|
|
7378
|
+
}
|
|
7379
|
+
};
|
|
6990
7380
|
/**
|
|
6991
7381
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
6992
7382
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -7041,7 +7431,7 @@ export function createControlPlaneApi(options) {
|
|
|
7041
7431
|
: { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
7042
7432
|
// #2210: the store scripts first, so the vertical is never served before what it reaches.
|
|
7043
7433
|
await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
|
|
7044
|
-
await options.deployVertical
|
|
7434
|
+
const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
|
|
7045
7435
|
entry: manifest.entry,
|
|
7046
7436
|
compatibilityDate: manifest.compatibilityDate,
|
|
7047
7437
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
@@ -7095,6 +7485,7 @@ export function createControlPlaneApi(options) {
|
|
|
7095
7485
|
}, serving
|
|
7096
7486
|
? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
|
|
7097
7487
|
: undefined);
|
|
7488
|
+
await recordWireUpload(admin, actor, versionId, wire);
|
|
7098
7489
|
const addedClasses = serving ? doClasses.some((cls) => !serving.doClasses.includes(cls)) : false;
|
|
7099
7490
|
await admin.setVerticalServing(actor, slug, {
|
|
7100
7491
|
ref,
|
|
@@ -7154,11 +7545,97 @@ export function createControlPlaneApi(options) {
|
|
|
7154
7545
|
return { minted };
|
|
7155
7546
|
}
|
|
7156
7547
|
catch (e) {
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
return {
|
|
7548
|
+
// The sweep mints across every installed tenant, so what failed is withheld from a confined
|
|
7549
|
+
// caller (#2540): it reads that the backfill failed, and the reference.
|
|
7550
|
+
const withheld = withheldFailure(c, { operation: 'promote.store-backfill', vertical: slug, message: messageOfThrow(e) }, e);
|
|
7551
|
+
return {
|
|
7552
|
+
minted: [],
|
|
7553
|
+
error: `${withheld.detail ?? 'the stores this version declares could not be minted for every install'} (reference ${withheld.reference})`,
|
|
7554
|
+
};
|
|
7555
|
+
}
|
|
7556
|
+
};
|
|
7557
|
+
/**
|
|
7558
|
+
* #2446: the scopes a prod promote of `versionId` would hold at their reconcile, judged before
|
|
7559
|
+
* anything moves. A pushed vertical's reconcile defines the incoming version's role set in each
|
|
7560
|
+
* scope it runs in (`refuseReservedRoleSet`), and refuses it where the scope's stored roles and
|
|
7561
|
+
* seats forbid it (#2525: a seated person's role made `holder: 'system'`, among others), which
|
|
7562
|
+
* holds that scope's whole reconcile while it serves the new code. So each scope the promote moves
|
|
7563
|
+
* onto the incoming version is asked for that state (`/internal/role-state`) and judged with the
|
|
7564
|
+
* reconcile's own rule and inputs (`releaseRoleSetRefusal`: the incoming registry's roles and
|
|
7565
|
+
* marks, which the push holds to the module declarations it carries).
|
|
7566
|
+
*
|
|
7567
|
+
* The scopes moved are the ones the in-place serve and the adopt after it move: every live primary
|
|
7568
|
+
* scope of the vertical already on its serving script, and, for a private vertical, every one the
|
|
7569
|
+
* owning tenant runs. Only a dispatch-backed version served in place moves them here; an embedded
|
|
7570
|
+
* vertical's roles are its host's, defined by no reconcile of the registry, so it is asked nothing.
|
|
7571
|
+
* Nor is a release no stored state can refuse (`releaseRolesAskScopes`), so a vertical with no
|
|
7572
|
+
* system-held role pays no read.
|
|
7573
|
+
*
|
|
7574
|
+
* At most `roleStateScopeCap` scopes are read, `ROLE_STATE_CONCURRENCY` at a time in (tenant,
|
|
7575
|
+
* scope) order; more is a 503 naming the two ways through (raise the cap, or staff's
|
|
7576
|
+
* `acknowledge.roleStateUnjudged`, which skips the read and is audited), never a partial
|
|
7577
|
+
* judgement. A deployment that cannot answer the read (one that predates the route, a 404, or one
|
|
7578
|
+
* whose scope host predates the method, its 501) is counted instead; its reconcile still refuses.
|
|
7579
|
+
* Any other failed read is collected in `failed`, with what the deployment said, and refuses the
|
|
7580
|
+
* promote with nothing moved. Nothing here is phrased for a caller: the route narrows every answer
|
|
7581
|
+
* to the scopes the caller may see, and what a deployment said reaches only the log and staff.
|
|
7582
|
+
*/
|
|
7583
|
+
const releaseRoleRefusalsOf = async (c, slug, versionId) => {
|
|
7584
|
+
const none = { refused: [], failed: [], unread: 0 };
|
|
7585
|
+
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
7586
|
+
return none; // no in-place serve moves anything
|
|
7587
|
+
const actor = c.get('actor');
|
|
7588
|
+
const incoming = await host.admin.getVersion(actor, versionId, slug);
|
|
7589
|
+
if (!incoming?.deploymentRef)
|
|
7590
|
+
return none;
|
|
7591
|
+
const manifestJson = await host.admin.versionManifest(actor, slug, versionId);
|
|
7592
|
+
const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
|
|
7593
|
+
if (!registry || !releaseRolesAskScopes(registry))
|
|
7594
|
+
return none;
|
|
7595
|
+
const v = await verticalOf(actor, slug);
|
|
7596
|
+
const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
|
|
7597
|
+
const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
|
|
7598
|
+
.filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
|
|
7599
|
+
.sort((a, b) => (a.tenantId === b.tenantId ? a.id.localeCompare(b.id) : a.tenantId.localeCompare(b.tenantId)));
|
|
7600
|
+
const cap = options.roleStateScopeCap ?? ROLE_STATE_SCOPE_CAP;
|
|
7601
|
+
if (moved.length > cap) {
|
|
7602
|
+
throw new ControlPlaneError(503, `this version declares a system-held role, so a promote judges the roles of every scope it moves before moving ` +
|
|
7603
|
+
`any, and it would move ${moved.length}, more than the ${cap} one promote reads. Nothing was promoted. Raise the control ` +
|
|
7604
|
+
'plane\'s `roleStateScopeCap`, or, as staff, promote with `acknowledge.roleStateUnjudged`, which skips the read and ' +
|
|
7605
|
+
'is recorded in the promote\'s audit row');
|
|
7161
7606
|
}
|
|
7607
|
+
const refused = [];
|
|
7608
|
+
const failed = [];
|
|
7609
|
+
let unread = 0;
|
|
7610
|
+
for (let i = 0; i < moved.length; i += ROLE_STATE_CONCURRENCY) {
|
|
7611
|
+
const batch = moved.slice(i, i + ROLE_STATE_CONCURRENCY);
|
|
7612
|
+
const states = await Promise.all(batch.map(async (s) => {
|
|
7613
|
+
try {
|
|
7614
|
+
const client = await verticalForScope(c, s);
|
|
7615
|
+
if (!client)
|
|
7616
|
+
return { failed: 'no deployment serves it' };
|
|
7617
|
+
return await client.roleState({ tenantId: s.tenantId, scopeId: s.id });
|
|
7618
|
+
}
|
|
7619
|
+
catch (e) {
|
|
7620
|
+
return { failed: e instanceof Error ? e.message : String(e) };
|
|
7621
|
+
}
|
|
7622
|
+
}));
|
|
7623
|
+
batch.forEach((s, j) => {
|
|
7624
|
+
const state = states[j];
|
|
7625
|
+
if (state === 'predates')
|
|
7626
|
+
return void unread++;
|
|
7627
|
+
if ('failed' in state) {
|
|
7628
|
+
console.warn('promote.role-state.unreadable', { slug, versionId, tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
|
|
7629
|
+
return void failed.push({ tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
|
|
7630
|
+
}
|
|
7631
|
+
const refusal = releaseRoleSetRefusal(registry, state);
|
|
7632
|
+
if (refusal)
|
|
7633
|
+
refused.push({ tenantId: s.tenantId, scopeId: s.id, refusal });
|
|
7634
|
+
});
|
|
7635
|
+
}
|
|
7636
|
+
if (unread > 0)
|
|
7637
|
+
console.warn('promote.role-state.predates', { slug, versionId, scopes: unread });
|
|
7638
|
+
return { refused, failed, unread };
|
|
7162
7639
|
};
|
|
7163
7640
|
/**
|
|
7164
7641
|
* Which installed apps promoting `versionId` to `channel` would break: the promote gate's own
|
|
@@ -7265,6 +7742,23 @@ export function createControlPlaneApi(options) {
|
|
|
7265
7742
|
return { affected: visible, ...(otherTenants ? { otherTenants } : {}) };
|
|
7266
7743
|
};
|
|
7267
7744
|
const exportBreaks = acknowledge?.exportBreak ? await breaksOf() : null;
|
|
7745
|
+
// #2446: the scopes whose reconcile this version's roles would hold, before anything moves. The
|
|
7746
|
+
// digest and export-break acknowledgements do not pass it: the way through is taking the seats
|
|
7747
|
+
// back, or a release that keeps the role as each scope stores it. It runs ahead of the host's
|
|
7748
|
+
// admission and digest gates rather than being skipped when they look set to refuse: those are
|
|
7749
|
+
// judged inside `promoteVersion` against the channel as it stands then, so a read skipped on a
|
|
7750
|
+
// prediction would let a promote through unjudged if an admit or a channel move landed between.
|
|
7751
|
+
// The read is paid only by a release declaring a system-held role, at most the cap.
|
|
7752
|
+
// `roleStateUnjudged` skips the read: staff only, and recorded with the promote's acknowledgements.
|
|
7753
|
+
if (acknowledge?.roleStateUnjudged && confinedTenant(p) !== null) {
|
|
7754
|
+
return refuse(c, 'forbidden', 'acknowledge.roleStateUnjudged is for staff: it skips the role-state read a promote judges its roles with');
|
|
7755
|
+
}
|
|
7756
|
+
const roles = acknowledge?.roleStateUnjudged
|
|
7757
|
+
? { refused: [], failed: [], unread: 0 }
|
|
7758
|
+
: await releaseRoleRefusalsOf(c, slug, versionId);
|
|
7759
|
+
const answer = roleStateAnswer(roles, confinedTenant(p));
|
|
7760
|
+
if (answer)
|
|
7761
|
+
return refuseStatus(c, answer.status, answer.message, answer.extra);
|
|
7268
7762
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
7269
7763
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
7270
7764
|
// surface as a 4xx through mapError, not a 500.
|
|
@@ -7276,10 +7770,16 @@ export function createControlPlaneApi(options) {
|
|
|
7276
7770
|
throw err;
|
|
7277
7771
|
// The refusal stands whether or not the listing can be read. A listing that fails turns
|
|
7278
7772
|
// into a note, never into a 500 that would hide that the promote was refused.
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7773
|
+
// The listing is read across every tenant that installed the vertical, so what failed is
|
|
7774
|
+
// withheld from a confined caller (#2540).
|
|
7775
|
+
const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => {
|
|
7776
|
+
const withheld = withheldFailure(c, { operation: 'promote.export-breaks', vertical: slug, version: versionId, message: messageOfThrow(e) }, e);
|
|
7777
|
+
return {
|
|
7778
|
+
exportBreaks: null,
|
|
7779
|
+
exportBreaksUnavailable: `which apps it breaks could not be read: ${withheld.detail ?? 'the platform recorded why'} ` +
|
|
7780
|
+
`(reference ${withheld.reference})`,
|
|
7781
|
+
};
|
|
7782
|
+
});
|
|
7283
7783
|
return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
|
|
7284
7784
|
}
|
|
7285
7785
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
@@ -7304,9 +7804,21 @@ export function createControlPlaneApi(options) {
|
|
|
7304
7804
|
backfill = await backfillFleetStores(c, slug);
|
|
7305
7805
|
}
|
|
7306
7806
|
catch (e) {
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7807
|
+
// The serve, the adopt and the backfill reach every install of the vertical, whoever's, so
|
|
7808
|
+
// what failed is withheld from a confined caller (#2540), and the tenant the backstop would
|
|
7809
|
+
// have filed it under reads only the sentence.
|
|
7810
|
+
const sentence = (reference) => `promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry (reference ${reference})`;
|
|
7811
|
+
const withheld = withheldFailure(c, {
|
|
7812
|
+
operation: 'promote.serve',
|
|
7813
|
+
stage: 'serve-in-place',
|
|
7814
|
+
vertical: slug,
|
|
7815
|
+
version: versionId,
|
|
7816
|
+
tenantId: (await failureSubject(c)).tenantId,
|
|
7817
|
+
status: 502,
|
|
7818
|
+
message: messageOfThrow(e),
|
|
7819
|
+
}, e, sentence);
|
|
7820
|
+
c.set('failureRecorded', true);
|
|
7821
|
+
return refuseStatus(c, 502, sentence(withheld.reference), withheld);
|
|
7310
7822
|
}
|
|
7311
7823
|
}
|
|
7312
7824
|
// #1756: served, and every install that could move has moved and been backfilled, but one
|
|
@@ -7350,6 +7862,8 @@ export function createControlPlaneApi(options) {
|
|
|
7350
7862
|
...promoted,
|
|
7351
7863
|
// What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
|
|
7352
7864
|
...(exportBreaks ? { exportBreaks } : {}),
|
|
7865
|
+
// #2446: scopes whose deployment predates the role-state read were moved unjudged, in counts.
|
|
7866
|
+
...(roles.unread > 0 ? { roleStateUnread: { scopes: roles.unread, note: ROLE_STATE_UNREAD_NOTE } } : {}),
|
|
7353
7867
|
...(backfill.minted.length || backfill.error
|
|
7354
7868
|
? {
|
|
7355
7869
|
storeBackfill: {
|
|
@@ -7584,8 +8098,9 @@ export function createControlPlaneApi(options) {
|
|
|
7584
8098
|
// #2210: the store scripts first, outside the vertical upload's try, so a store failure
|
|
7585
8099
|
// answers as itself and the vertical is not uploaded at all.
|
|
7586
8100
|
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
|
|
8101
|
+
let wire;
|
|
7587
8102
|
try {
|
|
7588
|
-
await options.deployVertical(deploymentRef, {
|
|
8103
|
+
wire = await deployVerticalScript(options.deployVertical, c.get('actor'), slug, deploymentRef, {
|
|
7589
8104
|
entry: manifest.entry,
|
|
7590
8105
|
compatibilityDate: manifest.compatibilityDate,
|
|
7591
8106
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
@@ -7689,6 +8204,7 @@ export function createControlPlaneApi(options) {
|
|
|
7689
8204
|
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
7690
8205
|
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
7691
8206
|
});
|
|
8207
|
+
await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
|
|
7692
8208
|
// Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
|
|
7693
8209
|
// surfaces, name any surface that hostnames are still bound to but the declaration
|
|
7694
8210
|
// dropped — the URL keeps resolving (routing never keys on the declaration), it just
|
|
@@ -7791,7 +8307,9 @@ export function createControlPlaneApi(options) {
|
|
|
7791
8307
|
}
|
|
7792
8308
|
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
7793
8309
|
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
7794
|
-
// merged stream back. Capped because each extra service is another backend query
|
|
8310
|
+
// merged stream back. Capped because each extra service is another backend query, and
|
|
8311
|
+
// the Cloudflare reader may add a few more to fill the merged page (at most
|
|
8312
|
+
// `MAX_FILL_QUERIES` per read, whatever the number of services).
|
|
7795
8313
|
const asked = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
|
|
7796
8314
|
const ownedLogServices = await ownedServices(c.get('principal'), c.get('actor'));
|
|
7797
8315
|
// A confined principal reads only its own services' lines. An ask that names none
|
|
@@ -7815,7 +8333,12 @@ export function createControlPlaneApi(options) {
|
|
|
7815
8333
|
hours: c.req.query('hours'),
|
|
7816
8334
|
limit: c.req.query('limit'),
|
|
7817
8335
|
});
|
|
7818
|
-
|
|
8336
|
+
const { events, incomplete } = await options.observability.recentLogs(input);
|
|
8337
|
+
// The body stays the list every caller reads; a read that stopped short of vouching for the
|
|
8338
|
+
// whole window says so beside it, so a short page is never silently "nothing more".
|
|
8339
|
+
if (incomplete)
|
|
8340
|
+
c.header(LOGS_INCOMPLETE_HEADER, '1');
|
|
8341
|
+
return c.json(events);
|
|
7819
8342
|
});
|
|
7820
8343
|
// -- the TENANT grain (observability.md §3 view 4, §4.2) -----------------------
|
|
7821
8344
|
//
|
|
@@ -7962,7 +8485,12 @@ export function createControlPlaneApi(options) {
|
|
|
7962
8485
|
});
|
|
7963
8486
|
// #1877: scoped to the app's own scripts — the scan and trust boundary.
|
|
7964
8487
|
const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
|
|
7965
|
-
|
|
8488
|
+
const { events, incomplete } = await options.observability.tenantLogs({ ...input, services });
|
|
8489
|
+
// The body stays the list every caller reads; a page that may be missing lines behind a cut
|
|
8490
|
+
// says so beside it, as `/observability/logs` does (#2521).
|
|
8491
|
+
if (incomplete)
|
|
8492
|
+
c.header(LOGS_INCOMPLETE_HEADER, '1');
|
|
8493
|
+
return c.json(events);
|
|
7966
8494
|
});
|
|
7967
8495
|
// #1331: one app's sampled field reports, joined to the router's own request lines.
|
|
7968
8496
|
app.get('/observability/tenant-field-coverage', async (c) => {
|
|
@@ -9742,7 +10270,12 @@ export function createControlPlaneApi(options) {
|
|
|
9742
10270
|
cursor: c.req.query('cursor'),
|
|
9743
10271
|
order: c.req.query('order'),
|
|
9744
10272
|
});
|
|
9745
|
-
const entries = await c.var.admin.listOpsFailures(c.get('actor'),
|
|
10273
|
+
const entries = await c.var.admin.listOpsFailures(c.get('actor'), {
|
|
10274
|
+
...filter,
|
|
10275
|
+
// #2540: staff's `reference` also finds a pair by the upstream handle its staff row names.
|
|
10276
|
+
// The text that matches is staff's, so a confined caller's search stays exact.
|
|
10277
|
+
...(staffCaller(c) ? { referenceInText: true } : {}),
|
|
10278
|
+
});
|
|
9746
10279
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
9747
10280
|
});
|
|
9748
10281
|
// -- sweep runs (#1232): the durable record of what a sweep pass touched -------
|
|
@@ -9946,19 +10479,19 @@ export function createControlPlaneApi(options) {
|
|
|
9946
10479
|
* `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
|
|
9947
10480
|
* browser marks as from another origin (403). A request with neither header is the CLI's.
|
|
9948
10481
|
*/
|
|
9949
|
-
const byAPerson = async (c) => {
|
|
10482
|
+
const byAPerson = async (c, words = { write: 'a store driver release write', person: 'a store driver release is published by a person' }) => {
|
|
9950
10483
|
if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
|
|
9951
|
-
throw new ControlPlaneError(415,
|
|
10484
|
+
throw new ControlPlaneError(415, `${words.write} takes application/json`);
|
|
9952
10485
|
}
|
|
9953
10486
|
const origin = c.req.header('origin');
|
|
9954
10487
|
const site = c.req.header('sec-fetch-site');
|
|
9955
10488
|
if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
|
|
9956
|
-
throw new ControlPlaneError(403,
|
|
10489
|
+
throw new ControlPlaneError(403, `${words.write} is not taken from another origin`);
|
|
9957
10490
|
}
|
|
9958
10491
|
const actor = c.get('actor');
|
|
9959
10492
|
const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
|
|
9960
10493
|
if (!person || person !== actor) {
|
|
9961
|
-
throw new ControlPlaneError(403,
|
|
10494
|
+
throw new ControlPlaneError(403, `${words.person}: this takes a staff sign-in, not a service token`);
|
|
9962
10495
|
}
|
|
9963
10496
|
return actor;
|
|
9964
10497
|
};
|
|
@@ -10122,8 +10655,86 @@ export function createControlPlaneApi(options) {
|
|
|
10122
10655
|
const again = ran.outcome === 'paused' || ran.outcome === 'held' || ran.outcome === 'busy';
|
|
10123
10656
|
return c.json({ pass: ran, rebuild }, again ? 202 : 200);
|
|
10124
10657
|
});
|
|
10658
|
+
// RFC wire-signing follow-on 4: one pass of the fleet walk, which brings every script's key lists,
|
|
10659
|
+
// audience and state to what this control plane gives now (`runWireWalk`). A person's act; a dry run
|
|
10660
|
+
// unless the body says `apply: true`; at most `limit` scripts, from `cursor`, answering where the next
|
|
10661
|
+
// pass starts. `release` names held scripts a person has looked at. Audited as `walkWireAuth`
|
|
10662
|
+
// however the pass ends. Not to be run while a push or promote of a walked vertical is in flight.
|
|
10663
|
+
app.post('/wire-auth/walk', async (c) => {
|
|
10664
|
+
const actor = await byAPerson(c, { write: 'a wire walk', person: 'the wire walk is run by a person' });
|
|
10665
|
+
const api = options.wireSettings;
|
|
10666
|
+
if (!api || !c.var.admin.listWireAuth || !c.var.admin.recordWireAuth || !c.var.admin.recordWireWalk) {
|
|
10667
|
+
throw new ControlPlaneError(501, 'this control plane does not walk the fleet’s wire settings');
|
|
10668
|
+
}
|
|
10669
|
+
const body = z
|
|
10670
|
+
.object({
|
|
10671
|
+
apply: z.boolean().optional(),
|
|
10672
|
+
confirm: z.boolean().optional(),
|
|
10673
|
+
cursor: z.string().min(1).max(512).optional(),
|
|
10674
|
+
limit: z.number().int().min(1).max(WIRE_WALK_LIMIT_MAX).optional(),
|
|
10675
|
+
release: z.array(z.string().min(1).max(255)).max(WIRE_WALK_RELEASE_MAX).optional(),
|
|
10676
|
+
})
|
|
10677
|
+
.strict()
|
|
10678
|
+
.parse(await readCappedJson(c.req.raw, WIRE_WALK_REQUEST_MAX_BYTES));
|
|
10679
|
+
const cursor = body.cursor === undefined ? null : parseWireWalkCursor(body.cursor);
|
|
10680
|
+
if (body.cursor !== undefined && cursor === null)
|
|
10681
|
+
throw new ControlPlaneError(422, 'the cursor is not one a pass answered');
|
|
10682
|
+
const apply = body.apply === true;
|
|
10683
|
+
const confirm = body.confirm === true;
|
|
10684
|
+
// Refused settings give every script nothing, which an applied pass would write as a fleet-wide
|
|
10685
|
+
// withdrawal: the pass is refused before it walks anything, dry or applied, and audited as such.
|
|
10686
|
+
const refused = await wireSettingsRefusal(options.wireUpload);
|
|
10687
|
+
if (refused) {
|
|
10688
|
+
await c.var.admin.recordWireWalk(actor, {
|
|
10689
|
+
apply, confirm, from: body.cursor ?? null, next: null, walked: 0,
|
|
10690
|
+
updated: [], held: [], released: [], skipped: [], failed: [],
|
|
10691
|
+
refused: `${refused.withheld}: ${refused.reason}`,
|
|
10692
|
+
});
|
|
10693
|
+
throw new ControlPlaneError(409, `the wire walk is refused: ${refused.withheld}: ${refused.reason}; fix the control plane's setting, then walk`);
|
|
10694
|
+
}
|
|
10695
|
+
// The pass puts each script's result here as it goes, so a pass that ends in an error is still
|
|
10696
|
+
// audited with what it did.
|
|
10697
|
+
const walked = [];
|
|
10698
|
+
let pass = null;
|
|
10699
|
+
let failedWith;
|
|
10700
|
+
try {
|
|
10701
|
+
pass = await runWireWalk({
|
|
10702
|
+
admin: c.var.admin,
|
|
10703
|
+
actor,
|
|
10704
|
+
settings: options.wireUpload,
|
|
10705
|
+
api,
|
|
10706
|
+
...(options.fetchVerticalModules ? { fetchModules: options.fetchVerticalModules } : {}),
|
|
10707
|
+
fail: ({ vertical, version, stage, message }) => recordFailure({ actor, operation: 'wire-walk', stage, vertical, ...(version ? { version } : {}), message }),
|
|
10708
|
+
}, { apply, confirm, cursor, limit: body.limit ?? WIRE_WALK_LIMIT_DEFAULT, release: body.release ?? [], sink: walked });
|
|
10709
|
+
}
|
|
10710
|
+
catch (error) {
|
|
10711
|
+
failedWith = 'the pass stopped on a directory read it could not make; the scripts it walked before are listed';
|
|
10712
|
+
throw error;
|
|
10713
|
+
}
|
|
10714
|
+
finally {
|
|
10715
|
+
// Written however the pass ended: a person reached the fleet, or tried to.
|
|
10716
|
+
const named = (pick) => walked.filter(pick).map((s) => s.deploymentRef);
|
|
10717
|
+
const why = (action) => walked.filter((s) => s.action === action).map((s) => ({ deploymentRef: s.deploymentRef, reason: s.reason ?? action }));
|
|
10718
|
+
await c.var.admin.recordWireWalk(actor, {
|
|
10719
|
+
apply,
|
|
10720
|
+
confirm,
|
|
10721
|
+
from: body.cursor ?? null,
|
|
10722
|
+
next: pass?.next ?? null,
|
|
10723
|
+
walked: walked.length,
|
|
10724
|
+
updated: named((s) => s.action === 'updated'),
|
|
10725
|
+
held: named((s) => s.held === true),
|
|
10726
|
+
released: named((s) => s.released === true),
|
|
10727
|
+
skipped: why('skipped'),
|
|
10728
|
+
failed: why('failed'),
|
|
10729
|
+
...(failedWith ? { error: failedWith } : {}),
|
|
10730
|
+
});
|
|
10731
|
+
}
|
|
10732
|
+
return c.json(pass);
|
|
10733
|
+
});
|
|
10125
10734
|
return app;
|
|
10126
10735
|
}
|
|
10736
|
+
/** The most a wire walk's request body may be: a cursor and up to `WIRE_WALK_RELEASE_MAX` script names. */
|
|
10737
|
+
const WIRE_WALK_REQUEST_MAX_BYTES = 64 * 1024;
|
|
10127
10738
|
/**
|
|
10128
10739
|
* The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
|
|
10129
10740
|
* for the JSON around it. A bigger body is refused (413) before it is read whole.
|