@serve.zone/interfaces 13.0.1 → 15.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 +17 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/deploymentoperation.d.ts +161 -0
- package/dist_ts/data/deploymentoperation.js +213 -0
- package/dist_ts/data/deploymentpreflight.d.ts +18 -16
- package/dist_ts/data/deploymentpreflight.js +75 -42
- package/dist_ts/data/immutableimage.d.ts +35 -1
- package/dist_ts/data/immutableimage.js +88 -1
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/service.d.ts +3 -1
- package/dist_ts/data/user.d.ts +14 -2
- package/dist_ts/data/user.js +54 -2
- package/dist_ts/requests/admin.d.ts +4 -6
- package/dist_ts/requests/service.d.ts +80 -5
- package/package.json +1 -1
- package/readme.md +14 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/deploymentoperation.ts +441 -0
- package/ts/data/deploymentpreflight.ts +128 -73
- package/ts/data/immutableimage.ts +120 -1
- package/ts/data/index.ts +1 -0
- package/ts/data/service.ts +3 -1
- package/ts/data/user.ts +85 -2
- package/ts/requests/admin.ts +4 -6
- package/ts/requests/service.ts +109 -5
|
@@ -2,14 +2,34 @@ import type {
|
|
|
2
2
|
TOciManifestMediaType,
|
|
3
3
|
TSha256Digest,
|
|
4
4
|
} from './immutableimage.js';
|
|
5
|
-
import { normalizeSha256Digest } from './immutableimage.js';
|
|
5
|
+
import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
|
|
6
6
|
import type { TServiceDeploymentCapability } from './user.js';
|
|
7
|
+
import type {
|
|
8
|
+
IDeploymentRouteRequest,
|
|
9
|
+
IServiceDeploymentOperation,
|
|
10
|
+
TDeploymentOperationMode,
|
|
11
|
+
TDeploymentOperationPhase,
|
|
12
|
+
} from './deploymentoperation.js';
|
|
13
|
+
import {
|
|
14
|
+
canonicalizeDeploymentHostname,
|
|
15
|
+
getDeploymentRouteVerificationHostname,
|
|
16
|
+
normalizeDeploymentReadinessPath,
|
|
17
|
+
validateDeploymentProposedConfiguration,
|
|
18
|
+
} from './deploymentoperation.js';
|
|
7
19
|
|
|
8
20
|
export type TDeploymentPreflightDecision = 'GO' | 'NO-GO';
|
|
9
21
|
|
|
10
22
|
export type TDeploymentPreflightBlockerCode =
|
|
11
23
|
| 'INVALID_REQUEST'
|
|
12
24
|
| 'AUTHORIZATION_MISSING'
|
|
25
|
+
| 'ORGANIZATION_SCOPE_MISSING'
|
|
26
|
+
| 'SERVICE_OWNERSHIP_MISMATCH'
|
|
27
|
+
| 'DEPLOYMENT_OPERATION_NOT_FOUND'
|
|
28
|
+
| 'DEPLOYMENT_OPERATION_CONFLICT'
|
|
29
|
+
| 'DEPLOY_ON_PUSH_MUST_BE_DISABLED'
|
|
30
|
+
| 'REGISTRY_AUTHENTICATION_UNAVAILABLE'
|
|
31
|
+
| 'REGISTRY_REPOSITORY_MISMATCH'
|
|
32
|
+
| 'REGISTRY_TAG_CONFLICT'
|
|
13
33
|
| 'SOURCE_REVISION_INVALID'
|
|
14
34
|
| 'SOURCE_WORKTREE_DIRTY'
|
|
15
35
|
| 'VERSION_NOT_RESERVED'
|
|
@@ -31,13 +51,19 @@ export type TDeploymentPreflightBlockerCode =
|
|
|
31
51
|
| 'IMMUTABLE_RELEASE_TAG_NOT_RESERVED'
|
|
32
52
|
| 'RELEASE_EVIDENCE_MISSING'
|
|
33
53
|
| 'RELEASE_EVIDENCE_INVALID'
|
|
54
|
+
| 'RELEASE_EVIDENCE_AMBIGUOUS'
|
|
34
55
|
| 'RELEASE_NOT_AUTHORIZED'
|
|
35
56
|
| 'GREENFIELD_EXECUTION_UNAVAILABLE'
|
|
36
57
|
| 'ROUTE_PROMOTION_FENCE_UNAVAILABLE'
|
|
37
58
|
| 'POST_DEPLOYMENT_VERIFICATION_UNAVAILABLE'
|
|
38
59
|
| 'WOULD_REPLACE_EXISTING_STATE'
|
|
39
60
|
| 'CORESTORE_INVENTORY_UNAVAILABLE'
|
|
40
|
-
| 'RUNTIME_ATTESTATION_MISMATCH'
|
|
61
|
+
| 'RUNTIME_ATTESTATION_MISMATCH'
|
|
62
|
+
| 'ROLLOUT_ACTIVE_CONFLICT'
|
|
63
|
+
| 'ROLLOUT_NOT_SUCCEEDED'
|
|
64
|
+
| 'ROUTE_UNAVAILABLE'
|
|
65
|
+
| 'ROUTE_VERIFICATION_FAILED'
|
|
66
|
+
| 'TLS_VERIFICATION_FAILED';
|
|
41
67
|
|
|
42
68
|
export interface IDeploymentPreflightBlocker {
|
|
43
69
|
code: TDeploymentPreflightBlockerCode;
|
|
@@ -64,6 +90,7 @@ export interface IDeploymentPreflightRuntimeTask {
|
|
|
64
90
|
status: string;
|
|
65
91
|
healthStatus?: string;
|
|
66
92
|
imageReference?: string;
|
|
93
|
+
observedDigest?: TSha256Digest;
|
|
67
94
|
reportedDigest?: TSha256Digest;
|
|
68
95
|
verificationStatus?: 'not-required' | 'pending' | 'verified' | 'missing' | 'mismatched';
|
|
69
96
|
}
|
|
@@ -130,6 +157,10 @@ export interface IDeploymentReleaseEvidence {
|
|
|
130
157
|
export interface IDeploymentPreflightRequestData {
|
|
131
158
|
requestId: string;
|
|
132
159
|
serviceId: string;
|
|
160
|
+
organizationId?: string;
|
|
161
|
+
mode?: TDeploymentOperationMode;
|
|
162
|
+
phase?: TDeploymentOperationPhase;
|
|
163
|
+
operationId?: string;
|
|
133
164
|
sourceRevision: string;
|
|
134
165
|
sourceDirty: boolean;
|
|
135
166
|
version: string;
|
|
@@ -138,7 +169,9 @@ export interface IDeploymentPreflightRequestData {
|
|
|
138
169
|
ociIndexDigest?: string;
|
|
139
170
|
releaseTag?: string;
|
|
140
171
|
releaseEvidence?: IDeploymentReleaseEvidence;
|
|
141
|
-
|
|
172
|
+
routes?: IDeploymentRouteRequest[];
|
|
173
|
+
/** Legacy v14 discriminator. Equivalent to mode: 'greenfield'. */
|
|
174
|
+
greenfield?: true;
|
|
142
175
|
}
|
|
143
176
|
|
|
144
177
|
export interface IDeploymentPreflightReport {
|
|
@@ -147,6 +180,7 @@ export interface IDeploymentPreflightReport {
|
|
|
147
180
|
mutationPerformed: false;
|
|
148
181
|
generatedAt: number;
|
|
149
182
|
service: {
|
|
183
|
+
organizationId?: string;
|
|
150
184
|
serviceId: string;
|
|
151
185
|
serviceName: string;
|
|
152
186
|
workspaceIdentity: string;
|
|
@@ -154,6 +188,9 @@ export interface IDeploymentPreflightReport {
|
|
|
154
188
|
registryRepository: string;
|
|
155
189
|
deployOnPush: boolean;
|
|
156
190
|
};
|
|
191
|
+
mode?: TDeploymentOperationMode;
|
|
192
|
+
phase?: TDeploymentOperationPhase;
|
|
193
|
+
operation?: IServiceDeploymentOperation;
|
|
157
194
|
source: {
|
|
158
195
|
revision: string;
|
|
159
196
|
dirty: boolean;
|
|
@@ -182,6 +219,8 @@ export interface IDeploymentPreflightReport {
|
|
|
182
219
|
observedReplicaCount: number;
|
|
183
220
|
healthyReplicaCount: number;
|
|
184
221
|
verifiedReplicaCount: number;
|
|
222
|
+
observedDigestReplicaCount: number;
|
|
223
|
+
observedDigestConsistent: boolean;
|
|
185
224
|
consistentDigest: boolean;
|
|
186
225
|
};
|
|
187
226
|
platformBindings: IDeploymentPreflightPlatformBinding[];
|
|
@@ -209,30 +248,8 @@ export interface IGreenfieldDeploymentFence {
|
|
|
209
248
|
targetNamespace: string;
|
|
210
249
|
}
|
|
211
250
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
data: {
|
|
215
|
-
idempotencyKey: string;
|
|
216
|
-
requestDigest: TSha256Digest;
|
|
217
|
-
fence: IGreenfieldDeploymentFence;
|
|
218
|
-
candidateServiceId?: string;
|
|
219
|
-
state:
|
|
220
|
-
| 'reserved'
|
|
221
|
-
| 'provisioning'
|
|
222
|
-
| 'deploying'
|
|
223
|
-
| 'verifying'
|
|
224
|
-
| 'ready-for-promotion'
|
|
225
|
-
| 'promoting'
|
|
226
|
-
| 'promoted'
|
|
227
|
-
| 'rolling-back'
|
|
228
|
-
| 'rolled-back'
|
|
229
|
-
| 'failed';
|
|
230
|
-
routeGeneration?: number;
|
|
231
|
-
createdAt: number;
|
|
232
|
-
updatedAt: number;
|
|
233
|
-
failureCode?: string;
|
|
234
|
-
};
|
|
235
|
-
}
|
|
251
|
+
/** @deprecated Use IServiceDeploymentOperation. */
|
|
252
|
+
export type IGreenfieldDeploymentOperation = IServiceDeploymentOperation;
|
|
236
253
|
|
|
237
254
|
const fullGitRevisionRegex = /^[a-f0-9]{40}$/;
|
|
238
255
|
const semanticVersionRegex = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
@@ -290,48 +307,15 @@ export const validateDeploymentPreflightRequest = (
|
|
|
290
307
|
const configuration = isRecord(requestArg.proposedConfiguration)
|
|
291
308
|
? requestArg.proposedConfiguration
|
|
292
309
|
: undefined;
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
&& patternArg.test(itemArg))
|
|
303
|
-
&& new Set(valueArg).size === valueArg.length;
|
|
304
|
-
const capabilities = configuration?.requiredCapabilities;
|
|
305
|
-
const volumeMounts = configuration?.volumeMounts;
|
|
306
|
-
if (
|
|
307
|
-
!configuration
|
|
308
|
-
|| configuration.schemaVersion !== 1
|
|
309
|
-
|| typeof configuration.declarationComplete !== 'boolean'
|
|
310
|
-
|| typeof configuration.projectName !== 'string'
|
|
311
|
-
|| !configuration.projectName.trim()
|
|
312
|
-
|| configuration.projectName.length > 200
|
|
313
|
-
|| !stringArrayValid(configuration.targetPlatforms, 8, /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/)
|
|
314
|
-
|| !stringArrayValid(configuration.publicDomains, 32, /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/)
|
|
315
|
-
|| !stringArrayValid(configuration.environmentVariableNames, 256, /^[A-Z_][A-Z0-9_]*$/)
|
|
316
|
-
|| !stringArrayValid(configuration.secretReferenceIds, 64, /^[A-Za-z0-9][A-Za-z0-9:._-]*$/)
|
|
317
|
-
|| !Array.isArray(configuration.containerPorts)
|
|
318
|
-
|| configuration.containerPorts.length > 64
|
|
319
|
-
|| configuration.containerPorts.some((portArg) => !Number.isSafeInteger(portArg) || portArg < 1 || portArg > 65535)
|
|
320
|
-
|| new Set(configuration.containerPorts).size !== configuration.containerPorts.length
|
|
321
|
-
|| !Array.isArray(volumeMounts)
|
|
322
|
-
|| volumeMounts.length > 32
|
|
323
|
-
|| volumeMounts.some((mountArg) => !isRecord(mountArg)
|
|
324
|
-
|| typeof mountArg.mountPath !== 'string'
|
|
325
|
-
|| !/^\/[A-Za-z0-9._/-]{0,254}$/.test(mountArg.mountPath)
|
|
326
|
-
|| (mountArg.storageClass !== 'corestore' && mountArg.storageClass !== 'ephemeral')
|
|
327
|
-
|| (mountArg.capability !== undefined
|
|
328
|
-
&& mountArg.capability !== 'database'
|
|
329
|
-
&& mountArg.capability !== 'objectstorage'))
|
|
330
|
-
|| !Array.isArray(capabilities)
|
|
331
|
-
|| capabilities.length > 2
|
|
332
|
-
|| capabilities.some((capabilityArg) => capabilityArg !== 'database' && capabilityArg !== 'objectstorage')
|
|
333
|
-
|| new Set(capabilities).size !== capabilities.length
|
|
334
|
-
) {
|
|
310
|
+
const configurationContainerPorts = Array.isArray(configuration?.containerPorts)
|
|
311
|
+
? configuration.containerPorts
|
|
312
|
+
: [];
|
|
313
|
+
const configurationPublicDomains = Array.isArray(configuration?.publicDomains)
|
|
314
|
+
? configuration.publicDomains.filter((domainArg): domainArg is string => (
|
|
315
|
+
typeof domainArg === 'string'
|
|
316
|
+
))
|
|
317
|
+
: [];
|
|
318
|
+
if (validateDeploymentProposedConfiguration(configuration).length > 0) {
|
|
335
319
|
blockers.push({ code: 'INVALID_REQUEST', message: 'The proposed deployment configuration is malformed.' });
|
|
336
320
|
}
|
|
337
321
|
if (requestArg.ociIndexDigest !== undefined
|
|
@@ -340,12 +324,83 @@ export const validateDeploymentPreflightRequest = (
|
|
|
340
324
|
blockers.push({ code: 'INVALID_REQUEST', message: 'OCI index digest is malformed.' });
|
|
341
325
|
}
|
|
342
326
|
if (requestArg.releaseTag !== undefined
|
|
343
|
-
&& (
|
|
344
|
-
|| !/^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/.test(requestArg.releaseTag))) {
|
|
327
|
+
&& !normalizeImmutableReleaseTag(requestArg.releaseTag)) {
|
|
345
328
|
blockers.push({ code: 'INVALID_REQUEST', message: 'Release tag is malformed.' });
|
|
346
329
|
}
|
|
347
|
-
|
|
348
|
-
|
|
330
|
+
const mode = requestArg.mode === undefined && requestArg.greenfield === true
|
|
331
|
+
? 'greenfield'
|
|
332
|
+
: requestArg.mode;
|
|
333
|
+
if (mode !== 'greenfield' && mode !== 'existing-service') {
|
|
334
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'A supported deployment mode is required.' });
|
|
335
|
+
}
|
|
336
|
+
if (requestArg.greenfield !== undefined
|
|
337
|
+
&& (requestArg.greenfield !== true || mode !== 'greenfield')) {
|
|
338
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'Legacy greenfield mode is contradictory.' });
|
|
339
|
+
}
|
|
340
|
+
if (requestArg.phase !== undefined
|
|
341
|
+
&& requestArg.phase !== 'reserve'
|
|
342
|
+
&& requestArg.phase !== 'promote'
|
|
343
|
+
&& requestArg.phase !== 'route') {
|
|
344
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment preflight phase is invalid.' });
|
|
345
|
+
}
|
|
346
|
+
if (requestArg.organizationId !== undefined
|
|
347
|
+
&& (typeof requestArg.organizationId !== 'string'
|
|
348
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(requestArg.organizationId))) {
|
|
349
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment organization identifier is malformed.' });
|
|
350
|
+
}
|
|
351
|
+
if (requestArg.operationId !== undefined
|
|
352
|
+
&& (typeof requestArg.operationId !== 'string'
|
|
353
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(requestArg.operationId))) {
|
|
354
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment operation identifier is malformed.' });
|
|
355
|
+
}
|
|
356
|
+
const routeHostnames = Array.isArray(requestArg.routes)
|
|
357
|
+
? requestArg.routes.map((routeArg) => (
|
|
358
|
+
isRecord(routeArg) && typeof routeArg.hostname === 'string'
|
|
359
|
+
? routeArg.hostname
|
|
360
|
+
: undefined
|
|
361
|
+
))
|
|
362
|
+
: [];
|
|
363
|
+
if (requestArg.routes !== undefined && (
|
|
364
|
+
!Array.isArray(requestArg.routes)
|
|
365
|
+
|| requestArg.routes.length > 32
|
|
366
|
+
|| new Set(routeHostnames).size !== routeHostnames.length
|
|
367
|
+
|| requestArg.routes.some((routeArg) => !isRecord(routeArg)
|
|
368
|
+
|| typeof routeArg.hostname !== 'string'
|
|
369
|
+
|| canonicalizeDeploymentHostname(routeArg.hostname) !== routeArg.hostname
|
|
370
|
+
|| !Number.isSafeInteger(routeArg.targetPort)
|
|
371
|
+
|| (typeof routeArg.targetPort === 'number'
|
|
372
|
+
&& (routeArg.targetPort < 1 || routeArg.targetPort > 65535))
|
|
373
|
+
|| !configurationContainerPorts.includes(routeArg.targetPort)
|
|
374
|
+
|| (routeArg.verificationHostname !== undefined
|
|
375
|
+
&& (typeof routeArg.verificationHostname !== 'string'
|
|
376
|
+
|| canonicalizeDeploymentHostname(routeArg.verificationHostname)
|
|
377
|
+
!== routeArg.verificationHostname))
|
|
378
|
+
|| getDeploymentRouteVerificationHostname(
|
|
379
|
+
routeArg as unknown as IDeploymentRouteRequest,
|
|
380
|
+
) === undefined
|
|
381
|
+
|| (routeArg.readinessPath !== undefined
|
|
382
|
+
&& (typeof routeArg.readinessPath !== 'string'
|
|
383
|
+
|| normalizeDeploymentReadinessPath(routeArg.readinessPath) !== routeArg.readinessPath))
|
|
384
|
+
|| (routeArg.expectedStatusCodes !== undefined
|
|
385
|
+
&& (!Array.isArray(routeArg.expectedStatusCodes)
|
|
386
|
+
|| routeArg.expectedStatusCodes.length < 1
|
|
387
|
+
|| routeArg.expectedStatusCodes.length > 16
|
|
388
|
+
|| new Set(routeArg.expectedStatusCodes).size !== routeArg.expectedStatusCodes.length
|
|
389
|
+
|| routeArg.expectedStatusCodes.some((statusArg) => (
|
|
390
|
+
!Number.isSafeInteger(statusArg) || statusArg < 200 || statusArg > 299
|
|
391
|
+
)))))
|
|
392
|
+
)) {
|
|
393
|
+
blockers.push({ code: 'INVALID_REQUEST', message: 'Deployment route declaration is malformed.' });
|
|
394
|
+
}
|
|
395
|
+
if (mode === 'greenfield' && Array.isArray(requestArg.routes)
|
|
396
|
+
&& JSON.stringify([...new Set(requestArg.routes.map((routeArg) => (
|
|
397
|
+
isRecord(routeArg) && typeof routeArg.hostname === 'string' ? routeArg.hostname : ''
|
|
398
|
+
)))].sort())
|
|
399
|
+
!== JSON.stringify([...new Set(configurationPublicDomains)].sort())) {
|
|
400
|
+
blockers.push({
|
|
401
|
+
code: 'INVALID_REQUEST',
|
|
402
|
+
message: 'Greenfield routes must exactly match proposed public domains.',
|
|
403
|
+
});
|
|
349
404
|
}
|
|
350
405
|
return blockers;
|
|
351
406
|
};
|
|
@@ -76,12 +76,114 @@ export interface IImageRelease {
|
|
|
76
76
|
firstObservedAt: number;
|
|
77
77
|
lastObservedAt: number;
|
|
78
78
|
observationCount: number;
|
|
79
|
+
/** Cloudly-created evidence from an authenticated exact-tag registry PUT. */
|
|
80
|
+
trustedEvidence: IImageReleaseTrustedEvidence[];
|
|
79
81
|
acceptedAt?: number;
|
|
80
82
|
lastPromotedAt?: number;
|
|
81
83
|
promotionCount?: number;
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
export interface IImageReleaseTrustedEvidence {
|
|
88
|
+
source: 'cloudly-registry';
|
|
89
|
+
/** Deterministic ID for an authenticated registry acceptance event/retry. */
|
|
90
|
+
evidenceId: string;
|
|
91
|
+
operationId: string;
|
|
92
|
+
actorUserId: string;
|
|
93
|
+
registryHost: string;
|
|
94
|
+
repository: string;
|
|
95
|
+
tag: string;
|
|
96
|
+
registryRootDigest: TSha256Digest;
|
|
97
|
+
manifestMediaType: TOciManifestMediaType;
|
|
98
|
+
recordedAt: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const selectTrustedImageReleaseEvidence = (argsArg: {
|
|
102
|
+
release: IImageRelease;
|
|
103
|
+
operationId: string;
|
|
104
|
+
actorUserId: string;
|
|
105
|
+
serviceId: string;
|
|
106
|
+
imageId: string;
|
|
107
|
+
registryHost: string;
|
|
108
|
+
repository: string;
|
|
109
|
+
tag: string;
|
|
110
|
+
digest: TSha256Digest;
|
|
111
|
+
}): { evidence?: IImageReleaseTrustedEvidence; errors: string[] } => {
|
|
112
|
+
const evidenceFingerprint = (evidenceArg: IImageReleaseTrustedEvidence) => [
|
|
113
|
+
evidenceArg.source,
|
|
114
|
+
evidenceArg.evidenceId,
|
|
115
|
+
evidenceArg.operationId,
|
|
116
|
+
evidenceArg.actorUserId,
|
|
117
|
+
evidenceArg.registryHost,
|
|
118
|
+
evidenceArg.repository,
|
|
119
|
+
evidenceArg.tag,
|
|
120
|
+
evidenceArg.registryRootDigest,
|
|
121
|
+
evidenceArg.manifestMediaType,
|
|
122
|
+
evidenceArg.recordedAt,
|
|
123
|
+
].join('\u0000');
|
|
124
|
+
const uniqueEvidence = new Map<string, IImageReleaseTrustedEvidence>();
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
for (const evidence of argsArg.release.data.trustedEvidence || []) {
|
|
127
|
+
const existing = uniqueEvidence.get(evidence.evidenceId);
|
|
128
|
+
if (existing && evidenceFingerprint(existing) !== evidenceFingerprint(evidence)) {
|
|
129
|
+
errors.push('trusted release evidence identity is contradictory');
|
|
130
|
+
} else {
|
|
131
|
+
uniqueEvidence.set(evidence.evidenceId, evidence);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const evidenceMatches = [...uniqueEvidence.values()].filter((evidenceArg) => (
|
|
135
|
+
evidenceArg.source === 'cloudly-registry'
|
|
136
|
+
&& evidenceArg.operationId === argsArg.operationId
|
|
137
|
+
&& evidenceArg.actorUserId === argsArg.actorUserId
|
|
138
|
+
&& evidenceArg.registryHost === argsArg.registryHost
|
|
139
|
+
&& evidenceArg.repository === argsArg.repository
|
|
140
|
+
&& evidenceArg.tag === argsArg.tag
|
|
141
|
+
&& evidenceArg.registryRootDigest === argsArg.digest
|
|
142
|
+
));
|
|
143
|
+
if (argsArg.release.data.serviceId !== argsArg.serviceId
|
|
144
|
+
|| argsArg.release.data.imageId !== argsArg.imageId
|
|
145
|
+
|| argsArg.release.data.registryHost !== argsArg.registryHost
|
|
146
|
+
|| argsArg.release.data.repository !== argsArg.repository
|
|
147
|
+
|| argsArg.release.data.registryRootDigest !== argsArg.digest) {
|
|
148
|
+
errors.push('release target or root digest does not match the deployment fence');
|
|
149
|
+
}
|
|
150
|
+
if (!argsArg.release.data.observedTags.includes(argsArg.tag)) {
|
|
151
|
+
errors.push('release has not observed the exact deployment tag');
|
|
152
|
+
}
|
|
153
|
+
if (evidenceMatches.length === 0) {
|
|
154
|
+
errors.push('trusted release evidence is missing');
|
|
155
|
+
} else if (evidenceMatches.length > 1) {
|
|
156
|
+
errors.push('trusted release evidence is ambiguous');
|
|
157
|
+
}
|
|
158
|
+
const evidence = evidenceMatches.length === 1 ? evidenceMatches[0] : undefined;
|
|
159
|
+
if (evidence && evidence.manifestMediaType !== argsArg.release.data.manifestMediaType) {
|
|
160
|
+
errors.push('trusted release and release root media types disagree');
|
|
161
|
+
}
|
|
162
|
+
if (![
|
|
163
|
+
'application/vnd.oci.image.index.v1+json',
|
|
164
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
165
|
+
].includes(argsArg.release.data.manifestMediaType)
|
|
166
|
+
|| (evidence && ![
|
|
167
|
+
'application/vnd.oci.image.index.v1+json',
|
|
168
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
169
|
+
].includes(evidence.manifestMediaType))) {
|
|
170
|
+
errors.push('trusted release evidence must identify an OCI image index');
|
|
171
|
+
}
|
|
172
|
+
return { evidence, errors };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const immutableReleaseTagRegex = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
176
|
+
|
|
177
|
+
export const normalizeImmutableReleaseTag = (tagArg: unknown): string | undefined => {
|
|
178
|
+
if (typeof tagArg !== 'string'
|
|
179
|
+
|| tagArg.trim() !== tagArg
|
|
180
|
+
|| !immutableReleaseTagRegex.test(tagArg)
|
|
181
|
+
|| tagArg.toLowerCase() === 'latest') {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return tagArg;
|
|
185
|
+
};
|
|
186
|
+
|
|
85
187
|
export interface IImmutableImageTargetScope {
|
|
86
188
|
targetClusterIds: string[];
|
|
87
189
|
targetNodeNames: string[];
|
|
@@ -95,6 +197,8 @@ export interface IImmutableImageDeploymentPlan {
|
|
|
95
197
|
rolloutId: string;
|
|
96
198
|
rolloutGeneration: number;
|
|
97
199
|
releaseId: string;
|
|
200
|
+
operationId: string;
|
|
201
|
+
releaseTag: string;
|
|
98
202
|
registryHost: string;
|
|
99
203
|
repository: string;
|
|
100
204
|
requestedDigest: TSha256Digest;
|
|
@@ -116,6 +220,8 @@ interface IRuntimeImageEvidenceBase {
|
|
|
116
220
|
taskReportedDigest?: TSha256Digest;
|
|
117
221
|
containerImageId?: string;
|
|
118
222
|
containerConfigImageReference?: string;
|
|
223
|
+
/** Digest observed from local Docker image state without implying rollout verification. */
|
|
224
|
+
observedDigest?: TSha256Digest;
|
|
119
225
|
resolvedDigest?: TSha256Digest;
|
|
120
226
|
platformManifestDigest?: TSha256Digest;
|
|
121
227
|
repoDigests?: string[];
|
|
@@ -199,7 +305,8 @@ export interface IImageRolloutStatus {
|
|
|
199
305
|
|
|
200
306
|
const sha256DigestRegex = /^sha256:[a-f0-9]{64}$/;
|
|
201
307
|
|
|
202
|
-
export const normalizeSha256Digest = (digest:
|
|
308
|
+
export const normalizeSha256Digest = (digest: unknown): TSha256Digest | undefined => {
|
|
309
|
+
if (typeof digest !== 'string') return undefined;
|
|
203
310
|
const normalizedDigest = digest.trim().toLowerCase();
|
|
204
311
|
return sha256DigestRegex.test(normalizedDigest)
|
|
205
312
|
? (normalizedDigest as TSha256Digest)
|
|
@@ -246,6 +353,18 @@ export const validateImmutableImageDeploymentPlan = (
|
|
|
246
353
|
if (!Number.isSafeInteger(planArg.createdAt) || planArg.createdAt < 1) {
|
|
247
354
|
errors.push('createdAt must be a positive safe integer');
|
|
248
355
|
}
|
|
356
|
+
if (!planArg.operationId || planArg.operationId.length > 200) {
|
|
357
|
+
errors.push('operationId must be a bounded non-empty identifier');
|
|
358
|
+
}
|
|
359
|
+
if (!normalizeImmutableReleaseTag(planArg.releaseTag)) {
|
|
360
|
+
errors.push('releaseTag must be a non-latest canonical OCI tag');
|
|
361
|
+
}
|
|
362
|
+
if (![
|
|
363
|
+
'application/vnd.oci.image.index.v1+json',
|
|
364
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
365
|
+
].includes(planArg.manifestMediaType)) {
|
|
366
|
+
errors.push('immutable rollout root must be an OCI image index');
|
|
367
|
+
}
|
|
249
368
|
let expectedReference: string | undefined;
|
|
250
369
|
try {
|
|
251
370
|
expectedReference = buildDigestPinnedImageReference(
|
package/ts/data/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './cloudlyconfig.js';
|
|
|
2
2
|
export * from './cluster.js';
|
|
3
3
|
export * from './config.js';
|
|
4
4
|
export * from './deployment.js';
|
|
5
|
+
export * from './deploymentoperation.js';
|
|
5
6
|
export * from './deploymentpreflight.js';
|
|
6
7
|
export * from './migration.js';
|
|
7
8
|
export * from './dns.js';
|
package/ts/data/service.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface IServiceReconciliationStatus {
|
|
|
68
68
|
export interface IService {
|
|
69
69
|
id: string;
|
|
70
70
|
data: {
|
|
71
|
+
/** Server-managed deployment ownership boundary. */
|
|
72
|
+
organizationId?: string;
|
|
71
73
|
name: string;
|
|
72
74
|
description: string;
|
|
73
75
|
imageId: string;
|
|
@@ -165,5 +167,5 @@ export interface IService {
|
|
|
165
167
|
/** Caller-writable service data. Immutable policy and rollout fields are server-managed. */
|
|
166
168
|
export type TServiceWritableData = Omit<
|
|
167
169
|
IService['data'],
|
|
168
|
-
'immutableImageRequired' | 'imageDeployment' | 'imageRolloutStatus'
|
|
170
|
+
'organizationId' | 'immutableImageRequired' | 'imageDeployment' | 'imageRolloutStatus'
|
|
169
171
|
>;
|
package/ts/data/user.ts
CHANGED
|
@@ -6,20 +6,103 @@ export interface IToken {
|
|
|
6
6
|
|
|
7
7
|
export type TServiceDeploymentCapability =
|
|
8
8
|
| 'deployment:preflight'
|
|
9
|
+
| 'deployment:reserve'
|
|
10
|
+
| 'deployment:registry-push'
|
|
11
|
+
| 'deployment:release-read'
|
|
12
|
+
| 'deployment:promote-image'
|
|
13
|
+
| 'deployment:status'
|
|
9
14
|
| 'deployment:create-greenfield'
|
|
10
15
|
| 'deployment:promote-route'
|
|
11
|
-
| 'deployment:rollback-
|
|
16
|
+
| 'deployment:rollback-image'
|
|
17
|
+
| 'deployment:rollback-route'
|
|
18
|
+
| 'deployment:retry'
|
|
19
|
+
| 'deployment:cleanup';
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
22
|
* Server-persisted authority for one logical service. These grants are never
|
|
15
23
|
* accepted from the identity payload supplied by a caller; Cloudly reloads
|
|
16
24
|
* the owning user from the verified JWT before evaluating them.
|
|
17
25
|
*/
|
|
18
|
-
|
|
26
|
+
interface IServiceDeploymentGrantBase {
|
|
27
|
+
organizationId: string;
|
|
19
28
|
serviceId: string;
|
|
20
29
|
capabilities: TServiceDeploymentCapability[];
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/** Authority over one service that already exists and has the same owner. */
|
|
33
|
+
export interface IExistingServiceDeploymentGrant extends IServiceDeploymentGrantBase {
|
|
34
|
+
scope: 'service';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Authority to create one exact, not-yet-existing service ID in an organization. */
|
|
38
|
+
export interface IOrganizationServiceSlotDeploymentGrant extends IServiceDeploymentGrantBase {
|
|
39
|
+
scope: 'organization-service-slot';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type IServiceDeploymentGrant =
|
|
43
|
+
| IExistingServiceDeploymentGrant
|
|
44
|
+
| IOrganizationServiceSlotDeploymentGrant;
|
|
45
|
+
|
|
46
|
+
const serviceDeploymentCapabilities = new Set<TServiceDeploymentCapability>([
|
|
47
|
+
'deployment:preflight',
|
|
48
|
+
'deployment:reserve',
|
|
49
|
+
'deployment:registry-push',
|
|
50
|
+
'deployment:release-read',
|
|
51
|
+
'deployment:promote-image',
|
|
52
|
+
'deployment:status',
|
|
53
|
+
'deployment:create-greenfield',
|
|
54
|
+
'deployment:promote-route',
|
|
55
|
+
'deployment:rollback-image',
|
|
56
|
+
'deployment:rollback-route',
|
|
57
|
+
'deployment:retry',
|
|
58
|
+
'deployment:cleanup',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const deploymentBoundaryIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
|
|
62
|
+
|
|
63
|
+
export const validateServiceDeploymentGrant = (
|
|
64
|
+
grantArg: unknown,
|
|
65
|
+
): string[] => {
|
|
66
|
+
const errors: string[] = [];
|
|
67
|
+
if (!grantArg || typeof grantArg !== 'object' || Array.isArray(grantArg)) {
|
|
68
|
+
return ['grant must be an object'];
|
|
69
|
+
}
|
|
70
|
+
const grant = grantArg as Record<string, unknown>;
|
|
71
|
+
if (grant.scope !== 'service' && grant.scope !== 'organization-service-slot') {
|
|
72
|
+
errors.push('scope must identify an existing service or an exact future service slot');
|
|
73
|
+
}
|
|
74
|
+
if (typeof grant.organizationId !== 'string'
|
|
75
|
+
|| !deploymentBoundaryIdentifierRegex.test(grant.organizationId)) {
|
|
76
|
+
errors.push('organizationId must be a bounded canonical identifier');
|
|
77
|
+
}
|
|
78
|
+
if (typeof grant.serviceId !== 'string'
|
|
79
|
+
|| !deploymentBoundaryIdentifierRegex.test(grant.serviceId)) {
|
|
80
|
+
errors.push('serviceId must be a bounded canonical identifier');
|
|
81
|
+
}
|
|
82
|
+
const capabilities = Array.isArray(grant.capabilities)
|
|
83
|
+
? grant.capabilities
|
|
84
|
+
: undefined;
|
|
85
|
+
if (!capabilities
|
|
86
|
+
|| capabilities.length < 1
|
|
87
|
+
|| capabilities.length > serviceDeploymentCapabilities.size
|
|
88
|
+
|| new Set(capabilities).size !== capabilities.length
|
|
89
|
+
|| capabilities.some((capabilityArg) => (
|
|
90
|
+
typeof capabilityArg !== 'string'
|
|
91
|
+
|| !serviceDeploymentCapabilities.has(capabilityArg as TServiceDeploymentCapability)
|
|
92
|
+
))) {
|
|
93
|
+
errors.push('capabilities must be a non-empty unique set of supported deployment capabilities');
|
|
94
|
+
}
|
|
95
|
+
if (capabilities && grant.scope === 'service'
|
|
96
|
+
&& capabilities.includes('deployment:create-greenfield')) {
|
|
97
|
+
errors.push('an existing-service grant cannot authorize greenfield creation');
|
|
98
|
+
}
|
|
99
|
+
if (capabilities && grant.scope === 'organization-service-slot'
|
|
100
|
+
&& !capabilities.includes('deployment:create-greenfield')) {
|
|
101
|
+
errors.push('an organization-service-slot grant must authorize greenfield creation');
|
|
102
|
+
}
|
|
103
|
+
return errors;
|
|
104
|
+
};
|
|
105
|
+
|
|
23
106
|
export interface IIdentityCredential {
|
|
24
107
|
jwt: string;
|
|
25
108
|
}
|
package/ts/requests/admin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import type { IIdentity,
|
|
2
|
+
import type { IIdentity, IIdentityCredential, IServiceDeploymentGrant } from '../data/user.js';
|
|
3
3
|
|
|
4
4
|
export interface IReq_Admin_LoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
|
5
5
|
plugins.typedrequestInterfaces.ITypedRequest,
|
|
@@ -22,15 +22,13 @@ extends plugins.typedrequestInterfaces.implementsTR<
|
|
|
22
22
|
> {
|
|
23
23
|
method: 'configureServiceDeploymentMachineUser';
|
|
24
24
|
request: {
|
|
25
|
-
identity:
|
|
25
|
+
identity: IIdentityCredential;
|
|
26
26
|
userId: string;
|
|
27
|
-
|
|
28
|
-
capabilities: TServiceDeploymentCapability[];
|
|
27
|
+
grant: IServiceDeploymentGrant;
|
|
29
28
|
};
|
|
30
29
|
response: {
|
|
31
30
|
userId: string;
|
|
32
|
-
|
|
33
|
-
capabilities: TServiceDeploymentCapability[];
|
|
31
|
+
grant: IServiceDeploymentGrant;
|
|
34
32
|
tokenRolesNormalized: true;
|
|
35
33
|
};
|
|
36
34
|
}
|