@serve.zone/interfaces 32.26.0 → 32.28.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 +36 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/clusternode.d.ts +15 -0
- package/dist_ts/data/clusternode.js +1 -1
- package/dist_ts/data/runtimelocalstorageclaim.d.ts +76 -0
- package/dist_ts/data/runtimelocalstorageclaim.js +99 -8
- package/dist_ts/data/serviceruntime.d.ts +5 -1
- package/dist_ts/data/serviceruntime.js +2 -1
- package/dist_ts/platform/database.d.ts +26 -0
- package/dist_ts/platform/database.js +49 -2
- package/dist_ts/platform/index.d.ts +1 -0
- package/dist_ts/platform/index.js +2 -1
- package/dist_ts/requests/node.d.ts +29 -0
- package/dist_ts/requests/runtimesession.d.ts +12 -0
- package/dist_ts/runtime.corestore.d.ts +36 -0
- package/dist_ts/runtime.corestore.js +79 -2
- package/package.json +3 -3
- package/readme.md +60 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +16 -0
- package/ts/data/runtimelocalstorageclaim.ts +173 -8
- package/ts/data/serviceruntime.ts +5 -0
- package/ts/platform/database.ts +79 -0
- package/ts/platform/index.ts +1 -0
- package/ts/requests/node.ts +33 -0
- package/ts/requests/runtimesession.ts +16 -0
- package/ts/runtime.corestore.ts +108 -1
|
@@ -3,8 +3,10 @@ import type { IRuntimeAssignment, IRuntimeAuthorityReference, IRuntimeController
|
|
|
3
3
|
import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
|
|
4
4
|
import type { IRuntimeSessionBinding } from './runtimesession.js';
|
|
5
5
|
import { snapshotRuntimeSessionBinding } from './runtimesession.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import type { IIdentityCredential } from './user.js';
|
|
7
|
+
import { captureNetwork, networkController, networkCounter, networkDigest, networkEqual, networkHash, networkId,
|
|
8
|
+
networkList, networkNodeId, networkRecord, networkReference, networkReferenceOf, networkRevision, networkSorted,
|
|
9
|
+
requireNetwork,
|
|
8
10
|
} from '../private/networkcontract.js';
|
|
9
11
|
import { parseCanonicalIpAddress } from '../private/ipaddress.js';
|
|
10
12
|
import { isRuntimeIdentityNumber } from '../private/runtimepolicy.js';
|
|
@@ -161,6 +163,60 @@ export interface IApplyRuntimeLocalStorageClaimResponse {
|
|
|
161
163
|
claim: IRuntimeAuthorityReference;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* What an administrator reads about one local storage claim a controller issued: where the volume
|
|
168
|
+
* is, whose it is, how it starts and what its node last answered.
|
|
169
|
+
*
|
|
170
|
+
* It is a projection of the controller's current generation, never the claim itself: it carries no
|
|
171
|
+
* digest, no controller epoch and no source beyond its kind, so an SMB claim's credential reference
|
|
172
|
+
* is not in it. `claimId` is the claim's `id`, the name the node keys the volume by, including the
|
|
173
|
+
* `import/<claimId>` directory an operator places an `import` volume's bytes in; a reader takes it
|
|
174
|
+
* from here and never derives it.
|
|
175
|
+
*
|
|
176
|
+
* `state` is what the node answered about exactly `generation`, or `null` while the node has not
|
|
177
|
+
* acknowledged that generation. It follows the rule `bindApplyRuntimeLocalStorageClaimResponse`
|
|
178
|
+
* holds an answer to: a bound claim is `ready`, `import-required` (an `import` claim only) or
|
|
179
|
+
* `uncertain`; a purge is `purged` or `uncertain`.
|
|
180
|
+
*/
|
|
181
|
+
export interface IRuntimeLocalStorageClaimSummary {
|
|
182
|
+
claimId: string;
|
|
183
|
+
generation: number;
|
|
184
|
+
clusterId: string;
|
|
185
|
+
nodeId: string;
|
|
186
|
+
serviceId: string;
|
|
187
|
+
volumeId: string;
|
|
188
|
+
mountPath: string;
|
|
189
|
+
sourceKind: TRuntimeLocalStorageSource['kind'];
|
|
190
|
+
initialization: IRuntimeLocalStorageClaim['initialization'];
|
|
191
|
+
lifecycle: TRuntimeLocalStorageLifecycle;
|
|
192
|
+
state: TRuntimeLocalStorageClaimState | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An administrator's read of the local storage claims of one cluster, optionally of one node.
|
|
197
|
+
*
|
|
198
|
+
* Paged: `cursor` is the opaque `nextCursor` of the previous page, and `limit` asks for at most that
|
|
199
|
+
* many summaries, which the controller may lower but never exceed; both absent read the first page
|
|
200
|
+
* at the controller's own size.
|
|
201
|
+
*/
|
|
202
|
+
export interface IGetRuntimeLocalStorageClaimsRequest {
|
|
203
|
+
/** The controller verifies this credential and derives every actor claim itself. */
|
|
204
|
+
identity: IIdentityCredential;
|
|
205
|
+
clusterId: string;
|
|
206
|
+
nodeId?: string;
|
|
207
|
+
cursor?: string;
|
|
208
|
+
limit?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One page of summaries in strictly ascending `claimId` order. `nextCursor` is omitted on the final
|
|
213
|
+
* page; a page that carries it carries at least one summary.
|
|
214
|
+
*/
|
|
215
|
+
export interface IGetRuntimeLocalStorageClaimsResponse {
|
|
216
|
+
claims: IRuntimeLocalStorageClaimSummary[];
|
|
217
|
+
nextCursor?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
export const runtimeLocalStorageClaimContract = Object.freeze({
|
|
165
221
|
digestDomain: 'serve.zone/runtime-local-storage-claim',
|
|
166
222
|
error: 'Runtime local storage claim contract rejected.',
|
|
@@ -178,6 +234,11 @@ export const runtimeLocalStorageClaimContract = Object.freeze({
|
|
|
178
234
|
maximumSegmentLength: 255,
|
|
179
235
|
/** The longest SMB share name. */
|
|
180
236
|
maximumShareLength: 80,
|
|
237
|
+
/**
|
|
238
|
+
* The most claim summaries one `getRuntimeLocalStorageClaims` page carries. A page of summaries
|
|
239
|
+
* with every field at its bound stays inside the network contract's one-mebibyte capture.
|
|
240
|
+
*/
|
|
241
|
+
maximumSummaryPageSize: 200,
|
|
181
242
|
} as const);
|
|
182
243
|
|
|
183
244
|
const fail = (): never => { throw new Error(runtimeLocalStorageClaimContract.error); };
|
|
@@ -241,6 +302,14 @@ const shareOf = (valueArg: unknown): string => {
|
|
|
241
302
|
return valueArg;
|
|
242
303
|
};
|
|
243
304
|
|
|
305
|
+
/** The states a node may answer about one generation, whatever the answer's status. */
|
|
306
|
+
const isClaimStateOf = (
|
|
307
|
+
claimArg: Pick<IRuntimeLocalStorageClaim, 'lifecycle' | 'initialization'>, stateArg: TRuntimeLocalStorageClaimState,
|
|
308
|
+
): boolean => claimArg.lifecycle === 'purge'
|
|
309
|
+
? stateArg === 'purged' || stateArg === 'uncertain'
|
|
310
|
+
: stateArg === 'ready' || stateArg === 'uncertain'
|
|
311
|
+
|| (stateArg === 'import-required' && claimArg.initialization === 'import');
|
|
312
|
+
|
|
244
313
|
const literalOf = <TLiteral extends string>(valueArg: unknown, allowedArg: readonly TLiteral[]): TLiteral => {
|
|
245
314
|
requireNetwork((allowedArg as readonly unknown[]).includes(valueArg));
|
|
246
315
|
return valueArg as TLiteral;
|
|
@@ -424,11 +493,107 @@ export const bindApplyRuntimeLocalStorageClaimResponse = (
|
|
|
424
493
|
const response = snapshotApplyRuntimeLocalStorageClaimResponse(valueArg);
|
|
425
494
|
const { claim } = snapshotApplyRuntimeLocalStorageClaimRequest(requestArg);
|
|
426
495
|
requireNetwork(networkEqual(response.claim, getRuntimeLocalStorageClaimReference(claim)));
|
|
427
|
-
if (response.status !== 'historical')
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
496
|
+
if (response.status !== 'historical') requireNetwork(isClaimStateOf(claim, response.state));
|
|
497
|
+
return response;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const sourceKinds = ['directory', 'nfs', 'smb'] as const;
|
|
501
|
+
const initializations = ['empty', 'import'] as const;
|
|
502
|
+
const lifecycles = ['bound', 'purge'] as const;
|
|
503
|
+
|
|
504
|
+
/** Detached exact-schema snapshot of one summary, its state judged against its lifecycle. */
|
|
505
|
+
export const snapshotRuntimeLocalStorageClaimSummary = (
|
|
506
|
+
valueArg: unknown,
|
|
507
|
+
): IRuntimeLocalStorageClaimSummary => safe(() => {
|
|
508
|
+
const value = networkRecord(captureNetwork(valueArg), ['claimId', 'generation', 'clusterId', 'nodeId',
|
|
509
|
+
'serviceId', 'volumeId', 'mountPath', 'sourceKind', 'initialization', 'lifecycle', 'state']);
|
|
510
|
+
const summary: IRuntimeLocalStorageClaimSummary = {
|
|
511
|
+
claimId: networkId(value.claimId),
|
|
512
|
+
generation: networkCounter(value.generation),
|
|
513
|
+
clusterId: networkId(value.clusterId),
|
|
514
|
+
nodeId: networkNodeId(value.nodeId),
|
|
515
|
+
serviceId: networkId(value.serviceId),
|
|
516
|
+
volumeId: networkId(value.volumeId),
|
|
517
|
+
mountPath: readMountPath(value.mountPath, 'mountPath'),
|
|
518
|
+
sourceKind: literalOf(value.sourceKind, sourceKinds),
|
|
519
|
+
initialization: literalOf(value.initialization, initializations),
|
|
520
|
+
lifecycle: literalOf(value.lifecycle, lifecycles),
|
|
521
|
+
state: value.state === null ? null : literalOf(value.state, claimStates),
|
|
522
|
+
};
|
|
523
|
+
// A purge always follows a bound generation, and an SMB volume always starts empty.
|
|
524
|
+
requireNetwork((summary.lifecycle === 'bound' || summary.generation > 1)
|
|
525
|
+
&& (summary.sourceKind !== 'smb' || summary.initialization === 'empty')
|
|
526
|
+
&& (summary.state === null || isClaimStateOf(summary, summary.state)));
|
|
527
|
+
return summary;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The one projection of a claim and its node's last answer into a summary. `stateArg` is what the
|
|
532
|
+
* node answered about exactly this generation, or `null` while it has not acknowledged it.
|
|
533
|
+
*/
|
|
534
|
+
export const summarizeRuntimeLocalStorageClaim = (
|
|
535
|
+
claimArg: unknown, stateArg: TRuntimeLocalStorageClaimState | null,
|
|
536
|
+
): IRuntimeLocalStorageClaimSummary => {
|
|
537
|
+
const claim = snapshotRuntimeLocalStorageClaim(claimArg);
|
|
538
|
+
return snapshotRuntimeLocalStorageClaimSummary({
|
|
539
|
+
claimId: claim.id,
|
|
540
|
+
generation: claim.generation,
|
|
541
|
+
clusterId: claim.clusterId,
|
|
542
|
+
nodeId: claim.nodeId,
|
|
543
|
+
serviceId: claim.serviceId,
|
|
544
|
+
volumeId: claim.volumeId,
|
|
545
|
+
mountPath: claim.mount.mountPath,
|
|
546
|
+
sourceKind: claim.source.kind,
|
|
547
|
+
initialization: claim.initialization,
|
|
548
|
+
lifecycle: claim.lifecycle,
|
|
549
|
+
state: stateArg,
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Detached exact-schema snapshot of a read request. It checks the identity's shape only: the
|
|
555
|
+
* controller authenticates it.
|
|
556
|
+
*/
|
|
557
|
+
export const snapshotGetRuntimeLocalStorageClaimsRequest = (
|
|
558
|
+
valueArg: unknown,
|
|
559
|
+
): IGetRuntimeLocalStorageClaimsRequest => safe(() => {
|
|
560
|
+
const value = networkRecord(captureNetwork(valueArg), ['identity', 'clusterId'], ['nodeId', 'cursor', 'limit']);
|
|
561
|
+
const identity = networkRecord(value.identity, ['jwt']);
|
|
562
|
+
requireNetwork(typeof identity.jwt === 'string' && identity.jwt.length > 0);
|
|
563
|
+
return {
|
|
564
|
+
identity: { jwt: identity.jwt as string },
|
|
565
|
+
clusterId: networkId(value.clusterId),
|
|
566
|
+
...(Object.hasOwn(value, 'nodeId') ? { nodeId: networkNodeId(value.nodeId) } : {}),
|
|
567
|
+
...(Object.hasOwn(value, 'cursor') ? { cursor: networkId(value.cursor) } : {}),
|
|
568
|
+
...(Object.hasOwn(value, 'limit')
|
|
569
|
+
? { limit: networkCounter(value.limit, runtimeLocalStorageClaimContract.maximumSummaryPageSize) } : {}),
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
/** Detached exact-schema snapshot of one page: bounded, strictly ascending and never empty with a cursor. */
|
|
574
|
+
export const snapshotGetRuntimeLocalStorageClaimsResponse = (
|
|
575
|
+
valueArg: unknown,
|
|
576
|
+
): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
|
|
577
|
+
const value = networkRecord(captureNetwork(valueArg), ['claims'], ['nextCursor']);
|
|
578
|
+
const claims = networkList(value.claims, runtimeLocalStorageClaimContract.maximumSummaryPageSize,
|
|
579
|
+
snapshotRuntimeLocalStorageClaimSummary);
|
|
580
|
+
networkSorted(claims, (claimArg) => claimArg.claimId);
|
|
581
|
+
if (!Object.hasOwn(value, 'nextCursor')) return { claims };
|
|
582
|
+
requireNetwork(claims.length > 0);
|
|
583
|
+
return { claims, nextCursor: networkId(value.nextCursor) };
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Matches a page to the read that asked for it: every summary names the requested cluster and, when
|
|
588
|
+
* the request named one, the requested node, and the page holds no more than the requested `limit`.
|
|
589
|
+
*/
|
|
590
|
+
export const bindGetRuntimeLocalStorageClaimsResponse = (
|
|
591
|
+
valueArg: unknown, requestArg: unknown,
|
|
592
|
+
): IGetRuntimeLocalStorageClaimsResponse => safe(() => {
|
|
593
|
+
const response = snapshotGetRuntimeLocalStorageClaimsResponse(valueArg);
|
|
594
|
+
const request = snapshotGetRuntimeLocalStorageClaimsRequest(requestArg);
|
|
595
|
+
requireNetwork(response.claims.length <= (request.limit ?? runtimeLocalStorageClaimContract.maximumSummaryPageSize)
|
|
596
|
+
&& response.claims.every((claimArg) => claimArg.clusterId === request.clusterId
|
|
597
|
+
&& (request.nodeId === undefined || claimArg.nodeId === request.nodeId)));
|
|
433
598
|
return response;
|
|
434
599
|
});
|
|
@@ -89,6 +89,7 @@ const serviceRuntimeRefusals = [
|
|
|
89
89
|
'volume-driver-unsupported', 'volume-options-unsupported', 'volume-shared-unsupported',
|
|
90
90
|
'volume-placement-unsupported', 'volume-initialization-unknown', 'volume-ownership-unknown',
|
|
91
91
|
'objectstorage-endpoint-missing', 'objectstorage-binding-invalid', 'objectstorage-environment-conflict',
|
|
92
|
+
'database-endpoint-missing',
|
|
92
93
|
] as const;
|
|
93
94
|
|
|
94
95
|
/**
|
|
@@ -128,6 +129,10 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
|
|
|
128
129
|
* states no valid `corestoreObjectStorage`, `objectstorage-binding-invalid` a binding
|
|
129
130
|
* `createObjectStorageBindingEnvironment` refuses, and `objectstorage-environment-conflict` a name
|
|
130
131
|
* the service's own environment or another binding already sets.
|
|
132
|
+
*
|
|
133
|
+
* `database-endpoint-missing` is an enabled database binding placed on a node that states no valid
|
|
134
|
+
* `corestoreDatabase`: its credential cannot be addressed to any host a workload reaches, so it is
|
|
135
|
+
* neither granted nor published, and the workload is not started with a guessed one.
|
|
131
136
|
*/
|
|
132
137
|
export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
|
|
133
138
|
|
package/ts/platform/database.ts
CHANGED
|
@@ -6,3 +6,82 @@ export interface IPlatformDatabaseBindingConfig {
|
|
|
6
6
|
version?: string;
|
|
7
7
|
extensions?: string[];
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a workload on one node reaches the node's Corestore database.
|
|
12
|
+
*
|
|
13
|
+
* Non-secret and value-free: it says where, never with what. Corestore is node-local, so this is
|
|
14
|
+
* a fact about the node that holds the database, stated by an administrator on
|
|
15
|
+
* `IClusterNode.data.corestoreDatabase`. Cloudly reads it from there and hands it to Corestore in
|
|
16
|
+
* the database binding request, and Corestore addresses the binding's connection values to it —
|
|
17
|
+
* never to its own public host, which on a converted root is an identity and not an address a
|
|
18
|
+
* workload can resolve.
|
|
19
|
+
*/
|
|
20
|
+
export interface IPlatformDatabaseEndpoint {
|
|
21
|
+
/**
|
|
22
|
+
* The host a workload on that node dials: an IPv4 literal such as `192.0.2.15`, an IPv6 literal
|
|
23
|
+
* without brackets, or a lowercase DNS name, each exactly as a URL states it.
|
|
24
|
+
*/
|
|
25
|
+
host: string;
|
|
26
|
+
/** The TCP port of the database wire protocol, 1 to 65535. */
|
|
27
|
+
port: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const platformDatabaseEndpointLimits = Object.freeze({
|
|
31
|
+
maximumHostLength: 253,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const platformDatabaseHostNamePattern =
|
|
35
|
+
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
36
|
+
|
|
37
|
+
const isPlainDatabaseEndpointRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
|
|
38
|
+
Boolean(valueArg)
|
|
39
|
+
&& typeof valueArg === 'object'
|
|
40
|
+
&& !Array.isArray(valueArg)
|
|
41
|
+
&& (Object.getPrototypeOf(valueArg) === Object.prototype
|
|
42
|
+
|| Object.getPrototypeOf(valueArg) === null)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** The host as a URL authority states it: an IPv6 literal in brackets, every other host as is. */
|
|
46
|
+
export const formatPlatformDatabaseEndpointHost = (hostArg: string): string => (
|
|
47
|
+
hostArg.includes(':') ? `[${hostArg}]` : hostArg
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Validates a database endpoint and names every reason it is refused. */
|
|
51
|
+
export const validatePlatformDatabaseEndpoint = (
|
|
52
|
+
valueArg: unknown,
|
|
53
|
+
pathArg = 'database endpoint',
|
|
54
|
+
): string[] => {
|
|
55
|
+
if (!isPlainDatabaseEndpointRecord(valueArg)
|
|
56
|
+
|| Object.keys(valueArg).length !== 2
|
|
57
|
+
|| !Object.hasOwn(valueArg, 'host')
|
|
58
|
+
|| !Object.hasOwn(valueArg, 'port')) {
|
|
59
|
+
return [`${pathArg} must use its exact schema`];
|
|
60
|
+
}
|
|
61
|
+
const errors: string[] = [];
|
|
62
|
+
const host = valueArg.host;
|
|
63
|
+
if (typeof host !== 'string'
|
|
64
|
+
|| host.length === 0
|
|
65
|
+
|| host.length > platformDatabaseEndpointLimits.maximumHostLength) {
|
|
66
|
+
errors.push(`${pathArg} host must be a string of 1 to `
|
|
67
|
+
+ `${platformDatabaseEndpointLimits.maximumHostLength} characters`);
|
|
68
|
+
} else {
|
|
69
|
+
const authorityHost = formatPlatformDatabaseEndpointHost(host);
|
|
70
|
+
let parsedHost: string | undefined;
|
|
71
|
+
try {
|
|
72
|
+
parsedHost = new URL(`http://${authorityHost}/`).hostname;
|
|
73
|
+
} catch {
|
|
74
|
+
parsedHost = undefined;
|
|
75
|
+
}
|
|
76
|
+
if (parsedHost !== authorityHost
|
|
77
|
+
|| (!host.includes(':') && !platformDatabaseHostNamePattern.test(host))) {
|
|
78
|
+
errors.push(`${pathArg} host must be a canonical IPv4 literal, IPv6 literal without `
|
|
79
|
+
+ 'brackets, or lowercase DNS name');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const port = valueArg.port;
|
|
83
|
+
if (typeof port !== 'number' || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
84
|
+
errors.push(`${pathArg} port must be an integer from 1 to 65535`);
|
|
85
|
+
}
|
|
86
|
+
return errors;
|
|
87
|
+
};
|
package/ts/platform/index.ts
CHANGED
package/ts/requests/node.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { IClusterNode, INodeAction, INodeMetricsSample, TNodeActionName } f
|
|
|
5
5
|
import type { IDeployment } from '../data/deployment.js';
|
|
6
6
|
import type { IIdentity } from '../data/user.js';
|
|
7
7
|
import type { IPlatformObjectStorageEndpoint } from '../platform/objectstorage.js';
|
|
8
|
+
import type { IPlatformDatabaseEndpoint } from '../platform/database.js';
|
|
8
9
|
import type {
|
|
9
10
|
INodeCredentialMetadata,
|
|
10
11
|
INodeCredentialMutationPreconditions,
|
|
@@ -315,6 +316,38 @@ export interface IReq_Any_Cloudly_SetClusterNodeCorestoreObjectStorageEndpoint e
|
|
|
315
316
|
};
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
/**
|
|
320
|
+
* An administrator states where a workload on one node reaches the node's Corestore database:
|
|
321
|
+
* `IClusterNode.data.corestoreDatabase`.
|
|
322
|
+
*
|
|
323
|
+
* A request of its own, separate from `setClusterNodeCorestoreEndpoint` and
|
|
324
|
+
* `setClusterNodeCorestoreObjectStorageEndpoint`: each sets one fact and never touches the others.
|
|
325
|
+
*
|
|
326
|
+
* Cloudly validates it with `validatePlatformDatabaseEndpoint` before it writes and takes the
|
|
327
|
+
* node's own real-write fence inside the writing transaction, as for the other two endpoints.
|
|
328
|
+
* `database: null` clears it, which is the same fact as never having set one: no database
|
|
329
|
+
* credential can be published for a binding on this node. A changed endpoint changes the binding
|
|
330
|
+
* request of every database binding placed on the node, so each is granted and published again,
|
|
331
|
+
* addressed to the new endpoint. The endpoint says where, never with what — no credential travels
|
|
332
|
+
* in this request or in the node document.
|
|
333
|
+
*/
|
|
334
|
+
export interface IReq_Any_Cloudly_SetClusterNodeCorestoreDatabaseEndpoint extends plugins.typedrequestInterfaces.implementsTR<
|
|
335
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
336
|
+
IReq_Any_Cloudly_SetClusterNodeCorestoreDatabaseEndpoint
|
|
337
|
+
> {
|
|
338
|
+
method: 'setClusterNodeCorestoreDatabaseEndpoint';
|
|
339
|
+
request: {
|
|
340
|
+
identity: IIdentity;
|
|
341
|
+
nodeId: string;
|
|
342
|
+
/** The node's database endpoint, or `null` to state that it has none. */
|
|
343
|
+
database: IPlatformDatabaseEndpoint | null;
|
|
344
|
+
};
|
|
345
|
+
response: {
|
|
346
|
+
/** The node as it now stands, so a caller needs no second read to see what it set. */
|
|
347
|
+
node: IClusterNode;
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
318
351
|
export interface IReq_Any_Cloudly_CreateNodeAction extends plugins.typedrequestInterfaces.implementsTR<
|
|
319
352
|
plugins.typedrequestInterfaces.ITypedRequest,
|
|
320
353
|
IReq_Any_Cloudly_CreateNodeAction
|
|
@@ -179,6 +179,22 @@ export interface IReq_Controller_Pallet_ApplyRuntimeLocalStorageClaim extends pl
|
|
|
179
179
|
response: localStorage.IApplyRuntimeLocalStorageClaimResponse;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* An administrator reads the local storage claims Cloudly issued in one cluster, optionally on one
|
|
184
|
+
* node, one page at a time: each claim's id, volume, mount path, initialization, lifecycle and what
|
|
185
|
+
* its node last answered. `snapshotGetRuntimeLocalStorageClaimsRequest` and
|
|
186
|
+
* `bindGetRuntimeLocalStorageClaimsResponse` judge both sides; a summary never carries the claim's
|
|
187
|
+
* source beyond its kind, so no credential reference leaves Cloudly through it.
|
|
188
|
+
*/
|
|
189
|
+
export interface IReq_Any_Cloudly_GetRuntimeLocalStorageClaims extends plugins.typedrequestInterfaces.implementsTR<
|
|
190
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
191
|
+
IReq_Any_Cloudly_GetRuntimeLocalStorageClaims
|
|
192
|
+
> {
|
|
193
|
+
method: 'getRuntimeLocalStorageClaims';
|
|
194
|
+
request: localStorage.IGetRuntimeLocalStorageClaimsRequest;
|
|
195
|
+
response: localStorage.IGetRuntimeLocalStorageClaimsResponse;
|
|
196
|
+
}
|
|
197
|
+
|
|
182
198
|
/** Current-peer report, including exact historical outbox recovery after reconnect. */
|
|
183
199
|
export interface IReq_Pallet_Controller_ReportRuntimeAssignmentObservation extends plugins.typedrequestInterfaces.implementsTR<
|
|
184
200
|
plugins.typedrequestInterfaces.ITypedRequest,
|
package/ts/runtime.corestore.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
validateObjectStorageRetentionIntent,
|
|
22
22
|
} from './platform/objectstorageretention.js';
|
|
23
23
|
import { validatePlatformObjectStorageBucketName } from './platform/objectstorage.js';
|
|
24
|
+
import type { IPlatformDatabaseEndpoint } from './platform/database.js';
|
|
25
|
+
import {
|
|
26
|
+
formatPlatformDatabaseEndpointHost,
|
|
27
|
+
validatePlatformDatabaseEndpoint,
|
|
28
|
+
} from './platform/database.js';
|
|
24
29
|
import {
|
|
25
30
|
canonicalizeStrictJson,
|
|
26
31
|
createCanonicalJsonSha256Hex,
|
|
@@ -415,6 +420,14 @@ interface ICorestoreCredentialBindingRequestBase {
|
|
|
415
420
|
export type TCorestoreCredentialBindingRequest =
|
|
416
421
|
| ICorestoreCredentialBindingRequestBase & {
|
|
417
422
|
capability: 'database';
|
|
423
|
+
/**
|
|
424
|
+
* Where a workload reaches the database: the `corestoreDatabase` of the node the binding is
|
|
425
|
+
* placed on. Corestore addresses the binding's connection values to it, and it is part of
|
|
426
|
+
* the request digest, so a changed endpoint is a changed request that is granted and
|
|
427
|
+
* published again. A request without it names no address a workload can reach; whether one
|
|
428
|
+
* is admitted is the provider's decision, never a default this contract supplies.
|
|
429
|
+
*/
|
|
430
|
+
endpoint?: IPlatformDatabaseEndpoint;
|
|
418
431
|
}
|
|
419
432
|
| ICorestoreCredentialBindingRequestBase & {
|
|
420
433
|
capability: 'objectstorage';
|
|
@@ -441,7 +454,7 @@ export const validateCorestoreCredentialBindingRequest = (
|
|
|
441
454
|
'environment',
|
|
442
455
|
];
|
|
443
456
|
if (requestArg.capability === 'database') {
|
|
444
|
-
if (!hasExactKeys(requestArg, baseKeys)) {
|
|
457
|
+
if (!hasExactKeys(requestArg, baseKeys, ['endpoint'])) {
|
|
445
458
|
return ['corestore database credential binding request must use its exact schema'];
|
|
446
459
|
}
|
|
447
460
|
} else if (requestArg.capability === 'objectstorage') {
|
|
@@ -462,6 +475,12 @@ export const validateCorestoreCredentialBindingRequest = (
|
|
|
462
475
|
|| requestArg.environment !== corestoreCredentialEnvironment) {
|
|
463
476
|
errors.push('corestore credential binding request constants must be canonical');
|
|
464
477
|
}
|
|
478
|
+
if (requestArg.capability === 'database' && requestArg.endpoint !== undefined) {
|
|
479
|
+
errors.push(...validatePlatformDatabaseEndpoint(
|
|
480
|
+
requestArg.endpoint,
|
|
481
|
+
'corestore database credential binding request endpoint',
|
|
482
|
+
));
|
|
483
|
+
}
|
|
465
484
|
if (requestArg.capability === 'objectstorage') {
|
|
466
485
|
errors.push(...validatePlatformObjectStorageBucketName(
|
|
467
486
|
requestArg.bucketName,
|
|
@@ -489,6 +508,9 @@ export const createCorestoreCredentialBindingRequestDigestInput = (
|
|
|
489
508
|
providerConfigId: requestArg.providerConfigId,
|
|
490
509
|
managementScope: requestArg.managementScope,
|
|
491
510
|
environment: requestArg.environment,
|
|
511
|
+
...(requestArg.capability === 'database' && requestArg.endpoint
|
|
512
|
+
? { endpoint: { host: requestArg.endpoint.host, port: requestArg.endpoint.port } }
|
|
513
|
+
: {}),
|
|
492
514
|
...(requestArg.capability === 'objectstorage'
|
|
493
515
|
? {
|
|
494
516
|
bucketName: requestArg.bucketName,
|
|
@@ -727,6 +749,91 @@ export const validateCorestoreDatabaseCredentialMaterial = (
|
|
|
727
749
|
return errors;
|
|
728
750
|
};
|
|
729
751
|
|
|
752
|
+
/** What a database binding's connection values are made of, besides where the database is. */
|
|
753
|
+
export interface ICorestoreDatabaseCredentialIdentity {
|
|
754
|
+
databaseName: string;
|
|
755
|
+
username: string;
|
|
756
|
+
password: string;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The connection URI of one database binding, addressed to `endpointArg`:
|
|
761
|
+
* `mongodb://<username>:<password>@<host>:<port>/<databaseName>?authSource=<databaseName>&directConnection=true`,
|
|
762
|
+
* the username, password and database name URI-component encoded, an IPv6 host in brackets.
|
|
763
|
+
* This is the one form Corestore writes and Cloudly checks, so the two can never disagree.
|
|
764
|
+
*/
|
|
765
|
+
export const createCorestoreDatabaseUri = (
|
|
766
|
+
endpointArg: IPlatformDatabaseEndpoint,
|
|
767
|
+
identityArg: ICorestoreDatabaseCredentialIdentity,
|
|
768
|
+
): string => {
|
|
769
|
+
const endpointErrors = validatePlatformDatabaseEndpoint(endpointArg);
|
|
770
|
+
if (endpointErrors.length > 0) throw new Error(endpointErrors[0]);
|
|
771
|
+
const query = new URLSearchParams({
|
|
772
|
+
authSource: identityArg.databaseName,
|
|
773
|
+
directConnection: 'true',
|
|
774
|
+
});
|
|
775
|
+
return `mongodb://${encodeURIComponent(identityArg.username)}:`
|
|
776
|
+
+ `${encodeURIComponent(identityArg.password)}@`
|
|
777
|
+
+ `${formatPlatformDatabaseEndpointHost(endpointArg.host)}:${endpointArg.port}`
|
|
778
|
+
+ `/${encodeURIComponent(identityArg.databaseName)}?${query.toString()}`;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The eleven connection values of one database binding, addressed to `endpointArg`: the three URI
|
|
783
|
+
* aliases from `createCorestoreDatabaseUri()`, `MONGODB_HOST` the endpoint host without brackets,
|
|
784
|
+
* `MONGODB_PORT` its port in decimal, and the database, username and password under both alias
|
|
785
|
+
* families. Throws the first reason the result would not be valid material; nothing is defaulted.
|
|
786
|
+
*/
|
|
787
|
+
export const createCorestoreDatabaseCredentialValues = (
|
|
788
|
+
endpointArg: IPlatformDatabaseEndpoint,
|
|
789
|
+
identityArg: ICorestoreDatabaseCredentialIdentity,
|
|
790
|
+
): TCorestoreDatabaseCredentialValues => {
|
|
791
|
+
const uri = createCorestoreDatabaseUri(endpointArg, identityArg);
|
|
792
|
+
const values: TCorestoreDatabaseCredentialValues = {
|
|
793
|
+
MONGODB_URI: uri,
|
|
794
|
+
MONGODB_URL: uri,
|
|
795
|
+
MONGO_URL: uri,
|
|
796
|
+
MONGODB_HOST: endpointArg.host,
|
|
797
|
+
MONGODB_PORT: String(endpointArg.port),
|
|
798
|
+
MONGODB_DATABASE: identityArg.databaseName,
|
|
799
|
+
MONGO_DBNAME: identityArg.databaseName,
|
|
800
|
+
MONGODB_USERNAME: identityArg.username,
|
|
801
|
+
MONGO_DBUSER: identityArg.username,
|
|
802
|
+
MONGODB_PASSWORD: identityArg.password,
|
|
803
|
+
MONGO_DBPASS: identityArg.password,
|
|
804
|
+
};
|
|
805
|
+
const errors = validateCorestoreDatabaseCredentialMaterial({ capability: 'database', values });
|
|
806
|
+
if (errors.length > 0) throw new Error(errors[0]);
|
|
807
|
+
return values;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Validates database material and that it is addressed to `endpointArg`: every value is the one
|
|
812
|
+
* `createCorestoreDatabaseCredentialValues()` derives from the endpoint and the material's own
|
|
813
|
+
* database name, username and password. Material a provider addressed to any other host or port —
|
|
814
|
+
* its own public host above all — is refused by name.
|
|
815
|
+
*/
|
|
816
|
+
export const validateCorestoreDatabaseCredentialMaterialEndpoint = (
|
|
817
|
+
materialArg: unknown,
|
|
818
|
+
endpointArg: IPlatformDatabaseEndpoint,
|
|
819
|
+
): string[] => {
|
|
820
|
+
const errors = validateCorestoreDatabaseCredentialMaterial(materialArg);
|
|
821
|
+
if (errors.length > 0) return errors;
|
|
822
|
+
const endpointErrors = validatePlatformDatabaseEndpoint(endpointArg);
|
|
823
|
+
if (endpointErrors.length > 0) return endpointErrors;
|
|
824
|
+
const values = (materialArg as ICorestoreDatabaseCredentialPlaintextMaterial).values;
|
|
825
|
+
const expected = createCorestoreDatabaseCredentialValues(endpointArg, {
|
|
826
|
+
databaseName: values.MONGODB_DATABASE,
|
|
827
|
+
username: values.MONGODB_USERNAME,
|
|
828
|
+
password: values.MONGODB_PASSWORD,
|
|
829
|
+
});
|
|
830
|
+
const mismatched = corestoreDatabaseCredentialKeys
|
|
831
|
+
.filter((keyArg) => values[keyArg] !== expected[keyArg]);
|
|
832
|
+
return mismatched.length > 0
|
|
833
|
+
? [`corestore database credential ${mismatched.join(', ')} must be addressed to the binding endpoint`]
|
|
834
|
+
: [];
|
|
835
|
+
};
|
|
836
|
+
|
|
730
837
|
export const validateCorestoreObjectStorageCredentialMaterial = async (
|
|
731
838
|
materialArg: unknown,
|
|
732
839
|
retentionExpectationArg?: IObjectStorageRetentionEvidenceExpectation,
|