@serve.zone/interfaces 32.14.0 → 32.16.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 +27 -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 +263 -0
- package/dist_ts/data/fleetcutover.js +784 -7
- package/dist_ts/data/runtimestorageclaim.d.ts +43 -0
- package/dist_ts/data/runtimestorageclaim.js +88 -4
- package/dist_ts/requests/fleetcutover.d.ts +70 -6
- package/dist_ts/requests/runtimesession.d.ts +47 -0
- package/package.json +1 -1
- package/readme.md +118 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +209 -2
- package/ts/data/fleetcutover.ts +1156 -5
- package/ts/data/runtimestorageclaim.ts +122 -3
- package/ts/requests/fleetcutover.ts +80 -5
- package/ts/requests/runtimesession.ts +57 -0
package/readme.md
CHANGED
|
@@ -1056,6 +1056,24 @@ sequence, observation time and fixed `freshUntil`; assembling a later census can
|
|
|
1056
1056
|
observation. It never transports environment, arbitrary labels, mount paths, volume names,
|
|
1057
1057
|
registry credentials or secret values.
|
|
1058
1058
|
|
|
1059
|
+
`data.computeFleetCutoverDockerServiceSpecDigest` hashes the complete open-schema Docker
|
|
1060
|
+
`Service.Spec`, including response properties unknown to this Interfaces version, under
|
|
1061
|
+
`serve.zone/fleet-cutover-docker-service-spec`. The helper returns only the digest: the full spec,
|
|
1062
|
+
including any environment values or secret references it contains, never belongs in the
|
|
1063
|
+
observation or logs. `data.computeFleetCutoverDockerConstraintSetIdentity` sorts exact constraint
|
|
1064
|
+
strings in UTF-16 code-unit order, preserves duplicates, and binds both the resulting list and its
|
|
1065
|
+
count. `data.snapshotFleetCutoverDockerDesiredTaskCount` admits only the non-negative safe integer
|
|
1066
|
+
from `ServiceStatus.DesiredTasks`; the producer must request service status and must not derive a
|
|
1067
|
+
replacement from task state. These helpers consume only the Docker response fields they name, so
|
|
1068
|
+
additional response properties do not make a newer daemon unreadable.
|
|
1069
|
+
|
|
1070
|
+
The only established historical service-identity ownership label is
|
|
1071
|
+
`data.fleetCutoverDockerLabelNames.serviceId`, whose exact value is `serve.zone.serviceId`.
|
|
1072
|
+
`ownershipLabels.platformOwner: null` means there is no known Docker-label evidence of a platform
|
|
1073
|
+
owner. It does not classify the service as an application, and neither a service name nor an
|
|
1074
|
+
unrecognized label supplies that classification. Cloudly's accepted administrator classification
|
|
1075
|
+
remains the authority for unlabeled legacy platform services.
|
|
1076
|
+
|
|
1059
1077
|
Mount identity has three explicit canonical domains. `serve.zone/fleet-cutover-mount-source-identity`
|
|
1060
1078
|
hashes `{ type, source }`, where `source` is the exact UTF-8 `Mount.Source` returned by Docker,
|
|
1061
1079
|
including an empty string. `serve.zone/fleet-cutover-mount-target-identity` hashes `{ target }`,
|
|
@@ -1068,6 +1086,26 @@ ownership also prevents completion rather than omitting the container. Structura
|
|
|
1068
1086
|
not establish freshness, authentication, manager consensus, ownership or Docker truth; Cloudly
|
|
1069
1087
|
establishes those policy facts while dereferencing the accepted observations.
|
|
1070
1088
|
|
|
1089
|
+
Docker exposes two different volume sources. A service-spec `Mount.Source` is the logical volume
|
|
1090
|
+
name, while a local container `MountPoint.Source` is its physical host path and `MountPoint.Name`
|
|
1091
|
+
is the logical name. `data.createFleetCutoverDockerServiceMountDescriptor` and
|
|
1092
|
+
`data.createFleetCutoverDockerLocalMountDescriptor` therefore keep their exact raw-source mount
|
|
1093
|
+
identities distinct. They never substitute `Name` for `Source`. The local-only
|
|
1094
|
+
`data.createFleetCutoverDockerPhysicalMountEvidence` adds a value-free correlation from the
|
|
1095
|
+
physical `mountIdentityDigest` to the service-spec identity derived from exact `MountPoint.Name`
|
|
1096
|
+
for volumes, or exact `Source` for bind and tmpfs mounts. Its independently domain-separated
|
|
1097
|
+
correlation digest binds that pair. When a physical container states `mountCorrelations`, the list
|
|
1098
|
+
must be sorted, unique, bounded, and a complete one-to-one mapping of its mounts. The property is
|
|
1099
|
+
optional only so observations created before this evidence existed still parse; absence proves no
|
|
1100
|
+
join, and a cutover consumer requiring a physical/service match must refuse it. Unsupported local
|
|
1101
|
+
mount types and volume mounts without a non-empty `Name` cannot produce correlation evidence.
|
|
1102
|
+
|
|
1103
|
+
The shared exact classifier recognizes only a writable
|
|
1104
|
+
`/var/run/docker.sock` to `/var/run/docker.sock` bind as `docker-socket`, a read-only
|
|
1105
|
+
`/sys/fs/cgroup` to `/host/cgroup` bind as `cgroup`, every volume as `persistent-volume`, other
|
|
1106
|
+
binds as `bind`, and tmpfs as `tmpfs`. Other Docker types remain `unknown`; no path normalization
|
|
1107
|
+
or service-name inference is performed.
|
|
1108
|
+
|
|
1071
1109
|
`data.IFleetCutoverScope` is the server-owned authoritative service set. It starts `incomplete`;
|
|
1072
1110
|
every `complete` entry binds an exact Docker service id to the current service authority and an
|
|
1073
1111
|
accepted classification authority. `unknown` cannot appear in a complete structural snapshot.
|
|
@@ -1093,19 +1131,61 @@ current Spark bundle/unit/configuration and old-mode refusal evidence. This is a
|
|
|
1093
1131
|
owner; it is never represented as a Pallet assignment. Downstream service holds and storage fences
|
|
1094
1132
|
therefore depend on a live relay without requiring assignment authority that cannot exist yet.
|
|
1095
1133
|
|
|
1096
|
-
The storage bootstrap
|
|
1097
|
-
Cloudly's accepted pre-switch
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1134
|
+
The storage bootstrap separates desired identity, observed legacy identity and provider-proved
|
|
1135
|
+
physical lineage. `IFleetCutoverPreparedVolumeHandoffIntent` is Cloudly's accepted pre-switch
|
|
1136
|
+
statement. It carries the full sealed `IRuntimeStorageClaimIntent` and the exact persistent
|
|
1137
|
+
`observedMount`, including Docker source, target and combined identity digests. The Docker source
|
|
1138
|
+
digest commits only to Docker's exact source bytes; it is never physical lineage. The prepared
|
|
1139
|
+
handoff deliberately has no `sourceLineageDigest`. Corestore creates that independent commitment
|
|
1140
|
+
from its private device, inode and content proof while the native attachment fence is held.
|
|
1141
|
+
|
|
1142
|
+
An administrator changes prepared handoffs through
|
|
1143
|
+
`prepareFleetCutoverVolumeHandoffs`. Each compare-and-swap mutation contains 1..32 combined upserts
|
|
1144
|
+
and explicit removals. An upsert supplies only `{ nodeId, serviceId, mountIdentityDigest, request }`,
|
|
1145
|
+
where `request` is the full canonical retained `ReadWriteOnce` filesystem request. Cloudly derives
|
|
1146
|
+
the volume, controller, organization, namespace, claim identity, evidence references and request
|
|
1147
|
+
digest from current owner rows. It re-derives the complete required set from the admitted manager
|
|
1148
|
+
task inventory and accepted local observations. The maximum possible physical set is
|
|
1149
|
+
`maximumTasks * maximumMountsPerOwner`, or `4096 * 32 = 131072`; node placement is already carried
|
|
1150
|
+
by each admitted task, so a service-count bound would undercount replicated placement.
|
|
1151
|
+
|
|
1152
|
+
`IFleetCutoverPreparedVolumeHandoffAggregate` is a revisioned header with required/actual counts,
|
|
1153
|
+
state, page count and a complete set digest. It does not inline page references. Each immutable
|
|
1154
|
+
`IFleetCutoverPreparedVolumeHandoffPage` contains 1..32 sorted handoff references, with the owning
|
|
1155
|
+
`nodeId` committed beside every exact handoff revision, plus its content digest and the exact
|
|
1156
|
+
aggregate revision. Readers pin one aggregate, fetch every consecutive page and reject missing,
|
|
1157
|
+
duplicate, stale or mixed-revision pages. The set digest covers the ordered page-content digests. A
|
|
1158
|
+
32-entry mutation is therefore a wire bound, not a fleet volume ceiling.
|
|
1159
|
+
|
|
1160
|
+
Spark reads one pinned page from
|
|
1161
|
+
`/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs`. The response carries the full immutable
|
|
1162
|
+
page and the authenticated node's complete full-handoff subset, including an explicit empty subset.
|
|
1163
|
+
Spark forwards held fences through the optional `fleetCutoverHeldVolumeFences` member available in
|
|
1164
|
+
every Swarm-observation state. The page's node-bearing references determine the exact subset; fetch
|
|
1165
|
+
responses and fence evidence must match every committed reference in page order, so omission is
|
|
1166
|
+
rejected and an empty report is valid only for a node with no entry on that page. The request binds
|
|
1167
|
+
the evidence node to the authenticated reporter. That evidence has a dedicated 128 KiB encoded
|
|
1168
|
+
budget and never displaces the existing census snapshots. One node's page report proves only that
|
|
1169
|
+
node's subset; Cloudly establishes global completeness across accepted reports for every page and
|
|
1170
|
+
node.
|
|
1171
|
+
|
|
1172
|
+
Pallet persists `TFleetCutoverVolumeFence` in `held` state while draining. A hold grants no claim,
|
|
1173
|
+
session, assignment, attachment, reservation, confirmation or execution authority. After the real
|
|
1174
|
+
current runtime reaches `pallet`, Pallet obtains `IFleetCutoverStorageAdoptionGrant` through its live
|
|
1175
|
+
authenticated session. The grant binds the exact prepared handoff, held fence, current pallet
|
|
1176
|
+
runtime, session generation/epoch and observed mount. Pallet keeps its per-volume lane while it
|
|
1177
|
+
invokes the isolated Corestore one-shot. Corestore performs physical-lineage proof and the initial
|
|
1178
|
+
ready-claim CAS under its native attachment fence, without calling back into Pallet, and returns the
|
|
1179
|
+
full claim with `IFleetCutoverCorestoreAdoptionReceipt`. Pallet revalidates the live session, runtime
|
|
1180
|
+
phase and grant after that potentially long operation before consuming the durable hold. A stale
|
|
1181
|
+
grant leaves the hold intact.
|
|
1182
|
+
|
|
1183
|
+
`bindFleetCutoverAdoptionToStorageClaim` joins the provider receipt and full ready claim to the
|
|
1184
|
+
prepared desired intent. `bindFleetCutoverStorageAdoptionBundle` additionally joins the current
|
|
1185
|
+
grant and consumed fence. Neither binder equates the provider physical-lineage digest with the
|
|
1186
|
+
observed Docker source digest or requires the provider's opaque `resourceRef` to equal Cloudly's
|
|
1187
|
+
volume ID. They are content checks; current owner dereferencing, peer authentication and native
|
|
1188
|
+
attachment fencing remain mandatory.
|
|
1109
1189
|
|
|
1110
1190
|
`IFleetCutoverCoreflowRetirementEvidence` binds the real pre-switch Docker Coreflow32 relay by exact
|
|
1111
1191
|
service ID, container ID, spec version/spec digest and image digest to accepted manager and local
|
|
@@ -1154,9 +1234,14 @@ application release may cite that authority. The legacy Docker Coretraffic insta
|
|
|
1154
1234
|
remains through `switching`; the new ingress runs without public ports before exact port ownership
|
|
1155
1235
|
moves, and applications remain held until the new authoritative route is read back.
|
|
1156
1236
|
|
|
1157
|
-
The owner methods live under `requests.fleetcutover`: administrator scope classification,
|
|
1158
|
-
preparation and forward phase CAS
|
|
1159
|
-
|
|
1237
|
+
The owner methods live under `requests.fleetcutover`: administrator scope classification, storage
|
|
1238
|
+
handoff preparation, ingress preparation and forward phase CAS; root-private prepared-fence hold,
|
|
1239
|
+
read and owner-triggered adoption; isolated Corestore prepared-volume adoption; ingress-authority
|
|
1240
|
+
read; and ingress/application spec release. Current-session adoption authorization, accepted bundle
|
|
1241
|
+
reporting and full claim readback live under `requests.runtimesession`. The published 32.x
|
|
1242
|
+
`holdFleetCutoverVolumeFence`, `consumeFleetCutoverVolumeFence` and `adoptFleetCutoverVolume`
|
|
1243
|
+
declarations remain for type compatibility only. Production owners do not register or adapt those
|
|
1244
|
+
incomplete operations; they are marked for removal at the next protocol major.
|
|
1160
1245
|
Spark transports local and manager evidence through its existing authenticated Swarm-observation
|
|
1161
1246
|
route, and Cloudly assembles census and Coreflow-retirement records from accepted owner state; there
|
|
1162
1247
|
is no caller-supplied census or retirement proof endpoint.
|
|
@@ -1671,6 +1756,15 @@ or sender addresses itself.
|
|
|
1671
1756
|
|
|
1672
1757
|
### Runtime filesystem storage claims
|
|
1673
1758
|
|
|
1759
|
+
`data.IRuntimeStorageClaimIntent` is the non-ready Cloudly-owned precursor to a claim. It fixes the
|
|
1760
|
+
controller epoch, organization, cluster, node, runtime namespace, service, claim ID/generation and
|
|
1761
|
+
predecessor, plus `IRuntimeStorageClaimRequestedFilesystemBinding`. That requested binding fixes the
|
|
1762
|
+
service, full normalized retained `ReadWriteOnce` filesystem request and its canonical bare digest.
|
|
1763
|
+
It structurally cannot state a provider binding ID/generation, policy selection, status, observed
|
|
1764
|
+
generation, resource reference or granted capability. The intent is exact-schema and digest-bound
|
|
1765
|
+
under `serve.zone/runtime-storage-claim-intent`; it grants no assignment, session, execution or
|
|
1766
|
+
attachment authority.
|
|
1767
|
+
|
|
1674
1768
|
`data.IRuntimeStorageClaim` is the portable, Cloudly-issued authority for one
|
|
1675
1769
|
ready, retained `ReadWriteOnce` filesystem binding on one cluster node. It
|
|
1676
1770
|
names the organization, service, cluster, node, runtime namespace, controller
|
|
@@ -1695,6 +1789,14 @@ provider readiness and policy, durably admit the assignment, and obtain a
|
|
|
1695
1789
|
fenced native attachment from the storage owner before a CRI mount. An absent
|
|
1696
1790
|
claim or an unverified opaque resource reference never authorizes a mount.
|
|
1697
1791
|
|
|
1792
|
+
`data.bindRuntimeStorageClaimToIntent(claim, intent)` verifies both canonical digests and requires
|
|
1793
|
+
the ready claim to preserve every Cloudly-owned identity and requested filesystem fact: service,
|
|
1794
|
+
request ID/digest, mount path, access mode and reclaim policy. Only the full claim may add the
|
|
1795
|
+
storage provider's binding identity/generation, selected policy, matching observed generation,
|
|
1796
|
+
`ready` status, opaque resource reference and persistent single-node capabilities. This is a content
|
|
1797
|
+
join: callers still authenticate Cloudly and the provider, check current readiness and retain the
|
|
1798
|
+
native fence.
|
|
1799
|
+
|
|
1698
1800
|
`platform.storagemigration` defines the provider-neutral cutover contract for a
|
|
1699
1801
|
named object-storage binding. Corestore atomically owns and fences the source
|
|
1700
1802
|
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'];
|