@serve.zone/interfaces 14.0.0 → 16.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.
@@ -0,0 +1,445 @@
1
+ import type {
2
+ IDeploymentPreflightRuntimeTask,
3
+ IOciPlatformDescriptorSummary,
4
+ } from './deploymentpreflight.js';
5
+ import type {
6
+ IImmutableImageDeploymentPlan,
7
+ IImmutableImageTargetScope,
8
+ TOciManifestMediaType,
9
+ TSha256Digest,
10
+ } from './immutableimage.js';
11
+ import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
12
+ import type { IRegistryTarget } from './registry.js';
13
+
14
+ export type TServiceDeploymentAdoptionDecision = 'GO' | 'NO-GO';
15
+
16
+ /**
17
+ * Canonical sha256 hash of the complete adoption preflight snapshot. It is a
18
+ * state/TOCTOU fence and must never be substituted with the OCI image digest.
19
+ */
20
+ export type TServiceDeploymentAdoptionDigest = TSha256Digest & {
21
+ readonly __serviceDeploymentAdoptionDigest: unique symbol;
22
+ };
23
+
24
+ export type TServiceDeploymentAdoptionBlockerCode =
25
+ | 'INVALID_REQUEST'
26
+ | 'AUTHORIZATION_MISSING'
27
+ | 'SERVICE_NOT_FOUND'
28
+ | 'SERVICE_OWNERSHIP_MISMATCH'
29
+ | 'SERVICE_ALREADY_IMMUTABLE'
30
+ | 'SERVICE_STATE_CONFLICT'
31
+ | 'REGISTRY_TARGET_MISSING'
32
+ | 'REGISTRY_REFERENCE_INVALID'
33
+ | 'REGISTRY_DIGEST_MISSING'
34
+ | 'OCI_INDEX_REQUIRED'
35
+ | 'OCI_PLATFORM_MISSING'
36
+ | 'RELEASE_NOT_FOUND'
37
+ | 'RELEASE_TAG_AMBIGUOUS'
38
+ | 'TARGET_SCOPE_UNAVAILABLE'
39
+ | 'RUNTIME_ATTESTATION_INCOMPLETE'
40
+ | 'RUNTIME_DIGEST_MISMATCH';
41
+
42
+ export interface IServiceDeploymentAdoptionBlocker {
43
+ code: TServiceDeploymentAdoptionBlockerCode;
44
+ message: string;
45
+ }
46
+
47
+ export interface IServiceDeploymentAdoptionPreflightInput {
48
+ requestId: string;
49
+ organizationId: string;
50
+ serviceId: string;
51
+ }
52
+
53
+ export interface IServiceDeploymentAdoptionInput
54
+ extends IServiceDeploymentAdoptionPreflightInput {
55
+ idempotencyKey: string;
56
+ expectedAdoptionDigest: TServiceDeploymentAdoptionDigest;
57
+ }
58
+
59
+ interface IServiceDeploymentAdoptionReportBase {
60
+ schemaVersion: 1;
61
+ mutationPerformed: false;
62
+ requestId: string;
63
+ generatedAt: number;
64
+ runtimeAttestation: {
65
+ requiredReplicaCount: number;
66
+ observedReplicaCount: number;
67
+ healthyReplicaCount: number;
68
+ observedDigestReplicaCount: number;
69
+ observedDigestConsistent: boolean;
70
+ };
71
+ }
72
+
73
+ interface IServiceDeploymentAdoptionServiceSnapshot {
74
+ organizationId: string;
75
+ serviceId: string;
76
+ serviceName: string;
77
+ imageId: string;
78
+ deployOnPush: boolean;
79
+ immutableImageRequired: boolean;
80
+ configuredReference: string;
81
+ registryTarget: IRegistryTarget;
82
+ }
83
+
84
+ interface IServiceDeploymentAdoptionRegistrySnapshot {
85
+ tag?: string;
86
+ digest?: TSha256Digest;
87
+ mediaType?: TOciManifestMediaType;
88
+ platforms: IOciPlatformDescriptorSummary[];
89
+ releaseId?: string;
90
+ releaseTag?: string;
91
+ }
92
+
93
+ export interface IServiceDeploymentAdoptionGoReport
94
+ extends IServiceDeploymentAdoptionReportBase {
95
+ decision: 'GO';
96
+ service: IServiceDeploymentAdoptionServiceSnapshot & {
97
+ deployOnPush: true;
98
+ immutableImageRequired: false;
99
+ };
100
+ registry: IServiceDeploymentAdoptionRegistrySnapshot & {
101
+ tag: string;
102
+ digest: TSha256Digest;
103
+ mediaType:
104
+ | 'application/vnd.oci.image.index.v1+json'
105
+ | 'application/vnd.docker.distribution.manifest.list.v2+json';
106
+ platforms: [IOciPlatformDescriptorSummary, ...IOciPlatformDescriptorSummary[]];
107
+ releaseId: string;
108
+ releaseTag: string;
109
+ };
110
+ runtimeTasks: [IDeploymentPreflightRuntimeTask, ...IDeploymentPreflightRuntimeTask[]];
111
+ targetScope: IImmutableImageTargetScope;
112
+ adoptionDigest: TServiceDeploymentAdoptionDigest;
113
+ blockers: [];
114
+ }
115
+
116
+ export interface IServiceDeploymentAdoptionNoGoReport
117
+ extends IServiceDeploymentAdoptionReportBase {
118
+ decision: 'NO-GO';
119
+ service?: IServiceDeploymentAdoptionServiceSnapshot;
120
+ registry: IServiceDeploymentAdoptionRegistrySnapshot;
121
+ runtimeTasks: IDeploymentPreflightRuntimeTask[];
122
+ targetScope?: IImmutableImageTargetScope;
123
+ adoptionDigest?: never;
124
+ blockers: [IServiceDeploymentAdoptionBlocker, ...IServiceDeploymentAdoptionBlocker[]];
125
+ }
126
+
127
+ export type IServiceDeploymentAdoptionReport =
128
+ | IServiceDeploymentAdoptionGoReport
129
+ | IServiceDeploymentAdoptionNoGoReport;
130
+
131
+ export interface IServiceDeploymentAdoptionResult {
132
+ adoptionId: string;
133
+ adoptionDigest: TServiceDeploymentAdoptionDigest;
134
+ idempotentReplay: boolean;
135
+ plan: IImmutableImageDeploymentPlan;
136
+ }
137
+
138
+ const boundaryIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
139
+ const indexMediaTypes = new Set<TOciManifestMediaType>([
140
+ 'application/vnd.oci.image.index.v1+json',
141
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
142
+ ]);
143
+ const adoptionBlockerCodes = new Set<TServiceDeploymentAdoptionBlockerCode>([
144
+ 'INVALID_REQUEST',
145
+ 'AUTHORIZATION_MISSING',
146
+ 'SERVICE_NOT_FOUND',
147
+ 'SERVICE_OWNERSHIP_MISMATCH',
148
+ 'SERVICE_ALREADY_IMMUTABLE',
149
+ 'SERVICE_STATE_CONFLICT',
150
+ 'REGISTRY_TARGET_MISSING',
151
+ 'REGISTRY_REFERENCE_INVALID',
152
+ 'REGISTRY_DIGEST_MISSING',
153
+ 'OCI_INDEX_REQUIRED',
154
+ 'OCI_PLATFORM_MISSING',
155
+ 'RELEASE_NOT_FOUND',
156
+ 'RELEASE_TAG_AMBIGUOUS',
157
+ 'TARGET_SCOPE_UNAVAILABLE',
158
+ 'RUNTIME_ATTESTATION_INCOMPLETE',
159
+ 'RUNTIME_DIGEST_MISMATCH',
160
+ ]);
161
+
162
+ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> =>
163
+ Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg);
164
+
165
+ const isBoundedIdentifier = (valueArg: unknown): valueArg is string =>
166
+ typeof valueArg === 'string' && boundaryIdentifierRegex.test(valueArg);
167
+
168
+ const isCanonicalDigest = (valueArg: unknown): valueArg is TSha256Digest =>
169
+ Boolean(normalizeSha256Digest(valueArg)) && normalizeSha256Digest(valueArg) === valueArg;
170
+
171
+ const isNonNegativeInteger = (valueArg: unknown): valueArg is number =>
172
+ Number.isSafeInteger(valueArg) && (valueArg as number) >= 0;
173
+
174
+ export const validateServiceDeploymentAdoptionPreflightInput = (
175
+ inputArg: unknown,
176
+ ): string[] => {
177
+ if (!isRecord(inputArg)) return ['adoption preflight input must be an object'];
178
+ const errors: string[] = [];
179
+ if (!isBoundedIdentifier(inputArg.requestId)) {
180
+ errors.push('requestId must be a bounded canonical identifier');
181
+ }
182
+ for (const key of ['organizationId', 'serviceId'] as const) {
183
+ if (!isBoundedIdentifier(inputArg[key])) {
184
+ errors.push(`${key} must be a bounded canonical identifier`);
185
+ }
186
+ }
187
+ return errors;
188
+ };
189
+
190
+ export const validateServiceDeploymentAdoptionInput = (inputArg: unknown): string[] => {
191
+ const errors = validateServiceDeploymentAdoptionPreflightInput(inputArg);
192
+ if (!isRecord(inputArg)) return errors;
193
+ if (!isBoundedIdentifier(inputArg.idempotencyKey)) {
194
+ errors.push('idempotencyKey must be a bounded canonical identifier');
195
+ }
196
+ if (!isCanonicalDigest(inputArg.expectedAdoptionDigest)) {
197
+ errors.push('expectedAdoptionDigest must be a canonical sha256 digest');
198
+ }
199
+ return errors;
200
+ };
201
+
202
+ export const validateServiceDeploymentAdoptionReport = (reportArg: unknown): string[] => {
203
+ if (!isRecord(reportArg)) return ['adoption report must be an object'];
204
+ const errors: string[] = [];
205
+ if (reportArg.schemaVersion !== 1) errors.push('schemaVersion must be 1');
206
+ if (reportArg.mutationPerformed !== false) {
207
+ errors.push('adoption preflight must be mutation-free');
208
+ }
209
+ if (!isBoundedIdentifier(reportArg.requestId)) {
210
+ errors.push('requestId must be a bounded canonical identifier');
211
+ }
212
+ if (!isNonNegativeInteger(reportArg.generatedAt)) {
213
+ errors.push('generatedAt must be a non-negative integer');
214
+ }
215
+ if (reportArg.decision !== 'GO' && reportArg.decision !== 'NO-GO') {
216
+ errors.push('decision must be GO or NO-GO');
217
+ }
218
+
219
+ const attestation = reportArg.runtimeAttestation;
220
+ if (!isRecord(attestation)) {
221
+ errors.push('runtimeAttestation must be an object');
222
+ } else {
223
+ for (const key of [
224
+ 'requiredReplicaCount',
225
+ 'observedReplicaCount',
226
+ 'healthyReplicaCount',
227
+ 'observedDigestReplicaCount',
228
+ ] as const) {
229
+ if (!isNonNegativeInteger(attestation[key])) {
230
+ errors.push(`runtimeAttestation.${key} must be a non-negative integer`);
231
+ }
232
+ }
233
+ if (typeof attestation.observedDigestConsistent !== 'boolean') {
234
+ errors.push('runtimeAttestation.observedDigestConsistent must be boolean');
235
+ }
236
+ }
237
+
238
+ const blockers = reportArg.blockers;
239
+ if (!Array.isArray(blockers)) {
240
+ errors.push('blockers must be an array');
241
+ } else {
242
+ for (const blocker of blockers) {
243
+ if (!isRecord(blocker)
244
+ || !adoptionBlockerCodes.has(blocker.code as TServiceDeploymentAdoptionBlockerCode)
245
+ || typeof blocker.message !== 'string'
246
+ || !blocker.message.trim()) {
247
+ errors.push('each blocker must contain a supported code and non-empty message');
248
+ break;
249
+ }
250
+ }
251
+ }
252
+
253
+ if (reportArg.decision === 'NO-GO') {
254
+ if (Array.isArray(blockers) && blockers.length === 0) {
255
+ errors.push('a NO-GO report must contain at least one blocker');
256
+ }
257
+ if (reportArg.adoptionDigest !== undefined) {
258
+ errors.push('a NO-GO report must not contain an adoption digest');
259
+ }
260
+ return errors;
261
+ }
262
+ if (reportArg.decision !== 'GO') return errors;
263
+
264
+ if (Array.isArray(blockers) && blockers.length !== 0) {
265
+ errors.push('a GO report must not contain blockers');
266
+ }
267
+ if (!isCanonicalDigest(reportArg.adoptionDigest)) {
268
+ errors.push('a GO report requires a canonical adoption digest');
269
+ }
270
+
271
+ const service = reportArg.service;
272
+ const registry = reportArg.registry;
273
+ const targetScope = reportArg.targetScope;
274
+ const runtimeTasks = reportArg.runtimeTasks;
275
+ if (!isRecord(service)) {
276
+ errors.push('a GO report requires a service snapshot');
277
+ } else {
278
+ for (const key of ['organizationId', 'serviceId', 'serviceName', 'imageId'] as const) {
279
+ if (!isBoundedIdentifier(service[key])) errors.push(`service.${key} is invalid`);
280
+ }
281
+ if (service.deployOnPush !== true || service.immutableImageRequired !== false) {
282
+ errors.push('a GO report requires the legacy mutable service state');
283
+ }
284
+ if (typeof service.configuredReference !== 'string' || !service.configuredReference.trim()) {
285
+ errors.push('service.configuredReference must be non-empty');
286
+ }
287
+ if (!isRecord(service.registryTarget)) {
288
+ errors.push('service.registryTarget must be present');
289
+ }
290
+ }
291
+
292
+ let registryDigest: TSha256Digest | undefined;
293
+ if (!isRecord(registry)) {
294
+ errors.push('a GO report requires a registry snapshot');
295
+ } else {
296
+ if (!normalizeImmutableReleaseTag(registry.tag) || registry.tag !== registry.releaseTag) {
297
+ errors.push('registry tag and releaseTag must be the same immutable tag');
298
+ }
299
+ if (!isCanonicalDigest(registry.digest)) {
300
+ errors.push('registry.digest must be a canonical sha256 digest');
301
+ } else {
302
+ registryDigest = registry.digest;
303
+ if (reportArg.adoptionDigest === registry.digest) {
304
+ errors.push('adoption digest must be a distinct full-state fence, not the image digest');
305
+ }
306
+ }
307
+ if (!indexMediaTypes.has(registry.mediaType as TOciManifestMediaType)) {
308
+ errors.push('registry.mediaType must identify an OCI image index');
309
+ }
310
+ if (!isBoundedIdentifier(registry.releaseId)) {
311
+ errors.push('registry.releaseId must be a bounded canonical identifier');
312
+ }
313
+ if (!Array.isArray(registry.platforms) || registry.platforms.length === 0) {
314
+ errors.push('registry.platforms must contain OCI platform evidence');
315
+ } else {
316
+ for (const architecture of ['amd64', 'arm64']) {
317
+ if (!registry.platforms.some((platformArg) => isRecord(platformArg)
318
+ && platformArg.os === 'linux'
319
+ && platformArg.architecture === architecture
320
+ && platformArg.runnable === true
321
+ && platformArg.manifestPresent === true
322
+ && isCanonicalDigest(platformArg.digest))) {
323
+ errors.push(`registry.platforms must contain runnable linux/${architecture} evidence`);
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ let requiredReplicaCount: number | undefined;
330
+ let targetNodeNames: string[] = [];
331
+ let replicasPerNode: number | undefined;
332
+ if (!isRecord(targetScope)) {
333
+ errors.push('a GO report requires an immutable target scope');
334
+ } else {
335
+ const clusterIds = targetScope.targetClusterIds;
336
+ const nodeNames = targetScope.targetNodeNames;
337
+ replicasPerNode = targetScope.replicasPerNode as number;
338
+ requiredReplicaCount = targetScope.requiredReplicaCount as number;
339
+ if (!Array.isArray(clusterIds) || clusterIds.length !== 1
340
+ || clusterIds.some((valueArg) => !isBoundedIdentifier(valueArg))
341
+ || new Set(clusterIds).size !== clusterIds.length) {
342
+ errors.push('targetScope.targetClusterIds must identify exactly one attested cluster');
343
+ }
344
+ if (!Array.isArray(nodeNames) || nodeNames.length === 0
345
+ || nodeNames.some((valueArg) => !isBoundedIdentifier(valueArg))
346
+ || new Set(nodeNames).size !== nodeNames.length) {
347
+ errors.push('targetScope.targetNodeNames must be non-empty and unique');
348
+ } else {
349
+ targetNodeNames = nodeNames as string[];
350
+ }
351
+ if (!Number.isSafeInteger(replicasPerNode) || replicasPerNode < 1) {
352
+ errors.push('targetScope.replicasPerNode must be a positive integer');
353
+ }
354
+ if (!Number.isSafeInteger(requiredReplicaCount) || requiredReplicaCount < 1
355
+ || requiredReplicaCount !== targetNodeNames.length * (replicasPerNode || 0)) {
356
+ errors.push('targetScope.requiredReplicaCount must match nodes times replicas');
357
+ }
358
+ }
359
+
360
+ if (!Array.isArray(runtimeTasks) || runtimeTasks.length === 0) {
361
+ errors.push('a GO report requires runtime task evidence');
362
+ } else {
363
+ if (requiredReplicaCount !== undefined && runtimeTasks.length !== requiredReplicaCount) {
364
+ errors.push('runtime task count must equal the required replica count');
365
+ }
366
+ const deploymentIds = new Set<string>();
367
+ const taskCountsByNode = new Map<string, number>();
368
+ for (const task of runtimeTasks) {
369
+ if (!isRecord(task)
370
+ || !isBoundedIdentifier(task.deploymentId)
371
+ || deploymentIds.has(task.deploymentId as string)) {
372
+ errors.push('runtime task deployment IDs must be valid and unique');
373
+ continue;
374
+ }
375
+ deploymentIds.add(task.deploymentId as string);
376
+ if (!isBoundedIdentifier(task.nodeName) || !targetNodeNames.includes(task.nodeName)) {
377
+ errors.push('every runtime task must belong to the exact target node scope');
378
+ } else {
379
+ taskCountsByNode.set(task.nodeName, (taskCountsByNode.get(task.nodeName) || 0) + 1);
380
+ }
381
+ if (task.status !== 'running' || task.healthStatus !== 'healthy') {
382
+ errors.push('every runtime task must be running and healthy');
383
+ }
384
+ if (!registryDigest || task.observedDigest !== registryDigest) {
385
+ errors.push('every runtime task must attest the exact registry digest');
386
+ }
387
+ if (typeof task.imageReference !== 'string' || !task.imageReference.trim()) {
388
+ errors.push('every runtime task must identify its image reference');
389
+ }
390
+ }
391
+ if (replicasPerNode !== undefined
392
+ && targetNodeNames.some((nodeNameArg) => taskCountsByNode.get(nodeNameArg) !== replicasPerNode)) {
393
+ errors.push('runtime task evidence must cover every target node and replica');
394
+ }
395
+ }
396
+
397
+ if (isRecord(attestation) && requiredReplicaCount !== undefined) {
398
+ if (attestation.requiredReplicaCount !== requiredReplicaCount
399
+ || attestation.observedReplicaCount !== requiredReplicaCount
400
+ || attestation.healthyReplicaCount !== requiredReplicaCount
401
+ || attestation.observedDigestReplicaCount !== requiredReplicaCount
402
+ || attestation.observedDigestConsistent !== true) {
403
+ errors.push('runtime attestation must consistently cover every required replica');
404
+ }
405
+ }
406
+
407
+ if (isRecord(service) && isRecord(service.registryTarget) && isRecord(registry)) {
408
+ const target = service.registryTarget;
409
+ if (target.protocol !== 'oci'
410
+ || target.tag !== registry.tag
411
+ || target.serviceId !== service.serviceId
412
+ || target.imageId !== service.imageId
413
+ || typeof target.registryHost !== 'string'
414
+ || !target.registryHost
415
+ || typeof target.repository !== 'string'
416
+ || !target.repository
417
+ || target.imageUrl !== `${target.registryHost}/${target.repository}:${target.tag}`) {
418
+ errors.push('service registry target must exactly bind the adopted service, image, and tag');
419
+ }
420
+ if (registryDigest) {
421
+ const digestPinnedReference = `${target.registryHost}/${target.repository}@${registryDigest}`;
422
+ const tagAndDigestReference = `${target.imageUrl}@${registryDigest}`;
423
+ const configuredReferences = new Set([
424
+ target.tag,
425
+ target.imageUrl,
426
+ digestPinnedReference,
427
+ tagAndDigestReference,
428
+ ]);
429
+ if (!configuredReferences.has(service.configuredReference as string)) {
430
+ errors.push('service configured reference must exactly bind the adopted registry target');
431
+ }
432
+ const runtimeReferences = new Set([
433
+ target.imageUrl,
434
+ digestPinnedReference,
435
+ tagAndDigestReference,
436
+ ]);
437
+ if (Array.isArray(runtimeTasks)
438
+ && runtimeTasks.some((taskArg) => !isRecord(taskArg)
439
+ || !runtimeReferences.has(taskArg.imageReference as string))) {
440
+ errors.push('every runtime task image reference must exactly bind the adopted registry target');
441
+ }
442
+ }
443
+ }
444
+ return errors;
445
+ };