@serve.zone/interfaces 23.2.0 → 24.0.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/readme.md CHANGED
@@ -30,13 +30,14 @@ import { appstore, data, platform, platformservice, requests } from '@serve.zone
30
30
 
31
31
  This package intentionally has no service implementation logic. It is a stable vocabulary for services that need to agree on payload shape, method names, and response types.
32
32
 
33
- ## Secrets v23 Pre-Cutover Contract
33
+ ## Secrets v24 Pre-Cutover Contract
34
34
 
35
- Version 23 removes the old grouped/bundled secret DTOs and plaintext runtime
36
- material API. Secret values enter Cloudly only as strict SmartCrypto X25519
37
- envelopes or an explicit bounded server-generation request. Coreflow receives a
38
- full digest-verified schema-v2 manifest plus one sealed envelope per pinned
39
- SecretVersion through the Node-only runtime export.
35
+ Version 24 retains the value-free v23 architecture while replacing the remaining
36
+ pre-cutover lifecycle and runtime authority gaps. Secret values enter Cloudly
37
+ only as strict SmartCrypto X25519 envelopes or an explicit bounded
38
+ server-generation request. Coreflow receives a full digest-verified schema-v2
39
+ manifest plus one sealed envelope per pinned SecretVersion through the Node-only
40
+ runtime export.
40
41
 
41
42
  This is a contract release before consumer cutover. It does **not** claim that
42
43
  clean-v2 migration, backup verification, historical secret erasure, or storage
@@ -53,12 +54,24 @@ Breaking removals include:
53
54
  credential references.
54
55
  - Hosted-app control-token identities and credential-bearing bootstrap actions.
55
56
 
56
- Primary v23 additions include:
57
+ The current secret contract includes:
57
58
 
58
- - `TSecretValueInput`, `TSecretIngressRecipient`, the
59
+ - `TSecretValueInput`, active-only `IActiveSecretRecipientMetadata`, the
59
60
  `requests.secret.IReq_GetSecretIngressRecipient` contract with method
60
61
  `getSecretIngressRecipient`, and fixed-order create/rotate/App Store context
61
62
  builders.
63
+ - `ISecretEnvelopeAdmissionBindingV1` under the Node-only runtime export binds
64
+ an exact envelope and request context to one recipient generation. The
65
+ binding is reproducible and does not itself prove admission. Retiring-key
66
+ retries require a trusted Cloudly mutation receipt created atomically while
67
+ that recipient was active; caller-supplied admission or issuance timestamps
68
+ are not part of the contract.
69
+ - `getSecretVersionPurgePreflight` returns bounded advisory reference pages and
70
+ complete blocker counts. `purgeSecretVersion` identifies one exact version
71
+ and fences the mutation by secret, version, and target revisions. The
72
+ non-issuable `purge-pending` lifecycle and revisioned
73
+ `pending`/`erasing`/`failed`/`succeeded` operation keep external erasure
74
+ durable across retries without treating preflight as authorization.
62
75
  - Schema-v2 `IResolvedSecretManifest` contracts, stable Docker resource naming,
63
76
  WorkloadInit map/wrapper helpers, and `ISealedResolvedSecretMaterial` under
64
77
  `@serve.zone/interfaces/runtime`.
@@ -67,7 +80,7 @@ Primary v23 additions include:
67
80
  - Mandatory `IImmutableContainerInvocationV1` evidence on immutable image
68
81
  deployment plans.
69
82
 
70
- ### Secrets v23.1 Runtime Registration And Reporting
83
+ ### Secrets v24 Runtime Registration And Reporting
71
84
 
72
85
  The Node-only `@serve.zone/interfaces/runtime` export adds the live contract
73
86
  that Cloudly must validate before publishing secret-bearing desired state to a
@@ -76,17 +89,30 @@ Coreflow connection:
76
89
  - `getSecretRecipientEnrollmentState` returns either an exact generation-zero
77
90
  empty state or the complete valid recipient set for the cluster derived from
78
91
  the verified JWT.
