@serve.zone/interfaces 32.13.0 → 32.15.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/changelog.md +30 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/clusternode.d.ts +35 -3
- package/dist_ts/data/clusternode.js +141 -3
- package/dist_ts/data/fleetcutover.d.ts +248 -0
- package/dist_ts/data/fleetcutover.js +648 -1
- package/dist_ts/data/runtimestorageclaim.d.ts +43 -0
- package/dist_ts/data/runtimestorageclaim.js +88 -4
- package/dist_ts/requests/fleetcutover.d.ts +87 -6
- package/dist_ts/requests/runtimesession.d.ts +47 -0
- package/package.json +1 -1
- package/readme.md +108 -18
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +209 -2
- package/ts/data/fleetcutover.ts +978 -0
- package/ts/data/runtimestorageclaim.ts +122 -3
- package/ts/requests/fleetcutover.ts +104 -5
- package/ts/requests/runtimesession.ts +57 -0
package/readme.md
CHANGED
|
@@ -1093,19 +1093,61 @@ current Spark bundle/unit/configuration and old-mode refusal evidence. This is a
|
|
|
1093
1093
|
owner; it is never represented as a Pallet assignment. Downstream service holds and storage fences
|
|
1094
1094
|
therefore depend on a live relay without requiring assignment authority that cannot exist yet.
|
|
1095
1095
|
|
|
1096
|
-
The storage bootstrap
|
|
1097
|
-
Cloudly's accepted pre-switch
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1096
|
+
The storage bootstrap separates desired identity, observed legacy identity and provider-proved
|
|
1097
|
+
physical lineage. `IFleetCutoverPreparedVolumeHandoffIntent` is Cloudly's accepted pre-switch
|
|
1098
|
+
statement. It carries the full sealed `IRuntimeStorageClaimIntent` and the exact persistent
|
|
1099
|
+
`observedMount`, including Docker source, target and combined identity digests. The Docker source
|
|
1100
|
+
digest commits only to Docker's exact source bytes; it is never physical lineage. The prepared
|
|
1101
|
+
handoff deliberately has no `sourceLineageDigest`. Corestore creates that independent commitment
|
|
1102
|
+
from its private device, inode and content proof while the native attachment fence is held.
|
|
1103
|
+
|
|
1104
|
+
An administrator changes prepared handoffs through
|
|
1105
|
+
`prepareFleetCutoverVolumeHandoffs`. Each compare-and-swap mutation contains 1..32 combined upserts
|
|
1106
|
+
and explicit removals. An upsert supplies only `{ nodeId, serviceId, mountIdentityDigest, request }`,
|
|
1107
|
+
where `request` is the full canonical retained `ReadWriteOnce` filesystem request. Cloudly derives
|
|
1108
|
+
the volume, controller, organization, namespace, claim identity, evidence references and request
|
|
1109
|
+
digest from current owner rows. It re-derives the complete required set from the admitted manager
|
|
1110
|
+
task inventory and accepted local observations. The maximum possible physical set is
|
|
1111
|
+
`maximumTasks * maximumMountsPerOwner`, or `4096 * 32 = 131072`; node placement is already carried
|
|
1112
|
+
by each admitted task, so a service-count bound would undercount replicated placement.
|
|
1113
|
+
|
|
1114
|
+
`IFleetCutoverPreparedVolumeHandoffAggregate` is a revisioned header with required/actual counts,
|
|
1115
|
+
state, page count and a complete set digest. It does not inline page references. Each immutable
|
|
1116
|
+
`IFleetCutoverPreparedVolumeHandoffPage` contains 1..32 sorted handoff references, with the owning
|
|
1117
|
+
`nodeId` committed beside every exact handoff revision, plus its content digest and the exact
|
|
1118
|
+
aggregate revision. Readers pin one aggregate, fetch every consecutive page and reject missing,
|
|
1119
|
+
duplicate, stale or mixed-revision pages. The set digest covers the ordered page-content digests. A
|
|
1120
|
+
32-entry mutation is therefore a wire bound, not a fleet volume ceiling.
|
|
1121
|
+
|
|
1122
|
+
Spark reads one pinned page from
|
|
1123
|
+
`/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs`. The response carries the full immutable
|
|
1124
|
+
page and the authenticated node's complete full-handoff subset, including an explicit empty subset.
|
|
1125
|
+
Spark forwards held fences through the optional `fleetCutoverHeldVolumeFences` member available in
|
|
1126
|
+
every Swarm-observation state. The page's node-bearing references determine the exact subset; fetch
|
|
1127
|
+
responses and fence evidence must match every committed reference in page order, so omission is
|
|
1128
|
+
rejected and an empty report is valid only for a node with no entry on that page. The request binds
|
|
1129
|
+
the evidence node to the authenticated reporter. That evidence has a dedicated 128 KiB encoded
|
|
1130
|
+
budget and never displaces the existing census snapshots. One node's page report proves only that
|
|
1131
|
+
node's subset; Cloudly establishes global completeness across accepted reports for every page and
|
|
1132
|
+
node.
|
|
1133
|
+
|
|
1134
|
+
Pallet persists `TFleetCutoverVolumeFence` in `held` state while draining. A hold grants no claim,
|
|
1135
|
+
session, assignment, attachment, reservation, confirmation or execution authority. After the real
|
|
1136
|
+
current runtime reaches `pallet`, Pallet obtains `IFleetCutoverStorageAdoptionGrant` through its live
|
|
1137
|
+
authenticated session. The grant binds the exact prepared handoff, held fence, current pallet
|
|
1138
|
+
runtime, session generation/epoch and observed mount. Pallet keeps its per-volume lane while it
|
|
1139
|
+
invokes the isolated Corestore one-shot. Corestore performs physical-lineage proof and the initial
|
|
1140
|
+
ready-claim CAS under its native attachment fence, without calling back into Pallet, and returns the
|
|
1141
|
+
full claim with `IFleetCutoverCorestoreAdoptionReceipt`. Pallet revalidates the live session, runtime
|
|
1142
|
+
phase and grant after that potentially long operation before consuming the durable hold. A stale
|
|
1143
|
+
grant leaves the hold intact.
|
|
1144
|
+
|
|
1145
|
+
`bindFleetCutoverAdoptionToStorageClaim` joins the provider receipt and full ready claim to the
|
|
1146
|
+
prepared desired intent. `bindFleetCutoverStorageAdoptionBundle` additionally joins the current
|
|
1147
|
+
grant and consumed fence. Neither binder equates the provider physical-lineage digest with the
|
|
1148
|
+
observed Docker source digest or requires the provider's opaque `resourceRef` to equal Cloudly's
|
|
1149
|
+
volume ID. They are content checks; current owner dereferencing, peer authentication and native
|
|
1150
|
+
attachment fencing remain mandatory.
|
|
1109
1151
|
|
|
1110
1152
|
`IFleetCutoverCoreflowRetirementEvidence` binds the real pre-switch Docker Coreflow32 relay by exact
|
|
1111
1153
|
service ID, container ID, spec version/spec digest and image digest to accepted manager and local
|
|
@@ -1119,8 +1161,34 @@ erasure are not cutover requirements. Its exact owner generation is retained wit
|
|
|
1119
1161
|
Retained recovery evidence is valid only while the released
|
|
1120
1162
|
owner cannot consume it into live mode and the old credential remains revoked.
|
|
1121
1163
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1164
|
+
Before any post-pallet assignment exists, an administrator calls
|
|
1165
|
+
`requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
|
|
1166
|
+
current `pallet` runtime, complete accepted scope and target set, held exact-cluster spec revision,
|
|
1167
|
+
and `expectedDesignation`. Null creates the first designation; an exact current designation
|
|
1168
|
+
reference permits only same-service credential rotation. Replacing the designated service is a
|
|
1169
|
+
different owner flow and is refused here. Cloudly's one owning transaction verifies the admin,
|
|
1170
|
+
same-organization live service, current references, held exact-cluster spec, zero configured and
|
|
1171
|
+
actual published/public ports, and absence of a conflicting designation. It generates the managed
|
|
1172
|
+
launcher-environment bearer privately, stores its active version and hash authority, advances the
|
|
1173
|
+
held spec if its secret input changed, and accepts `IFleetCutoverIngressDesignation`. The public
|
|
1174
|
+
designation exposes only credential ID/generation and the active `ISecretVersionReference`; neither
|
|
1175
|
+
it nor the response exposes the bearer, hash, private key, or any other secret value. Preparation
|
|
1176
|
+
requires no assignment, runtime session, ingress registration, legacy absence, publication,
|
|
1177
|
+
port-transfer, readback, or `IFleetCutoverIngressAuthority`.
|
|
1178
|
+
|
|
1179
|
+
The preparation request is a closed compare-and-swap body. A replay requires the same mutation ID
|
|
1180
|
+
and exactly the original normalized request payload and returns the recorded designation/spec
|
|
1181
|
+
result; it does not renew generation, acceptance time, freshness, or authority. Every downstream
|
|
1182
|
+
authorization must dereference the current designation, held spec, runtime, scope, and target set.
|
|
1183
|
+
A historical replay response never grants current authority. `snapshotFleetCutoverIngressPreparationRequest`
|
|
1184
|
+
and `snapshotFleetCutoverIngressDesignation` check exact structure, reference relationships and the
|
|
1185
|
+
designation's canonical digest. They cannot establish admin identity, currentness, organization,
|
|
1186
|
+
service liveness, held state, zero ports, or absence of a conflicting designation; Cloudly owns all
|
|
1187
|
+
of those acceptance checks.
|
|
1188
|
+
|
|
1189
|
+
The remaining post-pallet order is represented without a second route or publication model. First
|
|
1190
|
+
Cloudly releases only the designated new ingress spec under an accepted empty published-port
|
|
1191
|
+
policy. It then accepts
|
|
1124
1192
|
the existing cluster-ingress registration and route-table revision, exact Spark evidence that the
|
|
1125
1193
|
old ingress is absent, the current source and target publication records and owning port-transfer
|
|
1126
1194
|
receipt, and a readback of the current route revision as `IFleetCutoverIngressAuthority`. Only an
|
|
@@ -1128,9 +1196,14 @@ application release may cite that authority. The legacy Docker Coretraffic insta
|
|
|
1128
1196
|
remains through `switching`; the new ingress runs without public ports before exact port ownership
|
|
1129
1197
|
moves, and applications remain held until the new authoritative route is read back.
|
|
1130
1198
|
|
|
1131
|
-
The owner methods live under `requests.fleetcutover`: administrator scope classification
|
|
1132
|
-
forward phase CAS
|
|
1133
|
-
|
|
1199
|
+
The owner methods live under `requests.fleetcutover`: administrator scope classification, storage
|
|
1200
|
+
handoff preparation, ingress preparation and forward phase CAS; root-private prepared-fence hold,
|
|
1201
|
+
read and owner-triggered adoption; isolated Corestore prepared-volume adoption; ingress-authority
|
|
1202
|
+
read; and ingress/application spec release. Current-session adoption authorization, accepted bundle
|
|
1203
|
+
reporting and full claim readback live under `requests.runtimesession`. The published 32.x
|
|
1204
|
+
`holdFleetCutoverVolumeFence`, `consumeFleetCutoverVolumeFence` and `adoptFleetCutoverVolume`
|
|
1205
|
+
declarations remain for type compatibility only. Production owners do not register or adapt those
|
|
1206
|
+
incomplete operations; they are marked for removal at the next protocol major.
|
|
1134
1207
|
Spark transports local and manager evidence through its existing authenticated Swarm-observation
|
|
1135
1208
|
route, and Cloudly assembles census and Coreflow-retirement records from accepted owner state; there
|
|
1136
1209
|
is no caller-supplied census or retirement proof endpoint.
|
|
@@ -1645,6 +1718,15 @@ or sender addresses itself.
|
|
|
1645
1718
|
|
|
1646
1719
|
### Runtime filesystem storage claims
|
|
1647
1720
|
|
|
1721
|
+
`data.IRuntimeStorageClaimIntent` is the non-ready Cloudly-owned precursor to a claim. It fixes the
|
|
1722
|
+
controller epoch, organization, cluster, node, runtime namespace, service, claim ID/generation and
|
|
1723
|
+
predecessor, plus `IRuntimeStorageClaimRequestedFilesystemBinding`. That requested binding fixes the
|
|
1724
|
+
service, full normalized retained `ReadWriteOnce` filesystem request and its canonical bare digest.
|
|
1725
|
+
It structurally cannot state a provider binding ID/generation, policy selection, status, observed
|
|
1726
|
+
generation, resource reference or granted capability. The intent is exact-schema and digest-bound
|
|
1727
|
+
under `serve.zone/runtime-storage-claim-intent`; it grants no assignment, session, execution or
|
|
1728
|
+
attachment authority.
|
|
1729
|
+
|
|
1648
1730
|
`data.IRuntimeStorageClaim` is the portable, Cloudly-issued authority for one
|
|
1649
1731
|
ready, retained `ReadWriteOnce` filesystem binding on one cluster node. It
|
|
1650
1732
|
names the organization, service, cluster, node, runtime namespace, controller
|
|
@@ -1669,6 +1751,14 @@ provider readiness and policy, durably admit the assignment, and obtain a
|
|
|
1669
1751
|
fenced native attachment from the storage owner before a CRI mount. An absent
|
|
1670
1752
|
claim or an unverified opaque resource reference never authorizes a mount.
|
|
1671
1753
|
|
|
1754
|
+
`data.bindRuntimeStorageClaimToIntent(claim, intent)` verifies both canonical digests and requires
|
|
1755
|
+
the ready claim to preserve every Cloudly-owned identity and requested filesystem fact: service,
|
|
1756
|
+
request ID/digest, mount path, access mode and reclaim policy. Only the full claim may add the
|
|
1757
|
+
storage provider's binding identity/generation, selected policy, matching observed generation,
|
|
1758
|
+
`ready` status, opaque resource reference and persistent single-node capabilities. This is a content
|
|
1759
|
+
join: callers still authenticate Cloudly and the provider, check current readiness and retain the
|
|
1760
|
+
native fence.
|
|
1761
|
+
|
|
1672
1762
|
`platform.storagemigration` defines the provider-neutral cutover contract for a
|
|
1673
1763
|
named object-storage binding. Corestore atomically owns and fences the source
|
|
1674
1764
|
binding after validating a distinct, unfenced active-object snapshot. Onebox
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/data/clusternode.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import type
|
|
2
|
+
import { isSha256Digest, type TSha256Digest, type TImmutableContainerPlatform } from './immutableimage.js';
|
|
3
3
|
import type { ISparkNodeReportRecord } from './sparknodereport.js';
|
|
4
4
|
import {
|
|
5
|
+
fleetCutoverContract,
|
|
6
|
+
snapshotFleetCutoverPreparedVolumeHandoffAggregate,
|
|
7
|
+
snapshotFleetCutoverPreparedVolumeHandoffPage,
|
|
8
|
+
snapshotFleetCutoverPreparedVolumeHandoffIntent,
|
|
9
|
+
validateFleetCutoverHeldVolumeFenceEvidencePage,
|
|
10
|
+
validateFleetCutoverPreparedVolumeHandoffAggregate,
|
|
11
|
+
validateFleetCutoverPreparedVolumeHandoffPage,
|
|
12
|
+
validateFleetCutoverPreparedVolumeHandoffIntent,
|
|
5
13
|
validateFleetCutoverLocalNodeSnapshot,
|
|
6
14
|
validateFleetCutoverManagerSnapshot,
|
|
15
|
+
type IFleetCutoverAcceptedEvidenceReference,
|
|
16
|
+
type IFleetCutoverHeldVolumeFenceEvidencePage,
|
|
7
17
|
type IFleetCutoverLocalNodeSnapshot,
|
|
8
18
|
type IFleetCutoverManagerSnapshot,
|
|
19
|
+
type IFleetCutoverPreparedVolumeHandoffAggregate,
|
|
20
|
+
type IFleetCutoverPreparedVolumeHandoffIntent,
|
|
21
|
+
type IFleetCutoverPreparedVolumeHandoffPage,
|
|
9
22
|
} from './fleetcutover.js';
|
|
10
23
|
import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
11
24
|
import {
|
|
@@ -131,9 +144,15 @@ export const sparkSwarmNodeContracts = Object.freeze({
|
|
|
131
144
|
*/
|
|
132
145
|
swarmObservation: Object.freeze({
|
|
133
146
|
endpoint: '/spark/swarm-nodes/swarm-observation',
|
|
134
|
-
maxRequestBytes:
|
|
147
|
+
maxRequestBytes: 4_325_376,
|
|
135
148
|
maxResponseBytes: 2048,
|
|
136
149
|
} as const),
|
|
150
|
+
/** Pinned immutable aggregate page plus at most 32 full handoffs for this authenticated node. */
|
|
151
|
+
fleetCutoverPreparedVolumeHandoffs: Object.freeze({
|
|
152
|
+
endpoint: '/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs',
|
|
153
|
+
maxRequestBytes: 4096,
|
|
154
|
+
maxResponseBytes: 262_144,
|
|
155
|
+
} as const),
|
|
137
156
|
protocolRefusalStatus: protocolRefusalHttpStatus,
|
|
138
157
|
} as const);
|
|
139
158
|
|
|
@@ -221,6 +240,8 @@ interface ISparkSwarmObservationBase {
|
|
|
221
240
|
reporterSessionId: string;
|
|
222
241
|
observationSequence: number;
|
|
223
242
|
observedAt: number;
|
|
243
|
+
/** Available in every observation state; exact page membership proves an empty node subset. */
|
|
244
|
+
fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
|
|
224
245
|
}
|
|
225
246
|
|
|
226
247
|
export type TSparkSwarmObservation =
|
|
@@ -288,6 +309,32 @@ export interface ISparkSwarmObservationRequest {
|
|
|
288
309
|
observation: TSparkSwarmObservation;
|
|
289
310
|
}
|
|
290
311
|
|
|
312
|
+
/** First read may select current with null; all following page reads pin the exact aggregate. */
|
|
313
|
+
export interface ISparkGetFleetCutoverPreparedVolumeHandoffsRequest {
|
|
314
|
+
nodeId: string;
|
|
315
|
+
nodeToken: string;
|
|
316
|
+
protocol: IProtocolOffer;
|
|
317
|
+
aggregate: IFleetCutoverAcceptedEvidenceReference | null;
|
|
318
|
+
pageIndex: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type TSparkGetFleetCutoverPreparedVolumeHandoffsResponse =
|
|
322
|
+
| {
|
|
323
|
+
aggregate: null;
|
|
324
|
+
page: null;
|
|
325
|
+
nodeId: string;
|
|
326
|
+
nodeHandoffs: [];
|
|
327
|
+
protocol: IProtocolOffer;
|
|
328
|
+
}
|
|
329
|
+
| {
|
|
330
|
+
aggregate: IFleetCutoverPreparedVolumeHandoffAggregate;
|
|
331
|
+
page: IFleetCutoverPreparedVolumeHandoffPage;
|
|
332
|
+
nodeId: string;
|
|
333
|
+
/** Exact complete authenticated-node subset committed by this page; may be empty. */
|
|
334
|
+
nodeHandoffs: IFleetCutoverPreparedVolumeHandoffIntent[];
|
|
335
|
+
protocol: IProtocolOffer;
|
|
336
|
+
};
|
|
337
|
+
|
|
291
338
|
export interface ISparkSwarmObservationAcceptedReceipt {
|
|
292
339
|
state: 'accepted';
|
|
293
340
|
nodeId: string;
|
|
@@ -579,6 +626,8 @@ export const validateSparkSwarmObservation = async (
|
|
|
579
626
|
'observationSequence',
|
|
580
627
|
'observedAt',
|
|
581
628
|
'state',
|
|
629
|
+
...(observation.fleetCutoverHeldVolumeFences === undefined
|
|
630
|
+
? [] : ['fleetCutoverHeldVolumeFences']),
|
|
582
631
|
];
|
|
583
632
|
const expectedKeys = observation.state === 'unknown'
|
|
584
633
|
? [...commonKeys, 'reason']
|
|
@@ -619,6 +668,14 @@ export const validateSparkSwarmObservation = async (
|
|
|
619
668
|
.includes(observation.reason as string)) {
|
|
620
669
|
errors.push('Spark Swarm observation unknown reason must be canonical');
|
|
621
670
|
}
|
|
671
|
+
if (observation.fleetCutoverHeldVolumeFences !== undefined) {
|
|
672
|
+
const evidenceErrors = await validateFleetCutoverHeldVolumeFenceEvidencePage(
|
|
673
|
+
observation.fleetCutoverHeldVolumeFences,
|
|
674
|
+
);
|
|
675
|
+
errors.push(...evidenceErrors.map(
|
|
676
|
+
errorArg => `Spark Swarm observation ${errorArg}`,
|
|
677
|
+
));
|
|
678
|
+
}
|
|
622
679
|
if (observation.state === 'member') {
|
|
623
680
|
if (!isRuntimeIdentifier(observation.localSwarmNodeId)
|
|
624
681
|
|| typeof observation.controlAvailable !== 'boolean') {
|
|
@@ -694,6 +751,151 @@ const validateSparkReporterIdentity = (
|
|
|
694
751
|
return errors;
|
|
695
752
|
};
|
|
696
753
|
|
|
754
|
+
const readFleetCutoverEvidenceReference = (
|
|
755
|
+
valueArg: unknown,
|
|
756
|
+
): IFleetCutoverAcceptedEvidenceReference | null => {
|
|
757
|
+
if (valueArg === null) return null;
|
|
758
|
+
if (!isRuntimeRecord(valueArg)
|
|
759
|
+
|| !hasExactKeys(valueArg, ['id', 'generation', 'digest'])
|
|
760
|
+
|| !isRuntimeIdentifier(valueArg.id)
|
|
761
|
+
|| !isPositiveSafeInteger(valueArg.generation)
|
|
762
|
+
|| typeof valueArg.digest !== 'string'
|
|
763
|
+
|| !isSha256Digest(valueArg.digest)) return null;
|
|
764
|
+
return {
|
|
765
|
+
id: valueArg.id,
|
|
766
|
+
generation: valueArg.generation,
|
|
767
|
+
digest: valueArg.digest,
|
|
768
|
+
};
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
export const validateSparkGetFleetCutoverPreparedVolumeHandoffsRequest = (
|
|
772
|
+
requestArg: unknown,
|
|
773
|
+
): string[] => {
|
|
774
|
+
try {
|
|
775
|
+
if (!isRuntimeRecord(requestArg)
|
|
776
|
+
|| !hasExactKeys(requestArg, [
|
|
777
|
+
'nodeId', 'nodeToken', 'protocol', 'aggregate', 'pageIndex',
|
|
778
|
+
])) {
|
|
779
|
+
return ['Spark prepared volume handoff request must use its exact schema'];
|
|
780
|
+
}
|
|
781
|
+
canonicalizeStrictJson(
|
|
782
|
+
requestArg,
|
|
783
|
+
failSparkSwarmObservationCanonicalization,
|
|
784
|
+
'Spark prepared volume handoff request',
|
|
785
|
+
);
|
|
786
|
+
const errors = validateProtocolOfferOn(
|
|
787
|
+
requestArg,
|
|
788
|
+
'Spark prepared volume handoff request protocol',
|
|
789
|
+
);
|
|
790
|
+
errors.push(...validateSparkReporterIdentity(
|
|
791
|
+
requestArg,
|
|
792
|
+
'Spark prepared volume handoff request',
|
|
793
|
+
));
|
|
794
|
+
if ((requestArg.aggregate !== null
|
|
795
|
+
&& readFleetCutoverEvidenceReference(requestArg.aggregate) === null)
|
|
796
|
+
|| typeof requestArg.pageIndex !== 'number'
|
|
797
|
+
|| !Number.isSafeInteger(requestArg.pageIndex)
|
|
798
|
+
|| requestArg.pageIndex < 0
|
|
799
|
+
|| requestArg.pageIndex >= fleetCutoverContract.maximumPreparedVolumeHandoffPages) {
|
|
800
|
+
errors.push('Spark prepared volume handoff request pin must be canonical');
|
|
801
|
+
}
|
|
802
|
+
return errors;
|
|
803
|
+
} catch {
|
|
804
|
+
return ['Spark prepared volume handoff request must be safely inspectable'];
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
export const validateSparkGetFleetCutoverPreparedVolumeHandoffsResponse = async (
|
|
809
|
+
responseArg: unknown,
|
|
810
|
+
requestArg: ISparkGetFleetCutoverPreparedVolumeHandoffsRequest,
|
|
811
|
+
): Promise<string[]> => {
|
|
812
|
+
try {
|
|
813
|
+
if (validateSparkGetFleetCutoverPreparedVolumeHandoffsRequest(requestArg).length) {
|
|
814
|
+
return ['expected Spark prepared volume handoff request must be valid'];
|
|
815
|
+
}
|
|
816
|
+
if (!isRuntimeRecord(responseArg)
|
|
817
|
+
|| !hasExactKeys(responseArg, [
|
|
818
|
+
'aggregate', 'page', 'nodeId', 'nodeHandoffs', 'protocol',
|
|
819
|
+
])) {
|
|
820
|
+
return ['Spark prepared volume handoff response must use its exact schema'];
|
|
821
|
+
}
|
|
822
|
+
const canonical = canonicalizeStrictJson(
|
|
823
|
+
responseArg,
|
|
824
|
+
failSparkSwarmObservationCanonicalization,
|
|
825
|
+
'Spark prepared volume handoff response',
|
|
826
|
+
);
|
|
827
|
+
const errors = validateProtocolOfferOn(
|
|
828
|
+
responseArg,
|
|
829
|
+
'Spark prepared volume handoff response protocol',
|
|
830
|
+
);
|
|
831
|
+
if (new TextEncoder().encode(canonical).byteLength
|
|
832
|
+
> sparkSwarmNodeContracts.fleetCutoverPreparedVolumeHandoffs.maxResponseBytes) {
|
|
833
|
+
errors.push('Spark prepared volume handoff response exceeds its encoded byte bound');
|
|
834
|
+
}
|
|
835
|
+
if (responseArg.nodeId !== requestArg.nodeId) {
|
|
836
|
+
errors.push('Spark prepared volume handoff response must name the authenticated node');
|
|
837
|
+
}
|
|
838
|
+
if (responseArg.aggregate === null) {
|
|
839
|
+
if (requestArg.aggregate !== null || responseArg.page !== null
|
|
840
|
+
|| !Array.isArray(responseArg.nodeHandoffs)
|
|
841
|
+
|| responseArg.nodeHandoffs.length !== 0) {
|
|
842
|
+
errors.push('Spark empty prepared volume handoff response must be canonical');
|
|
843
|
+
}
|
|
844
|
+
return errors;
|
|
845
|
+
}
|
|
846
|
+
const aggregate = snapshotFleetCutoverPreparedVolumeHandoffAggregate(responseArg.aggregate);
|
|
847
|
+
const page = snapshotFleetCutoverPreparedVolumeHandoffPage(responseArg.page);
|
|
848
|
+
if ((await validateFleetCutoverPreparedVolumeHandoffAggregate(aggregate)).length
|
|
849
|
+
|| aggregate.state !== 'complete') {
|
|
850
|
+
errors.push('Spark prepared volume handoff response aggregate must be complete and valid');
|
|
851
|
+
}
|
|
852
|
+
if ((await validateFleetCutoverPreparedVolumeHandoffPage(page)).length) {
|
|
853
|
+
errors.push('Spark prepared volume handoff response page must be valid');
|
|
854
|
+
}
|
|
855
|
+
if (requestArg.aggregate !== null
|
|
856
|
+
&& (requestArg.aggregate.id !== aggregate.id
|
|
857
|
+
|| requestArg.aggregate.generation !== aggregate.generation
|
|
858
|
+
|| requestArg.aggregate.digest !== aggregate.digest)) {
|
|
859
|
+
errors.push('Spark prepared volume handoff response must preserve the aggregate pin');
|
|
860
|
+
}
|
|
861
|
+
if (page.aggregate.id !== aggregate.id
|
|
862
|
+
|| page.aggregate.generation !== aggregate.generation
|
|
863
|
+
|| page.aggregate.digest !== aggregate.digest
|
|
864
|
+
|| page.pageIndex !== requestArg.pageIndex
|
|
865
|
+
|| page.pageIndex >= aggregate.pageCount) {
|
|
866
|
+
errors.push('Spark prepared volume handoff response page must bind the requested aggregate page');
|
|
867
|
+
}
|
|
868
|
+
if (!Array.isArray(responseArg.nodeHandoffs)
|
|
869
|
+
|| responseArg.nodeHandoffs.length
|
|
870
|
+
> fleetCutoverContract.maximumPreparedVolumeHandoffMutationEntries) {
|
|
871
|
+
errors.push('Spark prepared volume handoff response node subset must be bounded');
|
|
872
|
+
return errors;
|
|
873
|
+
}
|
|
874
|
+
const expectedNodeHandoffs = page.handoffs.filter(
|
|
875
|
+
referenceArg => referenceArg.nodeId === requestArg.nodeId,
|
|
876
|
+
);
|
|
877
|
+
if (responseArg.nodeHandoffs.length !== expectedNodeHandoffs.length) {
|
|
878
|
+
errors.push('Spark prepared volume handoff response must include the complete node subset');
|
|
879
|
+
return errors;
|
|
880
|
+
}
|
|
881
|
+
for (const [index, handoffArg] of responseArg.nodeHandoffs.entries()) {
|
|
882
|
+
const handoff = snapshotFleetCutoverPreparedVolumeHandoffIntent(handoffArg);
|
|
883
|
+
const expected = expectedNodeHandoffs[index];
|
|
884
|
+
if ((await validateFleetCutoverPreparedVolumeHandoffIntent(handoff)).length
|
|
885
|
+
|| handoff.nodeId !== requestArg.nodeId
|
|
886
|
+
|| handoff.id !== expected.id
|
|
887
|
+
|| handoff.generation !== expected.generation
|
|
888
|
+
|| handoff.digest !== expected.digest) {
|
|
889
|
+
errors.push('Spark prepared volume handoff response node subset must exactly match page order and authority');
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return errors;
|
|
894
|
+
} catch {
|
|
895
|
+
return ['Spark prepared volume handoff response must be safely inspectable'];
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
|
|
697
899
|
export const validateSparkSwarmObservationRequest = async (
|
|
698
900
|
requestArg: unknown,
|
|
699
901
|
): Promise<string[]> => {
|
|
@@ -720,6 +922,11 @@ export const validateSparkSwarmObservationRequest = async (
|
|
|
720
922
|
requestArg,
|
|
721
923
|
'Spark Swarm observation request',
|
|
722
924
|
));
|
|
925
|
+
if (isRuntimeRecord(requestArg.observation)
|
|
926
|
+
&& isRuntimeRecord(requestArg.observation.fleetCutoverHeldVolumeFences)
|
|
927
|
+
&& requestArg.observation.fleetCutoverHeldVolumeFences.nodeId !== requestArg.nodeId) {
|
|
928
|
+
errors.push('Spark Swarm held volume fence evidence must name the authenticated node');
|
|
929
|
+
}
|
|
723
930
|
return errors;
|
|
724
931
|
} catch {
|
|
725
932
|
return ['Spark Swarm observation request must be safely inspectable'];
|