@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.
@@ -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
- greenfield: true;
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
- export interface IGreenfieldDeploymentOperation {
213
- id: string;
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 stringArrayValid = (
294
- valueArg: unknown,
295
- maxItemsArg: number,
296
- patternArg: RegExp,
297
- ): valueArg is string[] => Array.isArray(valueArg)
298
- && valueArg.length <= maxItemsArg
299
- && valueArg.every((itemArg) => typeof itemArg === 'string'
300
- && itemArg.length > 0
301
- && itemArg.length <= 253
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
- && (typeof requestArg.releaseTag !== 'string'
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
- if (requestArg.greenfield !== true) {
348
- 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
+ });
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: string): TSha256Digest | undefined => {
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';
@@ -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-route';
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
- export interface IServiceDeploymentGrant {
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
  }
@@ -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
  }