79
- - `getCoreflowSecretRuntimeRegistrationExpectation` returns the authoritative
80
- live reporter session, target-node set, active recipient, and approved
81
- WorkloadInit artifacts for the cluster derived from the verified JWT.
92
+ - `getCoreflowSecretRuntimeRegistrationExpectation` returns either an
93
+ `available` v2 expectation or an explicit unavailable reason. The available
94
+ expectation binds the live reporter session, active recipient, fresh
95
+ generation-fenced target authority, and active WorkloadInit approval through
96
+ `expectationDigest`.
97
+ - Spark sends authenticated, sequenced local Swarm membership observations and,
98
+ on managers, complete manager snapshots. Cloudly derives scope from the Spark
99
+ credential, reconciles manager consensus privately, and publishes one fresh
100
+ single-Swarm target authority or a targetless unavailable state. Structural
101
+ contract validation does not itself establish manager consensus.
102
+ - WorkloadInit approval binds a clean stable release identity, version-tagged
103
+ OCI index, exact amd64 and arm64 platform/executable digests, policy
104
+ generation, and a Cloudly summary of detached Cosign DSSE/SLSA verification.
105
+ Public shape and digest validators do not verify the signature, public-key
106
+ trust root, or private Cloudly policy.
82
107
  - `coreflowSecretRuntimeRegistrationTagId` is the sole dedicated TypedSocket
83
108
  tag identifier. Its payload is exactly
84
- `ICoreflowSecretRuntimeRegistrationV1`; cluster scope comes from the verified
109
+ `ICoreflowSecretRuntimeRegistrationV2`; cluster scope comes from the verified
85
110
  connection identity rather than the tag.
86
- - `ICoreflowSecretRuntimeRegistrationV1` binds a reporter session to the exact
87
- authoritative schedulable target-node set, required secret capabilities,
88
- active recipient, approved WorkloadInit OCI index, and exact per-platform
89
- manifest and installed-executable digests.
111
+ - `ICoreflowSecretRuntimeRegistrationV2` references the exact expectation,
112
+ target generation/digest, and WorkloadInit authority. It must cover every
113
+ Cloudly-node/Swarm-cluster/Swarm-node identity and approved per-platform
114
+ manifest and installed-executable digest without self-asserting placement or
115
+ approval.
90
116
  - `validateCoreflowSecretRuntimeRegistration` compares the registration with a
91
117
  trusted expectation built by Cloudly, including the live reporter session.
92
118
  Missing, extra, duplicate, reordered, or mismatched node evidence fails
@@ -410,6 +436,8 @@ Common data contracts include:
410
436
  aggregate owner fence, while
411
437
  `setServiceSecretSetAttachments` returns the independent
412
438
  `secretConfigurationRevision`. Generic service writes own neither revision.
439
+ Purge is a separate exact-version mutation with its own version revision and
440
+ durable operation; it is not a logical-secret lifecycle action.
413
441
  - Mail gateway contracts for domain authorities, address bindings, WorkApp bindings, managed SMTP/API credentials, spool items, delivery journals, and inbound/outbound message payloads.
414
442
  - Service-level mail configuration through `IService.data.mail`, including per-address inbound `smtpForward` settings and outbound credential metadata. Cloudly settings include dcrouter gateway, SMTP submission, and inbound forward-target keys for reconciling those bindings.
415
443
  - Web Push contracts for environment-specific service bindings, public credential state, public VAPID key rotation metadata, privacy-minimal notification signals, and redacted delivery state. Subscription endpoints, browser key material, provider ciphertext, VAPID private keys, and credential secrets are intentionally absent from public binding and status DTOs.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '23.2.0',
6
+ version: '24.0.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -5,7 +5,7 @@ import {
5
5
  validateSecretValueInputForRecipient,
6
6
  type TSecretDelivery,
7
7
  type TSecretGeneratedEncoding,
8
- type TSecretIngressRecipient,
8
+ type IActiveSecretRecipientMetadata,
9
9
  type TSecretValueInput,
10
10
  } from '../data/secret.js';
