@serve.zone/interfaces 32.35.0 → 32.36.1

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.
Files changed (41) hide show
  1. package/changelog.md +63 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/backup.d.ts +7 -1
  4. package/dist_ts/data/deploymentlegacyimport.d.ts +92 -0
  5. package/dist_ts/data/deploymentlegacyimport.js +237 -0
  6. package/dist_ts/data/deploymentoperation.d.ts +63 -38
  7. package/dist_ts/data/deploymentoperation.js +12 -2
  8. package/dist_ts/data/deploymentpreflight.d.ts +42 -10
  9. package/dist_ts/data/deploymentpreflight.js +47 -10
  10. package/dist_ts/data/immutableimage.d.ts +26 -2
  11. package/dist_ts/data/immutableimage.js +12 -2
  12. package/dist_ts/data/index.d.ts +5 -0
  13. package/dist_ts/data/index.js +6 -1
  14. package/dist_ts/data/runtimeassignmentexec.d.ts +110 -0
  15. package/dist_ts/data/runtimeassignmentexec.js +164 -0
  16. package/dist_ts/data/runtimeassignmentlogs.d.ts +121 -0
  17. package/dist_ts/data/runtimeassignmentlogs.js +139 -0
  18. package/dist_ts/data/runtimeassignmentstats.d.ts +60 -0
  19. package/dist_ts/data/runtimeassignmentstats.js +93 -0
  20. package/dist_ts/data/runtimelocalvolumearchive.d.ts +103 -0
  21. package/dist_ts/data/runtimelocalvolumearchive.js +111 -0
  22. package/dist_ts/data/user.d.ts +6 -1
  23. package/dist_ts/data/user.js +6 -1
  24. package/dist_ts/requests/runtimesession.d.ts +81 -0
  25. package/dist_ts/requests/service.d.ts +51 -0
  26. package/package.json +1 -1
  27. package/readme.md +105 -0
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/data/backup.ts +7 -1
  30. package/ts/data/deploymentlegacyimport.ts +371 -0
  31. package/ts/data/deploymentoperation.ts +83 -39
  32. package/ts/data/deploymentpreflight.ts +86 -18
  33. package/ts/data/immutableimage.ts +42 -3
  34. package/ts/data/index.ts +5 -0
  35. package/ts/data/runtimeassignmentexec.ts +271 -0
  36. package/ts/data/runtimeassignmentlogs.ts +249 -0
  37. package/ts/data/runtimeassignmentstats.ts +149 -0
  38. package/ts/data/runtimelocalvolumearchive.ts +207 -0
  39. package/ts/data/user.ts +10 -0
  40. package/ts/requests/runtimesession.ts +102 -0
  41. package/ts/requests/service.ts +66 -0
package/readme.md CHANGED
@@ -1227,6 +1227,104 @@ only the named Pallet attempt. They do not authorize storage relocation, prove
1227
1227
  whole-host or Docker/Coreflow absence, grant another controller or organization
1228
1228
  authority, or replace independently qualified readiness and routing evidence.
1229
1229
 
