@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/platform-drain.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { INERT_SCOPE_REASON, isPrimaryScope, lifecycleRefusal, platformIntentFailureMessage, ulid, } from '@substrat-run/kernel';
|
|
1
|
+
import { INERT_SCOPE_REASON, isPrimaryScope, lifecycleRefusal, platformIntentFailureMessage, resolveScopeRecord, ulid, withheldOpsFailure, withheldStaffFailure, } from '@substrat-run/kernel';
|
|
2
2
|
import { isTerminalDispatchFailure } from './provider-error.js';
|
|
3
|
-
import { principalId as principalIdSchema, scopeId as scopeIdSchema, tenantId as tenantIdSchema, provisionSiblingPayload, archiveScopePayload, callsDeclares, peerInvokePayload, undeclaredCallMessage, provisionTenantPayload, setEntitlementsPayload, connectorDispatchPayload, CONNECTOR_DISPATCH_KIND_PREFIX, modelUsageLine, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, isUndecodablePlatformRequest, platformRequestId, sweepRunsPayload, } from '@substrat-run/contracts';
|
|
3
|
+
import { principalId as principalIdSchema, scopeId as scopeIdSchema, tenantId as tenantIdSchema, provisionSiblingPayload, archiveScopePayload, callsDeclares, peerInvokePayload, undeclaredCallMessage, provisionTenantPayload, setEntitlementsPayload, connectorDispatchPayload, CONNECTOR_DISPATCH_KIND_PREFIX, modelUsageLine, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, isUndecodablePlatformRequest, platformRequestId, createTenantInput, errorCodeOf, sweepRunsPayload, neutralUpstreamMarkers, withoutUpstreamReference, } from '@substrat-run/contracts';
|
|
4
4
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
5
5
|
import { attributeFailure, terminalFailureNote } from './failure-attribution.js';
|
|
6
6
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
@@ -32,6 +32,92 @@ const undecodedRefusal = (decodeError) => `not executed: the intent row could no
|
|
|
32
32
|
* fault, short enough that the proposer learns the truth while it still matters.
|
|
33
33
|
*/
|
|
34
34
|
export const MAX_PLATFORM_REQUEST_ATTEMPTS = 100;
|
|
35
|
+
/**
|
|
36
|
+
* The ops-failure row of an intent the drain gave up on or settled `failed` (#570, #618). #2554:
|
|
37
|
+
* an intent's error is whatever its handler met, a provider's answer or another vertical's
|
|
38
|
+
* deployment among them, so the scope's tenant's row says only what became of the intent, under
|
|
39
|
+
* the platform's reference, and the error is staff's row under the same one (`withheldOpsFailure`).
|
|
40
|
+
*/
|
|
41
|
+
function intentFailure(ctx, kind, stage, outcome, raw, answered, reference) {
|
|
42
|
+
return withheldOpsFailure({
|
|
43
|
+
operation: `intent.${kind}`,
|
|
44
|
+
stage,
|
|
45
|
+
tenantId: ctx.tenantId,
|
|
46
|
+
scopeId: ctx.scopeId,
|
|
47
|
+
vertical: ctx.vertical,
|
|
48
|
+
version: ctx.versionId ?? null,
|
|
49
|
+
// The attribution the settle journals (#841) rides onto the fleet row too (#1233).
|
|
50
|
+
origin: outcome.failure?.origin ?? null,
|
|
51
|
+
code: outcome.failure?.code ?? null,
|
|
52
|
+
}, raw, answered, reference);
|
|
53
|
+
}
|
|
54
|
+
/** The stage of staff's row for a withheld failure on a pass that left the intent pending (#2556). */
|
|
55
|
+
const WITHHELD_TRANSIENT_STAGE = 'transient';
|
|
56
|
+
/**
|
|
57
|
+
* How long staff's row for a withheld transient failure stands for the same intent (#2556). Every
|
|
58
|
+
* pass of a stuck intent journals the same reference meanwhile, and files nothing: the staff digest
|
|
59
|
+
* mails every row since its watermark, so a row per pass would repeat one stuck intent every quarter
|
|
60
|
+
* hour. A condition that lasts is filed again after this, under a new reference.
|
|
61
|
+
*/
|
|
62
|
+
export const WITHHELD_REFILE_MS = 24 * 60 * 60_000;
|
|
63
|
+
/** The stage of staff's note that a withheld transient reference could not be looked up (#2556). */
|
|
64
|
+
const WITHHELD_LOOKUP_STAGE = 'transient-lookup';
|
|
65
|
+
/** How a journaled withheld sentence ends: the platform's reference staff's row is filed under. */
|
|
66
|
+
const JOURNALED_REFERENCE = /^(.*) \(reference ([0-9A-HJKMNP-TV-Z]{26})\)$/s;
|
|
67
|
+
/**
|
|
68
|
+
* How staff's row for a withheld transient failure opens: the intent, and the sentence its scope was
|
|
69
|
+
* told. A sentence may quote the payload (a peer call's operation, a tenant slug), so the opening is
|
|
70
|
+
* stored after any upstream marker, which is read from the raw text alone, and neutralised as the
|
|
71
|
+
* row stores it (`withUpstreamReference`), which is also the form the next pass compares.
|
|
72
|
+
*/
|
|
73
|
+
const withheldTransientOpening = (id, withheld) => neutralUpstreamMarkers(platformIntentFailureMessage(id, `pending (${withheld}): `));
|
|
74
|
+
/**
|
|
75
|
+
* The reference a withheld failure on a pass that left the intent pending is journaled under
|
|
76
|
+
* (#2556), keyed on the intent AND the sentence: a handler that gets one step further on a later
|
|
77
|
+
* pass is a different failure, with its own row. The previous settle journaled `<sentence>
|
|
78
|
+
* (reference R)` in the intent's `lastError`; when that sentence is this pass's, R is reused with
|
|
79
|
+
* nothing filed, once one ledger read by R confirms staff's row for this intent and sentence stands
|
|
80
|
+
* in the drained scope within `WITHHELD_REFILE_MS`. Otherwise a new reference is minted and staff's
|
|
81
|
+
* row filed under it, with no tenant, in the drained scope, so a subject erasure of the intent
|
|
82
|
+
* reaches it; the row opens with the sentence (`withheldTransientOpening`), which is what the next
|
|
83
|
+
* pass matches, never the raw text, whose upstream handles vary per call. A ledger that cannot be
|
|
84
|
+
* read files anew, since a second row is the safer error than a reference nothing resolves, and
|
|
85
|
+
* leaves staff a note saying the earlier reference went unconfirmed.
|
|
86
|
+
*/
|
|
87
|
+
async function withheldTransientReference(ctx, kind, id, previousError, withheld, outcome, opts) {
|
|
88
|
+
const operation = `intent.${kind}`;
|
|
89
|
+
const opening = withheldTransientOpening(id, withheld);
|
|
90
|
+
const entry = {
|
|
91
|
+
operation,
|
|
92
|
+
scopeId: ctx.scopeId,
|
|
93
|
+
vertical: ctx.vertical,
|
|
94
|
+
version: ctx.versionId ?? null,
|
|
95
|
+
origin: outcome.failure?.origin ?? null,
|
|
96
|
+
code: outcome.failure?.code ?? null,
|
|
97
|
+
};
|
|
98
|
+
const journaled = JOURNALED_REFERENCE.exec(previousError ?? '');
|
|
99
|
+
const previous = journaled?.[1] === withheld ? journaled[2] : null;
|
|
100
|
+
if (previous !== null && opts?.listFailures) {
|
|
101
|
+
const since = new Date((opts.now?.() ?? new Date()).getTime() - WITHHELD_REFILE_MS).toISOString();
|
|
102
|
+
try {
|
|
103
|
+
const rows = await opts.listFailures({ scopeId: ctx.scopeId, operation, reference: previous, since, limit: 10 });
|
|
104
|
+
const filed = rows.some((r) => r.tenantId === null && r.stage === WITHHELD_TRANSIENT_STAGE && withoutUpstreamReference(r.message).startsWith(opening));
|
|
105
|
+
if (filed)
|
|
106
|
+
return previous;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// Filed anew below; the note is staff's, and names only the reference and the ledger's own error.
|
|
110
|
+
opts.recordFailure?.(withheldStaffFailure({ ...entry, stage: WITHHELD_LOOKUP_STAGE }, platformIntentFailureMessage(id, `pending: reference ${previous} could not be confirmed in the failure ledger, so this pass files anew: ${e instanceof Error ? e.message : String(e)}`), ulid()));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const reference = ulid();
|
|
114
|
+
opts?.recordFailure?.(withheldStaffFailure({ ...entry, stage: WITHHELD_TRANSIENT_STAGE }, outcome.error ?? 'unknown', reference, opening));
|
|
115
|
+
return reference;
|
|
116
|
+
}
|
|
117
|
+
/** What a withheld failure's settle journals: its origin and code, never a permission read from the text. */
|
|
118
|
+
function withheldAttribution(failure) {
|
|
119
|
+
return failure ? { ...failure, permission: null } : null;
|
|
120
|
+
}
|
|
35
121
|
/**
|
|
36
122
|
* Drain one scope's pending platform intents: list them from the vertical, dispatch each to the
|
|
37
123
|
* handler for its `kind`, and settle the outcome back in the vertical. An unknown kind settles
|
|
@@ -118,30 +204,30 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
|
|
|
118
204
|
// lands an ops-failure row (#559) so a six-day retry loop is an operator's headline,
|
|
119
205
|
// not a spine-table archaeology find.
|
|
120
206
|
const maxAttempts = opts?.maxAttempts ?? MAX_PLATFORM_REQUEST_ATTEMPTS;
|
|
207
|
+
// #2556: a withheld error is journaled as its sentence under the platform's reference, the
|
|
208
|
+
// reference staff's row is filed under (terminal and give-up: the pair below; a pass that
|
|
209
|
+
// leaves the intent pending: `withheldTransientReference`).
|
|
210
|
+
const withheld = outcome.withheld;
|
|
211
|
+
let journaled = outcome.error ?? null;
|
|
121
212
|
if (outcome.status === 'pending' && attempts + 1 >= maxAttempts) {
|
|
213
|
+
const gaveUp = `gave up after ${attempts + 1} drain attempts`;
|
|
122
214
|
outcome = {
|
|
123
215
|
status: 'failed',
|
|
124
216
|
result: outcome.result,
|
|
125
|
-
error:
|
|
217
|
+
error: `${gaveUp} — last error: ${outcome.error ?? 'unknown'}`,
|
|
126
218
|
// The give-up inherits the last pass's attribution rather than inventing one: what
|
|
127
219
|
// refused a hundred times is what refused on the last attempt.
|
|
128
220
|
failure: outcome.failure,
|
|
221
|
+
...(withheld === undefined ? {} : { withheld }),
|
|
129
222
|
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
tenantId: ctx.tenantId,
|
|
134
|
-
scopeId: ctx.scopeId,
|
|
135
|
-
vertical: ctx.vertical,
|
|
136
|
-
version: ctx.versionId ?? null,
|
|
137
|
-
// The attribution the settle journals (#841) rides onto the fleet row too —
|
|
138
|
-
// until now the drain had {origin, code} in hand and dropped it here (#1233).
|
|
139
|
-
origin: outcome.failure?.origin ?? null,
|
|
140
|
-
code: outcome.failure?.code ?? null,
|
|
141
|
-
message: platformIntentFailureMessage(id, outcome.error ?? 'unknown'),
|
|
142
|
-
});
|
|
223
|
+
const reference = ulid();
|
|
224
|
+
journaled = withheld === undefined ? outcome.error : `${gaveUp} — last error: ${withheld} (reference ${reference})`;
|
|
225
|
+
opts?.recordFailure?.(intentFailure(ctx, kind, 'attempt-ceiling', outcome, platformIntentFailureMessage(id, outcome.error ?? 'unknown'), (r) => platformIntentFailureMessage(id, `${gaveUp} (reference ${r})`), reference));
|
|
143
226
|
}
|
|
144
227
|
else if (outcome.status === 'failed' && !inert) {
|
|
228
|
+
const reference = ulid();
|
|
229
|
+
if (withheld !== undefined)
|
|
230
|
+
journaled = `${withheld} (reference ${reference})`;
|
|
145
231
|
// An inert settle (#2005) is expected, not an operator's headline; the settle below
|
|
146
232
|
// still journals its reason and attribution.
|
|
147
233
|
//
|
|
@@ -149,17 +235,11 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
|
|
|
149
235
|
// over, nobody is coming back to it, and until now its only trace was a `last_error`
|
|
150
236
|
// column in the vertical's own DO. An unknown kind, a refused payload, a provider's 4xx:
|
|
151
237
|
// each is an operator's headline for the same reason a give-up is.
|
|
152
|
-
opts?.recordFailure?.({
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
vertical: ctx.vertical,
|
|
158
|
-
version: ctx.versionId ?? null,
|
|
159
|
-
origin: outcome.failure?.origin ?? null,
|
|
160
|
-
code: outcome.failure?.code ?? null,
|
|
161
|
-
message: platformIntentFailureMessage(id, `failed: ${outcome.error ?? 'unknown'}`),
|
|
162
|
-
});
|
|
238
|
+
opts?.recordFailure?.(intentFailure(ctx, kind, 'terminal', outcome, platformIntentFailureMessage(id, `failed: ${outcome.error ?? 'unknown'}`), (r) => platformIntentFailureMessage(id, `failed (reference ${r})`), reference));
|
|
239
|
+
}
|
|
240
|
+
else if (outcome.status === 'pending' && withheld !== undefined) {
|
|
241
|
+
const previousError = isUndecodablePlatformRequest(request) ? null : request.lastError;
|
|
242
|
+
journaled = `${withheld} (reference ${await withheldTransientReference(ctx, kind, id, previousError, withheld, outcome, opts)})`;
|
|
163
243
|
}
|
|
164
244
|
// No special case for a refused row: every row that reaches here has an id the settle
|
|
165
245
|
// route accepts (#1637 left the rest out above), so its settle is refused for the same
|
|
@@ -170,8 +250,8 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
|
|
|
170
250
|
await client.settlePlatformRequest(ctx.tenantId, ctx.scopeId, id, {
|
|
171
251
|
status,
|
|
172
252
|
result: outcome.result,
|
|
173
|
-
lastError:
|
|
174
|
-
failure: outcome.failure ?? null,
|
|
253
|
+
lastError: journaled,
|
|
254
|
+
failure: withheld === undefined ? (outcome.failure ?? null) : withheldAttribution(outcome.failure),
|
|
175
255
|
...(outcome.event ? { event: outcome.event } : {}),
|
|
176
256
|
});
|
|
177
257
|
report[status]++;
|
|
@@ -223,6 +303,7 @@ export async function provisionSiblingScope(deps, input) {
|
|
|
223
303
|
slug: input.slug,
|
|
224
304
|
name: input.name,
|
|
225
305
|
vertical: parent.vertical,
|
|
306
|
+
verticalSource: 'copy',
|
|
226
307
|
jurisdiction: parent.jurisdiction ?? undefined,
|
|
227
308
|
});
|
|
228
309
|
const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, input.tenantId, input.scopeId));
|
|
@@ -273,11 +354,23 @@ export async function provisionSiblingScope(deps, input) {
|
|
|
273
354
|
*/
|
|
274
355
|
export function provisionSiblingHandler(deps) {
|
|
275
356
|
return async (ctx, request) => {
|
|
276
|
-
const
|
|
357
|
+
const parsed = provisionSiblingPayload.safeParse(request.payload);
|
|
358
|
+
if (!parsed.success)
|
|
359
|
+
return malformedPayload('provision-sibling', parsed.error);
|
|
360
|
+
const payload = parsed.data;
|
|
277
361
|
const prior = request.result;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
362
|
+
// The sibling's own input, checked before `provisionSiblingScope` writes its row, by the rules
|
|
363
|
+
// that write applies: one it would refuse settles here and leaves no row behind.
|
|
364
|
+
const checked = checkedBeforeWrites(() => {
|
|
365
|
+
const scopeId = prior && typeof prior === 'object' && 'scopeId' in prior
|
|
366
|
+
? scopeIdSchema.parse(prior.scopeId)
|
|
367
|
+
: scopeIdSchema.parse(ulid());
|
|
368
|
+
resolveScopeRecord({ tenantId: ctx.tenantId, scopeId, slug: payload.slug, name: payload.name });
|
|
369
|
+
return scopeId;
|
|
370
|
+
});
|
|
371
|
+
if (!checked.ok)
|
|
372
|
+
return checked.refusal;
|
|
373
|
+
const scopeId = checked.value;
|
|
281
374
|
try {
|
|
282
375
|
const r = await provisionSiblingScope(deps, {
|
|
283
376
|
tenantId: ctx.tenantId,
|
|
@@ -292,12 +385,58 @@ export function provisionSiblingHandler(deps) {
|
|
|
292
385
|
: { status: 'failed', result: { scopeId }, error: r.error };
|
|
293
386
|
}
|
|
294
387
|
catch (e) {
|
|
388
|
+
// After the writes have begun, a throw is classified as it always was: a vertical's refusal
|
|
389
|
+
// is transient, and anything else leaves the request pending. A refusal settled terminal here
|
|
390
|
+
// could leave a half-provisioned sibling that no later pass finishes.
|
|
295
391
|
if (e instanceof ControlPlaneError)
|
|
296
392
|
return { status: 'pending', result: { scopeId }, error: e.message };
|
|
297
393
|
throw e;
|
|
298
394
|
}
|
|
299
395
|
};
|
|
300
396
|
}
|
|
397
|
+
/** The attribution of a request refused for its own input: ours, and the input's to fix. */
|
|
398
|
+
const INPUT_REFUSED = { origin: 'platform', code: 'validation_failed', permission: null };
|
|
399
|
+
/**
|
|
400
|
+
* #2556: a throw whose text is not the drained scope's to read (`PlatformRequestOutcome.withheld`),
|
|
401
|
+
* kept pending as the drain's own catch would keep it, attributed as that catch would, and
|
|
402
|
+
* journaled as `sentence`.
|
|
403
|
+
*/
|
|
404
|
+
function withheldThrow(e, sentence, result) {
|
|
405
|
+
return {
|
|
406
|
+
status: 'pending',
|
|
407
|
+
...(result === undefined ? {} : { result }),
|
|
408
|
+
error: e instanceof Error ? e.message : String(e),
|
|
409
|
+
failure: attributeFailure(e),
|
|
410
|
+
withheld: sentence,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** A payload that does not parse, settled `failed` at once: it never will parse. */
|
|
414
|
+
function malformedPayload(kind, error) {
|
|
415
|
+
const fields = error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
416
|
+
return { status: 'failed', error: `${kind} payload is malformed: ${fields}`, failure: INPUT_REFUSED };
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* A request's input, checked before its handler writes anything: `check` parses and resolves, and
|
|
420
|
+
* performs no I/O. A `validation_failed` from it (a parse failure reads as one) refuses the input
|
|
421
|
+
* itself, which is refused the same way on every pass, so it settles `failed` at once rather than
|
|
422
|
+
* spending attempts. This is the ONLY place such a refusal is terminal: once a handler has begun
|
|
423
|
+
* writing, a throw keeps its old classification, so a parse failing on a stored row mid-provision
|
|
424
|
+
* leaves the request pending to be finished, not a half-written tenant or scope settled for good.
|
|
425
|
+
* Anything else `check` throws is rethrown.
|
|
426
|
+
*/
|
|
427
|
+
function checkedBeforeWrites(check) {
|
|
428
|
+
try {
|
|
429
|
+
return { ok: true, value: check() };
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
if (errorCodeOf(e) !== 'validation_failed')
|
|
433
|
+
throw e;
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
refusal: { status: 'failed', error: e instanceof Error ? e.message : String(e), failure: INPUT_REFUSED },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
301
440
|
/**
|
|
302
441
|
* The `archive-scope` platform-request handler — archives a sibling scope named in the intent.
|
|
303
442
|
* The scope being drained proves the tenant; the target must be under that same tenant and run the
|
|
@@ -425,17 +564,25 @@ export function peerInvokeHandler(deps) {
|
|
|
425
564
|
if (!client) {
|
|
426
565
|
return { status: 'failed', error: `no deployment serves '${payload.vertical}' in this tenant` };
|
|
427
566
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
567
|
+
let result;
|
|
568
|
+
try {
|
|
569
|
+
result = await client.verticalInvoke({
|
|
570
|
+
// `tenantSlug`: the directory's, for the tenant this intent was drained in (#2252), so a
|
|
571
|
+
// pinned caller meets a bare `peers` entry at the door (`peerCallerAt`).
|
|
572
|
+
caller: { vertical: ctx.vertical, scope: ctx.scopeId, tenantSlug: tenant.slug },
|
|
573
|
+
tenantId: ctx.tenantId,
|
|
574
|
+
scopeId: resolution.instance.scopeId,
|
|
575
|
+
operation: payload.operation,
|
|
576
|
+
...(payload.input === undefined ? {} : { input: payload.input }),
|
|
577
|
+
// The intent id: a redelivery reaches the recorded response instead of running twice.
|
|
578
|
+
idempotencyKey: request.id,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
// #2556: the target's answer is its deployment's text, possibly another builder's code, so
|
|
583
|
+
// the caller's journal says only which call did not complete. Transient, as it always was.
|
|
584
|
+
return withheldThrow(e, `the call to '${payload.vertical}' (operation '${payload.operation}') did not complete at its deployment`);
|
|
585
|
+
}
|
|
439
586
|
return { status: 'done', result: { invoked: payload.operation, result: result ?? null } };
|
|
440
587
|
};
|
|
441
588
|
}
|
|
@@ -492,14 +639,39 @@ async function admitManager(deps, ctx, requestedKeys, targetVertical) {
|
|
|
492
639
|
*/
|
|
493
640
|
export function provisionTenantHandler(deps) {
|
|
494
641
|
return async (ctx, request) => {
|
|
495
|
-
|
|
642
|
+
// Terminal, like the other refusals here: a payload that does not parse never will, and a
|
|
643
|
+
// throw would leave it pending and retried every pass until the attempt ceiling.
|
|
644
|
+
const parsed = provisionTenantPayload.safeParse(request.payload);
|
|
645
|
+
if (!parsed.success)
|
|
646
|
+
return malformedPayload('provision-tenant', parsed.error);
|
|
647
|
+
const payload = parsed.data;
|
|
496
648
|
const admitted = await admitManager(deps, ctx, payload.entitlements.map((e) => e.key), payload.instance.vertical);
|
|
497
649
|
if ('refusal' in admitted)
|
|
498
650
|
return admitted.refusal;
|
|
651
|
+
// The whole input, checked BEFORE anything is written, by the same rules the writes below
|
|
652
|
+
// apply: an input they would refuse settles here and creates nothing, rather than leaving a
|
|
653
|
+
// tenant and its entitlements behind a scope that is never provisioned. After the capability
|
|
654
|
+
// check above, so a manager that may not provision at all is told that (#455), not what is
|
|
655
|
+
// wrong with a payload it could not have sent anyway.
|
|
656
|
+
const checked = checkedBeforeWrites(() => {
|
|
657
|
+
const tenantId = tenantIdSchema.parse(payload.tenant.id);
|
|
658
|
+
const scopeId = scopeIdSchema.parse(payload.instance.scopeId);
|
|
659
|
+
createTenantInput.parse({ id: tenantId, slug: payload.tenant.slug, name: payload.tenant.name, provisionedByTenant: ctx.tenantId });
|
|
660
|
+
const scopeInput = {
|
|
661
|
+
tenantId,
|
|
662
|
+
scopeId,
|
|
663
|
+
slug: payload.instance.slug,
|
|
664
|
+
name: payload.instance.name,
|
|
665
|
+
vertical: payload.instance.vertical,
|
|
666
|
+
};
|
|
667
|
+
resolveScopeRecord(scopeInput);
|
|
668
|
+
return { tenantId, scopeId, scopeInput };
|
|
669
|
+
});
|
|
670
|
+
if (!checked.ok)
|
|
671
|
+
return checked.refusal;
|
|
672
|
+
const { tenantId, scopeId, scopeInput } = checked.value;
|
|
499
673
|
const { host, actor } = deps;
|
|
500
674
|
const admin = host.admin;
|
|
501
|
-
const tenantId = tenantIdSchema.parse(payload.tenant.id);
|
|
502
|
-
const scopeId = scopeIdSchema.parse(payload.instance.scopeId);
|
|
503
675
|
try {
|
|
504
676
|
await admin.createTenant(actor, {
|
|
505
677
|
id: tenantId,
|
|
@@ -515,49 +687,54 @@ export function provisionTenantHandler(deps) {
|
|
|
515
687
|
const message = e instanceof Error ? e.message : String(e);
|
|
516
688
|
// Both adapters fail closed on a slug owned by a DIFFERENT tenant id with this
|
|
517
689
|
// phrasing — a retry can never converge, so settle terminal instead of pending.
|
|
690
|
+
// The text names only the payload's own slug (#2556), so it is journaled as it is.
|
|
518
691
|
if (message.includes('slugs are unique'))
|
|
519
692
|
return { status: 'failed', error: message };
|
|
520
693
|
throw e;
|
|
521
694
|
}
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
await host.provisionScope(actor, {
|
|
527
|
-
tenantId,
|
|
528
|
-
scopeId,
|
|
529
|
-
slug: payload.instance.slug,
|
|
530
|
-
name: payload.instance.name,
|
|
531
|
-
vertical: payload.instance.vertical,
|
|
532
|
-
});
|
|
533
|
-
const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
534
|
-
const vertical = created ? await deps.resolveVerticalForScope(created) : undefined;
|
|
535
|
-
if (!vertical) {
|
|
536
|
-
return {
|
|
537
|
-
status: 'failed',
|
|
538
|
-
result: { tenantId, scopeId },
|
|
539
|
-
error: `no deployment is bound for vertical '${payload.instance.vertical}'`,
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
const entitlements = await admin.listEntitlements(actor, tenantId);
|
|
543
|
-
// #301: a NEW tenant's first install is exactly the minting path — the stores are
|
|
544
|
-
// per (tenant, target vertical), keyed by the PAYLOAD's vertical, not the manager's.
|
|
545
|
-
const tenantStores = await collectTenantStoreHandles({
|
|
546
|
-
host,
|
|
547
|
-
actor,
|
|
548
|
-
slug: payload.instance.vertical,
|
|
549
|
-
tenantId,
|
|
550
|
-
patchBindings: deps.patchScriptBindings,
|
|
551
|
-
});
|
|
552
|
-
// #473: same blob-store minting on the manager-driven provision-tenant path.
|
|
553
|
-
await collectBlobStoreHandles({
|
|
554
|
-
host,
|
|
555
|
-
actor,
|
|
556
|
-
slug: payload.instance.vertical,
|
|
557
|
-
tenantId,
|
|
558
|
-
patchBindings: deps.patchScriptBindings,
|
|
559
|
-
});
|
|
695
|
+
// #2556: from here every write and call concerns the tenant being provisioned and the
|
|
696
|
+
// payload vertical's deployment, neither of them the manager's, so a throw's text is withheld
|
|
697
|
+
// from the manager's journal: it learns the step, and staff read the text under the reference.
|
|
698
|
+
let step = 'granting its entitlements';
|
|
560
699
|
try {
|
|
700
|
+
// Granted BEFORE provisionInstance so the #310 projection carries them on day one.
|
|
701
|
+
for (const grant of payload.entitlements) {
|
|
702
|
+
await admin.grantEntitlement(actor, tenantId, grant.key, { plan: grant.plan });
|
|
703
|
+
}
|
|
704
|
+
// Past the first write, so a throw keeps its old classification (pending, retried): the
|
|
705
|
+
// tenant exists, and a later pass is what finishes it.
|
|
706
|
+
step = 'provisioning its scope';
|
|
707
|
+
await host.provisionScope(actor, scopeInput);
|
|
708
|
+
const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
709
|
+
const vertical = created ? await deps.resolveVerticalForScope(created) : undefined;
|
|
710
|
+
if (!vertical) {
|
|
711
|
+
return {
|
|
712
|
+
status: 'failed',
|
|
713
|
+
result: { tenantId, scopeId },
|
|
714
|
+
error: `no deployment is bound for vertical '${payload.instance.vertical}'`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
step = 'collecting its stores';
|
|
718
|
+
const entitlements = await admin.listEntitlements(actor, tenantId);
|
|
719
|
+
// #301: a NEW tenant's first install is exactly the minting path — the stores are
|
|
720
|
+
// per (tenant, target vertical), keyed by the PAYLOAD's vertical, not the manager's.
|
|
721
|
+
const tenantStores = await collectTenantStoreHandles({
|
|
722
|
+
host,
|
|
723
|
+
actor,
|
|
724
|
+
slug: payload.instance.vertical,
|
|
725
|
+
tenantId,
|
|
726
|
+
patchBindings: deps.patchScriptBindings,
|
|
727
|
+
});
|
|
728
|
+
// #473: same blob-store minting on the manager-driven provision-tenant path.
|
|
729
|
+
await collectBlobStoreHandles({
|
|
730
|
+
host,
|
|
731
|
+
actor,
|
|
732
|
+
slug: payload.instance.vertical,
|
|
733
|
+
tenantId,
|
|
734
|
+
patchBindings: deps.patchScriptBindings,
|
|
735
|
+
});
|
|
736
|
+
// A refusal from the vertical is transient (`pending`), as is anything else thrown here.
|
|
737
|
+
step = `provisioning its '${payload.instance.vertical}' instance`;
|
|
561
738
|
// #1674: a re-drain reuses the proposed ids, so this can re-seat a wiped, switched-off scope.
|
|
562
739
|
await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.provisionInstance({
|
|
563
740
|
tenantId,
|
|
@@ -570,6 +747,7 @@ export function provisionTenantHandler(deps) {
|
|
|
570
747
|
...carry,
|
|
571
748
|
}));
|
|
572
749
|
if (payload.config && Object.keys(payload.config).length) {
|
|
750
|
+
step = `configuring its '${payload.instance.vertical}' instance`;
|
|
573
751
|
try {
|
|
574
752
|
await vertical.configureInstance({
|
|
575
753
|
tenantId,
|
|
@@ -584,14 +762,12 @@ export function provisionTenantHandler(deps) {
|
|
|
584
762
|
throw e;
|
|
585
763
|
}
|
|
586
764
|
}
|
|
765
|
+
step = 'activating its scope';
|
|
766
|
+
await admin.activateScope(actor, tenantId, scopeId);
|
|
587
767
|
}
|
|
588
768
|
catch (e) {
|
|
589
|
-
|
|
590
|
-
return { status: 'pending', result: { tenantId, scopeId }, error: e.message };
|
|
591
|
-
}
|
|
592
|
-
throw e;
|
|
769
|
+
return withheldThrow(e, `provisioning tenant '${payload.tenant.slug}' did not complete while ${step}`, { tenantId, scopeId });
|
|
593
770
|
}
|
|
594
|
-
await admin.activateScope(actor, tenantId, scopeId);
|
|
595
771
|
return { status: 'done', result: { tenantId, scopeId } };
|
|
596
772
|
};
|
|
597
773
|
}
|
|
@@ -664,34 +840,38 @@ export function setEntitlementsHandler(deps) {
|
|
|
664
840
|
if (!scope) {
|
|
665
841
|
return { status: 'failed', error: `unknown scope for tenant: (${tenantId}, ${scopeId})` };
|
|
666
842
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
843
|
+
// #2556: the writes and the reconcile concern the managed tenant and its scope's deployment,
|
|
844
|
+
// neither of them the manager's, so a throw's text is withheld from the manager's journal.
|
|
845
|
+
let step = 'reconciling its entitlements';
|
|
846
|
+
try {
|
|
847
|
+
const target = new Map(payload.entitlements.map((e) => [e.key, e.plan]));
|
|
848
|
+
for (const key of admitted.declared) {
|
|
849
|
+
if (target.has(key)) {
|
|
850
|
+
await admin.grantEntitlement(actor, tenantId, key, { plan: target.get(key) });
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
await admin.revokeEntitlement(actor, tenantId, key);
|
|
854
|
+
}
|
|
671
855
|
}
|
|
672
|
-
|
|
673
|
-
|
|
856
|
+
const vertical = await deps.resolveVerticalForScope(scope);
|
|
857
|
+
if (!vertical) {
|
|
858
|
+
// Named by the payload's own ids, not by what the managed tenant's scope runs (#2556).
|
|
859
|
+
return { status: 'failed', error: `no deployment is bound for scope ${scopeId} of tenant ${tenantId}` };
|
|
674
860
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
// drain-side reconcile must not leave a scope unable to seal to a connector it could
|
|
690
|
-
// seal to a moment ago.
|
|
691
|
-
const connectionKeys = scope.vertical
|
|
692
|
-
? await admin.connectionSealingKeys(tenantId, scope.vertical)
|
|
693
|
-
: [];
|
|
694
|
-
try {
|
|
861
|
+
step = 'gathering what its instance is reconciled with';
|
|
862
|
+
const entitlements = await admin.listEntitlements(actor, tenantId);
|
|
863
|
+
const identityLinks = (await admin.listIdentityLinks(actor, tenantId)).map(({ tenantId: _tenantId, ...link }) => link);
|
|
864
|
+
// #592: every reconcile re-delivers the tenant's live connection grants, whoever
|
|
865
|
+
// triggered it — this drain-side reconcile must not silently wipe or omit them.
|
|
866
|
+
const connectionGrants = connectionGrantsForScope(await admin.listConnectionGrants(actor, tenantId), scope.vertical, scopeId);
|
|
867
|
+
// #687: sealing keys ride every reconcile too, for the same reason grants do — a
|
|
868
|
+
// drain-side reconcile must not leave a scope unable to seal to a connector it could
|
|
869
|
+
// seal to a moment ago.
|
|
870
|
+
const connectionKeys = scope.vertical
|
|
871
|
+
? await admin.connectionSealingKeys(tenantId, scope.vertical)
|
|
872
|
+
: [];
|
|
873
|
+
// A refusal from the vertical is transient (`pending`), as is anything else thrown here.
|
|
874
|
+
step = 'reconciling its instance';
|
|
695
875
|
await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
|
|
696
876
|
tenantId,
|
|
697
877
|
scopeId,
|
|
@@ -703,9 +883,7 @@ export function setEntitlementsHandler(deps) {
|
|
|
703
883
|
}));
|
|
704
884
|
}
|
|
705
885
|
catch (e) {
|
|
706
|
-
|
|
707
|
-
return { status: 'pending', error: e.message };
|
|
708
|
-
throw e;
|
|
886
|
+
return withheldThrow(e, `setting the entitlements of tenant ${tenantId} did not complete while ${step}`);
|
|
709
887
|
}
|
|
710
888
|
return { status: 'done', result: { tenantId, plan: payload.plan } };
|
|
711
889
|
};
|