@substrat-run/control-plane-api 0.121.0 → 0.122.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +268 -52
- package/dist/api.js.map +1 -1
- package/package.json +6 -6
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, } from '@substrat-run/contracts';
|
|
3
|
+
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, } from '@substrat-run/contracts';
|
|
4
4
|
import { attributeFailure } from './failure-attribution.js';
|
|
5
|
-
import { crossVerticalHealth, isExportBreakRefusal, migrationProgress, ulid } from '@substrat-run/kernel';
|
|
5
|
+
import { BIND_EXPORT_BREAK_REFUSAL, bindExportBreakRefusal, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, migrationProgress, ulid, } from '@substrat-run/kernel';
|
|
6
6
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
7
7
|
import { mintTenantToken } from './tenant-token.js';
|
|
8
8
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
@@ -57,6 +57,22 @@ function outsideTenant(p, tenantId) {
|
|
|
57
57
|
const pin = confinedTenant(p);
|
|
58
58
|
return pin !== null && pin !== tenantId;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* #1756: a move refused because it would break apps in the scope's own tenant. Carries the
|
|
62
|
+
* listing, because every route that moves a scope pins that tenant in its path, so the caller may
|
|
63
|
+
* read it whole; the counted sentence is the host's (`bindExportBreakRefusal`).
|
|
64
|
+
*/
|
|
65
|
+
class ExportBreakRefused extends ControlPlaneError {
|
|
66
|
+
breaks;
|
|
67
|
+
constructor(breaks) {
|
|
68
|
+
super(409, bindExportBreakRefusal(breaks));
|
|
69
|
+
this.breaks = breaks;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The body a route answers an `ExportBreakRefused` with: the sentence and the listing. */
|
|
73
|
+
function exportBreakBody(e) {
|
|
74
|
+
return { error: e.message, ...(e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {}) };
|
|
75
|
+
}
|
|
60
76
|
/**
|
|
61
77
|
* A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
|
|
62
78
|
* tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
|
|
@@ -179,6 +195,9 @@ const bindScopeVersionBody = z.object({
|
|
|
179
195
|
// data first when this bind crosses a migration-digest boundary. Optional and
|
|
180
196
|
// ignored on a code-only rebind — the digest compare is the gate, not the flag.
|
|
181
197
|
snapshot: z.boolean().optional(),
|
|
198
|
+
// #1756: a bind that drops or re-versions an export an app in this tenant imports is
|
|
199
|
+
// refused unless acknowledged — the promote gate's `exportBreak`, for one install.
|
|
200
|
+
acknowledge: bindAcknowledgement.optional(),
|
|
182
201
|
});
|
|
183
202
|
const rebindScopeVerticalBody = z.object({
|
|
184
203
|
// The FULL registry id of the target lineage (e.g. `substrat-9yjbbn/manyfold`) —
|
|
@@ -195,7 +214,12 @@ const rebindScopeVerticalBody = z.object({
|
|
|
195
214
|
// backout copy exactly as in a carried rebind. The scope must be re-provisioned
|
|
196
215
|
// on the target afterwards (`/verticals/:slug/instances` is idempotent, K-31).
|
|
197
216
|
abandonData: z.boolean().optional(),
|
|
217
|
+
// #1756: within one lineage the rebind is an adopt, refused when it breaks an app in the tenant.
|
|
218
|
+
acknowledge: bindAcknowledgement.optional(),
|
|
198
219
|
});
|
|
220
|
+
// #1756: an adopt moves the scope onto what its vertical serves, and is refused when that breaks
|
|
221
|
+
// an app in the tenant. The body is optional, as it always was.
|
|
222
|
+
const adoptServingBody = z.object({ acknowledge: bindAcknowledgement.optional() });
|
|
199
223
|
// A snapshot request (preview-and-snapshots.md §3/§9). `expiresAt` opts into the GC
|
|
200
224
|
// sweep; absent = pinned until deliberately deleted. `kind` defaults to 'archive'.
|
|
201
225
|
const snapshotScopeBody = z.object({
|
|
@@ -815,6 +839,8 @@ export function createControlPlaneApi(options) {
|
|
|
815
839
|
if (err instanceof z.ZodError) {
|
|
816
840
|
return problem(c, { status: 400, body: toProblem(err, c.req.path) });
|
|
817
841
|
}
|
|
842
|
+
if (err instanceof ExportBreakRefused)
|
|
843
|
+
return c.json(exportBreakBody(err), 409);
|
|
818
844
|
const { status, body } = mapError(err);
|
|
819
845
|
// A 5xx is the PLATFORM failing — an unmapped throw, or a downstream vertical's
|
|
820
846
|
// own 5xx passing through (a DO storage fault during a preview restore is the
|
|
@@ -1597,6 +1623,25 @@ export function createControlPlaneApi(options) {
|
|
|
1597
1623
|
return (`no deployment is bound for vertical '${slug}': the scope is not on the serving script and its bound ` +
|
|
1598
1624
|
`version did not resolve. Promote a version to prod, then adopt the scope onto the serving script.`);
|
|
1599
1625
|
};
|
|
1626
|
+
/**
|
|
1627
|
+
* #1756: refuse, BEFORE any data moves, a move of this scope that would break apps in its
|
|
1628
|
+
* tenant, unless acknowledged. The host refuses the same move whoever calls; this is what keeps
|
|
1629
|
+
* a route that exports, restores or snapshots first from doing so for a move that is refused.
|
|
1630
|
+
* `servingRef` is the route the scope takes in the same act (an adopt).
|
|
1631
|
+
*/
|
|
1632
|
+
const refuseOnBreaks = async (actor, tenantId, scopeId, versionId, opts) => {
|
|
1633
|
+
if (opts.acknowledge?.exportBreak)
|
|
1634
|
+
return;
|
|
1635
|
+
const breaks = await admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
|
|
1636
|
+
if (breaks.length > 0)
|
|
1637
|
+
throw new ExportBreakRefused(breaks);
|
|
1638
|
+
};
|
|
1639
|
+
/** The host's own export-break refusal (the move changed since it was asked), as the same 409. */
|
|
1640
|
+
const relayHostRefusal = (err) => {
|
|
1641
|
+
if (isBindExportBreakRefusal(err))
|
|
1642
|
+
throw new ControlPlaneError(409, err.message);
|
|
1643
|
+
throw err;
|
|
1644
|
+
};
|
|
1600
1645
|
/**
|
|
1601
1646
|
* Move ONE legacy scope's data off its per-version dispatch script onto its vertical's
|
|
1602
1647
|
* stable serving script (#286/#321), then flip routing. The one primitive behind both
|
|
@@ -1610,7 +1655,7 @@ export function createControlPlaneApi(options) {
|
|
|
1610
1655
|
* installs short-circuit.
|
|
1611
1656
|
* Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
|
|
1612
1657
|
*/
|
|
1613
|
-
const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
|
|
1658
|
+
const adoptScopeOntoServing = async (c, tenantId, scopeId, opts = {}) => {
|
|
1614
1659
|
const actor = c.get('actor');
|
|
1615
1660
|
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1616
1661
|
if (!scope) {
|
|
@@ -1633,6 +1678,12 @@ export function createControlPlaneApi(options) {
|
|
|
1633
1678
|
if (!serving) {
|
|
1634
1679
|
throw new ControlPlaneError(409, `vertical '${scope.vertical}' has no serving script yet — promote a version to prod first`);
|
|
1635
1680
|
}
|
|
1681
|
+
// #1756: onto the serving script, the scope runs the served version from the moment routing
|
|
1682
|
+
// flips — whatever its own version exported. Asked before the export below moves anything.
|
|
1683
|
+
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
1684
|
+
servingRef: serving.ref,
|
|
1685
|
+
acknowledge: opts.acknowledge,
|
|
1686
|
+
});
|
|
1636
1687
|
const source = await verticalForScope(c, scope);
|
|
1637
1688
|
const dest = await options.resolveVerticalRef?.(serving.ref);
|
|
1638
1689
|
if (!source || !dest) {
|
|
@@ -1641,8 +1692,12 @@ export function createControlPlaneApi(options) {
|
|
|
1641
1692
|
const dump = await source.exportScope(scopeId);
|
|
1642
1693
|
const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
|
|
1643
1694
|
// Data landed — only now flip routing and move the version pointer.
|
|
1644
|
-
await admin
|
|
1645
|
-
|
|
1695
|
+
await admin
|
|
1696
|
+
.setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
|
|
1697
|
+
.catch(relayHostRefusal);
|
|
1698
|
+
await admin
|
|
1699
|
+
.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, { acknowledge: opts.acknowledge })
|
|
1700
|
+
.catch(relayHostRefusal);
|
|
1646
1701
|
// #1674: the scope now routes to a different store, so put the directory's recorded
|
|
1647
1702
|
// OFF positions back there — cheap and idempotent when the dump carried them.
|
|
1648
1703
|
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
@@ -1661,29 +1716,51 @@ export function createControlPlaneApi(options) {
|
|
|
1661
1716
|
* Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
|
|
1662
1717
|
* is still findable on a retry after a failed serve.
|
|
1663
1718
|
*/
|
|
1664
|
-
const adoptAndRebindOwnedScopes = async (c, slug, versionId
|
|
1719
|
+
const adoptAndRebindOwnedScopes = async (c, slug, versionId,
|
|
1720
|
+
// #1756: the promote's own acknowledgement. An install adopted here goes from ITS version to
|
|
1721
|
+
// the promoted one, which the promote gate judged only from the channel's previous version:
|
|
1722
|
+
// an install lagging behind that is judged again, and passes on the promote's ack or not at all.
|
|
1723
|
+
acknowledge) => {
|
|
1665
1724
|
const actor = c.get('actor');
|
|
1725
|
+
const none = { refused: [] };
|
|
1666
1726
|
const serving = await admin.verticalServing(actor, slug);
|
|
1667
1727
|
if (!serving)
|
|
1668
|
-
return; // embedded / not dispatch-backed — the host cascade handled rebinds
|
|
1728
|
+
return none; // embedded / not dispatch-backed — the host cascade handled rebinds
|
|
1669
1729
|
const v = await verticalOf(actor, slug);
|
|
1670
1730
|
if (!v || v.ownerTenant === null || v.listed)
|
|
1671
|
-
return; // private only, like the host cascade
|
|
1731
|
+
return none; // private only, like the host cascade
|
|
1672
1732
|
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
|
|
1733
|
+
// An install refused for what it would break is left where it is, and the rest still move:
|
|
1734
|
+
// one lagging app must not keep every other install's pointer, or the store backfill after
|
|
1735
|
+
// this, from happening. The caller answers with what was refused.
|
|
1736
|
+
const refused = [];
|
|
1737
|
+
let refusal;
|
|
1673
1738
|
for (const s of owned) {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1739
|
+
try {
|
|
1740
|
+
if (!s.servingRef) {
|
|
1741
|
+
// Adopt: export from the scope's current (un-rebound) dispatch → serving script,
|
|
1742
|
+
// then bind to the serving version. Data-first, so a failure here leaves the
|
|
1743
|
+
// scope intact on its old script for the next promote to retry.
|
|
1744
|
+
await adoptScopeOntoServing(c, s.tenantId, s.id, { acknowledge });
|
|
1745
|
+
}
|
|
1746
|
+
else if (s.verticalVersionId !== versionId) {
|
|
1747
|
+
// Already on the serving script (born there, or adopted earlier): routing is
|
|
1748
|
+
// pinned to servingRef, so advancing the version pointer only affects Update
|
|
1749
|
+
// offers. Snapshot on a migration-digest crossing (fork-before-promote, §4).
|
|
1750
|
+
await admin.bindScopeVersion(actor, s.tenantId, s.id, versionId, { snapshot: true, acknowledge });
|
|
1751
|
+
}
|
|
1679
1752
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1753
|
+
catch (e) {
|
|
1754
|
+
if (e instanceof ExportBreakRefused)
|
|
1755
|
+
refused.push(...e.breaks);
|
|
1756
|
+
else if (isBindExportBreakRefusal(e) || (e instanceof ControlPlaneError && e.message.startsWith(BIND_EXPORT_BREAK_REFUSAL))) {
|
|
1757
|
+
refusal ??= e.message; // the host's own refusal (the answer moved under us): counts only
|
|
1758
|
+
}
|
|
1759
|
+
else
|
|
1760
|
+
throw e;
|
|
1685
1761
|
}
|
|
1686
1762
|
}
|
|
1763
|
+
return { refused, ...(refusal ? { refusal } : {}) };
|
|
1687
1764
|
};
|
|
1688
1765
|
/**
|
|
1689
1766
|
* Move ONE scope onto a DIFFERENT vertical lineage's serving script (#389) — the
|
|
@@ -1721,13 +1798,20 @@ export function createControlPlaneApi(options) {
|
|
|
1721
1798
|
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1722
1799
|
return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
|
|
1723
1800
|
}
|
|
1801
|
+
// #1756: within one lineage this is an adopt, and judged as one before anything moves. A
|
|
1802
|
+
// crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
|
|
1803
|
+
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
1804
|
+
servingRef: serving.ref,
|
|
1805
|
+
acknowledge: opts.acknowledge,
|
|
1806
|
+
});
|
|
1807
|
+
const move = { acknowledge: opts.acknowledge };
|
|
1724
1808
|
if (opts.abandonData) {
|
|
1725
1809
|
// Directory-only crossing: no bytes move, so the frontier gate has nothing to
|
|
1726
1810
|
// protect — the target provisions its own schema from scratch. The source
|
|
1727
1811
|
// script's copy is untouched and remains the backout, same as a carried rebind.
|
|
1728
1812
|
// The scope serves nothing until `/verticals/:slug/instances` re-provisions it.
|
|
1729
|
-
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1730
|
-
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
|
|
1813
|
+
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
|
|
1814
|
+
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
|
|
1731
1815
|
return { servingRef: serving.ref, versionId: serving.versionId, dataAbandoned: true };
|
|
1732
1816
|
}
|
|
1733
1817
|
// The frontier gate. Digest equality proves the target's migration set is exactly
|
|
@@ -1755,8 +1839,8 @@ export function createControlPlaneApi(options) {
|
|
|
1755
1839
|
// `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
|
|
1756
1840
|
// extra snapshot here (adopt-serving's precedent): the source script's copy is the
|
|
1757
1841
|
// pre-migration state, and it is never deleted — that copy is the backout.
|
|
1758
|
-
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1759
|
-
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
|
|
1842
|
+
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
|
|
1843
|
+
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
|
|
1760
1844
|
// #1674: re-assert the recorded OFF positions in the store the scope now routes to.
|
|
1761
1845
|
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1762
1846
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
@@ -3231,13 +3315,14 @@ export function createControlPlaneApi(options) {
|
|
|
3231
3315
|
app.post('/tenants/:tenantId/scopes/:scopeId/adopt-serving', async (c) => {
|
|
3232
3316
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3233
3317
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3318
|
+
const { acknowledge } = adoptServingBody.parse(await c.req.json().catch(() => ({})));
|
|
3234
3319
|
try {
|
|
3235
|
-
const r = await adoptScopeOntoServing(c, tenantId, scopeId);
|
|
3320
|
+
const r = await adoptScopeOntoServing(c, tenantId, scopeId, { acknowledge });
|
|
3236
3321
|
return c.json({ adopted: scopeId, ...r });
|
|
3237
3322
|
}
|
|
3238
3323
|
catch (e) {
|
|
3239
3324
|
if (e instanceof ControlPlaneError) {
|
|
3240
|
-
return c.json(
|
|
3325
|
+
return c.json(exportBreakBody(e), e.status);
|
|
3241
3326
|
}
|
|
3242
3327
|
throw e;
|
|
3243
3328
|
}
|
|
@@ -3264,17 +3349,18 @@ export function createControlPlaneApi(options) {
|
|
|
3264
3349
|
return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
|
|
3265
3350
|
}
|
|
3266
3351
|
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
|
|
3352
|
+
const { acknowledge } = adoptServingBody.parse(await c.req.json().catch(() => ({})));
|
|
3267
3353
|
const adopted = [];
|
|
3268
3354
|
const alreadyAdopted = [];
|
|
3269
3355
|
try {
|
|
3270
3356
|
for (const s of owned) {
|
|
3271
|
-
const r = await adoptScopeOntoServing(c, s.tenantId, s.id);
|
|
3357
|
+
const r = await adoptScopeOntoServing(c, s.tenantId, s.id, { acknowledge });
|
|
3272
3358
|
(r.alreadyAdopted ? alreadyAdopted : adopted).push(s.id);
|
|
3273
3359
|
}
|
|
3274
3360
|
}
|
|
3275
3361
|
catch (e) {
|
|
3276
3362
|
if (e instanceof ControlPlaneError) {
|
|
3277
|
-
return c.json({
|
|
3363
|
+
return c.json({ ...exportBreakBody(e), adopted, alreadyAdopted }, e.status);
|
|
3278
3364
|
}
|
|
3279
3365
|
throw e;
|
|
3280
3366
|
}
|
|
@@ -3288,21 +3374,34 @@ export function createControlPlaneApi(options) {
|
|
|
3288
3374
|
app.post('/tenants/:tenantId/scopes/:scopeId/rebind-vertical', async (c) => {
|
|
3289
3375
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3290
3376
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3291
|
-
const { vertical, ackMigrations, abandonData } = rebindScopeVerticalBody.parse(await c.req.json());
|
|
3377
|
+
const { vertical, ackMigrations, abandonData, acknowledge } = rebindScopeVerticalBody.parse(await c.req.json());
|
|
3292
3378
|
try {
|
|
3293
3379
|
const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
|
|
3294
3380
|
ackMigrations,
|
|
3295
3381
|
abandonData,
|
|
3382
|
+
acknowledge,
|
|
3296
3383
|
});
|
|
3297
3384
|
return c.json({ rebound: scopeId, vertical, ...r });
|
|
3298
3385
|
}
|
|
3299
3386
|
catch (e) {
|
|
3300
3387
|
if (e instanceof ControlPlaneError) {
|
|
3301
|
-
return c.json(
|
|
3388
|
+
return c.json(exportBreakBody(e), e.status);
|
|
3302
3389
|
}
|
|
3303
3390
|
throw e;
|
|
3304
3391
|
}
|
|
3305
3392
|
});
|
|
3393
|
+
// #1756: which apps in this tenant binding `versionId` onto the scope would break — the
|
|
3394
|
+
// question the bind below refuses on, as a read, so a client can ask it before it records
|
|
3395
|
+
// anything of its own (the dashboard's Activity line) and show what an acknowledgement would
|
|
3396
|
+
// break. Refuses what the bind would refuse first (unknown scope or version, not admitted).
|
|
3397
|
+
// Callers: staff, and a tenant's own credential pinned to the tenant in the path; a builder's
|
|
3398
|
+
// push token is not in BUILDER_ROUTES. Every row is in that tenant.
|
|
3399
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/binding-impact', async (c) => {
|
|
3400
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3401
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3402
|
+
const versionId = z.string().min(1).parse(c.req.query('versionId'));
|
|
3403
|
+
return c.json({ affected: await admin.bindingImpact(c.get('actor'), tenantId, scopeId, versionId) });
|
|
3404
|
+
});
|
|
3306
3405
|
// Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
|
|
3307
3406
|
// non-admitted version below the seam — that refusal is the registry's reason to
|
|
3308
3407
|
// exist. A scope operation, so it keeps the scope route shape. `snapshot: true`
|
|
@@ -3318,9 +3417,16 @@ export function createControlPlaneApi(options) {
|
|
|
3318
3417
|
app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
|
|
3319
3418
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3320
3419
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3321
|
-
const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
|
|
3420
|
+
const { versionId, snapshot, acknowledge } = bindScopeVersionBody.parse(await c.req.json());
|
|
3322
3421
|
const actor = c.get('actor');
|
|
3323
3422
|
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3423
|
+
// #1756: the apps in this tenant the bind would break, asked BEFORE the carry and the
|
|
3424
|
+
// snapshot below, which move data. The host refuses too, whoever calls, but by then a
|
|
3425
|
+
// refusal would land after the bytes had moved. It refuses what the bind would refuse
|
|
3426
|
+
// first (a version not admitted) the same way. An unknown scope has no impact to read, and
|
|
3427
|
+
// the bind below refuses it as it always has.
|
|
3428
|
+
if (scope)
|
|
3429
|
+
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
3324
3430
|
// A refusal throws a ControlPlaneError, which the app's error boundary relays with its
|
|
3325
3431
|
// status and records as an ops failure. An unknown scope carries nothing, and the bind
|
|
3326
3432
|
// below refuses it as it always has.
|
|
@@ -3334,6 +3440,9 @@ export function createControlPlaneApi(options) {
|
|
|
3334
3440
|
if (scope)
|
|
3335
3441
|
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
3336
3442
|
};
|
|
3443
|
+
// The host's own refusal, should the impact have changed since it was read above (a
|
|
3444
|
+
// promote landing in between): relayed as the same 409, never as a 500.
|
|
3445
|
+
const bind = (opts) => admin.bindScopeVersion(actor, tenantId, scopeId, versionId, opts).catch(relayHostRefusal);
|
|
3337
3446
|
if (snapshot) {
|
|
3338
3447
|
if (!scope) {
|
|
3339
3448
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
@@ -3351,19 +3460,23 @@ export function createControlPlaneApi(options) {
|
|
|
3351
3460
|
migrationCrossing = Boolean(current && incoming && current.migrationDigest !== incoming.migrationDigest);
|
|
3352
3461
|
}
|
|
3353
3462
|
await carry();
|
|
3354
|
-
if (migrationCrossing)
|
|
3463
|
+
if (migrationCrossing) {
|
|
3464
|
+
// Asked again after the carry: a promote landing since the first question would
|
|
3465
|
+
// otherwise leave an archive behind for a bind the host then refuses.
|
|
3466
|
+
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
3355
3467
|
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
3356
|
-
|
|
3468
|
+
}
|
|
3469
|
+
await bind({ acknowledge });
|
|
3357
3470
|
}
|
|
3358
3471
|
else {
|
|
3359
3472
|
await carry();
|
|
3360
|
-
await
|
|
3473
|
+
await bind({ acknowledge, snapshot: true });
|
|
3361
3474
|
}
|
|
3362
3475
|
await reassert();
|
|
3363
3476
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3364
3477
|
}
|
|
3365
3478
|
await carry();
|
|
3366
|
-
await
|
|
3479
|
+
await bind({ acknowledge });
|
|
3367
3480
|
await reassert();
|
|
3368
3481
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3369
3482
|
});
|
|
@@ -3534,6 +3647,23 @@ export function createControlPlaneApi(options) {
|
|
|
3534
3647
|
const pin = confinedTenant(p);
|
|
3535
3648
|
return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
|
|
3536
3649
|
};
|
|
3650
|
+
/**
|
|
3651
|
+
* The READ twin of `notOwned`, for the routes an installed app's own pages ask
|
|
3652
|
+
* (versions, channels, and one version's registry, schedules, flow, model, assets). A
|
|
3653
|
+
* LISTED vertical is installable by every tenant, so every tenant running it reads what
|
|
3654
|
+
* it runs; ownership decides who may push or promote it, not who may see it. Without
|
|
3655
|
+
* this, the dashboard's tenant-narrowed seam (#977) reads a listed vertical another
|
|
3656
|
+
* tenant published as absent, and its app page shows no version at all. A PRIVATE
|
|
3657
|
+
* vertical stays owner-only, and so do the reads narrowed on purpose (migration SQL,
|
|
3658
|
+
* the prod history).
|
|
3659
|
+
*/
|
|
3660
|
+
const notReadable = async (p, slug) => {
|
|
3661
|
+
const pin = confinedTenant(p);
|
|
3662
|
+
if (pin === null)
|
|
3663
|
+
return false;
|
|
3664
|
+
const v = await verticalOf(p.actor, slug);
|
|
3665
|
+
return !v || (v.ownerTenant !== pin && !v.listed);
|
|
3666
|
+
};
|
|
3537
3667
|
// The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
|
|
3538
3668
|
// (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
|
|
3539
3669
|
// from their authenticated tenant — so two builders can each own a `helpdesk` with no
|
|
@@ -3708,7 +3838,7 @@ export function createControlPlaneApi(options) {
|
|
|
3708
3838
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3709
3839
|
// A builder reading a vertical it does not own gets 404 — indistinguishable from
|
|
3710
3840
|
// absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
|
|
3711
|
-
if (await
|
|
3841
|
+
if (await notReadable(p, slug)) {
|
|
3712
3842
|
return c.json({ error: 'not found' }, 404);
|
|
3713
3843
|
}
|
|
3714
3844
|
const page = pageParams(c);
|
|
@@ -3729,6 +3859,28 @@ export function createControlPlaneApi(options) {
|
|
|
3729
3859
|
return c.json({ error: 'forbidden' }, 403);
|
|
3730
3860
|
}
|
|
3731
3861
|
input.verticalSlug = slug;
|
|
3862
|
+
// `manifestJson` is a string the schema cannot look inside, and every reader parses it
|
|
3863
|
+
// with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
|
|
3864
|
+
// (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
|
|
3865
|
+
// and backout run (`serveVersionInPlace`), and `tenant-stores.ts`.
|
|
3866
|
+
// Hold it to that same parser here, at the trust boundary. The STORED
|
|
3867
|
+
// form, not the push-time one: this route keeps accepting what the platform already holds
|
|
3868
|
+
// (no `registry`, pre-#286 nulls), and refuses only what no reader could open. The string
|
|
3869
|
+
// is stored as sent — re-serializing would drop keys a newer publisher carries.
|
|
3870
|
+
if (input.manifestJson != null) {
|
|
3871
|
+
let raw;
|
|
3872
|
+
try {
|
|
3873
|
+
raw = JSON.parse(input.manifestJson);
|
|
3874
|
+
}
|
|
3875
|
+
catch {
|
|
3876
|
+
return c.json({ error: 'manifestJson is not valid JSON' }, 400);
|
|
3877
|
+
}
|
|
3878
|
+
const checked = storedDeployManifest.safeParse(raw);
|
|
3879
|
+
if (!checked.success) {
|
|
3880
|
+
const issue = checked.error.issues[0];
|
|
3881
|
+
return c.json({ error: `manifestJson is not a deploy manifest: ${issue.path.join('.') || '(root)'}: ${issue.message}` }, 400);
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3732
3884
|
await admin.publishVersion(c.get('actor'), input);
|
|
3733
3885
|
const version = await admin.getVersion(c.get('actor'), input.id, slug);
|
|
3734
3886
|
return c.json(version, 201);
|
|
@@ -3742,7 +3894,7 @@ export function createControlPlaneApi(options) {
|
|
|
3742
3894
|
app.get('/verticals/:slug/versions/:id/registry', async (c) => {
|
|
3743
3895
|
const p = c.get('principal');
|
|
3744
3896
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3745
|
-
if (await
|
|
3897
|
+
if (await notReadable(p, slug)) {
|
|
3746
3898
|
return c.json({ error: 'not found' }, 404);
|
|
3747
3899
|
}
|
|
3748
3900
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3753,20 +3905,18 @@ export function createControlPlaneApi(options) {
|
|
|
3753
3905
|
// and `substrat promote` show before a migration change is acknowledged. `?base=` names the
|
|
3754
3906
|
// version to compare against (the one `prod` serves); without it every migration is listed.
|
|
3755
3907
|
// Owner-narrowed like `/registry`, and never widened: migration SQL describes a schema.
|
|
3756
|
-
// `migrations` is null for a version
|
|
3757
|
-
//
|
|
3758
|
-
// "no migrations". Bounded in count and bytes (`migrationsOnTop`).
|
|
3908
|
+
// `migrations` is null for a version that carries none (pushed before the field, or over
|
|
3909
|
+
// the push's cap), which a reader must show as "SQL not available", never as
|
|
3910
|
+
// "no migrations". Bounded in count and bytes (`migrationsOnTop`). The SQL is stored apart
|
|
3911
|
+
// from the manifest (#1764), so this reads it without parsing either manifest.
|
|
3759
3912
|
app.get('/verticals/:slug/versions/:id/migrations', async (c) => {
|
|
3760
3913
|
const p = c.get('principal');
|
|
3761
3914
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3762
3915
|
if (await notOwned(p, slug)) {
|
|
3763
3916
|
return c.json({ error: 'not found' }, 404);
|
|
3764
3917
|
}
|
|
3765
|
-
// `
|
|
3766
|
-
const migrationsOf =
|
|
3767
|
-
const json = await admin.versionManifest(c.get('actor'), slug, id);
|
|
3768
|
-
return json ? (storedDeployManifest.parse(JSON.parse(json)).migrations ?? null) : null;
|
|
3769
|
-
};
|
|
3918
|
+
// `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
|
|
3919
|
+
const migrationsOf = (id) => admin.versionMigrations(c.get('actor'), slug, id);
|
|
3770
3920
|
const base = c.req.query('base');
|
|
3771
3921
|
const [incoming, baseline] = await Promise.all([
|
|
3772
3922
|
migrationsOf(c.req.param('id')),
|
|
@@ -3784,7 +3934,7 @@ export function createControlPlaneApi(options) {
|
|
|
3784
3934
|
app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
|
|
3785
3935
|
const p = c.get('principal');
|
|
3786
3936
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3787
|
-
if (await
|
|
3937
|
+
if (await notReadable(p, slug)) {
|
|
3788
3938
|
return c.json({ error: 'not found' }, 404);
|
|
3789
3939
|
}
|
|
3790
3940
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3807,7 +3957,7 @@ export function createControlPlaneApi(options) {
|
|
|
3807
3957
|
app.get('/verticals/:slug/versions/:id/flow', async (c) => {
|
|
3808
3958
|
const p = c.get('principal');
|
|
3809
3959
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3810
|
-
if (await
|
|
3960
|
+
if (await notReadable(p, slug)) {
|
|
3811
3961
|
return c.json({ error: 'not found' }, 404);
|
|
3812
3962
|
}
|
|
3813
3963
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3835,7 +3985,7 @@ export function createControlPlaneApi(options) {
|
|
|
3835
3985
|
app.get('/verticals/:slug/versions/:id/model', async (c) => {
|
|
3836
3986
|
const p = c.get('principal');
|
|
3837
3987
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3838
|
-
if (await
|
|
3988
|
+
if (await notReadable(p, slug)) {
|
|
3839
3989
|
return c.json({ error: 'not found' }, 404);
|
|
3840
3990
|
}
|
|
3841
3991
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3854,7 +4004,7 @@ export function createControlPlaneApi(options) {
|
|
|
3854
4004
|
app.get('/verticals/:slug/versions/:id/assets', async (c) => {
|
|
3855
4005
|
const p = c.get('principal');
|
|
3856
4006
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3857
|
-
if (await
|
|
4007
|
+
if (await notReadable(p, slug)) {
|
|
3858
4008
|
return c.json({ error: 'not found' }, 404);
|
|
3859
4009
|
}
|
|
3860
4010
|
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
@@ -3943,7 +4093,7 @@ export function createControlPlaneApi(options) {
|
|
|
3943
4093
|
app.get('/verticals/:slug/channels', async (c) => {
|
|
3944
4094
|
const p = c.get('principal');
|
|
3945
4095
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3946
|
-
if (await
|
|
4096
|
+
if (await notReadable(p, slug)) {
|
|
3947
4097
|
return c.json({ error: 'not found' }, 404);
|
|
3948
4098
|
}
|
|
3949
4099
|
const page = pageParams(c);
|
|
@@ -4118,6 +4268,39 @@ export function createControlPlaneApi(options) {
|
|
|
4118
4268
|
return { minted: [], error: message };
|
|
4119
4269
|
}
|
|
4120
4270
|
};
|
|
4271
|
+
/**
|
|
4272
|
+
* Which installed apps promoting `versionId` to `channel` would break: the promote gate's own
|
|
4273
|
+
* answer (`promotionImpact`, judged from the channel's previous version), and — for a PRIVATE,
|
|
4274
|
+
* dispatch-backed vertical's prod — what adopting each owned install still on its own version
|
|
4275
|
+
* would break (#1756). That adopt runs after the serve and carries the promote's acknowledgement,
|
|
4276
|
+
* so the acknowledgement must be asked against both, or it would cover breaks nobody was shown.
|
|
4277
|
+
* Over-approximates for a host that cannot serve in place, where no adopt runs: an extra line
|
|
4278
|
+
* costs a read, a missing one a silent stall.
|
|
4279
|
+
*/
|
|
4280
|
+
const promoteImpactOf = async (actor, slug, channel, versionId) => {
|
|
4281
|
+
const gate = await admin.promotionImpact(actor, slug, channel, versionId);
|
|
4282
|
+
if (channel !== 'prod')
|
|
4283
|
+
return gate;
|
|
4284
|
+
const v = await verticalOf(actor, slug);
|
|
4285
|
+
if (!v || v.ownerTenant === null || v.listed)
|
|
4286
|
+
return gate;
|
|
4287
|
+
const incoming = await admin.getVersion(actor, versionId, slug);
|
|
4288
|
+
if (!incoming?.deploymentRef || incoming.admission !== 'admitted')
|
|
4289
|
+
return gate;
|
|
4290
|
+
const lagging = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && !s.servingRef && s.verticalVersionId !== versionId);
|
|
4291
|
+
const seen = new Set(gate.map((b) => `${b.scopeId}|${b.type}`));
|
|
4292
|
+
const out = [...gate];
|
|
4293
|
+
for (const s of lagging) {
|
|
4294
|
+
// After the adopt it runs the promoted version, served in place: that pointer move, from
|
|
4295
|
+
// what it runs now, is the move to judge.
|
|
4296
|
+
for (const b of await admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
|
|
4297
|
+
const key = `${b.scopeId}|${b.type}`;
|
|
4298
|
+
if (!seen.has(key))
|
|
4299
|
+
(seen.add(key), out.push(b));
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
return out;
|
|
4303
|
+
};
|
|
4121
4304
|
// #1705 PR 3: which installed apps promoting `?versionId=` would break (an export they import,
|
|
4122
4305
|
// dropped or re-versioned), BEFORE promoting, so a dialog can show it and ask for the
|
|
4123
4306
|
// acknowledgement up front, as it does for the digests. The promote's own 409 carries the same
|
|
@@ -4133,7 +4316,7 @@ export function createControlPlaneApi(options) {
|
|
|
4133
4316
|
if (!v || v.ownerTenant !== pin)
|
|
4134
4317
|
return c.json({ error: 'forbidden' }, 403);
|
|
4135
4318
|
}
|
|
4136
|
-
const breaks = await
|
|
4319
|
+
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
4137
4320
|
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
4138
4321
|
return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
|
|
4139
4322
|
});
|
|
@@ -4179,8 +4362,13 @@ export function createControlPlaneApi(options) {
|
|
|
4179
4362
|
// the way the store backfill below is: a confined caller sees its own tenant's apps and the
|
|
4180
4363
|
// rest as a count, never another tenant's id. Read only when there is something to say: on
|
|
4181
4364
|
// the refusal, and before an acknowledged promotion moves the channel it is measured from.
|
|
4365
|
+
// The whole answer is kept beside the narrowed listing, so the refusal's sentence counts the
|
|
4366
|
+
// same breaks the listing lists (in counts, which name no tenant) — the host's own sentence
|
|
4367
|
+
// counts only the gate's half of them.
|
|
4368
|
+
let counted = [];
|
|
4182
4369
|
const breaksOf = async () => {
|
|
4183
|
-
const breaks = await
|
|
4370
|
+
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
4371
|
+
counted = breaks;
|
|
4184
4372
|
if (breaks.length === 0)
|
|
4185
4373
|
return null;
|
|
4186
4374
|
const { visible, otherTenants } = narrowToCaller(breaks, confinedTenant(p));
|
|
@@ -4202,13 +4390,14 @@ export function createControlPlaneApi(options) {
|
|
|
4202
4390
|
exportBreaks: null,
|
|
4203
4391
|
exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
4204
4392
|
}));
|
|
4205
|
-
return c.json({ error: err.message, ...listing }, 409);
|
|
4393
|
+
return c.json({ error: listing.exportBreaks ? exportBreakRefusal(counted) : err.message, ...listing }, 409);
|
|
4206
4394
|
}
|
|
4207
4395
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
4208
4396
|
// uploading first would deploy to live scopes before the acknowledgement check.
|
|
4209
4397
|
// A failed serve is NOT a failed promote: the channel moved (audited), old code
|
|
4210
4398
|
// still serves, and promoting again retries the upload.
|
|
4211
4399
|
let backfill = { minted: [] };
|
|
4400
|
+
let adopted = { refused: [] };
|
|
4212
4401
|
if (channel === 'prod') {
|
|
4213
4402
|
try {
|
|
4214
4403
|
await serveVersionInPlace(c.get('actor'), slug, versionId);
|
|
@@ -4217,7 +4406,9 @@ export function createControlPlaneApi(options) {
|
|
|
4217
4406
|
// dispatch-backed vertical (#321), in the correct order (serve → adopt → rebind),
|
|
4218
4407
|
// so a legacy scope's data survives the promote instead of being stranded on a
|
|
4219
4408
|
// fresh per-version script. Retry-safe: nothing rebound these scopes yet.
|
|
4220
|
-
|
|
4409
|
+
// Only the export-break acknowledgement is the adopt's to carry: the digest ones are the
|
|
4410
|
+
// promote's own, and a bind's strict acknowledgement refuses them (#1756).
|
|
4411
|
+
adopted = await adoptAndRebindOwnedScopes(c, slug, versionId, acknowledge?.exportBreak ? { exportBreak: true } : undefined);
|
|
4221
4412
|
// #825: and mint whatever THIS version newly declares for the tenants already
|
|
4222
4413
|
// installed — after the serve, so the declaration being read is the one now live.
|
|
4223
4414
|
backfill = await backfillFleetStores(c, slug);
|
|
@@ -4231,6 +4422,31 @@ export function createControlPlaneApi(options) {
|
|
|
4231
4422
|
}, 502);
|
|
4232
4423
|
}
|
|
4233
4424
|
}
|
|
4425
|
+
// #1756: served, and every install that could move has moved and been backfilled, but one
|
|
4426
|
+
// still on its own version would break apps in its tenant if moved onto the serving script —
|
|
4427
|
+
// a move the promote gate never judged, since that install was not on the channel's previous
|
|
4428
|
+
// version. It is left where it is. Promoting again acknowledged moves it.
|
|
4429
|
+
if (adopted.refused.length > 0 || adopted.refusal) {
|
|
4430
|
+
const sentence = adopted.refused.length > 0 ? bindExportBreakRefusal(adopted.refused) : adopted.refusal;
|
|
4431
|
+
return c.json({
|
|
4432
|
+
error: `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
|
|
4433
|
+
`${sentence}. Promote again with the export-break acknowledgement to move it`,
|
|
4434
|
+
...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
|
|
4435
|
+
// Narrowed as the success answer's is: a builder never reads another tenant's id here.
|
|
4436
|
+
...(backfill.minted.length || backfill.error
|
|
4437
|
+
? (() => {
|
|
4438
|
+
const { visible, otherTenants } = narrowToCaller(backfill.minted, confinedTenant(c.get('principal')));
|
|
4439
|
+
return {
|
|
4440
|
+
storeBackfill: {
|
|
4441
|
+
minted: visible,
|
|
4442
|
+
...(otherTenants ? { otherTenants } : {}),
|
|
4443
|
+
...(backfill.error ? { error: backfill.error } : {}),
|
|
4444
|
+
},
|
|
4445
|
+
};
|
|
4446
|
+
})()
|
|
4447
|
+
: {}),
|
|
4448
|
+
}, 409);
|
|
4449
|
+
}
|
|
4234
4450
|
const promoted = (await admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
|
|
4235
4451
|
// The store backfill rides the response only when it has something to say (#825), so
|
|
4236
4452
|
// nothing changes for the overwhelming majority of promotes — and when a store WAS
|