1230
+ ### Runtime Workload Logs, Stats, Exec And Volume Archives
1231
+
1232
+ These requests ride on a node's authenticated runtime session (`requests.runtimesession`). Every
1233
+ request carries `session`, and the receiver compares it with its own current session and with the
1234
+ connection the request arrived on: a serialized session is never authentication. Exec and volume
1235
+ archives belong to a controller on the node's own host alone (`controller.kind: 'onebox'`, which
1236
+ only a local controller binding carries), because the local socket's root-only permissions are
1237
+ what authenticate that controller.
1238
+
1239
+ **Logs.** `IReq_Pallet_Controller_ReportRuntimeAssignmentLogs` (`reportRuntimeAssignmentLogs`)
1240
+ pushes one `IRuntimeAssignmentLogBatch { assignmentId, attemptId, captureId, sequence, entries,
1241
+ final }` from the node to its controller. Logs are the controller's to keep
1242
+ (`IRuntimeConfig.logs`); the node's capture files are disposable transport artifacts, so the node
1243
+ pushes and the controller persists. The node discards a batch only after the controller
1244
+ acknowledged it, and sends the next one only then.
1245
+
1246
+ - `entries` are in capture order. A `line` entry `{ stream, timestampNs, text, partial }` is one
1247
+ piece of one line: `text` holds no `\n`, invalid UTF-8 becomes U+FFFD, and a line longer than
1248
+ one entry (`maximumEntryTextBytes`, 64 KiB) travels as pieces that state `partial: true` up to
1249
+ the last. The joined line is at most the config's `logs.maximumLineBytes`.
1250
+ - A `lost` entry `{ reason, stream, timestampNs, bytes, lines }` is evidence of output that did not
1251
+ arrive, placed where it was lost: `buffer-overflow` (the node's buffer reached
1252
+ `logs.maximumBufferedBytes` and it dropped the oldest bytes), `line-cut` (the rest of a line
1253
+ beyond `maximumLineBytes`, right after that line's last piece), `capture-restart` and
1254
+ `not-captured` (a container started without capture). The last two cannot know how much was
1255
+ lost and state `bytes` and `lines` as `null`; the others count exactly.
1256
+ - `captureId` names one capture of the attempt. `sequence` counts that capture's batches from 1.
1257
+ `final` states that the container is gone and its output drained; only a final batch may be
1258
+ empty. A report is at most 1 024 entries and one mebibyte of canonical JSON, and timestamps are
1259
+ nanoseconds since the Unix epoch as decimal digits.
1260
+
1261
+ The answer `{ status, assignmentId, captureId, sequence }` is `accepted`, or `replay` for a batch
1262
+ the controller already holds. The controller never refuses a batch for a gap in `sequence`: the
1263
+ node only discards what was acknowledged, so a gap is the controller's own loss, which it records.
1264
+ `bindReportRuntimeAssignmentLogsRequest(request, assignment, logPolicy, trustedSession)` checks the
1265
+ session, that the assignment is this node's and carries the batch's attempt, and every line's
1266
+ joined length against the policy. `bindReportRuntimeAssignmentLogsResponse(response, request)`
1267
+ binds the acknowledgement to the batch.
1268
+
1269
+ **Stats.** `IReq_Controller_Pallet_ReadRuntimeAssignmentStats` (`readRuntimeAssignmentStats`)
1270
+ reads one sample of one attempt: `{ session, assignment }` names the exact current revision, and
1271
+ the answer is `{ status: 'sampled', assignment, sample }` or `{ status: 'not-running', assignment,
1272
+ sample: null }`. An `IRuntimeAssignmentStatsSample` states `timestampNs`, the cumulative
1273
+ `cpuUsageCoreNanoseconds` (decimal digits: it outgrows a JSON number; utilisation comes from two
1274
+ samples), `memoryWorkingSetBytes`, `memoryLimitBytes` (the limit the node enforces, the config's
1275
+ `memoryBytes`), `network { receivedBytes, transmittedBytes }` of the sandbox, or `null`, and
1276
+ `writableLayerBytes`, or `null`. Any controller may read stats; a node answers one read per
1277
+ assignment at a time. `bindReadRuntimeAssignmentStatsRequest(request, assignment, trustedSession)`
1278
+ and `bindReadRuntimeAssignmentStatsResponse(response, request)` bind both sides.
1279
+
1280
+ **Exec.** `IReq_Onebox_Pallet_OpenRuntimeAssignmentExec` (`openRuntimeAssignmentExec`) runs one
1281
+ command in the container of a `run` assignment's running attempt. The request is `{ exec: { session,
1282
+ assignment, command, tty, terminalSize }, stdin }`: `command` is the argument vector the runtime
1283
+ executes directly (at most 256 arguments and 64 KiB, no NUL, a non-empty first element),
1284
+ `terminalSize { columns, rows }` is stated exactly for `tty`, and `stdin` is a sending
1285
+ VirtualStream or `null`, whose end is the process's end of input. The answer `{ exec: { execId,
1286
+ assignment, tty, idleTimeoutMs }, stdout, stderr }` returns receiving streams that outlive the
1287
+ request; a `tty` session's whole output is on `stdout` and its `stderr` is `null`.
1288
+ `resizeRuntimeAssignmentExec` changes a `tty` session's terminal size, and
1289
+ `closeRuntimeAssignmentExec` ends a session, releases its slot and answers how it ended: `exited`
1290
+ with `exitCode` (128 plus the signal number for a signalled process) or `terminated` with the
1291
+ `termination` that ended it first, `closed`, `idle-timeout` or `container-stopped`.
1292
+ `runtimeAssignmentExecContract` fixes the bounds: four sessions per node, counting ended ones not
1293
+ yet closed, and 15 minutes without a byte in either direction before the node ends one. A session
1294
+ also ends with the controller session it was opened under, and its `execId` is gone with it.
1295
+ `bindOpenRuntimeAssignmentExecRequest`, `bindResizeRuntimeAssignmentExecRequest` and
1296
+ `bindCloseRuntimeAssignmentExecRequest` take the node's trusted session and refuse any controller
1297
+ but a local one; the response binders match the answer to its request.
1298
+
1299
+ **Volume archives.** A local controller backs up and restores a node-local volume as one POSIX
1300
+ pax archive (`runtimeLocalVolumeArchiveContract`). The first entry is the volume root `./`, and
1301
+ entries are directories, regular files, symbolic links (stored, never followed), hard links to
1302
+ earlier entries and FIFOs, with numeric `uid`/`gid`, exact mode bits and modification times.
1303
+ Sockets are omitted; a device node is refused, and extended attributes and ACLs are not carried.
1304
+ An archive is a whole number of 512-byte blocks and at least 1 536 bytes. A node moves one archive
1305
+ per volume at a time.
1306
+
1307
+ - `readRuntimeLocalVolumeArchive` (`IReq_Onebox_Pallet_ReadRuntimeLocalVolumeArchive`):
1308
+ `{ session, claim }` names the node's current generation of a bound `directory` claim. The node
1309
+ refuses while a running container holds the volume. It reads a volume nobody holds, or one whose
1310
+ holder is stopped, which a node never restarts, and grants the volume to no container meanwhile.
1311
+ The answer `{ volume: { claim, format: 'pax', holder }, archive }` streams the archive. It
1312
+ carries no integrity, because the node learns the length and digest only while writing.
1313
+ - `writeRuntimeLocalVolumeArchive` (`IReq_Onebox_Pallet_WriteRuntimeLocalVolumeArchive`):
1314
+ `{ volume: { session, claim, format, byteLength, digest }, archive }` writes an archive as the
1315
+ bytes of an `import` claim that waits in `import-required`, the online form of the offline
1316
+ import. The stream's integrity must be `getRuntimeLocalVolumeArchiveIntegrity(volume)`. The node
1317
+ unpacks into a private staging tree and publishes it as the volume only after the whole archive
1318
+ verified. The tree keeps the archive's ownership, as every imported tree does. The answer
1319
+ `{ claim, status: 'receiving' }` permits sending; the stream's acceptance receipt confirms the
1320
+ volume is `ready`, and a rejected or aborted stream publishes nothing.
1321
+
1322
+ `bindReadRuntimeLocalVolumeArchiveRequest(request, claim, trustedSession)` and
1323
+ `bindWriteRuntimeLocalVolumeArchiveRequest` require a local controller's current session and the
1324
+ exact reference of a bound `directory` claim of its controller, cluster, node and runtime
1325
+ namespace, an `import` claim for a write. Whether the volume is `ready` or still
1326
+ `import-required`, and who holds it, are the node's ledger checks.
1327
+
1230
1328
  ### Cluster Runtime Phase
1231
1329
 
1232
1330
  `data.IClusterRuntime { id, phase, generation, changedAt, changedBy }` records which runtime owns a
@@ -2996,6 +3094,13 @@ The immutable deployment workflow is:
2996
3094
  4. Observe exact rollout and runtime-digest evidence with `getServiceDeploymentStatus`.
2997
3095
  5. For a greenfield service, expose and verify its public route with `promoteServiceDeploymentRoute` only after the immutable rollout succeeds.
2998
3096
 
3097
+ A legacy `deployOnPush` service without deployable source moves to a greenfield successor by a legacy import, which replaces step 2:
3098
+
3099
+ - While the legacy replicas still run — before its runtime is frozen — an administrator captures the evidence with `captureLegacyServiceRuntimeAttestation` (`requests.service.IRequest_Any_Cloudly_CaptureLegacyServiceRuntimeAttestation`). The stored `data.ILegacyServiceRuntimeAttestation` records every required replica healthy on one digest, the registry root the legacy tag names, and that root's platform manifests; `data.validateLegacyServiceRuntimeAttestation` states its rules. Incomplete evidence is refused, never stored, and there is no substitute once the runtime is stopped. `getLegacyServiceRuntimeAttestations` lists what Cloudly holds for a legacy service; both requests are for administrators only.
3100
+ - The greenfield reservation, and every preflight of it, names `legacyImport` (`data.ILegacyImageImportReference`: legacy service, attestation, asserted source root digest) instead of `sourceRevision`, `sourceDirty` and release evidence. Refusals are the blockers `LEGACY_IMPORT_ATTESTATION_MISSING`, `LEGACY_IMPORT_SOURCE_MISMATCH` and `LEGACY_IMPORT_PLATFORM_MISMATCH`.
3101
+ - Instead of the registry push, the deployer calls `importServiceDeploymentLegacyImage`: Cloudly imports the attested image from its own registry into the reserved tag — an index root as it is, a single platform manifest under a one-descriptor index of the manifest's own family — and records release evidence of source `cloudly-legacy-import` naming the legacy source. Steps 3 to 5 follow unchanged; `selectTrustedImageReleaseEvidence` accepts that evidence only with `evidenceSource: 'cloudly-legacy-import'`.
3102
+ - The reservation and the import need the capability `deployment:import-legacy`, which only an organization-service-slot grant can carry.
3103
+
2999
3104
  `data.IDeploymentRouteRequest.proxied` carries provider-specific DNS proxy intent as an optional boolean. New route declarations should set it explicitly; omission remains valid for persisted historical operations and legacy callers.
3000
3105
 
3001
3106
  `data.IServiceDeploymentOperation` is the durable revisioned compare-and-set fence for this workflow. Image promotion requires Cloudly-created trusted evidence that binds the operation, actor, repository, exact tag, root digest, and OCI index media type. The service request group also exposes deployment preflight, exact-digest rollback, retry, and cleanup contracts.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.35.0',
6
+ version: '32.36.1',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
package/ts/data/backup.ts CHANGED
@@ -134,8 +134,14 @@ export interface IArchiveRetentionPolicy {
134
134
 
135
135
  export interface IArchivePruneResult {
136
136
  removedSnapshots: number;
137
+ /** Packs holding no chunk of the committed index, removed whole. */
137
138
  removedPacks: number;
138
- rewrittenPacks: number;
139
+ /**
140
+ * Packs a prune rewrote to drop unreferenced chunks. Only Corestore up to 33.x (containerarchive
141
+ * 0.3.2) reports it; containerarchive 0.10 and Corestore 34.0.0 remove unreferenced packs whole
142
+ * and never rewrite one, so they omit it. Absent means no pack was rewritten.
143
+ */
144
+ rewrittenPacks?: number;
139
145
  freedBytes: number;
140
146
  dryRun: boolean;
141
147
  }
@@ -0,0 +1,371 @@
1
+ import type {
2
+ TImmutableContainerPlatform,
3
+ TOciManifestMediaType,
4
+ TSha256Digest,
5
+ } from './immutableimage.js';
6
+ import { normalizeSha256Digest } from './immutableimage.js';
7
+ import { hasExactKeys, isPlainRecord } from '../private/dataproperties.js';
8
+
9
+ /**
10
+ * Legacy image import: a greenfield deployment whose image is the one a legacy `deployOnPush`
11
+ * service already runs, instead of one built from a source revision.
12
+ *
13
+ * A legacy service without deployable source still has an image in Cloudly's registry and, until
14
+ * its runtime is frozen, healthy replicas running it. An administrator captures that fact once as
15
+ * a {@link ILegacyServiceRuntimeAttestation} while the replicas still run. A greenfield successor
16
+ * reservation names the attestation in `legacyImport` instead of a `sourceRevision`, and Cloudly
17
+ * then imports the attested image into the successor's own repository and release tag itself:
18
+ * the bytes never leave the registry and the release records that it was imported, not built.
19
+ *
20
+ * Deployment roots are indexes. An attested root that already is an index is imported as it is. A
21
+ * root that is one platform manifest is imported under an index whose only descriptor is that
22
+ * manifest, in the manifest's own family — a Docker manifest list around a Docker manifest, an OCI
23
+ * index around an OCI manifest — so the platform manifest stays byte-identical and its digest is
24
+ * still the one the legacy runtime ran. The attestation states everything that descriptor needs.
25
+ */
26
+
27
+ /** The media types a legacy image's per-platform manifest can carry. */
28
+ export type TLegacyImagePlatformManifestMediaType =
29
+ | 'application/vnd.oci.image.manifest.v1+json'
30
+ | 'application/vnd.docker.distribution.manifest.v2+json';
31
+
32
+ /** The index media type of the family a platform manifest belongs to. */
33
+ export type TLegacyImageImportIndexMediaType =
34
+ | 'application/vnd.oci.image.index.v1+json'
35
+ | 'application/vnd.docker.distribution.manifest.list.v2+json';
36
+
37
+ /** One platform manifest of a legacy image, as Cloudly read it from its own registry. */
38
+ export interface ILegacyImagePlatformManifest {
39
+ platform: TImmutableContainerPlatform;
40
+ manifestDigest: TSha256Digest;
41
+ manifestMediaType: TLegacyImagePlatformManifestMediaType;
42
+ /** Exact byte length of the platform manifest, which an index descriptor states. */
43
+ manifestSize: number;
44
+ imageConfigDigest: TSha256Digest;
45
+ }
46
+
47
+ /** The registry image a legacy service ran, under the mutable tag it was deployed from. */
48
+ export interface ILegacyServiceImageSource {
49
+ registryHost: string;
50
+ repository: string;
51
+ /** The mutable tag the legacy service was deployed from, `latest` included. */
52
+ tag: string;
53
+ /** Digest of the root the tag named: a single platform manifest or an index. */
54
+ rootDigest: TSha256Digest;
55
+ rootMediaType: TOciManifestMediaType;
56
+ /**
57
+ * The runnable Linux platforms the root carries, sorted by platform. A single-manifest root
58
+ * carries exactly one, whose manifest is the root itself.
59
+ */
60
+ platforms: ILegacyImagePlatformManifest[];
61
+ }
62
+
63
+ /** One healthy replica of the legacy service, observed running the attested digest. */
64
+ export interface ILegacyServiceRuntimeTaskAttestation {
65
+ deploymentId: string;
66
+ nodeName: string;
67
+ observedDigest: TSha256Digest;
68
+ }
69
+
70
+ /**
71
+ * Durable evidence, captured by an administrator while the legacy runtime still ran, that every
72
+ * required replica of a legacy service was healthy and ran exactly the registry root it names.
73
+ *
74
+ * Cloudly writes one and never changes it. A capture only succeeds on complete evidence, so a
75
+ * stored attestation is by construction one whose runtime digest is its source root digest.
76
+ */
77
+ export interface ILegacyServiceRuntimeAttestation {
78
+ id: string;
79
+ data: {
80
+ legacyServiceId: string;
81
+ organizationId: string;
82
+ source: ILegacyServiceImageSource;
83
+ runtime: {
84
+ requiredReplicaCount: number;
85
+ healthyReplicaCount: number;
86
+ runtimeDigest: TSha256Digest;
87
+ /** Sorted by deploymentId, one per required replica. */
88
+ tasks: ILegacyServiceRuntimeTaskAttestation[];
89
+ };
90
+ capturedByUserId: string;
91
+ capturedAt: number;
92
+ };
93
+ }
94
+
95
+ /**
96
+ * What a legacy-import reservation names in place of a source revision: the legacy service, the
97
+ * attestation Cloudly holds for it, and the root digest the deployer asserts that attestation
98
+ * carries.
99
+ */
100
+ export interface ILegacyImageImportReference {
101
+ legacyServiceId: string;
102
+ attestationId: string;
103
+ sourceRootDigest: TSha256Digest;
104
+ }
105
+
106
+ const identifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
107
+ /** An OCI tag, the mutable `latest` included: the source tag is whatever the legacy service ran. */
108
+ const ociTagRegex = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
109
+ const registryHostRegex = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::[0-9]{1,5})?$/;
110
+ const repositoryRegex = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/;
111
+ const maximumManifestSize = 4 * 1024 * 1024;
112
+ const maximumReplicaCount = 64;
113
+
114
+ const legacyImagePlatformManifestMediaTypes: readonly TLegacyImagePlatformManifestMediaType[] = [
115
+ 'application/vnd.oci.image.manifest.v1+json',
116
+ 'application/vnd.docker.distribution.manifest.v2+json',
117
+ ];
118
+ const legacyImageImportIndexMediaTypes: readonly TLegacyImageImportIndexMediaType[] = [
119
+ 'application/vnd.oci.image.index.v1+json',
120
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
121
+ ];
122
+ const legacyImagePlatforms: readonly TImmutableContainerPlatform[] = ['linux/amd64', 'linux/arm64'];
123
+
124
+ const isIdentifier = (valueArg: unknown): valueArg is string => (
125
+ typeof valueArg === 'string' && identifierRegex.test(valueArg)
126
+ );
127
+
128
+ const isCanonicalDigest = (valueArg: unknown): valueArg is TSha256Digest => (
129
+ typeof valueArg === 'string' && normalizeSha256Digest(valueArg) === valueArg
130
+ );
131
+
132
+ const isPositiveSafeInteger = (valueArg: unknown, maximumArg: number): valueArg is number => (
133
+ typeof valueArg === 'number'
134
+ && Number.isSafeInteger(valueArg)
135
+ && valueArg >= 1
136
+ && valueArg <= maximumArg
137
+ );
138
+
139
+ /** True when a media type names a single platform manifest rather than an index. */
140
+ export const isLegacyImagePlatformManifestMediaType = (
141
+ valueArg: unknown,
142
+ ): valueArg is TLegacyImagePlatformManifestMediaType => (
143
+ legacyImagePlatformManifestMediaTypes.some((mediaTypeArg) => mediaTypeArg === valueArg)
144
+ );
145
+
146
+ /** True when a media type names an index of either family. */
147
+ export const isLegacyImageImportIndexMediaType = (
148
+ valueArg: unknown,
149
+ ): valueArg is TLegacyImageImportIndexMediaType => (
150
+ legacyImageImportIndexMediaTypes.some((mediaTypeArg) => mediaTypeArg === valueArg)
151
+ );
152
+
153
+ export const validateLegacyImageImportReference = (referenceArg: unknown): string[] => {
154
+ if (!isPlainRecord(referenceArg)) return ['legacyImport must be an object'];
155
+ const errors: string[] = [];
156
+ if (!hasExactKeys(referenceArg, ['legacyServiceId', 'attestationId', 'sourceRootDigest'])) {
157
+ errors.push('legacyImport must use its exact schema');
158
+ }
159
+ if (!isIdentifier(referenceArg.legacyServiceId)) {
160
+ errors.push('legacyImport.legacyServiceId must be a bounded canonical identifier');
161
+ }
162
+ if (!isIdentifier(referenceArg.attestationId)) {
163
+ errors.push('legacyImport.attestationId must be a bounded canonical identifier');
164
+ }
165
+ if (!isCanonicalDigest(referenceArg.sourceRootDigest)) {
166
+ errors.push('legacyImport.sourceRootDigest must be a canonical sha256 digest');
167
+ }
168
+ return errors;
169
+ };
170
+
171
+ const validatePlatformManifest = (
172
+ manifestArg: unknown,
173
+ indexArg: number,
174
+ ): string[] => {
175
+ const label = `source.platforms[${indexArg}]`;
176
+ if (!isPlainRecord(manifestArg)) return [`${label} must be an object`];
177
+ const errors: string[] = [];
178
+ if (!hasExactKeys(manifestArg, [
179
+ 'platform',
180
+ 'manifestDigest',
181
+ 'manifestMediaType',
182
+ 'manifestSize',
183
+ 'imageConfigDigest',
184
+ ])) {
185
+ errors.push(`${label} must use its exact schema`);
186
+ }
187
+ if (!legacyImagePlatforms.some((platformArg) => platformArg === manifestArg.platform)) {
188
+ errors.push(`${label}.platform must be linux/amd64 or linux/arm64`);
189
+ }
190
+ if (!isCanonicalDigest(manifestArg.manifestDigest)) {
191
+ errors.push(`${label}.manifestDigest must be a canonical sha256 digest`);
192
+ }
193
+ if (!isLegacyImagePlatformManifestMediaType(manifestArg.manifestMediaType)) {
194
+ errors.push(`${label}.manifestMediaType must name an OCI or Docker image manifest`);
195
+ }
196
+ if (!isPositiveSafeInteger(manifestArg.manifestSize, maximumManifestSize)) {
197
+ errors.push(`${label}.manifestSize must be a positive bounded byte length`);
198
+ }
199
+ if (!isCanonicalDigest(manifestArg.imageConfigDigest)) {
200
+ errors.push(`${label}.imageConfigDigest must be a canonical sha256 digest`);
201
+ }
202
+ return errors;
203
+ };
204
+
205
+ const validateImageSource = (sourceArg: unknown): string[] => {
206
+ if (!isPlainRecord(sourceArg)) return ['source must be an object'];
207
+ const errors: string[] = [];
208
+ if (!hasExactKeys(sourceArg, [
209
+ 'registryHost',
210
+ 'repository',
211
+ 'tag',
212
+ 'rootDigest',
213
+ 'rootMediaType',
214
+ 'platforms',
215
+ ])) {
216
+ errors.push('source must use its exact schema');
217
+ }
218
+ if (typeof sourceArg.registryHost !== 'string' || !registryHostRegex.test(sourceArg.registryHost)) {
219
+ errors.push('source.registryHost must be a canonical registry host');
220
+ }
221
+ if (typeof sourceArg.repository !== 'string'
222
+ || sourceArg.repository.length > 255
223
+ || !repositoryRegex.test(sourceArg.repository)) {
224
+ errors.push('source.repository must be a canonical OCI repository name');
225
+ }
226
+ if (typeof sourceArg.tag !== 'string' || !ociTagRegex.test(sourceArg.tag)) {
227
+ errors.push('source.tag must be an OCI tag');
228
+ }
229
+ if (!isCanonicalDigest(sourceArg.rootDigest)) {
230
+ errors.push('source.rootDigest must be a canonical sha256 digest');
231
+ }
232
+ const rootIsManifest = isLegacyImagePlatformManifestMediaType(sourceArg.rootMediaType);
233
+ const rootIsIndex = isLegacyImageImportIndexMediaType(sourceArg.rootMediaType);
234
+ if (!rootIsManifest && !rootIsIndex) {
235
+ errors.push('source.rootMediaType must name an OCI or Docker manifest or index');
236
+ }
237
+ if (!Array.isArray(sourceArg.platforms)
238
+ || sourceArg.platforms.length < 1
239
+ || sourceArg.platforms.length > legacyImagePlatforms.length) {
240
+ errors.push('source.platforms must carry one or two runnable Linux platforms');
241
+ return errors;
242
+ }
243
+ const platforms = sourceArg.platforms as unknown[];
244
+ platforms.forEach((manifestArg, indexArg) => {
245
+ errors.push(...validatePlatformManifest(manifestArg, indexArg));
246
+ });
247
+ const platformNames = platforms.map((manifestArg) => (
248
+ isPlainRecord(manifestArg) ? manifestArg.platform : undefined
249
+ ));
250
+ const sortedPlatformNames = legacyImagePlatforms.filter((platformArg) => (
251
+ platformNames.includes(platformArg)
252
+ ));
253
+ if (JSON.stringify(platformNames) !== JSON.stringify(sortedPlatformNames)) {
254
+ errors.push('source.platforms must be unique and sorted by platform');
255
+ }
256
+ if (rootIsManifest) {
257
+ const onlyPlatform = platforms[0];
258
+ if (platforms.length !== 1
259
+ || !isPlainRecord(onlyPlatform)
260
+ || onlyPlatform.manifestDigest !== sourceArg.rootDigest
261
+ || onlyPlatform.manifestMediaType !== sourceArg.rootMediaType) {
262
+ errors.push('a single-manifest source root must be its own one platform manifest');
263
+ }
264
+ }
265
+ if (rootIsIndex && platforms.some((manifestArg) => (
266
+ isPlainRecord(manifestArg) && manifestArg.manifestDigest === sourceArg.rootDigest
267
+ ))) {
268
+ errors.push('an index source root cannot also be one of its platform manifests');
269
+ }
270
+ return errors;
271
+ };
272
+
273
+ const validateRuntime = (runtimeArg: unknown, rootDigestArg: unknown): string[] => {
274
+ if (!isPlainRecord(runtimeArg)) return ['runtime must be an object'];
275
+ const errors: string[] = [];
276
+ if (!hasExactKeys(runtimeArg, [
277
+ 'requiredReplicaCount',
278
+ 'healthyReplicaCount',
279
+ 'runtimeDigest',
280
+ 'tasks',
281
+ ])) {
282
+ errors.push('runtime must use its exact schema');
283
+ }
284
+ if (!isPositiveSafeInteger(runtimeArg.requiredReplicaCount, maximumReplicaCount)) {
285
+ errors.push('runtime.requiredReplicaCount must be a positive bounded replica count');
286
+ }
287
+ if (runtimeArg.healthyReplicaCount !== runtimeArg.requiredReplicaCount) {
288
+ errors.push('runtime.healthyReplicaCount must equal the required replica count');
289
+ }
290
+ if (!isCanonicalDigest(runtimeArg.runtimeDigest)) {
291
+ errors.push('runtime.runtimeDigest must be a canonical sha256 digest');
292
+ } else if (runtimeArg.runtimeDigest !== rootDigestArg) {
293
+ errors.push('runtime.runtimeDigest must be the attested source root digest');
294
+ }
295
+ if (!Array.isArray(runtimeArg.tasks) || runtimeArg.tasks.length !== runtimeArg.requiredReplicaCount) {
296
+ errors.push('runtime.tasks must hold exactly one task per required replica');
297
+ return errors;
298
+ }
299
+ const tasks = runtimeArg.tasks as unknown[];
300
+ tasks.forEach((taskArg, indexArg) => {
301
+ const label = `runtime.tasks[${indexArg}]`;
302
+ if (!isPlainRecord(taskArg)) {
303
+ errors.push(`${label} must be an object`);
304
+ return;
305
+ }
306
+ if (!hasExactKeys(taskArg, ['deploymentId', 'nodeName', 'observedDigest'])) {
307
+ errors.push(`${label} must use its exact schema`);
308
+ }
309
+ if (!isIdentifier(taskArg.deploymentId)) {
310
+ errors.push(`${label}.deploymentId must be a bounded canonical identifier`);
311
+ }
312
+ if (typeof taskArg.nodeName !== 'string' || !taskArg.nodeName || taskArg.nodeName.length > 253) {
313
+ errors.push(`${label}.nodeName must be a bounded non-empty name`);
314
+ }
315
+ if (taskArg.observedDigest !== runtimeArg.runtimeDigest) {
316
+ errors.push(`${label}.observedDigest must be the attested runtime digest`);
317
+ }
318
+ });
319
+ const deploymentIds = tasks.map((taskArg) => (
320
+ isPlainRecord(taskArg) ? taskArg.deploymentId : undefined
321
+ ));
322
+ const sortedDeploymentIds = deploymentIds
323
+ .filter((idArg): idArg is string => typeof idArg === 'string')
324
+ .sort();
325
+ if (new Set(deploymentIds).size !== deploymentIds.length
326
+ || JSON.stringify(deploymentIds) !== JSON.stringify(sortedDeploymentIds)) {
327
+ errors.push('runtime.tasks must be unique and sorted by deploymentId');
328
+ }
329
+ return errors;
330
+ };
331
+
332
+ export const validateLegacyServiceRuntimeAttestation = (attestationArg: unknown): string[] => {
333
+ if (!isPlainRecord(attestationArg)) return ['attestation must be an object'];
334
+ const errors: string[] = [];
335
+ if (!hasExactKeys(attestationArg, ['id', 'data'])) {
336
+ errors.push('attestation must use its exact schema');
337
+ }
338
+ if (!isIdentifier(attestationArg.id)) {
339
+ errors.push('id must be a bounded canonical identifier');
340
+ }
341
+ const data = attestationArg.data;
342
+ if (!isPlainRecord(data)) return [...errors, 'data must be an object'];
343
+ if (!hasExactKeys(data, [
344
+ 'legacyServiceId',
345
+ 'organizationId',
346
+ 'source',
347
+ 'runtime',
348
+ 'capturedByUserId',
349
+ 'capturedAt',
350
+ ])) {
351
+ errors.push('data must use its exact schema');
352
+ }
353
+ if (!isIdentifier(data.legacyServiceId)) {
354
+ errors.push('legacyServiceId must be a bounded canonical identifier');
355
+ }
356
+ if (!isIdentifier(data.organizationId)) {
357
+ errors.push('organizationId must be a bounded canonical identifier');
358
+ }
359
+ if (!isIdentifier(data.capturedByUserId)) {
360
+ errors.push('capturedByUserId must be a bounded canonical identifier');
361
+ }
362
+ if (!isPositiveSafeInteger(data.capturedAt, Number.MAX_SAFE_INTEGER)) {
363
+ errors.push('capturedAt must be a positive timestamp');
364
+ }
365
+ errors.push(...validateImageSource(data.source));
366
+ errors.push(...validateRuntime(
367
+ data.runtime,
368
+ isPlainRecord(data.source) ? data.source.rootDigest : undefined,
369
+ ));
370
+ return errors;
371
+ };