11
11
  import {
@@ -511,7 +511,7 @@ export const validateAppStoreEnvironmentDeclarations = (
511
511
  export const validateAppStoreInstallRequest = async (
512
512
  requestArg: unknown,
513
513
  configArg: IAppStoreVersionConfig,
514
- activeIngressRecipientArg: TSecretIngressRecipient,
514
+ activeIngressRecipientArg: IActiveSecretRecipientMetadata,
515
515
  ): Promise<string[]> => {
516
516
  if (!requestArg || typeof requestArg !== 'object' || Array.isArray(requestArg)) {
517
517
  return ['install request must be an object'];
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import type { TSha256Digest, TImmutableContainerPlatform } from './immutableimage.js';
2
3
 
3
4
  export interface IClusterNodeMetrics {
4
5
  cpuUsagePercent: number;
@@ -91,6 +92,518 @@ export interface ISparkActionResultResponse {
91
92
  message?: string;
92
93
  }
93
94
 
95
+ export interface ISwarmManagerObservedNodeV1 {
96
+ swarmNodeId: string;
97
+ nodeName: string;
98
+ platform: TImmutableContainerPlatform;
99
+ role: 'manager' | 'worker';
100
+ availability: 'active' | 'pause' | 'drain';
101
+ state: 'unknown' | 'down' | 'ready' | 'disconnected';
102
+ managerStatus?: {
103
+ leader: boolean;
104
+ reachability: 'unknown' | 'unreachable' | 'reachable';
105
+ };
106
+ }
107
+
108
+ export interface ISwarmManagerSnapshotV1 {
109
+ snapshotVersion: 1;
110
+ snapshotDigest: TSha256Digest;
111
+ nodeCount: number;
112
+ managerCount: number;
113
+ nodes: ISwarmManagerObservedNodeV1[];
114
+ }
115
+
116
+ interface ISparkSwarmObservationBaseV1 {
117
+ schemaVersion: 1;
118
+ reporterSessionId: string;
119
+ observationSequence: number;
120
+ observedAt: number;
121
+ }
122
+
123
+ export type TSparkSwarmObservationV1 =
124
+ | (ISparkSwarmObservationBaseV1 & {
125
+ state: 'unknown';
126
+ reason: 'docker-unavailable' | 'query-failed' | 'not-initialized';
127
+ })
128
+ | (ISparkSwarmObservationBaseV1 & {
129
+ state: 'not-member';
130
+ })
131
+ | (ISparkSwarmObservationBaseV1 & {
132
+ state: 'member';
133
+ swarmClusterId: string;
134
+ localSwarmNodeId: string;
135
+ controlAvailable: boolean;
136
+ managerSnapshot?: ISwarmManagerSnapshotV1;
137
+ });
138
+
139
+ /** Cloudly derives node and cluster scope from this authenticated Spark request. */
140
+ export interface ISparkSwarmObservationRequest {
141
+ nodeId: string;
142
+ nodeToken: string;
143
+ observation: TSparkSwarmObservationV1;
144
+ }
145
+
146
+ export interface ISparkSwarmObservationResponse {
147
+ accepted: boolean;
148
+ acceptedSequence?: number;
149
+ targetGeneration?: number;
150
+ message?: string;
151
+ }
152
+
153
+ export interface IClusterRuntimeTargetV1 {
154
+ cloudlyNodeId: string;
155
+ swarmClusterId: string;
156
+ swarmNodeId: string;
157
+ nodeName: string;
158
+ platform: TImmutableContainerPlatform;
159
+ }
160
+
161
+ export interface IClusterRuntimeTargetSetReadyV1 {
162
+ schemaVersion: 1;
163
+ state: 'ready';
164
+ cloudlyClusterId: string;
165
+ swarmClusterId: string;
166
+ generation: number;
167
+ targetSetDigest: TSha256Digest;
168
+ acceptedAt: number;
169
+ freshUntil: number;
170
+ targets: IClusterRuntimeTargetV1[];
171
+ }
172
+
173
+ export interface IClusterRuntimeTargetSetUnavailableV1 {
174
+ schemaVersion: 1;
175
+ state: 'unavailable';
176
+ cloudlyClusterId: string;
177
+ generation: number;
178
+ changedAt: number;
179
+ reason:
180
+ | 'never-observed'
181
+ | 'stale'
182
+ | 'no-manager-consensus'
183
+ | 'identity-conflict'
184
+ | 'no-schedulable-targets';
185
+ }
186
+
187
+ export type TClusterRuntimeTargetSetV1 =
188
+ | IClusterRuntimeTargetSetReadyV1
189
+ | IClusterRuntimeTargetSetUnavailableV1;
190
+
191
+ const runtimeIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
192
+ const runtimeDigestRegex = /^sha256:[a-f0-9]{64}$/;
193
+ const runtimePlatforms = new Set<TImmutableContainerPlatform>([
194
+ 'linux/amd64',
195
+ 'linux/arm64',
196
+ ]);
197
+ const maximumSwarmNodes = 1024;
198
+
199
+ const isRuntimeRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
200
+ Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg)
201
+ );
202
+
203
+ const hasExactRuntimeKeys = (
204
+ valueArg: Record<string, unknown>,
205
+ keysArg: string[],
206
+ ): boolean => JSON.stringify(Object.keys(valueArg).sort()) === JSON.stringify([...keysArg].sort());
207
+
208
+ const isRuntimeIdentifier = (valueArg: unknown): valueArg is string => (
209
+ typeof valueArg === 'string' && runtimeIdentifierRegex.test(valueArg)
210
+ );
211
+
212
+ const isPositiveSafeInteger = (valueArg: unknown): valueArg is number => (
213
+ Number.isSafeInteger(valueArg) && (valueArg as number) > 0
214
+ );
215
+
216
+ const computeSha256 = async (inputArg: string): Promise<TSha256Digest> => {
217
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest(
218
+ 'SHA-256',
219
+ new TextEncoder().encode(inputArg),
220
+ ));
221
+ return `sha256:${[...digest]
222
+ .map((byteArg) => byteArg.toString(16).padStart(2, '0'))
223
+ .join('')}` as TSha256Digest;
224
+ };
225
+
226
+ const canonicalSwarmNode = (
227
+ nodeArg: ISwarmManagerObservedNodeV1,
228
+ ): Record<string, unknown> => ({
229
+ swarmNodeId: nodeArg.swarmNodeId,
230
+ nodeName: nodeArg.nodeName,
231
+ platform: nodeArg.platform,
232
+ role: nodeArg.role,
233
+ availability: nodeArg.availability,
234
+ state: nodeArg.state,
235
+ managerStatus: nodeArg.managerStatus
236
+ ? {
237
+ leader: nodeArg.managerStatus.leader,
238
+ reachability: nodeArg.managerStatus.reachability,
239
+ }
240
+ : null,
241
+ });
242
+
243
+ export const createSwarmManagerSnapshotDigestInput = (
244
+ swarmClusterIdArg: string,
245
+ snapshotArg: ISwarmManagerSnapshotV1,
246
+ ): string => JSON.stringify({
247
+ schemaVersion: 1,
248
+ purpose: 'serve.zone/swarm-manager-snapshot',
249
+ swarmClusterId: swarmClusterIdArg,
250
+ snapshotVersion: snapshotArg.snapshotVersion,
251
+ nodeCount: snapshotArg.nodeCount,
252
+ managerCount: snapshotArg.managerCount,
253
+ nodes: snapshotArg.nodes.map(canonicalSwarmNode),
254
+ });
255
+
256
+ export const computeSwarmManagerSnapshotDigest = async (
257
+ swarmClusterIdArg: string,
258
+ snapshotArg: ISwarmManagerSnapshotV1,
259
+ ): Promise<TSha256Digest> => computeSha256(
260
+ createSwarmManagerSnapshotDigestInput(swarmClusterIdArg, snapshotArg),
261
+ );
262
+
263
+ const validateSwarmManagerSnapshot = async (
264
+ swarmClusterIdArg: string,
265
+ localSwarmNodeIdArg: string,
266
+ snapshotArg: unknown,
267
+ ): Promise<string[]> => {
268
+ if (!isRuntimeRecord(snapshotArg)
269
+ || !hasExactRuntimeKeys(snapshotArg, [
270
+ 'snapshotVersion',
271
+ 'snapshotDigest',
272
+ 'nodeCount',
273
+ 'managerCount',
274
+ 'nodes',
275
+ ])) {
276
+ return ['Spark Swarm manager snapshot must use its exact schema'];
277
+ }
278
+ const errors: string[] = [];
279
+ if (snapshotArg.snapshotVersion !== 1) {
280
+ errors.push('Spark Swarm manager snapshotVersion must be 1');
281
+ }
282
+ if (typeof snapshotArg.snapshotDigest !== 'string'
283
+ || !runtimeDigestRegex.test(snapshotArg.snapshotDigest)) {
284
+ errors.push('Spark Swarm manager snapshot digest must be canonical');
285
+ }
286
+ if (!Array.isArray(snapshotArg.nodes)
287
+ || snapshotArg.nodes.length === 0
288
+ || snapshotArg.nodes.length > maximumSwarmNodes) {
289
+ errors.push('Spark Swarm manager snapshot nodes must be a non-empty bounded array');
290
+ return errors;
291
+ }
292
+ if (snapshotArg.nodeCount !== snapshotArg.nodes.length
293
+ || !isPositiveSafeInteger(snapshotArg.nodeCount)) {
294
+ errors.push('Spark Swarm manager snapshot nodeCount must match nodes');
295
+ }
296
+ const nodeIds = new Set<string>();
297
+ const nodeNames = new Set<string>();
298
+ let managerCount = 0;
299
+ let leaderCount = 0;
300
+ let previousSortKey: string | undefined;
301
+ for (const [index, nodeArg] of snapshotArg.nodes.entries()) {
302
+ if (!isRuntimeRecord(nodeArg)) {
303
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] must be an object`);
304
+ continue;
305
+ }
306
+ const isManager = nodeArg.role === 'manager';
307
+ const expectedKeys = [
308
+ 'swarmNodeId',
309
+ 'nodeName',
310
+ 'platform',
311
+ 'role',
312
+ 'availability',
313
+ 'state',
314
+ ...(isManager ? ['managerStatus'] : []),
315
+ ];
316
+ if (!hasExactRuntimeKeys(nodeArg, expectedKeys)) {
317
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] must use its exact role schema`);
318
+ }
319
+ if (!isRuntimeIdentifier(nodeArg.swarmNodeId) || !isRuntimeIdentifier(nodeArg.nodeName)) {
320
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] identity must be canonical`);
321
+ }
322
+ if (!runtimePlatforms.has(nodeArg.platform as TImmutableContainerPlatform)) {
323
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] platform must be supported`);
324
+ }
325
+ if (!['manager', 'worker'].includes(nodeArg.role as string)
326
+ || !['active', 'pause', 'drain'].includes(nodeArg.availability as string)
327
+ || !['unknown', 'down', 'ready', 'disconnected'].includes(nodeArg.state as string)) {
328
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] scheduler state must be canonical`);
329
+ }
330
+ if (isManager) {
331
+ managerCount++;
332
+ if (!isRuntimeRecord(nodeArg.managerStatus)
333
+ || !hasExactRuntimeKeys(nodeArg.managerStatus, ['leader', 'reachability'])
334
+ || typeof nodeArg.managerStatus.leader !== 'boolean'
335
+ || !['unknown', 'unreachable', 'reachable']
336
+ .includes(nodeArg.managerStatus.reachability as string)) {
337
+ errors.push(`Spark Swarm manager snapshot nodes[${index}] managerStatus must be canonical`);
338
+ } else if (nodeArg.managerStatus.leader) {
339
+ leaderCount++;
340
+ }
341
+ }
342
+ if (isRuntimeIdentifier(nodeArg.swarmNodeId)) {
343
+ if (nodeIds.has(nodeArg.swarmNodeId)) {
344
+ errors.push('Spark Swarm manager snapshot node IDs must be unique');
345
+ }
346
+ nodeIds.add(nodeArg.swarmNodeId);
347
+ }
348
+ if (isRuntimeIdentifier(nodeArg.nodeName)) {
349
+ if (nodeNames.has(nodeArg.nodeName)) {
350
+ errors.push('Spark Swarm manager snapshot node names must be unique');
351
+ }
352
+ nodeNames.add(nodeArg.nodeName);
353
+ }
354
+ if (isRuntimeIdentifier(nodeArg.swarmNodeId) && isRuntimeIdentifier(nodeArg.nodeName)) {
355
+ const sortKey = `${nodeArg.swarmNodeId}\0${nodeArg.nodeName}\0${nodeArg.platform}`;
356
+ if (previousSortKey !== undefined && sortKey <= previousSortKey) {
357
+ errors.push('Spark Swarm manager snapshot nodes must be uniquely sorted');
358
+ }
359
+ previousSortKey = sortKey;
360
+ }
361
+ }
362
+ if (snapshotArg.managerCount !== managerCount || !isPositiveSafeInteger(snapshotArg.managerCount)) {
363
+ errors.push('Spark Swarm manager snapshot managerCount must match nodes');
364
+ }
365
+ if (leaderCount > 1) errors.push('Spark Swarm manager snapshot may contain at most one leader');
366
+ const sourceNode = snapshotArg.nodes.find((nodeArg) => (
367
+ isRuntimeRecord(nodeArg) && nodeArg.swarmNodeId === localSwarmNodeIdArg
368
+ ));
369
+ if (!isRuntimeRecord(sourceNode)
370
+ || sourceNode.role !== 'manager'
371
+ || sourceNode.availability !== 'active'
372
+ || sourceNode.state !== 'ready'
373
+ || !isRuntimeRecord(sourceNode.managerStatus)
374
+ || sourceNode.managerStatus.reachability !== 'reachable') {
375
+ errors.push('Spark Swarm manager snapshot source must be an active reachable manager');
376
+ }
377
+ if (errors.length === 0
378
+ && snapshotArg.snapshotDigest !== await computeSwarmManagerSnapshotDigest(
379
+ swarmClusterIdArg,
380
+ snapshotArg as unknown as ISwarmManagerSnapshotV1,
381
+ )) {
382
+ errors.push('Spark Swarm manager snapshot digest does not match');
383
+ }
384
+ return errors;
385
+ };
386
+
387
+ export const validateSparkSwarmObservation = async (
388
+ observationArg: unknown,
389
+ ): Promise<string[]> => {
390
+ try {
391
+ if (!isRuntimeRecord(observationArg)) return ['Spark Swarm observation must be an object'];
392
+ const observation = observationArg as Record<string, unknown>;
393
+ const commonKeys = [
394
+ 'schemaVersion',
395
+ 'reporterSessionId',
396
+ 'observationSequence',
397
+ 'observedAt',
398
+ 'state',
399
+ ];
400
+ const expectedKeys = observation.state === 'unknown'
401
+ ? [...commonKeys, 'reason']
402
+ : observation.state === 'not-member'
403
+ ? commonKeys
404
+ : observation.state === 'member'
405
+ ? [
406
+ ...commonKeys,
407
+ 'swarmClusterId',
408
+ 'localSwarmNodeId',
409
+ 'controlAvailable',
410
+ ...(observation.managerSnapshot === undefined ? [] : ['managerSnapshot']),
411
+ ]
412
+ : [];
413
+ if (expectedKeys.length === 0 || !hasExactRuntimeKeys(observation, expectedKeys)) {
414
+ return ['Spark Swarm observation must use its exact state schema'];
415
+ }
416
+ const errors: string[] = [];
417
+ if (observation.schemaVersion !== 1) errors.push('Spark Swarm observation schemaVersion must be 1');
418
+ if (!isRuntimeIdentifier(observation.reporterSessionId)
419
+ || !isPositiveSafeInteger(observation.observationSequence)
420
+ || !isPositiveSafeInteger(observation.observedAt)) {
421
+ errors.push('Spark Swarm observation identity, sequence, and timestamp must be canonical');
422
+ }
423
+ if (observation.state === 'unknown'
424
+ && !['docker-unavailable', 'query-failed', 'not-initialized']
425
+ .includes(observation.reason as string)) {
426
+ errors.push('Spark Swarm observation unknown reason must be canonical');
427
+ }
428
+ if (observation.state === 'member') {
429
+ if (!isRuntimeIdentifier(observation.swarmClusterId)
430
+ || !isRuntimeIdentifier(observation.localSwarmNodeId)
431
+ || typeof observation.controlAvailable !== 'boolean') {
432
+ errors.push('Spark Swarm member observation must have canonical local authority');
433
+ }
434
+ if (observation.managerSnapshot !== undefined) {
435
+ if (observation.controlAvailable !== true) {
436
+ errors.push('Spark Swarm manager snapshot requires controlAvailable');
437
+ } else if (isRuntimeIdentifier(observation.swarmClusterId)
438
+ && isRuntimeIdentifier(observation.localSwarmNodeId)) {
439
+ errors.push(...await validateSwarmManagerSnapshot(
440
+ observation.swarmClusterId,
441
+ observation.localSwarmNodeId,
442
+ observation.managerSnapshot,
443
+ ));
444
+ }
445
+ }
446
+ }
447
+ return errors;
448
+ } catch {
449
+ return ['Spark Swarm observation must be safely inspectable'];
450
+ }
451
+ };
452
+
453
+ const canonicalRuntimeTarget = (
454
+ targetArg: IClusterRuntimeTargetV1,
455
+ ): IClusterRuntimeTargetV1 => ({
456
+ cloudlyNodeId: targetArg.cloudlyNodeId,
457
+ swarmClusterId: targetArg.swarmClusterId,
458
+ swarmNodeId: targetArg.swarmNodeId,
459
+ nodeName: targetArg.nodeName,
460
+ platform: targetArg.platform,
461
+ });
462
+
463
+ export const createClusterRuntimeTargetSetDigestInput = (
464
+ targetSetArg: IClusterRuntimeTargetSetReadyV1,
465
+ ): string => JSON.stringify({
466
+ schemaVersion: targetSetArg.schemaVersion,
467
+ purpose: 'serve.zone/cluster-runtime-target-set',
468
+ cloudlyClusterId: targetSetArg.cloudlyClusterId,
469
+ swarmClusterId: targetSetArg.swarmClusterId,
470
+ generation: targetSetArg.generation,
471
+ targets: targetSetArg.targets.map(canonicalRuntimeTarget),
472
+ });
473
+
474
+ export const computeClusterRuntimeTargetSetDigest = async (
475
+ targetSetArg: IClusterRuntimeTargetSetReadyV1,
476
+ ): Promise<TSha256Digest> => computeSha256(createClusterRuntimeTargetSetDigestInput(targetSetArg));
477
+
478
+ export const validateClusterRuntimeTargetSet = async (
479
+ targetSetArg: unknown,
480
+ ): Promise<string[]> => {
481
+ try {
482
+ if (!isRuntimeRecord(targetSetArg)) return ['cluster runtime target set must be an object'];
483
+ if (targetSetArg.state === 'unavailable') {
484
+ if (!hasExactRuntimeKeys(targetSetArg, [
485
+ 'schemaVersion',
486
+ 'state',
487
+ 'cloudlyClusterId',
488
+ 'generation',
489
+ 'changedAt',
490
+ 'reason',
491
+ ])) {
492
+ return ['unavailable cluster runtime target set must use its exact schema'];
493
+ }
494
+ const errors: string[] = [];
495
+ if (targetSetArg.schemaVersion !== 1
496
+ || !isRuntimeIdentifier(targetSetArg.cloudlyClusterId)
497
+ || !Number.isSafeInteger(targetSetArg.generation)
498
+ || (targetSetArg.generation as number) < 0
499
+ || !isPositiveSafeInteger(targetSetArg.changedAt)
500
+ || ![
501
+ 'never-observed',
502
+ 'stale',
503
+ 'no-manager-consensus',
504
+ 'identity-conflict',
505
+ 'no-schedulable-targets',
506
+ ].includes(targetSetArg.reason as string)) {
507
+ errors.push('unavailable cluster runtime target set fields must be canonical');
508
+ }
509
+ if (targetSetArg.reason === 'never-observed' && targetSetArg.generation !== 0) {
510
+ errors.push('never-observed target state must use generation zero');
511
+ }
512
+ return errors;
513
+ }
514
+ if (targetSetArg.state !== 'ready'
515
+ || !hasExactRuntimeKeys(targetSetArg, [
516
+ 'schemaVersion',
517
+ 'state',
518
+ 'cloudlyClusterId',
519
+ 'swarmClusterId',
520
+ 'generation',
521
+ 'targetSetDigest',
522
+ 'acceptedAt',
523
+ 'freshUntil',
524
+ 'targets',
525
+ ])) {
526
+ return ['ready cluster runtime target set must use its exact schema'];
527
+ }
528
+ const errors: string[] = [];
529
+ if (targetSetArg.schemaVersion !== 1
530
+ || !isRuntimeIdentifier(targetSetArg.cloudlyClusterId)
531
+ || !isRuntimeIdentifier(targetSetArg.swarmClusterId)
532
+ || !isPositiveSafeInteger(targetSetArg.generation)
533
+ || !isPositiveSafeInteger(targetSetArg.acceptedAt)
534
+ || !isPositiveSafeInteger(targetSetArg.freshUntil)
535
+ || (targetSetArg.freshUntil as number) <= (targetSetArg.acceptedAt as number)
536
+ || typeof targetSetArg.targetSetDigest !== 'string'
537
+ || !runtimeDigestRegex.test(targetSetArg.targetSetDigest)) {
538
+ errors.push('ready cluster runtime target set authority fields must be canonical');
539
+ }
540
+ if (!Array.isArray(targetSetArg.targets)
541
+ || targetSetArg.targets.length === 0
542
+ || targetSetArg.targets.length > maximumSwarmNodes) {
543
+ errors.push('ready cluster runtime target set targets must be a non-empty bounded array');
544
+ return errors;
545
+ }
546
+ const cloudlyNodeIds = new Set<string>();
547
+ const swarmNodeScopes = new Set<string>();
548
+ const nodeNames = new Set<string>();
549
+ let previousSortKey: string | undefined;
550
+ for (const [index, targetArg] of targetSetArg.targets.entries()) {
551
+ if (!isRuntimeRecord(targetArg)
552
+ || !hasExactRuntimeKeys(targetArg, [
553
+ 'cloudlyNodeId',
554
+ 'swarmClusterId',
555
+ 'swarmNodeId',
556
+ 'nodeName',
557
+ 'platform',
558
+ ])
559
+ || !isRuntimeIdentifier(targetArg.cloudlyNodeId)
560
+ || !isRuntimeIdentifier(targetArg.swarmClusterId)
561
+ || !isRuntimeIdentifier(targetArg.swarmNodeId)
562
+ || !isRuntimeIdentifier(targetArg.nodeName)
563
+ || targetArg.swarmClusterId !== targetSetArg.swarmClusterId
564
+ || !runtimePlatforms.has(targetArg.platform as TImmutableContainerPlatform)) {
565
+ errors.push(`cluster runtime target set targets[${index}] must be canonical`);
566
+ continue;
567
+ }
568
+ const sortKey = `${targetArg.cloudlyNodeId}\0${targetArg.swarmClusterId}\0${
569
+ targetArg.swarmNodeId
570
+ }\0${targetArg.nodeName}\0${targetArg.platform}`;
571
+ if (previousSortKey !== undefined && sortKey <= previousSortKey) {
572
+ errors.push('cluster runtime target set targets must be uniquely sorted');
573
+ }
574
+ previousSortKey = sortKey;
575
+ const swarmScope = `${targetArg.swarmClusterId}\0${targetArg.swarmNodeId}`;
576
+ if (cloudlyNodeIds.has(targetArg.cloudlyNodeId)
577
+ || swarmNodeScopes.has(swarmScope)
578
+ || nodeNames.has(targetArg.nodeName)) {
579
+ errors.push('cluster runtime target identities must be unique');
580
+ }
581
+ cloudlyNodeIds.add(targetArg.cloudlyNodeId);
582
+ swarmNodeScopes.add(swarmScope);
583
+ nodeNames.add(targetArg.nodeName);
584
+ }
585
+ if (errors.length === 0
586
+ && targetSetArg.targetSetDigest !== await computeClusterRuntimeTargetSetDigest(
587
+ targetSetArg as unknown as IClusterRuntimeTargetSetReadyV1,
588
+ )) {
589
+ errors.push('cluster runtime target set digest does not match');
590
+ }
591
+ return errors;
592
+ } catch {
593
+ return ['cluster runtime target set must be safely inspectable'];
594
+ }
595
+ };
596
+
597
+ export const isClusterRuntimeTargetSetFresh = (
598
+ targetSetArg: TClusterRuntimeTargetSetV1,
599
+ nowArg: number,
600
+ ): targetSetArg is IClusterRuntimeTargetSetReadyV1 => (
601
+ targetSetArg.state === 'ready'
602
+ && Number.isSafeInteger(nowArg)
603
+ && nowArg >= targetSetArg.acceptedAt
604
+ && nowArg < targetSetArg.freshUntil
605
+ );
606
+
94
607
  export type TSparkNodeMode = 'cloudly' | 'coreflow-node';
95
608
 
96
609
  export type TSparkCloudlyConnectionStatus =