@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.
@@ -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
- greenfield: true;
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
- export interface IGreenfieldDeploymentOperation {
216
- id: string;
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 stringArrayValid = (
297
- valueArg: unknown,
298
- maxItemsArg: number,
299
- patternArg: RegExp,
300
- ): valueArg is string[] => Array.isArray(valueArg)
301
- && valueArg.length <= maxItemsArg
302
- && valueArg.every((itemArg) => typeof itemArg === 'string'
303
- && itemArg.length > 0
304
- && itemArg.length <= 253
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
- && (typeof requestArg.releaseTag !== 'string'
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
- if (requestArg.greenfield !== true) {
351
- blockers.push({ code: 'WOULD_REPLACE_EXISTING_STATE', message: 'Only greenfield deployment preflight is supported.' });
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: string): TSha256Digest | undefined => {
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';
@@ -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-route';
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
- export interface IServiceDeploymentGrant {
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
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IIdentity, TServiceDeploymentCapability } from '../data/user.js';
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: IIdentity;
25
+ identity: IIdentityCredential;
26
26
  userId: string;
27
- serviceId: string;
28
- capabilities: TServiceDeploymentCapability[];
27
+ grant: IServiceDeploymentGrant;
29
28
  };
30
29
  response: {
31
30
  userId: string;
32
- serviceId: string;
33
- capabilities: TServiceDeploymentCapability[];
31
+ grant: IServiceDeploymentGrant;
34
32
  tokenRolesNormalized: true;
35
33
  };
36
34
  }