@serve.zone/interfaces 32.32.0 → 32.34.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 +38 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/corestorelegacyadoption.d.ts +47 -16
- package/dist_ts/data/corestorelegacyadoption.js +85 -21
- package/dist_ts/data/deploymentmachineuser.d.ts +94 -0
- package/dist_ts/data/deploymentmachineuser.js +121 -0
- package/dist_ts/data/deploymentpreflight.d.ts +1 -1
- package/dist_ts/data/deploymentpreflight.js +7 -1
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/platform/types.d.ts +7 -3
- package/dist_ts/requests/admin.d.ts +35 -0
- package/dist_ts/requests/corestore.d.ts +6 -2
- package/dist_ts/requests/service.d.ts +8 -0
- package/package.json +1 -1
- package/readme.md +41 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/corestorelegacyadoption.ts +132 -29
- package/ts/data/deploymentmachineuser.ts +174 -0
- package/ts/data/deploymentpreflight.ts +6 -0
- package/ts/data/index.ts +1 -0
- package/ts/platform/types.ts +7 -3
- package/ts/requests/admin.ts +37 -0
- package/ts/requests/corestore.ts +6 -2
- package/ts/requests/service.ts +8 -0
|
@@ -29,6 +29,12 @@ import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
|
29
29
|
* answers the plan (`ICorestoreLegacyResourceAdoptionPlanEntry[]`).
|
|
30
30
|
* 3. The operator stops Corestore and runs its `legacy-resource-adoption` migration with that plan.
|
|
31
31
|
* Until then Corestore refuses the binding, which reports `legacy-adoption-pending`.
|
|
32
|
+
*
|
|
33
|
+
* A resource is adopted by a binding of the service that owns it, or, when the selection names a
|
|
34
|
+
* `successorServiceId`, by a binding of that successor: a fresh service of the same organization
|
|
35
|
+
* that replaces the legacy one and takes over its data. The successor's binding records the legacy
|
|
36
|
+
* owner as `corestoreLegacyAdoption.legacyServiceId`, and the plan entry carries the successor, so
|
|
37
|
+
* Corestore moves the resource from the legacy owner's manifest entry to the successor's binding.
|
|
32
38
|
*/
|
|
33
39
|
|
|
34
40
|
export type TCorestoreLegacyCapability = 'database' | 'objectstorage';
|
|
@@ -100,21 +106,30 @@ export type TClusterCorestoreLegacyResourceInventory =
|
|
|
100
106
|
errorCode: 'CORESTORE_ENDPOINT_UNKNOWN' | 'CORESTORE_UNREACHABLE';
|
|
101
107
|
};
|
|
102
108
|
|
|
103
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* One resource an administrator selects for adoption: the legacy resource `serviceId` owns of
|
|
111
|
+
* `capability`. Without `successorServiceId` a binding of `serviceId` adopts it; with it, a binding of
|
|
112
|
+
* that other service does, which must be a greenfield service of the same organization.
|
|
113
|
+
*/
|
|
104
114
|
export interface ICorestoreLegacyResourceSelection {
|
|
105
115
|
serviceId: string;
|
|
106
116
|
capability: TCorestoreLegacyCapability;
|
|
117
|
+
/** The service whose binding adopts the resource, when it is not `serviceId`; never `serviceId`. */
|
|
118
|
+
successorServiceId?: string;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
/**
|
|
110
122
|
* One entry of Corestore's offline adoption plan, exactly the object its
|
|
111
|
-
* `legacy-resource-adoption` migration reads: the service
|
|
112
|
-
*
|
|
123
|
+
* `legacy-resource-adoption` migration reads: the service that owns the legacy resource, the
|
|
124
|
+
* capability, the binding that may adopt it and, when that binding belongs to another service, that
|
|
125
|
+
* successor service. The legacy identity is Corestore's own and is not restated here.
|
|
113
126
|
*/
|
|
114
127
|
export interface ICorestoreLegacyResourceAdoptionPlanEntry {
|
|
115
128
|
serviceId: string;
|
|
116
129
|
capability: TCorestoreLegacyCapability;
|
|
117
130
|
bindingId: string;
|
|
131
|
+
/** The service `bindingId` belongs to, when it is not `serviceId`; never `serviceId`. */
|
|
132
|
+
successorServiceId?: string;
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
/**
|
|
@@ -126,9 +141,17 @@ export interface ICorestoreLegacyResourceAdoptionPlanEntry {
|
|
|
126
141
|
* - `legacy-resource-blocked`: a selected resource is unadopted with a blocker;
|
|
127
142
|
* - `legacy-resource-authorized-elsewhere`: a selected resource is authorized for a binding Cloudly
|
|
128
143
|
* does not hold as that resource's adoption;
|
|
129
|
-
* - `
|
|
130
|
-
*
|
|
131
|
-
*
|
|
144
|
+
* - `legacy-resource-adopted-elsewhere`: Cloudly already holds a binding of another service as a
|
|
145
|
+
* selected resource's adoption;
|
|
146
|
+
* - `service-absent`: Cloudly has no such service, legacy owner or successor;
|
|
147
|
+
* - `binding-exists`: the adopting service already has a binding of that capability that adopts
|
|
148
|
+
* nothing, or adopts another resource;
|
|
149
|
+
* - `successor-organization-mismatch`: the successor belongs to another organization than the
|
|
150
|
+
* legacy owner, or either names none;
|
|
151
|
+
* - `successor-not-greenfield`: the successor is not an immutable-deployment service (it deploys on
|
|
152
|
+
* push, or does not require an immutable image);
|
|
153
|
+
* - `successor-owns-legacy-resource`: the successor owns a legacy resource of that capability
|
|
154
|
+
* itself, which Corestore would keep beside the adopted one.
|
|
132
155
|
*/
|
|
133
156
|
export type TCorestoreLegacyAdoptionRefusal =
|
|
134
157
|
| 'legacy-inventory-unreachable'
|
|
@@ -136,8 +159,12 @@ export type TCorestoreLegacyAdoptionRefusal =
|
|
|
136
159
|
| 'legacy-resource-absent'
|
|
137
160
|
| 'legacy-resource-blocked'
|
|
138
161
|
| 'legacy-resource-authorized-elsewhere'
|
|
162
|
+
| 'legacy-resource-adopted-elsewhere'
|
|
139
163
|
| 'service-absent'
|
|
140
|
-
| 'binding-exists'
|
|
164
|
+
| 'binding-exists'
|
|
165
|
+
| 'successor-organization-mismatch'
|
|
166
|
+
| 'successor-not-greenfield'
|
|
167
|
+
| 'successor-owns-legacy-resource';
|
|
141
168
|
|
|
142
169
|
/** The named refusal of `adoptCorestoreLegacyResources`, sent as `TypedResponseError.errorData`. */
|
|
143
170
|
export interface ICorestoreLegacyAdoptionErrorData {
|
|
@@ -327,7 +354,29 @@ export const computeClusterCorestoreLegacyResourceInventorySha256 = async (
|
|
|
327
354
|
const selectionKey = (entryArg: { serviceId: string; capability: string }): string =>
|
|
328
355
|
`${entryArg.serviceId}\u0000${entryArg.capability}`;
|
|
329
356
|
|
|
330
|
-
/**
|
|
357
|
+
/** The service capability whose binding adopts the entry's resource: the successor's, or the owner's. */
|
|
358
|
+
const adoptingKey = (entryArg: { serviceId: string; capability: string; successorServiceId?: string }): string =>
|
|
359
|
+
selectionKey({ serviceId: entryArg.successorServiceId ?? entryArg.serviceId, capability: entryArg.capability });
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Whether an entry has exactly `baseKeysArg`, and `successorServiceId` at most: `shape` when not,
|
|
363
|
+
* `successor` when its successor is not a canonical identifier other than its `serviceId`.
|
|
364
|
+
*/
|
|
365
|
+
const successorShape = (
|
|
366
|
+
entryArg: Record<string, unknown>,
|
|
367
|
+
baseKeysArg: readonly string[],
|
|
368
|
+
): 'exact' | 'shape' | 'successor' => {
|
|
369
|
+
if (!hasExactKeys(entryArg, baseKeysArg, ['successorServiceId'])) return 'shape';
|
|
370
|
+
return !Object.keys(entryArg).includes('successorServiceId')
|
|
371
|
+
|| (isIdentifier(entryArg.successorServiceId) && entryArg.successorServiceId !== entryArg.serviceId)
|
|
372
|
+
? 'exact'
|
|
373
|
+
: 'successor';
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Validates an administrator's selection: 1..4096 exact entries, sorted by service then capability,
|
|
378
|
+
* and no service capability adopting twice, whether as legacy owner or as successor.
|
|
379
|
+
*/
|
|
331
380
|
export const validateCorestoreLegacyResourceSelections = (
|
|
332
381
|
valueArg: unknown,
|
|
333
382
|
pathArg = 'corestore legacy resource selection',
|
|
@@ -338,24 +387,36 @@ export const validateCorestoreLegacyResourceSelections = (
|
|
|
338
387
|
}
|
|
339
388
|
const errors: string[] = [];
|
|
340
389
|
let previous: string | undefined;
|
|
390
|
+
const adopting = new Set<string>();
|
|
341
391
|
for (const [index, entry] of valueArg.entries()) {
|
|
342
|
-
|
|
343
|
-
|
|
392
|
+
const shape = isPlainRecord(entry) ? successorShape(entry, ['serviceId', 'capability']) : 'shape';
|
|
393
|
+
if (shape === 'shape' || !isIdentifier((entry as Record<string, unknown>).serviceId)
|
|
394
|
+
|| !isCapability((entry as Record<string, unknown>).capability)) {
|
|
344
395
|
errors.push(`${pathArg}[${index}] must be an exact { serviceId, capability }`);
|
|
345
396
|
continue;
|
|
346
397
|
}
|
|
347
|
-
|
|
398
|
+
if (shape === 'successor') {
|
|
399
|
+
errors.push(`${pathArg}[${index}] successorServiceId must name another service`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const selection = entry as ICorestoreLegacyResourceSelection;
|
|
403
|
+
const key = selectionKey(selection);
|
|
348
404
|
if (previous !== undefined && compareWireTokens(previous, key) >= 0) {
|
|
349
405
|
errors.push(`${pathArg} must be sorted by serviceId then capability without repeats`);
|
|
350
406
|
}
|
|
351
407
|
previous = key;
|
|
408
|
+
if (adopting.has(adoptingKey(selection))) {
|
|
409
|
+
errors.push(`${pathArg}[${index}] adopts into a service capability another entry adopts into`);
|
|
410
|
+
}
|
|
411
|
+
adopting.add(adoptingKey(selection));
|
|
352
412
|
}
|
|
353
413
|
return errors;
|
|
354
414
|
};
|
|
355
415
|
|
|
356
416
|
/**
|
|
357
417
|
* Validates an adoption plan by the rules Corestore's migration applies: a non-empty array of at
|
|
358
|
-
* most 4096 exact `{ serviceId, capability, bindingId }` entries,
|
|
418
|
+
* most 4096 exact `{ serviceId, capability, bindingId }` entries, each with `successorServiceId`
|
|
419
|
+
* when it names another service, canonical identifiers, and no legacy service capability, adopting
|
|
359
420
|
* service capability or binding id twice. Order is free.
|
|
360
421
|
*/
|
|
361
422
|
export const validateCorestoreLegacyResourceAdoptionPlan = (
|
|
@@ -368,19 +429,30 @@ export const validateCorestoreLegacyResourceAdoptionPlan = (
|
|
|
368
429
|
}
|
|
369
430
|
const errors: string[] = [];
|
|
370
431
|
const serviceCapabilities = new Set<string>();
|
|
432
|
+
const adoptingCapabilities = new Set<string>();
|
|
371
433
|
const bindingIds = new Set<string>();
|
|
372
434
|
for (const [index, entry] of valueArg.entries()) {
|
|
373
|
-
|
|
374
|
-
|
|
435
|
+
const shape = isPlainRecord(entry) ? successorShape(entry, ['serviceId', 'capability', 'bindingId']) : 'shape';
|
|
436
|
+
const record = entry as Record<string, unknown>;
|
|
437
|
+
if (shape === 'shape' || !isIdentifier(record.serviceId) || !isIdentifier(record.bindingId)
|
|
438
|
+
|| !isCapability(record.capability)) {
|
|
375
439
|
errors.push(`${pathArg} entry ${index} must be an exact { serviceId, capability, bindingId }`);
|
|
376
440
|
continue;
|
|
377
441
|
}
|
|
378
|
-
|
|
379
|
-
|
|
442
|
+
if (shape === 'successor') {
|
|
443
|
+
errors.push(`${pathArg} entry ${index} successorServiceId must name another service`);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const planEntry = entry as ICorestoreLegacyResourceAdoptionPlanEntry;
|
|
447
|
+
const serviceCapability = selectionKey(planEntry);
|
|
448
|
+
const adoptingCapability = adoptingKey(planEntry);
|
|
449
|
+
if (serviceCapabilities.has(serviceCapability) || adoptingCapabilities.has(adoptingCapability)
|
|
450
|
+
|| bindingIds.has(planEntry.bindingId)) {
|
|
380
451
|
errors.push(`${pathArg} entry ${index} repeats a service capability or binding id`);
|
|
381
452
|
}
|
|
382
453
|
serviceCapabilities.add(serviceCapability);
|
|
383
|
-
|
|
454
|
+
adoptingCapabilities.add(adoptingCapability);
|
|
455
|
+
bindingIds.add(planEntry.bindingId);
|
|
384
456
|
}
|
|
385
457
|
return errors;
|
|
386
458
|
};
|
|
@@ -392,16 +464,27 @@ export interface ICorestoreLegacyAdoptionBindingView {
|
|
|
392
464
|
capability: string;
|
|
393
465
|
objectstorageBucketName?: string;
|
|
394
466
|
objectstorageRetention?: { intent: IObjectStorageRetentionIntent };
|
|
395
|
-
corestoreLegacyAdoption?: {
|
|
467
|
+
corestoreLegacyAdoption?: {
|
|
468
|
+
nodeId: string;
|
|
469
|
+
resourceName: string;
|
|
470
|
+
inventorySha256: string;
|
|
471
|
+
legacyServiceId?: string;
|
|
472
|
+
};
|
|
396
473
|
}
|
|
397
474
|
|
|
475
|
+
/** The service that owns the legacy resource a binding adopts: its `legacyServiceId`, else its own. */
|
|
476
|
+
const legacyOwnerOf = (bindingArg: ICorestoreLegacyAdoptionBindingView): string =>
|
|
477
|
+
bindingArg.corestoreLegacyAdoption?.legacyServiceId ?? bindingArg.serviceId;
|
|
478
|
+
|
|
398
479
|
/**
|
|
399
480
|
* Content check of one adopting binding against the inventory it was adopted at, and the one rule
|
|
400
481
|
* that lets a binding carry a legacy bucket name: the binding names this node, this inventory
|
|
401
|
-
* digest and the legacy resource's name; the resource
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
482
|
+
* digest and the legacy resource's name; the resource belongs to `legacyServiceId` when the binding
|
|
483
|
+
* names one, and then its own service owns no legacy resource of the capability, otherwise to its
|
|
484
|
+
* own service; the resource is unadopted without a blocker or authorized for exactly this binding;
|
|
485
|
+
* an adopted bucket's `objectstorageBucketName` is the legacy bucket and its retention intent the
|
|
486
|
+
* legacy intent exactly, absent when the legacy bucket has none; a database binding carries
|
|
487
|
+
* neither. Whether the digest is current is the caller's to check.
|
|
405
488
|
*/
|
|
406
489
|
export const validatePlatformBindingCorestoreLegacyAdoption = (
|
|
407
490
|
bindingArg: ICorestoreLegacyAdoptionBindingView,
|
|
@@ -414,16 +497,27 @@ export const validatePlatformBindingCorestoreLegacyAdoption = (
|
|
|
414
497
|
return [`${pathArg} needs a valid reachable inventory and its digest`];
|
|
415
498
|
}
|
|
416
499
|
const adoption = bindingArg.corestoreLegacyAdoption;
|
|
417
|
-
if (!isPlainRecord(adoption)
|
|
500
|
+
if (!isPlainRecord(adoption)
|
|
501
|
+
|| !hasExactKeys(adoption, ['nodeId', 'resourceName', 'inventorySha256'], ['legacyServiceId'])) {
|
|
418
502
|
return [`${pathArg} must use its exact schema`];
|
|
419
503
|
}
|
|
504
|
+
if (Object.keys(adoption).includes('legacyServiceId')
|
|
505
|
+
&& (!isIdentifier(adoption.legacyServiceId) || adoption.legacyServiceId === bindingArg.serviceId)) {
|
|
506
|
+
return [`${pathArg} legacyServiceId must name another service than the binding's`];
|
|
507
|
+
}
|
|
420
508
|
if (adoption.nodeId !== inventoryArg.nodeId || adoption.inventorySha256 !== inventorySha256Arg) {
|
|
421
509
|
return [`${pathArg} must name the inventory's node and digest`];
|
|
422
510
|
}
|
|
423
511
|
if (!isCapability(bindingArg.capability)) return [`${pathArg} needs a database or objectstorage binding`];
|
|
424
|
-
const
|
|
512
|
+
const resourceOf = (serviceIdArg: string) => inventoryArg.services
|
|
513
|
+
.find((serviceArg) => serviceArg.serviceId === serviceIdArg)
|
|
425
514
|
?.resources.find((resourceArg) => resourceArg.capability === bindingArg.capability);
|
|
515
|
+
const legacyOwner = legacyOwnerOf(bindingArg);
|
|
516
|
+
const resource = resourceOf(legacyOwner);
|
|
426
517
|
if (!resource) return [`${pathArg} names no legacy resource of the service and capability`];
|
|
518
|
+
if (legacyOwner !== bindingArg.serviceId && resourceOf(bindingArg.serviceId)) {
|
|
519
|
+
return [`${pathArg} names a successor that owns a legacy resource of the capability itself`];
|
|
520
|
+
}
|
|
427
521
|
const errors: string[] = [];
|
|
428
522
|
const legacyName = resource.capability === 'database' ? resource.databaseName : resource.bucketName;
|
|
429
523
|
if (adoption.resourceName !== legacyName) errors.push(`${pathArg} resourceName must be the legacy resource's name`);
|
|
@@ -447,18 +541,27 @@ export const validatePlatformBindingCorestoreLegacyAdoption = (
|
|
|
447
541
|
};
|
|
448
542
|
|
|
449
543
|
/**
|
|
450
|
-
* The plan for every adopting binding, sorted by service then capability, in the exact entry
|
|
451
|
-
* Corestore's migration reads
|
|
452
|
-
*
|
|
544
|
+
* The plan for every adopting binding, sorted by legacy service then capability, in the exact entry
|
|
545
|
+
* shape Corestore's migration reads: a binding adopting another service's resource names that
|
|
546
|
+
* service as `serviceId` and its own as `successorServiceId`. Throws unless every binding adopts a
|
|
547
|
+
* legacy resource and the result is a valid plan.
|
|
453
548
|
*/
|
|
454
549
|
export const createCorestoreLegacyResourceAdoptionPlan = (
|
|
455
550
|
bindingsArg: readonly ICorestoreLegacyAdoptionBindingView[],
|
|
456
551
|
): ICorestoreLegacyResourceAdoptionPlanEntry[] => {
|
|
457
|
-
const plan = bindingsArg.map((bindingArg) => {
|
|
552
|
+
const plan = bindingsArg.map((bindingArg): ICorestoreLegacyResourceAdoptionPlanEntry => {
|
|
458
553
|
if (bindingArg.corestoreLegacyAdoption === undefined || !isCapability(bindingArg.capability)) {
|
|
459
554
|
throw new Error(`binding ${bindingArg.id} adopts no legacy Corestore resource`);
|
|
460
555
|
}
|
|
461
|
-
|
|
556
|
+
const legacyServiceId = bindingArg.corestoreLegacyAdoption.legacyServiceId;
|
|
557
|
+
return legacyServiceId === undefined
|
|
558
|
+
? { serviceId: bindingArg.serviceId, capability: bindingArg.capability, bindingId: bindingArg.id }
|
|
559
|
+
: {
|
|
560
|
+
serviceId: legacyServiceId,
|
|
561
|
+
capability: bindingArg.capability,
|
|
562
|
+
bindingId: bindingArg.id,
|
|
563
|
+
successorServiceId: bindingArg.serviceId,
|
|
564
|
+
};
|
|
462
565
|
}).sort((leftArg, rightArg) => compareWireTokens(selectionKey(leftArg), selectionKey(rightArg)));
|
|
463
566
|
const errors = validateCorestoreLegacyResourceAdoptionPlan(plan);
|
|
464
567
|
if (errors.length > 0) throw new Error(errors[0]);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readDataElements, readDataProperty, readExactDataRecord } from '../private/dataproperties.js';
|
|
2
|
+
import { validateServiceDeploymentGrant, type IServiceDeploymentGrant } from './user.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A deployment machine user is the identity a deployer such as `@git.zone/tsdeploy` authenticates
|
|
6
|
+
* as: an API machine user that carries a bearer token and the deployment grants an administrator
|
|
7
|
+
* attaches with `configureServiceDeploymentMachineUser`. An administrator creates one, rotates or
|
|
8
|
+
* revokes its token, and lists them; nothing else mints one.
|
|
9
|
+
*/
|
|
10
|
+
export const deploymentMachineUserContract = Object.freeze({
|
|
11
|
+
/** A token lives at most one year from the mutation that issued it, like every machine token. */
|
|
12
|
+
maximumTokenLifetimeMs: 365 * 24 * 60 * 60 * 1000,
|
|
13
|
+
/**
|
|
14
|
+
* The longest username a created user may take. The username is also the registry login name
|
|
15
|
+
* a deployer states, so it stays short, lowercase and free of `:` and `/`.
|
|
16
|
+
*/
|
|
17
|
+
maximumCreatedUsernameLength: 64,
|
|
18
|
+
/** The longest stored username a listing reports, the bound Cloudly has always stored. */
|
|
19
|
+
maximumUsernameLength: 200,
|
|
20
|
+
/** Grants one user carries at most. */
|
|
21
|
+
maximumGrants: 64,
|
|
22
|
+
/** Tokens one listed user reports at most. */
|
|
23
|
+
maximumTokens: 64,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** One token of a deployment machine user, without its value. */
|
|
27
|
+
export interface IDeploymentMachineTokenMetadata {
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Value-free metadata of one deployment machine user. No token value is ever part of it. */
|
|
32
|
+
export interface IDeploymentMachineUserMetadata {
|
|
33
|
+
userId: string;
|
|
34
|
+
username: string;
|
|
35
|
+
/** Ascending by expiry; empty after `revoke-token`. */
|
|
36
|
+
tokens: IDeploymentMachineTokenMetadata[];
|
|
37
|
+
/** Sorted by `organizationId:serviceId`, as Cloudly stores them. */
|
|
38
|
+
grants: IServiceDeploymentGrant[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface IDeploymentMachineUserMutationBase {
|
|
42
|
+
/** Retain the complete mutation and this ID across retries. */
|
|
43
|
+
mutationId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `create` makes a new user with one token; the username must not be taken by any user.
|
|
48
|
+
* `rotate-token` replaces every token of the user with one new token. `revoke-token` removes every
|
|
49
|
+
* token, which ends the user's authentication while keeping its grants for a later rotation.
|
|
50
|
+
*/
|
|
51
|
+
export type TDeploymentMachineUserMutation = IDeploymentMachineUserMutationBase & (
|
|
52
|
+
| { operation: 'create'; username: string; expiresAt: number }
|
|
53
|
+
| { operation: 'rotate-token'; userId: string; expiresAt: number }
|
|
54
|
+
| { operation: 'revoke-token'; userId: string }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export interface IDeploymentMachineUserMutationResult {
|
|
58
|
+
/** Historical on replay; read `getDeploymentMachineUsers` for current state. */
|
|
59
|
+
user: IDeploymentMachineUserMetadata;
|
|
60
|
+
/**
|
|
61
|
+
* The new token, answered exactly once: on the first answer to `create` or `rotate-token`.
|
|
62
|
+
* `revoke-token` and every replay answer `null`; a lost token is replaced by a rotation.
|
|
63
|
+
*/
|
|
64
|
+
token: string | null;
|
|
65
|
+
replayed: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every refusal the deployment machine user requests name, as `IDeploymentMachineUserErrorData`.
|
|
70
|
+
*
|
|
71
|
+
* - `ADMIN_AUTHORIZATION_REQUIRED`: the caller is not an administrator.
|
|
72
|
+
* - `DEPLOYMENT_MACHINE_USER_REQUEST_INVALID`: the request does not satisfy this contract.
|
|
73
|
+
* - `DEPLOYMENT_MACHINE_USERNAME_TAKEN`: another user already has the username.
|
|
74
|
+
* - `DEPLOYMENT_MACHINE_USER_INVALID`: the user is not a deployment machine user (it does not
|
|
75
|
+
* exist, or it is a human, cluster, administrator or service workload identity).
|
|
76
|
+
* - `DEPLOYMENT_MACHINE_TOKEN_EXPIRY_INVALID`: the expiry is not in the future, or is more than
|
|
77
|
+
* `maximumTokenLifetimeMs` away (`isDeploymentMachineTokenExpiryAdmissible`).
|
|
78
|
+
* - `DEPLOYMENT_MACHINE_USER_MUTATION_CONFLICT`: the mutation ID was used for a different mutation
|
|
79
|
+
* or by a different administrator.
|
|
80
|
+
*/
|
|
81
|
+
export const deploymentMachineUserRefusals = [
|
|
82
|
+
'ADMIN_AUTHORIZATION_REQUIRED',
|
|
83
|
+
'DEPLOYMENT_MACHINE_USER_REQUEST_INVALID',
|
|
84
|
+
'DEPLOYMENT_MACHINE_USERNAME_TAKEN',
|
|
85
|
+
'DEPLOYMENT_MACHINE_USER_INVALID',
|
|
86
|
+
'DEPLOYMENT_MACHINE_TOKEN_EXPIRY_INVALID',
|
|
87
|
+
'DEPLOYMENT_MACHINE_USER_MUTATION_CONFLICT',
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
export type TDeploymentMachineUserRefusal = typeof deploymentMachineUserRefusals[number];
|
|
91
|
+
|
|
92
|
+
export interface IDeploymentMachineUserErrorData {
|
|
93
|
+
code: TDeploymentMachineUserRefusal;
|
|
94
|
+
retryable: false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fail = (): never => { throw new Error('Deployment machine user contract rejected.'); };
|
|
98
|
+
function requireValue(value: unknown): asserts value { if (!value) fail(); }
|
|
99
|
+
const safe = <T>(operation: () => T): T => { try { return operation(); } catch { return fail(); } };
|
|
100
|
+
/** Exactly this shape's keys, read once as data; an accessor is refused, never invoked. */
|
|
101
|
+
const object = (value: unknown, keys: readonly string[]): Record<string, unknown> => {
|
|
102
|
+
const copy = readExactDataRecord(value, keys);
|
|
103
|
+
requireValue(copy !== null);
|
|
104
|
+
return copy;
|
|
105
|
+
};
|
|
106
|
+
const identifier = (value: unknown): string => {
|
|
107
|
+
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value));
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
const timestamp = (value: unknown): number => {
|
|
111
|
+
requireValue(typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
|
|
112
|
+
return value;
|
|
113
|
+
};
|
|
114
|
+
const createdUsernamePattern = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
115
|
+
|
|
116
|
+
/** Whether a username may be given to a new deployment machine user. */
|
|
117
|
+
export const isDeploymentMachineUsername = (value: unknown): value is string => (
|
|
118
|
+
typeof value === 'string' && createdUsernamePattern.test(value)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/** Whether a token expiry is in the future and at most `maximumTokenLifetimeMs` after `now`. */
|
|
122
|
+
export const isDeploymentMachineTokenExpiryAdmissible = (expiresAt: number, now: number): boolean => (
|
|
123
|
+
Number.isSafeInteger(expiresAt) && Number.isSafeInteger(now) && expiresAt > now
|
|
124
|
+
&& expiresAt <= now + deploymentMachineUserContract.maximumTokenLifetimeMs
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** Structural copy only; the backend authorizes the administrator and the target user. */
|
|
128
|
+
export const snapshotDeploymentMachineUserMutation = (value: unknown): TDeploymentMachineUserMutation => safe(() => {
|
|
129
|
+
const operation = readDataProperty(value, 'operation')?.value;
|
|
130
|
+
requireValue(operation === 'create' || operation === 'rotate-token' || operation === 'revoke-token');
|
|
131
|
+
if (operation === 'create') {
|
|
132
|
+
const request = object(value, ['mutationId', 'operation', 'username', 'expiresAt']);
|
|
133
|
+
requireValue(isDeploymentMachineUsername(request.username));
|
|
134
|
+
return { mutationId: identifier(request.mutationId), operation, username: request.username,
|
|
135
|
+
expiresAt: timestamp(request.expiresAt) };
|
|
136
|
+
}
|
|
137
|
+
if (operation === 'rotate-token') {
|
|
138
|
+
const request = object(value, ['mutationId', 'operation', 'userId', 'expiresAt']);
|
|
139
|
+
return { mutationId: identifier(request.mutationId), operation, userId: identifier(request.userId),
|
|
140
|
+
expiresAt: timestamp(request.expiresAt) };
|
|
141
|
+
}
|
|
142
|
+
const request = object(value, ['mutationId', 'operation', 'userId']);
|
|
143
|
+
return { mutationId: identifier(request.mutationId), operation, userId: identifier(request.userId) };
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** Structural copy of listed metadata: a stored username, tokens by expiry, valid sorted grants. */
|
|
147
|
+
export const snapshotDeploymentMachineUserMetadata = (value: unknown): IDeploymentMachineUserMetadata => safe(() => {
|
|
148
|
+
const metadata = object(value, ['userId', 'username', 'tokens', 'grants']);
|
|
149
|
+
requireValue(typeof metadata.username === 'string' && metadata.username.length > 0
|
|
150
|
+
&& metadata.username.length <= deploymentMachineUserContract.maximumUsernameLength
|
|
151
|
+
&& metadata.username.trim() === metadata.username && !/[\u0000-\u001f\u007f]/.test(metadata.username));
|
|
152
|
+
const tokenEntries = readDataElements(metadata.tokens, deploymentMachineUserContract.maximumTokens);
|
|
153
|
+
requireValue(tokenEntries !== null);
|
|
154
|
+
const tokens = tokenEntries.map((entry) => ({ expiresAt: timestamp(object(entry, ['expiresAt']).expiresAt) }));
|
|
155
|
+
for (let index = 1; index < tokens.length; index++) {
|
|
156
|
+
requireValue(tokens[index - 1].expiresAt <= tokens[index].expiresAt);
|
|
157
|
+
}
|
|
158
|
+
const grantEntries = readDataElements(metadata.grants, deploymentMachineUserContract.maximumGrants);
|
|
159
|
+
requireValue(grantEntries !== null);
|
|
160
|
+
const grants = grantEntries.map((entry) => {
|
|
161
|
+
const grant = object(entry, ['scope', 'organizationId', 'serviceId', 'capabilities']);
|
|
162
|
+
const capabilities = readDataElements(grant.capabilities, 64);
|
|
163
|
+
requireValue(capabilities !== null);
|
|
164
|
+
const copy = { scope: grant.scope, organizationId: grant.organizationId, serviceId: grant.serviceId,
|
|
165
|
+
capabilities: [...capabilities] };
|
|
166
|
+
requireValue(validateServiceDeploymentGrant(copy).length === 0);
|
|
167
|
+
return copy as IServiceDeploymentGrant;
|
|
168
|
+
});
|
|
169
|
+
const grantKeys = grants.map((grant) => `${grant.organizationId}:${grant.serviceId}`);
|
|
170
|
+
for (let index = 1; index < grantKeys.length; index++) {
|
|
171
|
+
requireValue(grantKeys[index - 1].localeCompare(grantKeys[index]) < 0);
|
|
172
|
+
}
|
|
173
|
+
return { userId: identifier(metadata.userId), username: metadata.username, tokens, grants };
|
|
174
|
+
});
|
|
@@ -29,6 +29,12 @@ export type TDeploymentPreflightDecision = 'GO' | 'NO-GO';
|
|
|
29
29
|
export const deploymentPreflightBlockerCodes = [
|
|
30
30
|
'INVALID_REQUEST',
|
|
31
31
|
'AUTHORIZATION_MISSING',
|
|
32
|
+
/**
|
|
33
|
+
* A reservation is made by a deployment machine user (an API machine user with a deployment
|
|
34
|
+
* grant) and never by an administrator, whose registry push is not bound to any operation; the
|
|
35
|
+
* reserve request refuses such an actor with the same code.
|
|
36
|
+
*/
|
|
37
|
+
'DEPLOYMENT_MACHINE_IDENTITY_REQUIRED',
|
|
32
38
|
'ORGANIZATION_SCOPE_MISSING',
|
|
33
39
|
'SERVICE_OWNERSHIP_MISMATCH',
|
|
34
40
|
'DEPLOYMENT_OPERATION_NOT_FOUND',
|
package/ts/data/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './coremail.js';
|
|
|
12
12
|
export * from './coremail.runtime.js';
|
|
13
13
|
export * from './deployment.js';
|
|
14
14
|
export * from './deploymentadoption.js';
|
|
15
|
+
export * from './deploymentmachineuser.js';
|
|
15
16
|
export * from './deploymentoperation.js';
|
|
16
17
|
export * from './deploymentpreflight.js';
|
|
17
18
|
export * from './migration.js';
|
package/ts/platform/types.ts
CHANGED
|
@@ -116,14 +116,18 @@ export interface IPlatformServiceEndpoint {
|
|
|
116
116
|
* the binding, and Cloudly refuses it (and `objectstorageBucketName`) on `upsertPlatformBinding`.
|
|
117
117
|
* `nodeId` is the node whose Corestore holds the resource, so the binding is placed there and
|
|
118
118
|
* nowhere else; `resourceName` is the legacy database or bucket name; `inventorySha256` is the
|
|
119
|
-
* legacy inventory the adoption was checked against
|
|
120
|
-
*
|
|
121
|
-
*
|
|
119
|
+
* legacy inventory the adoption was checked against; `legacyServiceId` is the service that owned the
|
|
120
|
+
* legacy resource when that is not the binding's own service (a successor adopting its
|
|
121
|
+
* predecessor's data), absent otherwise. An adopted bucket's `objectstorageBucketName` is the legacy
|
|
122
|
+
* bucket and its `objectstorageRetention.intent` the legacy intent exactly, or absent when the
|
|
123
|
+
* legacy bucket has none.
|
|
122
124
|
*/
|
|
123
125
|
export interface IPlatformBindingCorestoreLegacyAdoption {
|
|
124
126
|
nodeId: string;
|
|
125
127
|
resourceName: string;
|
|
126
128
|
inventorySha256: string;
|
|
129
|
+
/** The legacy resource's owner, when it is another service than the binding's; never its own. */
|
|
130
|
+
legacyServiceId?: string;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
export interface IPlatformBinding {
|
package/ts/requests/admin.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
IServiceMachineCredentialMetadata, IServiceMachineCredentialMutationResult,
|
|
5
5
|
TServiceMachineCredentialMutation,
|
|
6
6
|
} from '../data/servicecredential.js';
|
|
7
|
+
import type {
|
|
8
|
+
IDeploymentMachineUserMetadata, IDeploymentMachineUserMutationResult,
|
|
9
|
+
TDeploymentMachineUserMutation,
|
|
10
|
+
} from '../data/deploymentmachineuser.js';
|
|
7
11
|
|
|
8
12
|
export interface IReq_Admin_GetServiceMachineCredential extends plugins.typedrequestInterfaces.implementsTR<
|
|
9
13
|
plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_GetServiceMachineCredential
|
|
@@ -84,6 +88,39 @@ export interface IReq_Admin_LoginWithUsernameAndPassword extends plugins.typedre
|
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Lists every deployment machine user, value-free: no token value is ever answered here.
|
|
93
|
+
* Requires an administrator; refusals travel as `IDeploymentMachineUserErrorData`.
|
|
94
|
+
*/
|
|
95
|
+
export interface IReq_Admin_GetDeploymentMachineUsers extends plugins.typedrequestInterfaces.implementsTR<
|
|
96
|
+
plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_GetDeploymentMachineUsers
|
|
97
|
+
> {
|
|
98
|
+
method: 'getDeploymentMachineUsers';
|
|
99
|
+
request: { identity: IIdentityCredential };
|
|
100
|
+
response: { users: IDeploymentMachineUserMetadata[] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Creates a deployment machine user, or rotates or revokes its token.
|
|
105
|
+
*
|
|
106
|
+
* Requires an administrator. The token is answered exactly once, on the first answer to `create`
|
|
107
|
+
* or `rotate-token`; a replay of the same mutation ID by the same administrator answers the
|
|
108
|
+
* historical metadata with `token: null` and never issues another token. Grants are attached with
|
|
109
|
+
* `configureServiceDeploymentMachineUser`. Refusals travel as `IDeploymentMachineUserErrorData`.
|
|
110
|
+
*/
|
|
111
|
+
export interface IReq_Admin_MutateDeploymentMachineUser extends plugins.typedrequestInterfaces.implementsTR<
|
|
112
|
+
plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_MutateDeploymentMachineUser
|
|
113
|
+
> {
|
|
114
|
+
method: 'mutateDeploymentMachineUser';
|
|
115
|
+
request: { identity: IIdentityCredential; mutation: TDeploymentMachineUserMutation };
|
|
116
|
+
response: IDeploymentMachineUserMutationResult;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Attaches one deployment grant to a deployment machine user that holds a token, replacing the
|
|
121
|
+
* grant it held for the same organization and service. `mutateDeploymentMachineUser` creates such
|
|
122
|
+
* a user.
|
|
123
|
+
*/
|
|
87
124
|
export interface IReq_Admin_ConfigureServiceDeploymentMachineUser
|
|
88
125
|
extends plugins.typedrequestInterfaces.implementsTR<
|
|
89
126
|
plugins.typedrequestInterfaces.ITypedRequest,
|
package/ts/requests/corestore.ts
CHANGED
|
@@ -332,8 +332,12 @@ extends plugins.typedrequestInterfaces.implementsTR<
|
|
|
332
332
|
* Cloudly reads the node's legacy inventory again, requires `expectedInventorySha256`, and for
|
|
333
333
|
* each selected resource creates the adopting binding in one transaction: enabled, placed on
|
|
334
334
|
* `nodeId`, with `corestoreLegacyAdoption`, and for a bucket the legacy bucket name and retention
|
|
335
|
-
* intent (`validatePlatformBindingCorestoreLegacyAdoption`). A
|
|
336
|
-
*
|
|
335
|
+
* intent (`validatePlatformBindingCorestoreLegacyAdoption`). A selection naming a
|
|
336
|
+
* `successorServiceId` creates the binding on that successor instead, with
|
|
337
|
+
* `corestoreLegacyAdoption.legacyServiceId` naming the legacy owner; the successor must exist, share
|
|
338
|
+
* the legacy owner's organization, be an immutable-deployment service, own no legacy resource of
|
|
339
|
+
* that capability and hold no other binding of it. A legacy resource is adopted by one service
|
|
340
|
+
* only. A resource whose adopting binding already exists is answered unchanged. The response lists the adopting bindings and the plan
|
|
337
341
|
* (`createCorestoreLegacyResourceAdoptionPlan`) the operator hands to Corestore's offline
|
|
338
342
|
* `legacy-resource-adoption` migration. Refusals travel as `ICorestoreLegacyAdoptionErrorData`.
|
|
339
343
|
*/
|
package/ts/requests/service.ts
CHANGED
|
@@ -231,6 +231,14 @@ extends plugins.typedrequestInterfaces.implementsTR<
|
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Reserves one immutable deployment for the authenticated deployment machine user.
|
|
236
|
+
*
|
|
237
|
+
* Only an API machine user can complete the operation it reserves, because only its registry
|
|
238
|
+
* push is bound to that operation. An actor that is not an API machine user, an administrator
|
|
239
|
+
* included, is refused with the code `DEPLOYMENT_MACHINE_IDENTITY_REQUIRED` before anything is
|
|
240
|
+
* reserved.
|
|
241
|
+
*/
|
|
234
242
|
export interface IRequest_Any_Cloudly_ReserveServiceDeployment
|
|
235
243
|
extends plugins.typedrequestInterfaces.implementsTR<
|
|
236
244
|
plugins.typedrequestInterfaces.ITypedRequest,
|