@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.
- package/changelog.md +63 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/backup.d.ts +7 -1
- package/dist_ts/data/deploymentlegacyimport.d.ts +92 -0
- package/dist_ts/data/deploymentlegacyimport.js +237 -0
- package/dist_ts/data/deploymentoperation.d.ts +63 -38
- package/dist_ts/data/deploymentoperation.js +12 -2
- package/dist_ts/data/deploymentpreflight.d.ts +42 -10
- package/dist_ts/data/deploymentpreflight.js +47 -10
- package/dist_ts/data/immutableimage.d.ts +26 -2
- package/dist_ts/data/immutableimage.js +12 -2
- package/dist_ts/data/index.d.ts +5 -0
- package/dist_ts/data/index.js +6 -1
- package/dist_ts/data/runtimeassignmentexec.d.ts +110 -0
- package/dist_ts/data/runtimeassignmentexec.js +164 -0
- package/dist_ts/data/runtimeassignmentlogs.d.ts +121 -0
- package/dist_ts/data/runtimeassignmentlogs.js +139 -0
- package/dist_ts/data/runtimeassignmentstats.d.ts +60 -0
- package/dist_ts/data/runtimeassignmentstats.js +93 -0
- package/dist_ts/data/runtimelocalvolumearchive.d.ts +103 -0
- package/dist_ts/data/runtimelocalvolumearchive.js +111 -0
- package/dist_ts/data/user.d.ts +6 -1
- package/dist_ts/data/user.js +6 -1
- package/dist_ts/requests/runtimesession.d.ts +81 -0
- package/dist_ts/requests/service.d.ts +51 -0
- package/package.json +1 -1
- package/readme.md +105 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/backup.ts +7 -1
- package/ts/data/deploymentlegacyimport.ts +371 -0
- package/ts/data/deploymentoperation.ts +83 -39
- package/ts/data/deploymentpreflight.ts +86 -18
- package/ts/data/immutableimage.ts +42 -3
- package/ts/data/index.ts +5 -0
- package/ts/data/runtimeassignmentexec.ts +271 -0
- package/ts/data/runtimeassignmentlogs.ts +249 -0
- package/ts/data/runtimeassignmentstats.ts +149 -0
- package/ts/data/runtimelocalvolumearchive.ts +207 -0
- package/ts/data/user.ts +10 -0
- package/ts/requests/runtimesession.ts +102 -0
- 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.
|
package/ts/00_commitinfo_data.ts
CHANGED
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
|
-
|
|
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
|
+
};
|