@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/changelog.md +9 -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 +85 -0
- package/dist_ts/data/clusternode.js +355 -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 +45 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/appstore/types.ts +2 -2
- package/ts/data/clusternode.ts +513 -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/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
|
|
33
|
+
## Secrets v24 Pre-Cutover Contract
|
|
34
34
|
|
|
35
|
-
Version
|
|
36
|
-
|
|
37
|
-
envelopes or an explicit bounded
|
|
38
|
-
full digest-verified schema-v2
|
|
39
|
-
SecretVersion through the Node-only
|
|
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
|
-
|
|
57
|
+
The current secret contract includes:
|
|
57
58
|
|
|
58
|
-
- `TSecretValueInput`, `
|
|
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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
`
|
|
109
|
+
`ICoreflowSecretRuntimeRegistrationV2`; cluster scope comes from the verified
|
|
85
110
|
connection identity rather than the tag.
|
|
86
|
-
- `
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
manifest and installed-executable
|
|
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.
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/appstore/types.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
validateSecretValueInputForRecipient,
|
|
6
6
|
type TSecretDelivery,
|
|
7
7
|
type TSecretGeneratedEncoding,
|
|
8
|
-
type
|
|
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:
|
|
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'];
|
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,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 =
|