@serve.zone/interfaces 23.2.0 → 24.1.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 +17 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/appstore/types.d.ts +2 -2
- package/dist_ts/appstore/types.js +1 -1
- package/dist_ts/data/clusternode.d.ts +108 -0
- package/dist_ts/data/clusternode.js +420 -1
- package/dist_ts/data/secret.d.ts +51 -4
- package/dist_ts/data/secret.js +326 -10
- package/dist_ts/requests/secret.d.ts +33 -6
- package/dist_ts/requests/secret.js +198 -1
- package/dist_ts/runtime.d.ts +52 -29
- package/dist_ts/runtime.js +239 -94
- package/dist_ts/runtime.workloadinit.d.ts +129 -0
- package/dist_ts/runtime.workloadinit.js +405 -0
- package/package.json +1 -1
- package/readme.md +53 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/appstore/types.ts +2 -2
- package/ts/data/clusternode.ts +610 -0
- package/ts/data/secret.ts +402 -16
- package/ts/requests/secret.ts +248 -6
- package/ts/runtime.ts +366 -141
- package/ts/runtime.workloadinit.ts +582 -0
package/ts/data/clusternode.ts
CHANGED
|
@@ -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,615 @@ 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
|
+
interface ISparkSwarmObservationBaseV2 {
|
|
140
|
+
schemaVersion: 2;
|
|
141
|
+
reporterSessionId: string;
|
|
142
|
+
observationSequence: number;
|
|
143
|
+
observedAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type TSparkSwarmObservationV2 =
|
|
147
|
+
| (ISparkSwarmObservationBaseV2 & {
|
|
148
|
+
state: 'unknown';
|
|
149
|
+
reason: 'docker-unavailable' | 'query-failed' | 'not-initialized';
|
|
150
|
+
})
|
|
151
|
+
| (ISparkSwarmObservationBaseV2 & {
|
|
152
|
+
state: 'not-member';
|
|
153
|
+
})
|
|
154
|
+
| (ISparkSwarmObservationBaseV2 & {
|
|
155
|
+
state: 'member';
|
|
156
|
+
localSwarmNodeId: string;
|
|
157
|
+
controlAvailable: false;
|
|
158
|
+
})
|
|
159
|
+
| (ISparkSwarmObservationBaseV2 & {
|
|
160
|
+
state: 'member';
|
|
161
|
+
swarmClusterId: string;
|
|
162
|
+
localSwarmNodeId: string;
|
|
163
|
+
controlAvailable: true;
|
|
164
|
+
managerSnapshot?: ISwarmManagerSnapshotV1;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/** Cloudly derives node and cluster scope from this authenticated Spark request. */
|
|
168
|
+
export interface ISparkSwarmObservationRequest {
|
|
169
|
+
nodeId: string;
|
|
170
|
+
nodeToken: string;
|
|
171
|
+
observation: TSparkSwarmObservationV1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ISparkSwarmObservationResponse {
|
|
175
|
+
accepted: boolean;
|
|
176
|
+
acceptedSequence?: number;
|
|
177
|
+
targetGeneration?: number;
|
|
178
|
+
message?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface IClusterRuntimeTargetV1 {
|
|
182
|
+
cloudlyNodeId: string;
|
|
183
|
+
swarmClusterId: string;
|
|
184
|
+
swarmNodeId: string;
|
|
185
|
+
nodeName: string;
|
|
186
|
+
platform: TImmutableContainerPlatform;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface IClusterRuntimeTargetSetReadyV1 {
|
|
190
|
+
schemaVersion: 1;
|
|
191
|
+
state: 'ready';
|
|
192
|
+
cloudlyClusterId: string;
|
|
193
|
+
swarmClusterId: string;
|
|
194
|
+
generation: number;
|
|
195
|
+
targetSetDigest: TSha256Digest;
|
|
196
|
+
acceptedAt: number;
|
|
197
|
+
freshUntil: number;
|
|
198
|
+
targets: IClusterRuntimeTargetV1[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface IClusterRuntimeTargetSetUnavailableV1 {
|
|
202
|
+
schemaVersion: 1;
|
|
203
|
+
state: 'unavailable';
|
|
204
|
+
cloudlyClusterId: string;
|
|
205
|
+
generation: number;
|
|
206
|
+
changedAt: number;
|
|
207
|
+
reason:
|
|
208
|
+
| 'never-observed'
|
|
209
|
+
| 'stale'
|
|
210
|
+
| 'no-manager-consensus'
|
|
211
|
+
| 'identity-conflict'
|
|
212
|
+
| 'no-schedulable-targets';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type TClusterRuntimeTargetSetV1 =
|
|
216
|
+
| IClusterRuntimeTargetSetReadyV1
|
|
217
|
+
| IClusterRuntimeTargetSetUnavailableV1;
|
|
218
|
+
|
|
219
|
+
const runtimeIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
|
|
220
|
+
const runtimeDigestRegex = /^sha256:[a-f0-9]{64}$/;
|
|
221
|
+
const runtimePlatforms = new Set<TImmutableContainerPlatform>([
|
|
222
|
+
'linux/amd64',
|
|
223
|
+
'linux/arm64',
|
|
224
|
+
]);
|
|
225
|
+
const maximumSwarmNodes = 1024;
|
|
226
|
+
|
|
227
|
+
const isRuntimeRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
|
|
228
|
+
Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const hasExactRuntimeKeys = (
|
|
232
|
+
valueArg: Record<string, unknown>,
|
|
233
|
+
keysArg: string[],
|
|
234
|
+
): boolean => JSON.stringify(Object.keys(valueArg).sort()) === JSON.stringify([...keysArg].sort());
|
|
235
|
+
|
|
236
|
+
const isRuntimeIdentifier = (valueArg: unknown): valueArg is string => (
|
|
237
|
+
typeof valueArg === 'string' && runtimeIdentifierRegex.test(valueArg)
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const isPositiveSafeInteger = (valueArg: unknown): valueArg is number => (
|
|
241
|
+
Number.isSafeInteger(valueArg) && (valueArg as number) > 0
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const computeSha256 = async (inputArg: string): Promise<TSha256Digest> => {
|
|
245
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest(
|
|
246
|
+
'SHA-256',
|
|
247
|
+
new TextEncoder().encode(inputArg),
|
|
248
|
+
));
|
|
249
|
+
return `sha256:${[...digest]
|
|
250
|
+
.map((byteArg) => byteArg.toString(16).padStart(2, '0'))
|
|
251
|
+
.join('')}` as TSha256Digest;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const canonicalSwarmNode = (
|
|
255
|
+
nodeArg: ISwarmManagerObservedNodeV1,
|
|
256
|
+
): Record<string, unknown> => ({
|
|
257
|
+
swarmNodeId: nodeArg.swarmNodeId,
|
|
258
|
+
nodeName: nodeArg.nodeName,
|
|
259
|
+
platform: nodeArg.platform,
|
|
260
|
+
role: nodeArg.role,
|
|
261
|
+
availability: nodeArg.availability,
|
|
262
|
+
state: nodeArg.state,
|
|
263
|
+
managerStatus: nodeArg.managerStatus
|
|
264
|
+
? {
|
|
265
|
+
leader: nodeArg.managerStatus.leader,
|
|
266
|
+
reachability: nodeArg.managerStatus.reachability,
|
|
267
|
+
}
|
|
268
|
+
: null,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
export const createSwarmManagerSnapshotDigestInput = (
|
|
272
|
+
swarmClusterIdArg: string,
|
|
273
|
+
snapshotArg: ISwarmManagerSnapshotV1,
|
|
274
|
+
): string => JSON.stringify({
|
|
275
|
+
schemaVersion: 1,
|
|
276
|
+
purpose: 'serve.zone/swarm-manager-snapshot',
|
|
277
|
+
swarmClusterId: swarmClusterIdArg,
|
|
278
|
+
snapshotVersion: snapshotArg.snapshotVersion,
|
|
279
|
+
nodeCount: snapshotArg.nodeCount,
|
|
280
|
+
managerCount: snapshotArg.managerCount,
|
|
281
|
+
nodes: snapshotArg.nodes.map(canonicalSwarmNode),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
export const computeSwarmManagerSnapshotDigest = async (
|
|
285
|
+
swarmClusterIdArg: string,
|
|
286
|
+
snapshotArg: ISwarmManagerSnapshotV1,
|
|
287
|
+
): Promise<TSha256Digest> => computeSha256(
|
|
288
|
+
createSwarmManagerSnapshotDigestInput(swarmClusterIdArg, snapshotArg),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const validateSwarmManagerSnapshot = async (
|
|
292
|
+
swarmClusterIdArg: string,
|
|
293
|
+
localSwarmNodeIdArg: string,
|
|
294
|
+
snapshotArg: unknown,
|
|
295
|
+
): Promise<string[]> => {
|
|
296
|
+
if (!isRuntimeRecord(snapshotArg)
|
|
297
|
+
|| !hasExactRuntimeKeys(snapshotArg, [
|
|
298
|
+
'snapshotVersion',
|
|
299
|
+
'snapshotDigest',
|
|
300
|
+
'nodeCount',
|
|
301
|
+
'managerCount',
|
|
302
|
+
'nodes',
|
|
303
|
+
])) {
|
|
304
|
+
return ['Spark Swarm manager snapshot must use its exact schema'];
|
|
305
|
+
}
|
|
306
|
+
const errors: string[] = [];
|
|
307
|
+
if (snapshotArg.snapshotVersion !== 1) {
|
|
308
|
+
errors.push('Spark Swarm manager snapshotVersion must be 1');
|
|
309
|
+
}
|
|
310
|
+
if (typeof snapshotArg.snapshotDigest !== 'string'
|
|
311
|
+
|| !runtimeDigestRegex.test(snapshotArg.snapshotDigest)) {
|
|
312
|
+
errors.push('Spark Swarm manager snapshot digest must be canonical');
|
|
313
|
+
}
|
|
314
|
+
if (!Array.isArray(snapshotArg.nodes)
|
|
315
|
+
|| snapshotArg.nodes.length === 0
|
|
316
|
+
|| snapshotArg.nodes.length > maximumSwarmNodes) {
|
|
317
|
+
errors.push('Spark Swarm manager snapshot nodes must be a non-empty bounded array');
|
|
318
|
+
return errors;
|
|
319
|
+
}
|
|
320
|
+
if (snapshotArg.nodeCount !== snapshotArg.nodes.length
|
|
321
|
+
|| !isPositiveSafeInteger(snapshotArg.nodeCount)) {
|
|
322
|
+
errors.push('Spark Swarm manager snapshot nodeCount must match nodes');
|
|
323
|
+
}
|
|
324
|
+
const nodeIds = new Set<string>();
|
|
325
|
+
const nodeNames = new Set<string>();
|
|
326
|
+
let managerCount = 0;
|
|
327
|
+
let leaderCount = 0;
|
|
328
|
+
let previousSortKey: string | undefined;
|
|
329
|
+
for (const [index, nodeArg] of snapshotArg.nodes.entries()) {
|
|
330
|
+
if (!isRuntimeRecord(nodeArg)) {
|
|
331
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] must be an object`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const isManager = nodeArg.role === 'manager';
|
|
335
|
+
const expectedKeys = [
|
|
336
|
+
'swarmNodeId',
|
|
337
|
+
'nodeName',
|
|
338
|
+
'platform',
|
|
339
|
+
'role',
|
|
340
|
+
'availability',
|
|
341
|
+
'state',
|
|
342
|
+
...(isManager ? ['managerStatus'] : []),
|
|
343
|
+
];
|
|
344
|
+
if (!hasExactRuntimeKeys(nodeArg, expectedKeys)) {
|
|
345
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] must use its exact role schema`);
|
|
346
|
+
}
|
|
347
|
+
if (!isRuntimeIdentifier(nodeArg.swarmNodeId) || !isRuntimeIdentifier(nodeArg.nodeName)) {
|
|
348
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] identity must be canonical`);
|
|
349
|
+
}
|
|
350
|
+
if (!runtimePlatforms.has(nodeArg.platform as TImmutableContainerPlatform)) {
|
|
351
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] platform must be supported`);
|
|
352
|
+
}
|
|
353
|
+
if (!['manager', 'worker'].includes(nodeArg.role as string)
|
|
354
|
+
|| !['active', 'pause', 'drain'].includes(nodeArg.availability as string)
|
|
355
|
+
|| !['unknown', 'down', 'ready', 'disconnected'].includes(nodeArg.state as string)) {
|
|
356
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] scheduler state must be canonical`);
|
|
357
|
+
}
|
|
358
|
+
if (isManager) {
|
|
359
|
+
managerCount++;
|
|
360
|
+
if (!isRuntimeRecord(nodeArg.managerStatus)
|
|
361
|
+
|| !hasExactRuntimeKeys(nodeArg.managerStatus, ['leader', 'reachability'])
|
|
362
|
+
|| typeof nodeArg.managerStatus.leader !== 'boolean'
|
|
363
|
+
|| !['unknown', 'unreachable', 'reachable']
|
|
364
|
+
.includes(nodeArg.managerStatus.reachability as string)) {
|
|
365
|
+
errors.push(`Spark Swarm manager snapshot nodes[${index}] managerStatus must be canonical`);
|
|
366
|
+
} else if (nodeArg.managerStatus.leader) {
|
|
367
|
+
leaderCount++;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (isRuntimeIdentifier(nodeArg.swarmNodeId)) {
|
|
371
|
+
if (nodeIds.has(nodeArg.swarmNodeId)) {
|
|
372
|
+
errors.push('Spark Swarm manager snapshot node IDs must be unique');
|
|
373
|
+
}
|
|
374
|
+
nodeIds.add(nodeArg.swarmNodeId);
|
|
375
|
+
}
|
|
376
|
+
if (isRuntimeIdentifier(nodeArg.nodeName)) {
|
|
377
|
+
if (nodeNames.has(nodeArg.nodeName)) {
|
|
378
|
+
errors.push('Spark Swarm manager snapshot node names must be unique');
|
|
379
|
+
}
|
|
380
|
+
nodeNames.add(nodeArg.nodeName);
|
|
381
|
+
}
|
|
382
|
+
if (isRuntimeIdentifier(nodeArg.swarmNodeId) && isRuntimeIdentifier(nodeArg.nodeName)) {
|
|
383
|
+
const sortKey = `${nodeArg.swarmNodeId}\0${nodeArg.nodeName}\0${nodeArg.platform}`;
|
|
384
|
+
if (previousSortKey !== undefined && sortKey <= previousSortKey) {
|
|
385
|
+
errors.push('Spark Swarm manager snapshot nodes must be uniquely sorted');
|
|
386
|
+
}
|
|
387
|
+
previousSortKey = sortKey;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (snapshotArg.managerCount !== managerCount || !isPositiveSafeInteger(snapshotArg.managerCount)) {
|
|
391
|
+
errors.push('Spark Swarm manager snapshot managerCount must match nodes');
|
|
392
|
+
}
|
|
393
|
+
if (leaderCount > 1) errors.push('Spark Swarm manager snapshot may contain at most one leader');
|
|
394
|
+
const sourceNode = snapshotArg.nodes.find((nodeArg) => (
|
|
395
|
+
isRuntimeRecord(nodeArg) && nodeArg.swarmNodeId === localSwarmNodeIdArg
|
|
396
|
+
));
|
|
397
|
+
if (!isRuntimeRecord(sourceNode)
|
|
398
|
+
|| sourceNode.role !== 'manager'
|
|
399
|
+
|| sourceNode.availability !== 'active'
|
|
400
|
+
|| sourceNode.state !== 'ready'
|
|
401
|
+
|| !isRuntimeRecord(sourceNode.managerStatus)
|
|
402
|
+
|| sourceNode.managerStatus.reachability !== 'reachable') {
|
|
403
|
+
errors.push('Spark Swarm manager snapshot source must be an active reachable manager');
|
|
404
|
+
}
|
|
405
|
+
if (errors.length === 0
|
|
406
|
+
&& snapshotArg.snapshotDigest !== await computeSwarmManagerSnapshotDigest(
|
|
407
|
+
swarmClusterIdArg,
|
|
408
|
+
snapshotArg as unknown as ISwarmManagerSnapshotV1,
|
|
409
|
+
)) {
|
|
410
|
+
errors.push('Spark Swarm manager snapshot digest does not match');
|
|
411
|
+
}
|
|
412
|
+
return errors;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
export const validateSparkSwarmObservation = async (
|
|
416
|
+
observationArg: unknown,
|
|
417
|
+
): Promise<string[]> => {
|
|
418
|
+
try {
|
|
419
|
+
if (!isRuntimeRecord(observationArg)) return ['Spark Swarm observation must be an object'];
|
|
420
|
+
const observation = observationArg as Record<string, unknown>;
|
|
421
|
+
const commonKeys = [
|
|
422
|
+
'schemaVersion',
|
|
423
|
+
'reporterSessionId',
|
|
424
|
+
'observationSequence',
|
|
425
|
+
'observedAt',
|
|
426
|
+
'state',
|
|
427
|
+
];
|
|
428
|
+
const expectedKeys = observation.state === 'unknown'
|
|
429
|
+
? [...commonKeys, 'reason']
|
|
430
|
+
: observation.state === 'not-member'
|
|
431
|
+
? commonKeys
|
|
432
|
+
: observation.state === 'member'
|
|
433
|
+
? [
|
|
434
|
+
...commonKeys,
|
|
435
|
+
'swarmClusterId',
|
|
436
|
+
'localSwarmNodeId',
|
|
437
|
+
'controlAvailable',
|
|
438
|
+
...(observation.managerSnapshot === undefined ? [] : ['managerSnapshot']),
|
|
439
|
+
]
|
|
440
|
+
: [];
|
|
441
|
+
if (expectedKeys.length === 0 || !hasExactRuntimeKeys(observation, expectedKeys)) {
|
|
442
|
+
return ['Spark Swarm observation must use its exact state schema'];
|
|
443
|
+
}
|
|
444
|
+
const errors: string[] = [];
|
|
445
|
+
if (observation.schemaVersion !== 1) errors.push('Spark Swarm observation schemaVersion must be 1');
|
|
446
|
+
if (!isRuntimeIdentifier(observation.reporterSessionId)
|
|
447
|
+
|| !isPositiveSafeInteger(observation.observationSequence)
|
|
448
|
+
|| !isPositiveSafeInteger(observation.observedAt)) {
|
|
449
|
+
errors.push('Spark Swarm observation identity, sequence, and timestamp must be canonical');
|
|
450
|
+
}
|
|
451
|
+
if (observation.state === 'unknown'
|
|
452
|
+
&& !['docker-unavailable', 'query-failed', 'not-initialized']
|
|
453
|
+
.includes(observation.reason as string)) {
|
|
454
|
+
errors.push('Spark Swarm observation unknown reason must be canonical');
|
|
455
|
+
}
|
|
456
|
+
if (observation.state === 'member') {
|
|
457
|
+
if (!isRuntimeIdentifier(observation.swarmClusterId)
|
|
458
|
+
|| !isRuntimeIdentifier(observation.localSwarmNodeId)
|
|
459
|
+
|| typeof observation.controlAvailable !== 'boolean') {
|
|
460
|
+
errors.push('Spark Swarm member observation must have canonical local authority');
|
|
461
|
+
}
|
|
462
|
+
if (observation.managerSnapshot !== undefined) {
|
|
463
|
+
if (observation.controlAvailable !== true) {
|
|
464
|
+
errors.push('Spark Swarm manager snapshot requires controlAvailable');
|
|
465
|
+
} else if (isRuntimeIdentifier(observation.swarmClusterId)
|
|
466
|
+
&& isRuntimeIdentifier(observation.localSwarmNodeId)) {
|
|
467
|
+
errors.push(...await validateSwarmManagerSnapshot(
|
|
468
|
+
observation.swarmClusterId,
|
|
469
|
+
observation.localSwarmNodeId,
|
|
470
|
+
observation.managerSnapshot,
|
|
471
|
+
));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return errors;
|
|
476
|
+
} catch {
|
|
477
|
+
return ['Spark Swarm observation must be safely inspectable'];
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export const validateSparkSwarmObservationV2 = async (
|
|
482
|
+
observationArg: unknown,
|
|
483
|
+
): Promise<string[]> => {
|
|
484
|
+
try {
|
|
485
|
+
if (!isRuntimeRecord(observationArg)) return ['Spark Swarm observation v2 must be an object'];
|
|
486
|
+
const observation = observationArg as Record<string, unknown>;
|
|
487
|
+
const commonKeys = [
|
|
488
|
+
'schemaVersion',
|
|
489
|
+
'reporterSessionId',
|
|
490
|
+
'observationSequence',
|
|
491
|
+
'observedAt',
|
|
492
|
+
'state',
|
|
493
|
+
];
|
|
494
|
+
const expectedKeys = observation.state === 'unknown'
|
|
495
|
+
? [...commonKeys, 'reason']
|
|
496
|
+
: observation.state === 'not-member'
|
|
497
|
+
? commonKeys
|
|
498
|
+
: observation.state === 'member' && observation.controlAvailable === false
|
|
499
|
+
? [...commonKeys, 'localSwarmNodeId', 'controlAvailable']
|
|
500
|
+
: observation.state === 'member' && observation.controlAvailable === true
|
|
501
|
+
? [
|
|
502
|
+
...commonKeys,
|
|
503
|
+
'swarmClusterId',
|
|
504
|
+
'localSwarmNodeId',
|
|
505
|
+
'controlAvailable',
|
|
506
|
+
...(observation.managerSnapshot === undefined ? [] : ['managerSnapshot']),
|
|
507
|
+
]
|
|
508
|
+
: [];
|
|
509
|
+
if (expectedKeys.length === 0 || !hasExactRuntimeKeys(observation, expectedKeys)) {
|
|
510
|
+
return ['Spark Swarm observation v2 must use its exact state schema'];
|
|
511
|
+
}
|
|
512
|
+
const errors: string[] = [];
|
|
513
|
+
if (observation.schemaVersion !== 2) {
|
|
514
|
+
errors.push('Spark Swarm observation v2 schemaVersion must be 2');
|
|
515
|
+
}
|
|
516
|
+
if (!isRuntimeIdentifier(observation.reporterSessionId)
|
|
517
|
+
|| !isPositiveSafeInteger(observation.observationSequence)
|
|
518
|
+
|| !isPositiveSafeInteger(observation.observedAt)) {
|
|
519
|
+
errors.push('Spark Swarm observation v2 identity, sequence, and timestamp must be canonical');
|
|
520
|
+
}
|
|
521
|
+
if (observation.state === 'unknown'
|
|
522
|
+
&& !['docker-unavailable', 'query-failed', 'not-initialized']
|
|
523
|
+
.includes(observation.reason as string)) {
|
|
524
|
+
errors.push('Spark Swarm observation v2 unknown reason must be canonical');
|
|
525
|
+
}
|
|
526
|
+
if (observation.state === 'member') {
|
|
527
|
+
if (!isRuntimeIdentifier(observation.localSwarmNodeId)
|
|
528
|
+
|| typeof observation.controlAvailable !== 'boolean') {
|
|
529
|
+
errors.push('Spark Swarm member observation v2 must have canonical local authority');
|
|
530
|
+
}
|
|
531
|
+
if (observation.controlAvailable === true) {
|
|
532
|
+
if (!isRuntimeIdentifier(observation.swarmClusterId)) {
|
|
533
|
+
errors.push('Spark Swarm manager observation v2 must have canonical cluster authority');
|
|
534
|
+
} else if (observation.managerSnapshot !== undefined
|
|
535
|
+
&& isRuntimeIdentifier(observation.localSwarmNodeId)) {
|
|
536
|
+
errors.push(...await validateSwarmManagerSnapshot(
|
|
537
|
+
observation.swarmClusterId,
|
|
538
|
+
observation.localSwarmNodeId,
|
|
539
|
+
observation.managerSnapshot,
|
|
540
|
+
));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return errors;
|
|
545
|
+
} catch {
|
|
546
|
+
return ['Spark Swarm observation v2 must be safely inspectable'];
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const canonicalRuntimeTarget = (
|
|
551
|
+
targetArg: IClusterRuntimeTargetV1,
|
|
552
|
+
): IClusterRuntimeTargetV1 => ({
|
|
553
|
+
cloudlyNodeId: targetArg.cloudlyNodeId,
|
|
554
|
+
swarmClusterId: targetArg.swarmClusterId,
|
|
555
|
+
swarmNodeId: targetArg.swarmNodeId,
|
|
556
|
+
nodeName: targetArg.nodeName,
|
|
557
|
+
platform: targetArg.platform,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
export const createClusterRuntimeTargetSetDigestInput = (
|
|
561
|
+
targetSetArg: IClusterRuntimeTargetSetReadyV1,
|
|
562
|
+
): string => JSON.stringify({
|
|
563
|
+
schemaVersion: targetSetArg.schemaVersion,
|
|
564
|
+
purpose: 'serve.zone/cluster-runtime-target-set',
|
|
565
|
+
cloudlyClusterId: targetSetArg.cloudlyClusterId,
|
|
566
|
+
swarmClusterId: targetSetArg.swarmClusterId,
|
|
567
|
+
generation: targetSetArg.generation,
|
|
568
|
+
targets: targetSetArg.targets.map(canonicalRuntimeTarget),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
export const computeClusterRuntimeTargetSetDigest = async (
|
|
572
|
+
targetSetArg: IClusterRuntimeTargetSetReadyV1,
|
|
573
|
+
): Promise<TSha256Digest> => computeSha256(createClusterRuntimeTargetSetDigestInput(targetSetArg));
|
|
574
|
+
|
|
575
|
+
export const validateClusterRuntimeTargetSet = async (
|
|
576
|
+
targetSetArg: unknown,
|
|
577
|
+
): Promise<string[]> => {
|
|
578
|
+
try {
|
|
579
|
+
if (!isRuntimeRecord(targetSetArg)) return ['cluster runtime target set must be an object'];
|
|
580
|
+
if (targetSetArg.state === 'unavailable') {
|
|
581
|
+
if (!hasExactRuntimeKeys(targetSetArg, [
|
|
582
|
+
'schemaVersion',
|
|
583
|
+
'state',
|
|
584
|
+
'cloudlyClusterId',
|
|
585
|
+
'generation',
|
|
586
|
+
'changedAt',
|
|
587
|
+
'reason',
|
|
588
|
+
])) {
|
|
589
|
+
return ['unavailable cluster runtime target set must use its exact schema'];
|
|
590
|
+
}
|
|
591
|
+
const errors: string[] = [];
|
|
592
|
+
if (targetSetArg.schemaVersion !== 1
|
|
593
|
+
|| !isRuntimeIdentifier(targetSetArg.cloudlyClusterId)
|
|
594
|
+
|| !Number.isSafeInteger(targetSetArg.generation)
|
|
595
|
+
|| (targetSetArg.generation as number) < 0
|
|
596
|
+
|| !isPositiveSafeInteger(targetSetArg.changedAt)
|
|
597
|
+
|| ![
|
|
598
|
+
'never-observed',
|
|
599
|
+
'stale',
|
|
600
|
+
'no-manager-consensus',
|
|
601
|
+
'identity-conflict',
|
|
602
|
+
'no-schedulable-targets',
|
|
603
|
+
].includes(targetSetArg.reason as string)) {
|
|
604
|
+
errors.push('unavailable cluster runtime target set fields must be canonical');
|
|
605
|
+
}
|
|
606
|
+
if (targetSetArg.reason === 'never-observed' && targetSetArg.generation !== 0) {
|
|
607
|
+
errors.push('never-observed target state must use generation zero');
|
|
608
|
+
}
|
|
609
|
+
return errors;
|
|
610
|
+
}
|
|
611
|
+
if (targetSetArg.state !== 'ready'
|
|
612
|
+
|| !hasExactRuntimeKeys(targetSetArg, [
|
|
613
|
+
'schemaVersion',
|
|
614
|
+
'state',
|
|
615
|
+
'cloudlyClusterId',
|
|
616
|
+
'swarmClusterId',
|
|
617
|
+
'generation',
|
|
618
|
+
'targetSetDigest',
|
|
619
|
+
'acceptedAt',
|
|
620
|
+
'freshUntil',
|
|
621
|
+
'targets',
|
|
622
|
+
])) {
|
|
623
|
+
return ['ready cluster runtime target set must use its exact schema'];
|
|
624
|
+
}
|
|
625
|
+
const errors: string[] = [];
|
|
626
|
+
if (targetSetArg.schemaVersion !== 1
|
|
627
|
+
|| !isRuntimeIdentifier(targetSetArg.cloudlyClusterId)
|
|
628
|
+
|| !isRuntimeIdentifier(targetSetArg.swarmClusterId)
|
|
629
|
+
|| !isPositiveSafeInteger(targetSetArg.generation)
|
|
630
|
+
|| !isPositiveSafeInteger(targetSetArg.acceptedAt)
|
|
631
|
+
|| !isPositiveSafeInteger(targetSetArg.freshUntil)
|
|
632
|
+
|| (targetSetArg.freshUntil as number) <= (targetSetArg.acceptedAt as number)
|
|
633
|
+
|| typeof targetSetArg.targetSetDigest !== 'string'
|
|
634
|
+
|| !runtimeDigestRegex.test(targetSetArg.targetSetDigest)) {
|
|
635
|
+
errors.push('ready cluster runtime target set authority fields must be canonical');
|
|
636
|
+
}
|
|
637
|
+
if (!Array.isArray(targetSetArg.targets)
|
|
638
|
+
|| targetSetArg.targets.length === 0
|
|
639
|
+
|| targetSetArg.targets.length > maximumSwarmNodes) {
|
|
640
|
+
errors.push('ready cluster runtime target set targets must be a non-empty bounded array');
|
|
641
|
+
return errors;
|
|
642
|
+
}
|
|
643
|
+
const cloudlyNodeIds = new Set<string>();
|
|
644
|
+
const swarmNodeScopes = new Set<string>();
|
|
645
|
+
const nodeNames = new Set<string>();
|
|
646
|
+
let previousSortKey: string | undefined;
|
|
647
|
+
for (const [index, targetArg] of targetSetArg.targets.entries()) {
|
|
648
|
+
if (!isRuntimeRecord(targetArg)
|
|
649
|
+
|| !hasExactRuntimeKeys(targetArg, [
|
|
650
|
+
'cloudlyNodeId',
|
|
651
|
+
'swarmClusterId',
|
|
652
|
+
'swarmNodeId',
|
|
653
|
+
'nodeName',
|
|
654
|
+
'platform',
|
|
655
|
+
])
|
|
656
|
+
|| !isRuntimeIdentifier(targetArg.cloudlyNodeId)
|
|
657
|
+
|| !isRuntimeIdentifier(targetArg.swarmClusterId)
|
|
658
|
+
|| !isRuntimeIdentifier(targetArg.swarmNodeId)
|
|
659
|
+
|| !isRuntimeIdentifier(targetArg.nodeName)
|
|
660
|
+
|| targetArg.swarmClusterId !== targetSetArg.swarmClusterId
|
|
661
|
+
|| !runtimePlatforms.has(targetArg.platform as TImmutableContainerPlatform)) {
|
|
662
|
+
errors.push(`cluster runtime target set targets[${index}] must be canonical`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const sortKey = `${targetArg.cloudlyNodeId}\0${targetArg.swarmClusterId}\0${
|
|
666
|
+
targetArg.swarmNodeId
|
|
667
|
+
}\0${targetArg.nodeName}\0${targetArg.platform}`;
|
|
668
|
+
if (previousSortKey !== undefined && sortKey <= previousSortKey) {
|
|
669
|
+
errors.push('cluster runtime target set targets must be uniquely sorted');
|
|
670
|
+
}
|
|
671
|
+
previousSortKey = sortKey;
|
|
672
|
+
const swarmScope = `${targetArg.swarmClusterId}\0${targetArg.swarmNodeId}`;
|
|
673
|
+
if (cloudlyNodeIds.has(targetArg.cloudlyNodeId)
|
|
674
|
+
|| swarmNodeScopes.has(swarmScope)
|
|
675
|
+
|| nodeNames.has(targetArg.nodeName)) {
|
|
676
|
+
errors.push('cluster runtime target identities must be unique');
|
|
677
|
+
}
|
|
678
|
+
cloudlyNodeIds.add(targetArg.cloudlyNodeId);
|
|
679
|
+
swarmNodeScopes.add(swarmScope);
|
|
680
|
+
nodeNames.add(targetArg.nodeName);
|
|
681
|
+
}
|
|
682
|
+
if (errors.length === 0
|
|
683
|
+
&& targetSetArg.targetSetDigest !== await computeClusterRuntimeTargetSetDigest(
|
|
684
|
+
targetSetArg as unknown as IClusterRuntimeTargetSetReadyV1,
|
|
685
|
+
)) {
|
|
686
|
+
errors.push('cluster runtime target set digest does not match');
|
|
687
|
+
}
|
|
688
|
+
return errors;
|
|
689
|
+
} catch {
|
|
690
|
+
return ['cluster runtime target set must be safely inspectable'];
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
export const isClusterRuntimeTargetSetFresh = (
|
|
695
|
+
targetSetArg: TClusterRuntimeTargetSetV1,
|
|
696
|
+
nowArg: number,
|
|
697
|
+
): targetSetArg is IClusterRuntimeTargetSetReadyV1 => (
|
|
698
|
+
targetSetArg.state === 'ready'
|
|
699
|
+
&& Number.isSafeInteger(nowArg)
|
|
700
|
+
&& nowArg >= targetSetArg.acceptedAt
|
|
701
|
+
&& nowArg < targetSetArg.freshUntil
|
|
702
|
+
);
|
|
703
|
+
|
|
94
704
|
export type TSparkNodeMode = 'cloudly' | 'coreflow-node';
|
|
95
705
|
|
|
96
706
|
export type TSparkCloudlyConnectionStatus =
|