@serve.zone/interfaces 32.25.0 → 32.27.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 +45 -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/serviceruntime.d.ts +18 -5
- package/dist_ts/data/serviceruntime.js +4 -2
- 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/platform/objectstorageenvironment.d.ts +59 -0
- package/dist_ts/platform/objectstorageenvironment.js +116 -41
- package/dist_ts/requests/node.d.ts +29 -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 +72 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +16 -0
- package/ts/data/serviceruntime.ts +19 -4
- package/ts/platform/database.ts +79 -0
- package/ts/platform/index.ts +1 -0
- package/ts/platform/objectstorageenvironment.ts +199 -54
- package/ts/requests/node.ts +33 -0
- package/ts/runtime.corestore.ts +108 -1
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
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
objectStorageEndpointEnvironmentKeys,
|
|
10
10
|
validateObjectStorageEndpointEnvironment,
|
|
11
11
|
validatePlatformObjectStorageBucketName,
|
|
12
|
+
validatePlatformObjectStorageEndpoint,
|
|
12
13
|
} from './objectstorage.js';
|
|
14
|
+
import type { TServiceRuntimeRefusal, TServiceRuntimeWaitingReason } from '../data/serviceruntime.js';
|
|
13
15
|
import type {
|
|
14
16
|
IObjectStorageRetentionEvidence,
|
|
17
|
+
IObjectStorageRetentionEvidenceExpectation,
|
|
15
18
|
IObjectStorageRetentionIntent,
|
|
16
19
|
TObjectStorageRetentionEnvironment,
|
|
17
20
|
TObjectStorageRetentionEnvironmentKey,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
createObjectStorageRetentionEnvironment,
|
|
21
24
|
objectStorageRetentionEnvironmentKeys,
|
|
22
25
|
validateObjectStorageRetentionEnvironment,
|
|
26
|
+
validateObjectStorageRetentionEvidence,
|
|
23
27
|
validateObjectStorageRetentionIntent,
|
|
24
28
|
} from './objectstorageretention.js';
|
|
25
29
|
import { isPlainRecord, readDataProperties } from '../private/dataproperties.js';
|
|
@@ -45,78 +49,118 @@ export type TObjectStorageBindingEnvironment =
|
|
|
45
49
|
| TObjectStorageEndpointEnvironment
|
|
46
50
|
| (TObjectStorageEndpointEnvironment & TObjectStorageRetentionEnvironment);
|
|
47
51
|
|
|
52
|
+
/** How one binding's environment resolves, in the service runtime vocabulary. */
|
|
53
|
+
type TBindingEnvironmentResolution =
|
|
54
|
+
| { status: 'composed'; environment: TObjectStorageBindingEnvironment }
|
|
55
|
+
| { status: 'waiting'; reason: TObjectStorageEnvironmentWaitingReason; message: string }
|
|
56
|
+
| {
|
|
57
|
+
status: 'refused';
|
|
58
|
+
reason: Exclude<TObjectStorageEnvironmentRefusal, 'objectstorage-environment-conflict'>;
|
|
59
|
+
message: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
48
62
|
/**
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
51
|
-
* `bindingArg` is the Cloudly binding document and `endpointArg` the
|
|
52
|
-
* `IClusterNode.data.corestoreObjectStorage` of the node the binding is placed on. Refused by
|
|
53
|
-
* name, never guessed:
|
|
54
|
-
*
|
|
55
|
-
* - a binding that is not `objectstorage` or carries no canonical `objectstorageBucketName`;
|
|
56
|
-
* - an endpoint that `validatePlatformObjectStorageEndpoint()` refuses;
|
|
57
|
-
* - a binding that declares `objectstorageRetention` without `evidence`. Its bucket's retention is
|
|
58
|
-
* not proven yet, so the workload is not given an environment that would let it run as if its
|
|
59
|
-
* bucket were ordinary storage; the caller waits for the evidence.
|
|
60
|
-
* - evidence that does not prove the binding's own service, binding id, bucket and intent.
|
|
61
|
-
*
|
|
62
|
-
* A binding without `objectstorageRetention` gets the endpoint names alone: it promised no
|
|
63
|
-
* retention, so there is nothing to state.
|
|
64
|
-
*
|
|
65
|
-
* The evidence's reconciliation generation and binding-request digest are read from its own
|
|
66
|
-
* authority: they are what Cloudly checked when it admitted the evidence, and the binding document
|
|
67
|
-
* does not repeat them. Everything the binding does state is checked again here.
|
|
63
|
+
* The one place a binding's environment is decided. `createObjectStorageBindingEnvironment()`
|
|
64
|
+
* throws its message, `composeObjectStorageServiceEnvironment()` reports its reason.
|
|
68
65
|
*/
|
|
69
|
-
|
|
66
|
+
const resolveBindingEnvironment = async (
|
|
70
67
|
bindingArg: IPlatformBinding,
|
|
71
|
-
endpointArg: IPlatformObjectStorageEndpoint,
|
|
72
|
-
): Promise<
|
|
68
|
+
endpointArg: IPlatformObjectStorageEndpoint | null,
|
|
69
|
+
): Promise<TBindingEnvironmentResolution> => {
|
|
73
70
|
const binding = readDataProperties(bindingArg);
|
|
74
71
|
if (binding === null || binding.capability !== 'objectstorage') {
|
|
75
|
-
|
|
72
|
+
return { status: 'refused', reason: 'objectstorage-binding-invalid',
|
|
73
|
+
message: 'objectstorage binding environment requires an objectstorage binding' };
|
|
76
74
|
}
|
|
77
75
|
const bindingName = typeof binding.id === 'string' ? binding.id : '(unnamed)';
|
|
78
76
|
if (validatePlatformObjectStorageBucketName(binding.objectstorageBucketName).length > 0) {
|
|
79
|
-
|
|
80
|
-
`objectstorage binding ${bindingName} carries no canonical bucket name; Cloudly owns
|
|
81
|
-
+ 'no environment invents one'
|
|
82
|
-
|
|
77
|
+
return { status: 'refused', reason: 'objectstorage-binding-invalid',
|
|
78
|
+
message: `objectstorage binding ${bindingName} carries no canonical bucket name; Cloudly owns `
|
|
79
|
+
+ 'it and no environment invents one' };
|
|
80
|
+
}
|
|
81
|
+
if (endpointArg === null) {
|
|
82
|
+
return { status: 'refused', reason: 'objectstorage-endpoint-missing',
|
|
83
|
+
message: `objectstorage binding ${bindingName} is placed on a node that states no `
|
|
84
|
+
+ 'corestoreObjectStorage endpoint' };
|
|
85
|
+
}
|
|
86
|
+
const endpointErrors = validatePlatformObjectStorageEndpoint(endpointArg);
|
|
87
|
+
if (endpointErrors.length > 0) {
|
|
88
|
+
return { status: 'refused', reason: 'objectstorage-endpoint-missing', message: endpointErrors[0] };
|
|
83
89
|
}
|
|
84
90
|
const bucketName = binding.objectstorageBucketName as string;
|
|
85
91
|
const endpointEnvironment = createObjectStorageEndpointEnvironment(endpointArg, bucketName);
|
|
86
92
|
const retention = binding.objectstorageRetention;
|
|
87
93
|
if (retention === undefined) {
|
|
88
|
-
return endpointEnvironment;
|
|
94
|
+
return { status: 'composed', environment: endpointEnvironment };
|
|
89
95
|
}
|
|
90
96
|
const retentionBinding = readDataProperties(retention);
|
|
91
97
|
if (retentionBinding === null
|
|
92
98
|
|| validateObjectStorageRetentionIntent(retentionBinding.intent).length > 0) {
|
|
93
|
-
|
|
94
|
-
`objectstorage binding ${bindingName} must declare canonical compliance-retention
|
|
95
|
-
|
|
99
|
+
return { status: 'refused', reason: 'objectstorage-binding-invalid',
|
|
100
|
+
message: `objectstorage binding ${bindingName} must declare canonical compliance-retention `
|
|
101
|
+
+ 'intent' };
|
|
96
102
|
}
|
|
97
103
|
const evidence = retentionBinding.evidence;
|
|
98
104
|
if (evidence === undefined) {
|
|
99
|
-
|
|
100
|
-
`objectstorage binding ${bindingName} declares compliance retention without
|
|
101
|
-
+ 'evidence; its workload environment waits for the evidence'
|
|
102
|
-
);
|
|
105
|
+
return { status: 'waiting', reason: 'objectstorage-retention-pending',
|
|
106
|
+
message: `objectstorage binding ${bindingName} declares compliance retention without `
|
|
107
|
+
+ 'verified evidence; its workload environment waits for the evidence' };
|
|
103
108
|
}
|
|
104
109
|
const authority = isPlainRecord(evidence) ? readDataProperties(evidence.authority) : null;
|
|
105
110
|
if (authority === null) {
|
|
106
|
-
|
|
111
|
+
return { status: 'refused', reason: 'objectstorage-binding-invalid',
|
|
112
|
+
message: 'objectstorage retention evidence must use its exact schema' };
|
|
113
|
+
}
|
|
114
|
+
const expectation: IObjectStorageRetentionEvidenceExpectation = {
|
|
115
|
+
serviceId: binding.serviceId as string,
|
|
116
|
+
bindingId: binding.id as string,
|
|
117
|
+
reconciliationGeneration: authority.reconciliationGeneration as number,
|
|
118
|
+
bindingRequestDigest: authority.bindingRequestDigest as string,
|
|
119
|
+
bucketName,
|
|
120
|
+
intent: retentionBinding.intent as IObjectStorageRetentionIntent,
|
|
121
|
+
};
|
|
122
|
+
const evidenceErrors = await validateObjectStorageRetentionEvidence(evidence, expectation);
|
|
123
|
+
if (evidenceErrors.length > 0) {
|
|
124
|
+
return { status: 'refused', reason: 'objectstorage-binding-invalid', message: evidenceErrors[0] };
|
|
107
125
|
}
|
|
108
126
|
const retentionEnvironment = await createObjectStorageRetentionEnvironment(
|
|
109
127
|
evidence as IObjectStorageRetentionEvidence,
|
|
110
|
-
|
|
111
|
-
serviceId: binding.serviceId as string,
|
|
112
|
-
bindingId: binding.id as string,
|
|
113
|
-
reconciliationGeneration: authority.reconciliationGeneration as number,
|
|
114
|
-
bindingRequestDigest: authority.bindingRequestDigest as string,
|
|
115
|
-
bucketName,
|
|
116
|
-
intent: retentionBinding.intent as IObjectStorageRetentionIntent,
|
|
117
|
-
},
|
|
128
|
+
expectation,
|
|
118
129
|
);
|
|
119
|
-
return { ...endpointEnvironment, ...retentionEnvironment };
|
|
130
|
+
return { status: 'composed', environment: { ...endpointEnvironment, ...retentionEnvironment } };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The environment Cloudly composes into a workload's runtime configuration for one binding.
|
|
135
|
+
*
|
|
136
|
+
* `bindingArg` is the Cloudly binding document and `endpointArg` the
|
|
137
|
+
* `IClusterNode.data.corestoreObjectStorage` of the node the binding is placed on. Refused by
|
|
138
|
+
* name, never guessed:
|
|
139
|
+
*
|
|
140
|
+
* - a binding that is not `objectstorage` or carries no canonical `objectstorageBucketName`;
|
|
141
|
+
* - an endpoint that `validatePlatformObjectStorageEndpoint()` refuses;
|
|
142
|
+
* - a binding that declares `objectstorageRetention` without `evidence`. Its bucket's retention is
|
|
143
|
+
* not proven yet, so the workload is not given an environment that would let it run as if its
|
|
144
|
+
* bucket were ordinary storage; the caller waits for the evidence.
|
|
145
|
+
* - evidence that does not prove the binding's own service, binding id, bucket and intent.
|
|
146
|
+
*
|
|
147
|
+
* A binding without `objectstorageRetention` gets the endpoint names alone: it promised no
|
|
148
|
+
* retention, so there is nothing to state.
|
|
149
|
+
*
|
|
150
|
+
* The evidence's reconciliation generation and binding-request digest are read from its own
|
|
151
|
+
* authority: they are what Cloudly checked when it admitted the evidence, and the binding document
|
|
152
|
+
* does not repeat them. Everything the binding does state is checked again here.
|
|
153
|
+
*
|
|
154
|
+
* `composeObjectStorageServiceEnvironment()` answers the same questions with a service runtime
|
|
155
|
+
* reason instead of an error.
|
|
156
|
+
*/
|
|
157
|
+
export const createObjectStorageBindingEnvironment = async (
|
|
158
|
+
bindingArg: IPlatformBinding,
|
|
159
|
+
endpointArg: IPlatformObjectStorageEndpoint,
|
|
160
|
+
): Promise<TObjectStorageBindingEnvironment> => {
|
|
161
|
+
const resolution = await resolveBindingEnvironment(bindingArg, endpointArg);
|
|
162
|
+
if (resolution.status !== 'composed') throw new Error(resolution.message);
|
|
163
|
+
return resolution.environment;
|
|
120
164
|
};
|
|
121
165
|
|
|
122
166
|
/**
|
|
@@ -152,6 +196,16 @@ export const validateObjectStorageBindingEnvironment = async (
|
|
|
152
196
|
return errors;
|
|
153
197
|
};
|
|
154
198
|
|
|
199
|
+
const findEnvironmentCollisions = (
|
|
200
|
+
claimedKeysArg: ReadonlySet<string>,
|
|
201
|
+
keysArg: readonly string[],
|
|
202
|
+
): string[] => keysArg.filter((keyArg) => claimedKeysArg.has(keyArg)).sort();
|
|
203
|
+
|
|
204
|
+
const describeEnvironmentCollisions = (collisionsArg: readonly string[]): string => (
|
|
205
|
+
`objectstorage binding environment collides with ${collisionsArg.join(', ')} already set on the `
|
|
206
|
+
+ 'workload'
|
|
207
|
+
);
|
|
208
|
+
|
|
155
209
|
/**
|
|
156
210
|
* A workload environment with one binding's environment added.
|
|
157
211
|
*
|
|
@@ -163,14 +217,105 @@ export const mergeObjectStorageBindingEnvironment = (
|
|
|
163
217
|
environmentArg: Readonly<Record<string, string>>,
|
|
164
218
|
bindingEnvironmentArg: TObjectStorageBindingEnvironment,
|
|
165
219
|
): Record<string, string> => {
|
|
166
|
-
const collisions =
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`objectstorage binding environment collides with ${collisions.join(', ')} already set on the `
|
|
172
|
-
+ 'workload',
|
|
173
|
-
);
|
|
174
|
-
}
|
|
220
|
+
const collisions = findEnvironmentCollisions(
|
|
221
|
+
new Set(Object.keys(environmentArg)),
|
|
222
|
+
Object.keys(bindingEnvironmentArg),
|
|
223
|
+
);
|
|
224
|
+
if (collisions.length > 0) throw new Error(describeEnvironmentCollisions(collisions));
|
|
175
225
|
return { ...environmentArg, ...bindingEnvironmentArg };
|
|
176
226
|
};
|
|
227
|
+
|
|
228
|
+
/** One enabled object-storage binding of a service and the endpoint of the node it is placed on. */
|
|
229
|
+
export interface IObjectStorageBindingEnvironmentSource {
|
|
230
|
+
binding: IPlatformBinding;
|
|
231
|
+
/**
|
|
232
|
+
* The `IClusterNode.data.corestoreObjectStorage` of the node the binding is placed on, `null`
|
|
233
|
+
* when that node states none. A binding placed on no node is not a source: its service waits as
|
|
234
|
+
* `bindings-pending`.
|
|
235
|
+
*/
|
|
236
|
+
endpoint: IPlatformObjectStorageEndpoint | null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The service runtime waiting reason an object-storage environment reports:
|
|
241
|
+
* `objectstorage-retention-pending`, a binding that declares compliance retention whose evidence
|
|
242
|
+
* has not arrived.
|
|
243
|
+
*/
|
|
244
|
+
export type TObjectStorageEnvironmentWaitingReason = Extract<
|
|
245
|
+
TServiceRuntimeWaitingReason,
|
|
246
|
+
'objectstorage-retention-pending'
|
|
247
|
+
>;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The service runtime refusals an object-storage environment reports.
|
|
251
|
+
* `objectstorage-endpoint-missing` is a binding whose node states no valid
|
|
252
|
+
* `corestoreObjectStorage`; `objectstorage-binding-invalid` a binding
|
|
253
|
+
* `createObjectStorageBindingEnvironment()` refuses (not `objectstorage`, no canonical bucket, no
|
|
254
|
+
* canonical retention intent, or evidence that does not prove this binding);
|
|
255
|
+
* `objectstorage-environment-conflict` a name the service's own environment or another binding
|
|
256
|
+
* already sets.
|
|
257
|
+
*/
|
|
258
|
+
export type TObjectStorageEnvironmentRefusal = Extract<
|
|
259
|
+
TServiceRuntimeRefusal,
|
|
260
|
+
| 'objectstorage-endpoint-missing'
|
|
261
|
+
| 'objectstorage-binding-invalid'
|
|
262
|
+
| 'objectstorage-environment-conflict'
|
|
263
|
+
>;
|
|
264
|
+
|
|
265
|
+
/** A service's environment with every object-storage binding added, or the one reason it is not. */
|
|
266
|
+
export type TObjectStorageServiceEnvironmentComposition =
|
|
267
|
+
| { status: 'composed'; environment: Record<string, string> }
|
|
268
|
+
| {
|
|
269
|
+
status: 'waiting';
|
|
270
|
+
reason: TObjectStorageEnvironmentWaitingReason;
|
|
271
|
+
bindingId: string;
|
|
272
|
+
message: string;
|
|
273
|
+
}
|
|
274
|
+
| { status: 'refused'; reason: TObjectStorageEnvironmentRefusal; bindingId: string; message: string };
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The service's own environment with the environment of every enabled object-storage binding
|
|
278
|
+
* added, or the service runtime reason it cannot be — so a producer reports the contract's
|
|
279
|
+
* verdict instead of classifying its errors.
|
|
280
|
+
*
|
|
281
|
+
* Each binding's names are `createObjectStorageBindingEnvironment()`'s from its node's endpoint,
|
|
282
|
+
* added as `mergeObjectStorageBindingEnvironment()` adds them. Bindings are taken in id order, so
|
|
283
|
+
* the verdict does not depend on the order of `sourcesArg`. A refusal outranks a wait, because a
|
|
284
|
+
* wait resolves on its own and a refusal never does: a binding waiting for retention evidence
|
|
285
|
+
* already claims all twenty of its names, so a collision with it is refused now, not once its
|
|
286
|
+
* evidence arrives. The first refusal is answered, otherwise the first wait, otherwise the
|
|
287
|
+
* composed environment. The inputs are not changed.
|
|
288
|
+
*/
|
|
289
|
+
export const composeObjectStorageServiceEnvironment = async (
|
|
290
|
+
serviceEnvironmentArg: Readonly<Record<string, string>>,
|
|
291
|
+
sourcesArg: readonly IObjectStorageBindingEnvironmentSource[],
|
|
292
|
+
): Promise<TObjectStorageServiceEnvironmentComposition> => {
|
|
293
|
+
const sources = [...sourcesArg].sort((leftArg, rightArg) => (
|
|
294
|
+
leftArg.binding.id < rightArg.binding.id ? -1 : leftArg.binding.id > rightArg.binding.id ? 1 : 0
|
|
295
|
+
));
|
|
296
|
+
let environment: Record<string, string> = { ...serviceEnvironmentArg };
|
|
297
|
+
const claimedKeys = new Set(Object.keys(environment));
|
|
298
|
+
let wait: Extract<TObjectStorageServiceEnvironmentComposition, { status: 'waiting' }> | null = null;
|
|
299
|
+
for (const source of sources) {
|
|
300
|
+
const bindingId = source.binding.id;
|
|
301
|
+
const resolution = await resolveBindingEnvironment(source.binding, source.endpoint);
|
|
302
|
+
if (resolution.status === 'refused') {
|
|
303
|
+
return { status: 'refused', reason: resolution.reason, bindingId, message: resolution.message };
|
|
304
|
+
}
|
|
305
|
+
const keys: readonly string[] = resolution.status === 'composed'
|
|
306
|
+
? Object.keys(resolution.environment)
|
|
307
|
+
: objectStorageBindingEnvironmentKeys;
|
|
308
|
+
const collisions = findEnvironmentCollisions(claimedKeys, keys);
|
|
309
|
+
if (collisions.length > 0) {
|
|
310
|
+
return { status: 'refused', reason: 'objectstorage-environment-conflict', bindingId,
|
|
311
|
+
message: describeEnvironmentCollisions(collisions) };
|
|
312
|
+
}
|
|
313
|
+
for (const key of keys) claimedKeys.add(key);
|
|
314
|
+
if (resolution.status === 'composed') {
|
|
315
|
+
environment = { ...environment, ...resolution.environment };
|
|
316
|
+
} else {
|
|
317
|
+
wait ??= { status: 'waiting', reason: resolution.reason, bindingId, message: resolution.message };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return wait ?? { status: 'composed', environment };
|
|
321
|
+
};
|
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
|
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,
|