@serve.zone/interfaces 14.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +19 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/deploymentadoption.d.ts +95 -0
- package/dist_ts/data/deploymentadoption.js +303 -0
- package/dist_ts/data/deploymentoperation.d.ts +161 -0
- package/dist_ts/data/deploymentoperation.js +213 -0
- package/dist_ts/data/deploymentpreflight.d.ts +15 -16
- package/dist_ts/data/deploymentpreflight.js +75 -42
- package/dist_ts/data/immutableimage.d.ts +33 -1
- package/dist_ts/data/immutableimage.js +88 -1
- package/dist_ts/data/index.d.ts +2 -0
- package/dist_ts/data/index.js +3 -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 +59 -2
- package/dist_ts/requests/admin.d.ts +4 -6
- package/dist_ts/requests/service.d.ts +100 -5
- package/package.json +1 -1
- package/readme.md +14 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/deploymentadoption.ts +445 -0
- package/ts/data/deploymentoperation.ts +441 -0
- package/ts/data/deploymentpreflight.ts +125 -73
- package/ts/data/immutableimage.ts +118 -1
- package/ts/data/index.ts +2 -0
- package/ts/data/service.ts +3 -1
- package/ts/data/user.ts +91 -2
- package/ts/requests/admin.ts +4 -6
- package/ts/requests/service.ts +144 -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;
|
|
@@ -131,6 +157,10 @@ export interface IDeploymentReleaseEvidence {
|
|
|
131
157
|
export interface IDeploymentPreflightRequestData {
|
|
132
158
|
requestId: string;
|
|
133
159
|
serviceId: string;
|
|
160
|
+
organizationId?: string;
|
|
161
|
+
mode?: TDeploymentOperationMode;
|
|
162
|
+
phase?: TDeploymentOperationPhase;
|
|
163
|
+
operationId?: string;
|
|
134
164
|
sourceRevision: string;
|
|
135
165
|
sourceDirty: boolean;
|
|
136
166
|
version: string;
|
|
@@ -139,7 +169,9 @@ export interface IDeploymentPreflightRequestData {
|
|
|
139
169
|
ociIndexDigest?: string;
|
|
140
170
|
releaseTag?: string;
|
|
141
171
|
releaseEvidence?: IDeploymentReleaseEvidence;
|
|
142
|
-
|
|
172
|
+
routes?: IDeploymentRouteRequest[];
|
|
173
|
+
/** Legacy v14 discriminator. Equivalent to mode: 'greenfield'. */
|
|
174
|
+
greenfield?: true;
|
|
143
175
|
}
|
|
144
176
|
|
|
145
177
|
export interface IDeploymentPreflightReport {
|
|
@@ -148,6 +180,7 @@ export interface IDeploymentPreflightReport {
|
|
|
148
180
|
mutationPerformed: false;
|
|
149
181
|
generatedAt: number;
|
|
150
182
|
service: {
|
|
183
|
+
organizationId?: string;
|
|
151
184
|
serviceId: string;
|
|
152
185
|
serviceName: string;
|
|
153
186
|
workspaceIdentity: string;
|
|
@@ -155,6 +188,9 @@ export interface IDeploymentPreflightReport {
|
|
|
155
188
|
registryRepository: string;
|
|
156
189
|
deployOnPush: boolean;
|
|
157
190
|
};
|
|
191
|
+
mode?: TDeploymentOperationMode;
|
|
192
|
+
phase?: TDeploymentOperationPhase;
|
|
193
|
+
operation?: IServiceDeploymentOperation;
|
|
158
194
|
source: {
|
|
159
195
|
revision: string;
|
|
160
196
|
dirty: boolean;
|
|
@@ -212,30 +248,8 @@ export interface IGreenfieldDeploymentFence {
|
|
|
212
248
|
targetNamespace: string;
|
|
213
249
|
}
|
|
214
250
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
data: {
|
|
218
|
-
idempotencyKey: string;
|
|
219
|
-
requestDigest: TSha256Digest;
|
|
220
|
-
fence: IGreenfieldDeploymentFence;
|
|
221
|
-
candidateServiceId?: string;
|
|
222
|
-
state:
|
|
223
|
-
| 'reserved'
|
|
224
|
-
| 'provisioning'
|
|
225
|
-
| 'deploying'
|
|
226
|
-
| 'verifying'
|
|
227
|
-
| 'ready-for-promotion'
|
|
228
|
-
| 'promoting'
|
|
229
|
-
| 'promoted'
|
|
230
|
-
| 'rolling-back'
|
|
231
|
-
| 'rolled-back'
|
|
232
|
-
| 'failed';
|
|
233
|
-
routeGeneration?: number;
|
|
234
|
-
createdAt: number;
|
|
235
|
-
updatedAt: number;
|
|
236
|
-
failureCode?: string;
|
|
237
|
-
};
|
|
238
|
-
}
|
|
251
|
+
/** @deprecated Use IServiceDeploymentOperation. */
|
|
252
|
+
export type IGreenfieldDeploymentOperation = IServiceDeploymentOperation;
|
|
239
253
|
|
|
240
254
|
const fullGitRevisionRegex = /^[a-f0-9]{40}$/;
|
|
241
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-]+)*)?$/;
|
|
@@ -293,48 +307,15 @@ export const validateDeploymentPreflightRequest = (
|
|
|
293
307
|
const configuration = isRecord(requestArg.proposedConfiguration)
|
|
294
308
|
? requestArg.proposedConfiguration
|
|
295
309
|
: undefined;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
&& patternArg.test(itemArg))
|
|
306
|
-
&& new Set(valueArg).size === valueArg.length;
|
|
307
|
-
const capabilities = configuration?.requiredCapabilities;
|
|
308
|
-
const volumeMounts = configuration?.volumeMounts;
|
|
309
|
-
if (
|
|
310
|
-
!configuration
|
|
311
|
-
|| configuration.schemaVersion !== 1
|
|
312
|
-
|| typeof configuration.declarationComplete !== 'boolean'
|
|
313
|
-
|| typeof configuration.projectName !== 'string'
|
|
314
|
-
|| !configuration.projectName.trim()
|
|
315
|
-
|| configuration.projectName.length > 200
|
|
316
|
-
|| !stringArrayValid(configuration.targetPlatforms, 8, /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/)
|
|
317
|
-
|| !stringArrayValid(configuration.publicDomains, 32, /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/)
|
|
318
|
-
|| !stringArrayValid(configuration.environmentVariableNames, 256, /^[A-Z_][A-Z0-9_]*$/)
|
|
319
|
-
|| !stringArrayValid(configuration.secretReferenceIds, 64, /^[A-Za-z0-9][A-Za-z0-9:._-]*$/)
|
|
320
|
-
|| !Array.isArray(configuration.containerPorts)
|
|
321
|
-
|| configuration.containerPorts.length > 64
|
|
322
|
-
|| configuration.containerPorts.some((portArg) => !Number.isSafeInteger(portArg) || portArg < 1 || portArg > 65535)
|
|
323
|
-
|| new Set(configuration.containerPorts).size !== configuration.containerPorts.length
|
|
324
|
-
|| !Array.isArray(volumeMounts)
|
|
325
|
-
|| volumeMounts.length > 32
|
|
326
|
-
|| volumeMounts.some((mountArg) => !isRecord(mountArg)
|
|
327
|
-
|| typeof mountArg.mountPath !== 'string'
|
|
328
|
-
|| !/^\/[A-Za-z0-9._/-]{0,254}$/.test(mountArg.mountPath)
|
|
329
|
-
|| (mountArg.storageClass !== 'corestore' && mountArg.storageClass !== 'ephemeral')
|
|
330
|
-
|| (mountArg.capability !== undefined
|
|
331
|
-
&& mountArg.capability !== 'database'
|
|
332
|
-
&& mountArg.capability !== 'objectstorage'))
|
|
333
|
-
|| !Array.isArray(capabilities)
|
|
334
|
-
|| capabilities.length > 2
|
|
335
|
-
|| capabilities.some((capabilityArg) => capabilityArg !== 'database' && capabilityArg !== 'objectstorage')
|
|
336
|
-
|| new Set(capabilities).size !== capabilities.length
|
|
337
|
-
) {
|
|
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) {
|
|
338
319
|
blockers.push({ code: 'INVALID_REQUEST', message: 'The proposed deployment configuration is malformed.' });
|
|
339
320
|
}
|
|
340
321
|
if (requestArg.ociIndexDigest !== undefined
|
|
@@ -343,12 +324,83 @@ export const validateDeploymentPreflightRequest = (
|
|
|
343
324
|
blockers.push({ code: 'INVALID_REQUEST', message: 'OCI index digest is malformed.' });
|
|
344
325
|
}
|
|
345
326
|
if (requestArg.releaseTag !== undefined
|
|
346
|
-
&& (
|
|
347
|
-
|| !/^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/.test(requestArg.releaseTag))) {
|
|
327
|
+
&& !normalizeImmutableReleaseTag(requestArg.releaseTag)) {
|
|
348
328
|
blockers.push({ code: 'INVALID_REQUEST', message: 'Release tag is malformed.' });
|
|
349
329
|
}
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
});
|
|
352
404
|
}
|
|
353
405
|
return blockers;
|
|
354
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;
|
|
@@ -201,7 +305,8 @@ export interface IImageRolloutStatus {
|
|
|
201
305
|
|
|
202
306
|
const sha256DigestRegex = /^sha256:[a-f0-9]{64}$/;
|
|
203
307
|
|
|
204
|
-
export const normalizeSha256Digest = (digest:
|
|
308
|
+
export const normalizeSha256Digest = (digest: unknown): TSha256Digest | undefined => {
|
|
309
|
+
if (typeof digest !== 'string') return undefined;
|
|
205
310
|
const normalizedDigest = digest.trim().toLowerCase();
|
|
206
311
|
return sha256DigestRegex.test(normalizedDigest)
|
|
207
312
|
? (normalizedDigest as TSha256Digest)
|
|
@@ -248,6 +353,18 @@ export const validateImmutableImageDeploymentPlan = (
|
|
|
248
353
|
if (!Number.isSafeInteger(planArg.createdAt) || planArg.createdAt < 1) {
|
|
249
354
|
errors.push('createdAt must be a positive safe integer');
|
|
250
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
|
+
}
|
|
251
368
|
let expectedReference: string | undefined;
|
|
252
369
|
try {
|
|
253
370
|
expectedReference = buildDigestPinnedImageReference(
|
package/ts/data/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ 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 './deploymentadoption.js';
|
|
6
|
+
export * from './deploymentoperation.js';
|
|
5
7
|
export * from './deploymentpreflight.js';
|
|
6
8
|
export * from './migration.js';
|
|
7
9
|
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,109 @@ 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:adopt-existing'
|
|
14
|
+
| 'deployment:status'
|
|
9
15
|
| 'deployment:create-greenfield'
|
|
10
16
|
| 'deployment:promote-route'
|
|
11
|
-
| 'deployment:rollback-
|
|
17
|
+
| 'deployment:rollback-image'
|
|
18
|
+
| 'deployment:rollback-route'
|
|
19
|
+
| 'deployment:retry'
|
|
20
|
+
| 'deployment:cleanup';
|
|
12
21
|
|
|
13
22
|
/**
|
|
14
23
|
* Server-persisted authority for one logical service. These grants are never
|
|
15
24
|
* accepted from the identity payload supplied by a caller; Cloudly reloads
|
|
16
25
|
* the owning user from the verified JWT before evaluating them.
|
|
17
26
|
*/
|
|
18
|
-
|
|
27
|
+
interface IServiceDeploymentGrantBase {
|
|
28
|
+
organizationId: string;
|
|
19
29
|
serviceId: string;
|
|
20
30
|
capabilities: TServiceDeploymentCapability[];
|
|
21
31
|
}
|
|
22
32
|
|
|
33
|
+
/** Authority over one service that already exists and has the same owner. */
|
|
34
|
+
export interface IExistingServiceDeploymentGrant extends IServiceDeploymentGrantBase {
|
|
35
|
+
scope: 'service';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Authority to create one exact, not-yet-existing service ID in an organization. */
|
|
39
|
+
export interface IOrganizationServiceSlotDeploymentGrant extends IServiceDeploymentGrantBase {
|
|
40
|
+
scope: 'organization-service-slot';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type IServiceDeploymentGrant =
|
|
44
|
+
| IExistingServiceDeploymentGrant
|
|
45
|
+
| IOrganizationServiceSlotDeploymentGrant;
|
|
46
|
+
|
|
47
|
+
const serviceDeploymentCapabilities = new Set<TServiceDeploymentCapability>([
|
|
48
|
+
'deployment:preflight',
|
|
49
|
+
'deployment:reserve',
|
|
50
|
+
'deployment:registry-push',
|
|
51
|
+
'deployment:release-read',
|
|
52
|
+
'deployment:promote-image',
|
|
53
|
+
'deployment:adopt-existing',
|
|
54
|
+
'deployment:status',
|
|
55
|
+
'deployment:create-greenfield',
|
|
56
|
+
'deployment:promote-route',
|
|
57
|
+
'deployment:rollback-image',
|
|
58
|
+
'deployment:rollback-route',
|
|
59
|
+
'deployment:retry',
|
|
60
|
+
'deployment:cleanup',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const deploymentBoundaryIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
|
|
64
|
+
|
|
65
|
+
export const validateServiceDeploymentGrant = (
|
|
66
|
+
grantArg: unknown,
|
|
67
|
+
): string[] => {
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
if (!grantArg || typeof grantArg !== 'object' || Array.isArray(grantArg)) {
|
|
70
|
+
return ['grant must be an object'];
|
|
71
|
+
}
|
|
72
|
+
const grant = grantArg as Record<string, unknown>;
|
|
73
|
+
if (grant.scope !== 'service' && grant.scope !== 'organization-service-slot') {
|
|
74
|
+
errors.push('scope must identify an existing service or an exact future service slot');
|
|
75
|
+
}
|
|
76
|
+
if (typeof grant.organizationId !== 'string'
|
|
77
|
+
|| !deploymentBoundaryIdentifierRegex.test(grant.organizationId)) {
|
|
78
|
+
errors.push('organizationId must be a bounded canonical identifier');
|
|
79
|
+
}
|
|
80
|
+
if (typeof grant.serviceId !== 'string'
|
|
81
|
+
|| !deploymentBoundaryIdentifierRegex.test(grant.serviceId)) {
|
|
82
|
+
errors.push('serviceId must be a bounded canonical identifier');
|
|
83
|
+
}
|
|
84
|
+
const capabilities = Array.isArray(grant.capabilities)
|
|
85
|
+
? grant.capabilities
|
|
86
|
+
: undefined;
|
|
87
|
+
if (!capabilities
|
|
88
|
+
|| capabilities.length < 1
|
|
89
|
+
|| capabilities.length > serviceDeploymentCapabilities.size
|
|
90
|
+
|| new Set(capabilities).size !== capabilities.length
|
|
91
|
+
|| capabilities.some((capabilityArg) => (
|
|
92
|
+
typeof capabilityArg !== 'string'
|
|
93
|
+
|| !serviceDeploymentCapabilities.has(capabilityArg as TServiceDeploymentCapability)
|
|
94
|
+
))) {
|
|
95
|
+
errors.push('capabilities must be a non-empty unique set of supported deployment capabilities');
|
|
96
|
+
}
|
|
97
|
+
if (capabilities && grant.scope === 'service'
|
|
98
|
+
&& capabilities.includes('deployment:create-greenfield')) {
|
|
99
|
+
errors.push('an existing-service grant cannot authorize greenfield creation');
|
|
100
|
+
}
|
|
101
|
+
if (capabilities && grant.scope === 'organization-service-slot'
|
|
102
|
+
&& !capabilities.includes('deployment:create-greenfield')) {
|
|
103
|
+
errors.push('an organization-service-slot grant must authorize greenfield creation');
|
|
104
|
+
}
|
|
105
|
+
if (capabilities && grant.scope !== 'service'
|
|
106
|
+
&& capabilities.includes('deployment:adopt-existing')) {
|
|
107
|
+
errors.push('deployment:adopt-existing requires an existing-service grant');
|
|
108
|
+
}
|
|
109
|
+
return errors;
|
|
110
|
+
};
|
|
111
|
+
|
|
23
112
|
export interface IIdentityCredential {
|
|
24
113
|
jwt: string;
|
|
25
114
|
}
|
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
|
}
|