@serve.zone/interfaces 32.24.0 → 32.25.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 +25 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/clusternode.d.ts +16 -1
- package/dist_ts/data/clusternode.js +1 -1
- package/dist_ts/platform/index.d.ts +3 -1
- package/dist_ts/platform/index.js +4 -2
- package/dist_ts/platform/objectstorage.d.ts +58 -0
- package/dist_ts/platform/objectstorage.js +136 -1
- package/dist_ts/platform/objectstorageenvironment.d.ts +49 -0
- package/dist_ts/platform/objectstorageenvironment.js +119 -0
- package/dist_ts/platform/objectstorageretention.d.ts +40 -0
- package/dist_ts/platform/objectstorageretention.js +121 -1
- package/dist_ts/requests/node.d.ts +29 -0
- package/package.json +1 -1
- package/readme.md +74 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusternode.ts +17 -1
- package/ts/platform/index.ts +3 -0
- package/ts/platform/objectstorage.ts +180 -0
- package/ts/platform/objectstorageenvironment.ts +176 -0
- package/ts/platform/objectstorageretention.ts +141 -0
- package/ts/requests/node.ts +33 -0
|
@@ -31,3 +31,183 @@ export const validatePlatformObjectStorageBucketName = (
|
|
|
31
31
|
}
|
|
32
32
|
return [];
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a workload reaches one node's S3 object storage, and the region it signs requests for.
|
|
37
|
+
*
|
|
38
|
+
* Non-secret and value-free: it says where, never with what. The access key and secret stay in
|
|
39
|
+
* the binding's sealed Secret set. Corestore is node-local, so this is a fact about the node that
|
|
40
|
+
* holds the bucket, stated by an administrator on `IClusterNode.data.corestoreObjectStorage`;
|
|
41
|
+
* Cloudly reads it from there and never derives a port, a scheme or a region.
|
|
42
|
+
*/
|
|
43
|
+
export interface IPlatformObjectStorageEndpoint {
|
|
44
|
+
/**
|
|
45
|
+
* The canonical `http:` or `https:` origin of the S3 API, for example `http://192.0.2.15:9000`:
|
|
46
|
+
* a host, an optional port, and nothing else — no path, query, fragment, userinfo, trailing
|
|
47
|
+
* slash or default port. The host is the one a workload on that node can reach.
|
|
48
|
+
*/
|
|
49
|
+
endpoint: string;
|
|
50
|
+
/** The SigV4 signing region the object storage answers for, for example `us-east-1`. */
|
|
51
|
+
region: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const platformObjectStorageEndpointLimits = Object.freeze({
|
|
55
|
+
maximumEndpointLength: 2048,
|
|
56
|
+
maximumRegionLength: 64,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const platformObjectStorageRegionPattern = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
60
|
+
|
|
61
|
+
const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> => (
|
|
62
|
+
Boolean(valueArg)
|
|
63
|
+
&& typeof valueArg === 'object'
|
|
64
|
+
&& !Array.isArray(valueArg)
|
|
65
|
+
&& (Object.getPrototypeOf(valueArg) === Object.prototype
|
|
66
|
+
|| Object.getPrototypeOf(valueArg) === null)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** Validates an object-storage endpoint and names every reason it is refused. */
|
|
70
|
+
export const validatePlatformObjectStorageEndpoint = (
|
|
71
|
+
valueArg: unknown,
|
|
72
|
+
pathArg = 'objectstorage endpoint',
|
|
73
|
+
): string[] => {
|
|
74
|
+
if (!isPlainObject(valueArg)
|
|
75
|
+
|| Object.keys(valueArg).length !== 2
|
|
76
|
+
|| !Object.hasOwn(valueArg, 'endpoint')
|
|
77
|
+
|| !Object.hasOwn(valueArg, 'region')) {
|
|
78
|
+
return [`${pathArg} must use its exact schema`];
|
|
79
|
+
}
|
|
80
|
+
const errors: string[] = [];
|
|
81
|
+
const endpoint = valueArg.endpoint;
|
|
82
|
+
if (typeof endpoint !== 'string'
|
|
83
|
+
|| endpoint.length === 0
|
|
84
|
+
|| endpoint.length > platformObjectStorageEndpointLimits.maximumEndpointLength) {
|
|
85
|
+
errors.push(`${pathArg} endpoint must be a string of 1 to `
|
|
86
|
+
+ `${platformObjectStorageEndpointLimits.maximumEndpointLength} characters`);
|
|
87
|
+
} else {
|
|
88
|
+
let parsedEndpoint: URL | undefined;
|
|
89
|
+
try {
|
|
90
|
+
parsedEndpoint = new URL(endpoint);
|
|
91
|
+
} catch {
|
|
92
|
+
errors.push(`${pathArg} endpoint must be a parsable URL`);
|
|
93
|
+
}
|
|
94
|
+
if (parsedEndpoint) {
|
|
95
|
+
if (parsedEndpoint.protocol !== 'http:' && parsedEndpoint.protocol !== 'https:') {
|
|
96
|
+
errors.push(`${pathArg} endpoint must use the http or https scheme`);
|
|
97
|
+
}
|
|
98
|
+
if (parsedEndpoint.username || parsedEndpoint.password) {
|
|
99
|
+
errors.push(`${pathArg} endpoint must not carry userinfo`);
|
|
100
|
+
}
|
|
101
|
+
if (parsedEndpoint.origin !== endpoint) {
|
|
102
|
+
errors.push(`${pathArg} endpoint must be a canonical origin without a path, query, `
|
|
103
|
+
+ 'fragment, trailing slash or default port');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof valueArg.region !== 'string'
|
|
108
|
+
|| !platformObjectStorageRegionPattern.test(valueArg.region)) {
|
|
109
|
+
errors.push(`${pathArg} region must be 1 to `
|
|
110
|
+
+ `${platformObjectStorageEndpointLimits.maximumRegionLength} lowercase letters, digits `
|
|
111
|
+
+ 'and inner hyphens');
|
|
112
|
+
}
|
|
113
|
+
return errors;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The non-secret environment names that tell a workload where its bucket is.
|
|
118
|
+
*
|
|
119
|
+
* These are the names Corestore has always handed a workload for a bucket, so an application that
|
|
120
|
+
* read them keeps working unchanged. The six credential aliases (`S3_ACCESS_KEY`,
|
|
121
|
+
* `S3_ACCESS_KEY_ID`, `S3_SECRET_KEY`, `S3_SECRET_ACCESS_KEY`, `AWS_ACCESS_KEY_ID`,
|
|
122
|
+
* `AWS_SECRET_ACCESS_KEY`) are not among them: they are Secrets and reach the workload only through
|
|
123
|
+
* the binding's sealed Secret set.
|
|
124
|
+
*
|
|
125
|
+
* - `S3_ENDPOINT`, `AWS_ENDPOINT_URL`: the endpoint origin, identical.
|
|
126
|
+
* - `S3_ENDPOINT_HOST`: the origin's host, as the URL states it (an IPv6 literal keeps its brackets).
|
|
127
|
+
* - `S3_PORT`: the origin's port in decimal, `80` or `443` when the origin states none.
|
|
128
|
+
* - `S3_USE_SSL`: `true` for an `https:` origin, `false` for `http:`.
|
|
129
|
+
* - `S3_REGION`, `AWS_REGION`: the signing region, identical.
|
|
130
|
+
* - `S3_BUCKET`: the binding's bucket, `IPlatformBinding.objectstorageBucketName`.
|
|
131
|
+
* - `AWS_S3_FORCE_PATH_STYLE`: always `true` — a node-local origin has no wildcard DNS and a bucket
|
|
132
|
+
* name may contain dots, so path-style addressing is the only one that reaches the bucket.
|
|
133
|
+
*/
|
|
134
|
+
export const objectStorageEndpointEnvironmentKeys = Object.freeze([
|
|
135
|
+
'S3_ENDPOINT',
|
|
136
|
+
'S3_ENDPOINT_HOST',
|
|
137
|
+
'S3_PORT',
|
|
138
|
+
'S3_REGION',
|
|
139
|
+
'S3_BUCKET',
|
|
140
|
+
'S3_USE_SSL',
|
|
141
|
+
'AWS_ENDPOINT_URL',
|
|
142
|
+
'AWS_REGION',
|
|
143
|
+
'AWS_S3_FORCE_PATH_STYLE',
|
|
144
|
+
] as const);
|
|
145
|
+
|
|
146
|
+
export type TObjectStorageEndpointEnvironmentKey =
|
|
147
|
+
typeof objectStorageEndpointEnvironmentKeys[number];
|
|
148
|
+
|
|
149
|
+
export type TObjectStorageEndpointEnvironment = {
|
|
150
|
+
[TKey in TObjectStorageEndpointEnvironmentKey]: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The endpoint environment for one bucket.
|
|
155
|
+
*
|
|
156
|
+
* Throws the first reason the endpoint or the bucket name is refused; nothing is defaulted.
|
|
157
|
+
*/
|
|
158
|
+
export const createObjectStorageEndpointEnvironment = (
|
|
159
|
+
endpointArg: IPlatformObjectStorageEndpoint,
|
|
160
|
+
bucketNameArg: string,
|
|
161
|
+
): TObjectStorageEndpointEnvironment => {
|
|
162
|
+
const errors = [
|
|
163
|
+
...validatePlatformObjectStorageEndpoint(endpointArg),
|
|
164
|
+
...validatePlatformObjectStorageBucketName(bucketNameArg),
|
|
165
|
+
];
|
|
166
|
+
if (errors.length > 0) throw new Error(errors[0]);
|
|
167
|
+
const origin = new URL(endpointArg.endpoint);
|
|
168
|
+
const useSsl = origin.protocol === 'https:';
|
|
169
|
+
return {
|
|
170
|
+
S3_ENDPOINT: endpointArg.endpoint,
|
|
171
|
+
S3_ENDPOINT_HOST: origin.hostname,
|
|
172
|
+
S3_PORT: origin.port || (useSsl ? '443' : '80'),
|
|
173
|
+
S3_REGION: endpointArg.region,
|
|
174
|
+
S3_BUCKET: bucketNameArg,
|
|
175
|
+
S3_USE_SSL: useSsl ? 'true' : 'false',
|
|
176
|
+
AWS_ENDPOINT_URL: endpointArg.endpoint,
|
|
177
|
+
AWS_REGION: endpointArg.region,
|
|
178
|
+
AWS_S3_FORCE_PATH_STYLE: 'true',
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Validates an endpoint environment: exactly its nine keys, each value the one
|
|
184
|
+
* `createObjectStorageEndpointEnvironment()` derives from the same endpoint and bucket.
|
|
185
|
+
*/
|
|
186
|
+
export const validateObjectStorageEndpointEnvironment = (
|
|
187
|
+
environmentArg: unknown,
|
|
188
|
+
pathArg = 'objectstorage endpoint environment',
|
|
189
|
+
): string[] => {
|
|
190
|
+
if (!isPlainObject(environmentArg)
|
|
191
|
+
|| Object.keys(environmentArg).length !== objectStorageEndpointEnvironmentKeys.length
|
|
192
|
+
|| objectStorageEndpointEnvironmentKeys.some((keyArg) => (
|
|
193
|
+
!Object.hasOwn(environmentArg, keyArg) || typeof environmentArg[keyArg] !== 'string'
|
|
194
|
+
))) {
|
|
195
|
+
return [`${pathArg} must contain exactly its string keys`];
|
|
196
|
+
}
|
|
197
|
+
const endpoint = { endpoint: environmentArg.S3_ENDPOINT, region: environmentArg.S3_REGION };
|
|
198
|
+
const errors = [
|
|
199
|
+
...validatePlatformObjectStorageEndpoint(endpoint, pathArg),
|
|
200
|
+
...validatePlatformObjectStorageBucketName(environmentArg.S3_BUCKET, `${pathArg} S3_BUCKET`),
|
|
201
|
+
];
|
|
202
|
+
if (errors.length > 0) return errors;
|
|
203
|
+
const expected = createObjectStorageEndpointEnvironment(
|
|
204
|
+
endpoint as IPlatformObjectStorageEndpoint,
|
|
205
|
+
environmentArg.S3_BUCKET as string,
|
|
206
|
+
);
|
|
207
|
+
for (const key of objectStorageEndpointEnvironmentKeys) {
|
|
208
|
+
if (environmentArg[key] !== expected[key]) {
|
|
209
|
+
errors.push(`${pathArg} ${key} must equal the value derived from its endpoint`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return errors;
|
|
213
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { IPlatformBinding } from './types.js';
|
|
2
|
+
import type {
|
|
3
|
+
IPlatformObjectStorageEndpoint,
|
|
4
|
+
TObjectStorageEndpointEnvironment,
|
|
5
|
+
TObjectStorageEndpointEnvironmentKey,
|
|
6
|
+
} from './objectstorage.js';
|
|
7
|
+
import {
|
|
8
|
+
createObjectStorageEndpointEnvironment,
|
|
9
|
+
objectStorageEndpointEnvironmentKeys,
|
|
10
|
+
validateObjectStorageEndpointEnvironment,
|
|
11
|
+
validatePlatformObjectStorageBucketName,
|
|
12
|
+
} from './objectstorage.js';
|
|
13
|
+
import type {
|
|
14
|
+
IObjectStorageRetentionEvidence,
|
|
15
|
+
IObjectStorageRetentionIntent,
|
|
16
|
+
TObjectStorageRetentionEnvironment,
|
|
17
|
+
TObjectStorageRetentionEnvironmentKey,
|
|
18
|
+
} from './objectstorageretention.js';
|
|
19
|
+
import {
|
|
20
|
+
createObjectStorageRetentionEnvironment,
|
|
21
|
+
objectStorageRetentionEnvironmentKeys,
|
|
22
|
+
validateObjectStorageRetentionEnvironment,
|
|
23
|
+
validateObjectStorageRetentionIntent,
|
|
24
|
+
} from './objectstorageretention.js';
|
|
25
|
+
import { isPlainRecord, readDataProperties } from '../private/dataproperties.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every non-secret environment name an object-storage binding can give a workload: the nine
|
|
29
|
+
* endpoint names first, then the eleven retention names.
|
|
30
|
+
*/
|
|
31
|
+
export const objectStorageBindingEnvironmentKeys = Object.freeze([
|
|
32
|
+
...objectStorageEndpointEnvironmentKeys,
|
|
33
|
+
...objectStorageRetentionEnvironmentKeys,
|
|
34
|
+
] as const);
|
|
35
|
+
|
|
36
|
+
export type TObjectStorageBindingEnvironmentKey =
|
|
37
|
+
| TObjectStorageEndpointEnvironmentKey
|
|
38
|
+
| TObjectStorageRetentionEnvironmentKey;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The non-secret environment of one object-storage binding: always the endpoint names, and the
|
|
42
|
+
* retention names exactly when the binding declares compliance retention. Never a credential.
|
|
43
|
+
*/
|
|
44
|
+
export type TObjectStorageBindingEnvironment =
|
|
45
|
+
| TObjectStorageEndpointEnvironment
|
|
46
|
+
| (TObjectStorageEndpointEnvironment & TObjectStorageRetentionEnvironment);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The environment Cloudly composes into a workload's runtime configuration for one binding.
|
|
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.
|
|
68
|
+
*/
|
|
69
|
+
export const createObjectStorageBindingEnvironment = async (
|
|
70
|
+
bindingArg: IPlatformBinding,
|
|
71
|
+
endpointArg: IPlatformObjectStorageEndpoint,
|
|
72
|
+
): Promise<TObjectStorageBindingEnvironment> => {
|
|
73
|
+
const binding = readDataProperties(bindingArg);
|
|
74
|
+
if (binding === null || binding.capability !== 'objectstorage') {
|
|
75
|
+
throw new Error('objectstorage binding environment requires an objectstorage binding');
|
|
76
|
+
}
|
|
77
|
+
const bindingName = typeof binding.id === 'string' ? binding.id : '(unnamed)';
|
|
78
|
+
if (validatePlatformObjectStorageBucketName(binding.objectstorageBucketName).length > 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`objectstorage binding ${bindingName} carries no canonical bucket name; Cloudly owns it and `
|
|
81
|
+
+ 'no environment invents one',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const bucketName = binding.objectstorageBucketName as string;
|
|
85
|
+
const endpointEnvironment = createObjectStorageEndpointEnvironment(endpointArg, bucketName);
|
|
86
|
+
const retention = binding.objectstorageRetention;
|
|
87
|
+
if (retention === undefined) {
|
|
88
|
+
return endpointEnvironment;
|
|
89
|
+
}
|
|
90
|
+
const retentionBinding = readDataProperties(retention);
|
|
91
|
+
if (retentionBinding === null
|
|
92
|
+
|| validateObjectStorageRetentionIntent(retentionBinding.intent).length > 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`objectstorage binding ${bindingName} must declare canonical compliance-retention intent`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const evidence = retentionBinding.evidence;
|
|
98
|
+
if (evidence === undefined) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`objectstorage binding ${bindingName} declares compliance retention without verified `
|
|
101
|
+
+ 'evidence; its workload environment waits for the evidence',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const authority = isPlainRecord(evidence) ? readDataProperties(evidence.authority) : null;
|
|
105
|
+
if (authority === null) {
|
|
106
|
+
throw new Error('objectstorage retention evidence must use its exact schema');
|
|
107
|
+
}
|
|
108
|
+
const retentionEnvironment = await createObjectStorageRetentionEnvironment(
|
|
109
|
+
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
|
+
},
|
|
118
|
+
);
|
|
119
|
+
return { ...endpointEnvironment, ...retentionEnvironment };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validates one binding's environment: the nine endpoint names, the eleven retention names all or
|
|
124
|
+
* none, and nothing else — so a credential alias, or any other name, is refused.
|
|
125
|
+
*/
|
|
126
|
+
export const validateObjectStorageBindingEnvironment = async (
|
|
127
|
+
environmentArg: unknown,
|
|
128
|
+
pathArg = 'objectstorage binding environment',
|
|
129
|
+
): Promise<string[]> => {
|
|
130
|
+
const environment = readDataProperties(environmentArg);
|
|
131
|
+
if (environment === null) {
|
|
132
|
+
return [`${pathArg} must be a plain record of data properties`];
|
|
133
|
+
}
|
|
134
|
+
const allowedKeys: readonly string[] = objectStorageBindingEnvironmentKeys;
|
|
135
|
+
const foreignKeys = Object.keys(environment).filter((keyArg) => !allowedKeys.includes(keyArg));
|
|
136
|
+
if (foreignKeys.length > 0) {
|
|
137
|
+
return [`${pathArg} must not contain ${foreignKeys.sort().join(', ')}`];
|
|
138
|
+
}
|
|
139
|
+
const endpointEnvironment: Record<string, unknown> = {};
|
|
140
|
+
for (const key of objectStorageEndpointEnvironmentKeys) {
|
|
141
|
+
if (Object.hasOwn(environment, key)) endpointEnvironment[key] = environment[key];
|
|
142
|
+
}
|
|
143
|
+
const errors = validateObjectStorageEndpointEnvironment(endpointEnvironment, pathArg);
|
|
144
|
+
const retentionKeys = objectStorageRetentionEnvironmentKeys.filter((keyArg) => (
|
|
145
|
+
Object.hasOwn(environment, keyArg)
|
|
146
|
+
));
|
|
147
|
+
if (retentionKeys.length > 0) {
|
|
148
|
+
const retentionEnvironment: Record<string, unknown> = {};
|
|
149
|
+
for (const key of retentionKeys) retentionEnvironment[key] = environment[key];
|
|
150
|
+
errors.push(...await validateObjectStorageRetentionEnvironment(retentionEnvironment, pathArg));
|
|
151
|
+
}
|
|
152
|
+
return errors;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A workload environment with one binding's environment added.
|
|
157
|
+
*
|
|
158
|
+
* Refuses, naming every key, when the environment already holds one of the binding's names: a
|
|
159
|
+
* service that sets `S3_ENDPOINT` itself, or a second object-storage binding on the same service,
|
|
160
|
+
* would otherwise have one value silently replace the other. The inputs are not changed.
|
|
161
|
+
*/
|
|
162
|
+
export const mergeObjectStorageBindingEnvironment = (
|
|
163
|
+
environmentArg: Readonly<Record<string, string>>,
|
|
164
|
+
bindingEnvironmentArg: TObjectStorageBindingEnvironment,
|
|
165
|
+
): Record<string, string> => {
|
|
166
|
+
const collisions = Object.keys(bindingEnvironmentArg)
|
|
167
|
+
.filter((keyArg) => Object.hasOwn(environmentArg, keyArg))
|
|
168
|
+
.sort();
|
|
169
|
+
if (collisions.length > 0) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`objectstorage binding environment collides with ${collisions.join(', ')} already set on the `
|
|
172
|
+
+ 'workload',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return { ...environmentArg, ...bindingEnvironmentArg };
|
|
176
|
+
};
|
|
@@ -370,3 +370,144 @@ export const validatePlatformBindingObjectStorageRetention = async (
|
|
|
370
370
|
}
|
|
371
371
|
return validateObjectStorageRetentionBinding(bindingArg.objectstorageRetention, expectedArg);
|
|
372
372
|
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The non-secret environment names that tell a workload which retention its bucket carries.
|
|
376
|
+
*
|
|
377
|
+
* The names are the ones the cluster relay handed workloads on the Docker path, so an application
|
|
378
|
+
* that read them keeps working unchanged. Every value is a projection of verified
|
|
379
|
+
* `IObjectStorageRetentionEvidence`; none is a credential.
|
|
380
|
+
*
|
|
381
|
+
* - `S3_RETENTION_MODE`: `intent.mode`, always `compliance`.
|
|
382
|
+
* - `S3_RETENTION_POLICY_ID`: `intent.policyId`.
|
|
383
|
+
* - `S3_RETENTION_DURATION_SECONDS`: `intent.retentionDurationSeconds` in decimal.
|
|
384
|
+
* - `S3_RETENTION_INTENT_SHA256`: `intentSha256`.
|
|
385
|
+
* - `S3_RETENTION_SENTINEL_KEY`: `receipt.sentinelKey`.
|
|
386
|
+
* - `S3_RETENTION_CONFIGURED_AT`: `receipt.configuredAt`, Unix milliseconds in decimal.
|
|
387
|
+
* - `S3_RETENTION_SENTINEL_CREATED_AT`: `receipt.sentinel.createdAt`, Unix milliseconds in decimal.
|
|
388
|
+
* - `S3_RETENTION_SENTINEL_RETAIN_UNTIL`: `receipt.sentinel.retainUntil`, Unix milliseconds in
|
|
389
|
+
* decimal.
|
|
390
|
+
* - `S3_RETENTION_SENTINEL_PAYLOAD_SHA256`: `receipt.sentinel.payloadSha256`.
|
|
391
|
+
* - `S3_RETENTION_SENTINEL_METADATA_SHA256`: `receipt.sentinel.metadataSha256`.
|
|
392
|
+
* - `S3_RETENTION_SENTINEL_ETAG`: `receipt.sentinel.etag`.
|
|
393
|
+
*/
|
|
394
|
+
export const objectStorageRetentionEnvironmentKeys = Object.freeze([
|
|
395
|
+
'S3_RETENTION_MODE',
|
|
396
|
+
'S3_RETENTION_POLICY_ID',
|
|
397
|
+
'S3_RETENTION_DURATION_SECONDS',
|
|
398
|
+
'S3_RETENTION_INTENT_SHA256',
|
|
399
|
+
'S3_RETENTION_SENTINEL_KEY',
|
|
400
|
+
'S3_RETENTION_CONFIGURED_AT',
|
|
401
|
+
'S3_RETENTION_SENTINEL_CREATED_AT',
|
|
402
|
+
'S3_RETENTION_SENTINEL_RETAIN_UNTIL',
|
|
403
|
+
'S3_RETENTION_SENTINEL_PAYLOAD_SHA256',
|
|
404
|
+
'S3_RETENTION_SENTINEL_METADATA_SHA256',
|
|
405
|
+
'S3_RETENTION_SENTINEL_ETAG',
|
|
406
|
+
] as const);
|
|
407
|
+
|
|
408
|
+
export type TObjectStorageRetentionEnvironmentKey =
|
|
409
|
+
typeof objectStorageRetentionEnvironmentKeys[number];
|
|
410
|
+
|
|
411
|
+
export type TObjectStorageRetentionEnvironment = {
|
|
412
|
+
[TKey in TObjectStorageRetentionEnvironmentKey]: string;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The retention environment of evidence that proves `expectedArg`.
|
|
417
|
+
*
|
|
418
|
+
* The evidence is validated against the expectation first, with the same validator every other
|
|
419
|
+
* consumer of retention evidence uses; the first reason it is refused is thrown. A workload is
|
|
420
|
+
* never told about a retention nobody proved.
|
|
421
|
+
*/
|
|
422
|
+
export const createObjectStorageRetentionEnvironment = async (
|
|
423
|
+
evidenceArg: IObjectStorageRetentionEvidence,
|
|
424
|
+
expectedArg: IObjectStorageRetentionEvidenceExpectation,
|
|
425
|
+
): Promise<TObjectStorageRetentionEnvironment> => {
|
|
426
|
+
const errors = await validateObjectStorageRetentionEvidence(evidenceArg, expectedArg);
|
|
427
|
+
if (errors.length > 0) throw new Error(errors[0]);
|
|
428
|
+
return {
|
|
429
|
+
S3_RETENTION_MODE: evidenceArg.intent.mode,
|
|
430
|
+
S3_RETENTION_POLICY_ID: evidenceArg.intent.policyId,
|
|
431
|
+
S3_RETENTION_DURATION_SECONDS: String(evidenceArg.intent.retentionDurationSeconds),
|
|
432
|
+
S3_RETENTION_INTENT_SHA256: evidenceArg.intentSha256,
|
|
433
|
+
S3_RETENTION_SENTINEL_KEY: evidenceArg.receipt.sentinelKey,
|
|
434
|
+
S3_RETENTION_CONFIGURED_AT: String(evidenceArg.receipt.configuredAt),
|
|
435
|
+
S3_RETENTION_SENTINEL_CREATED_AT: String(evidenceArg.receipt.sentinel.createdAt),
|
|
436
|
+
S3_RETENTION_SENTINEL_RETAIN_UNTIL: String(evidenceArg.receipt.sentinel.retainUntil),
|
|
437
|
+
S3_RETENTION_SENTINEL_PAYLOAD_SHA256: evidenceArg.receipt.sentinel.payloadSha256,
|
|
438
|
+
S3_RETENTION_SENTINEL_METADATA_SHA256: evidenceArg.receipt.sentinel.metadataSha256,
|
|
439
|
+
S3_RETENTION_SENTINEL_ETAG: evidenceArg.receipt.sentinel.etag,
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const canonicalDecimalPattern = /^[1-9][0-9]*$/;
|
|
444
|
+
|
|
445
|
+
const readCanonicalPositiveInteger = (valueArg: unknown): number | undefined => (
|
|
446
|
+
typeof valueArg === 'string'
|
|
447
|
+
&& canonicalDecimalPattern.test(valueArg)
|
|
448
|
+
&& Number.isSafeInteger(Number(valueArg))
|
|
449
|
+
? Number(valueArg)
|
|
450
|
+
: undefined
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validates a retention environment on its own: exactly its eleven keys, canonical values, an
|
|
455
|
+
* intent digest that matches the stated intent, and sentinel timestamps that agree with the
|
|
456
|
+
* duration. It cannot prove the authority or the bucket the evidence was bound to — the
|
|
457
|
+
* environment does not carry them — so it judges the shape a workload reads, not the proof.
|
|
458
|
+
*/
|
|
459
|
+
export const validateObjectStorageRetentionEnvironment = async (
|
|
460
|
+
environmentArg: unknown,
|
|
461
|
+
pathArg = 'objectstorage retention environment',
|
|
462
|
+
): Promise<string[]> => {
|
|
463
|
+
if (!isRecord(environmentArg)
|
|
464
|
+
|| !hasExactKeys(environmentArg, objectStorageRetentionEnvironmentKeys)
|
|
465
|
+
|| objectStorageRetentionEnvironmentKeys.some((keyArg) => (
|
|
466
|
+
typeof environmentArg[keyArg] !== 'string'
|
|
467
|
+
))) {
|
|
468
|
+
return [`${pathArg} must contain exactly its string keys`];
|
|
469
|
+
}
|
|
470
|
+
const environment = environmentArg as TObjectStorageRetentionEnvironment;
|
|
471
|
+
const errors: string[] = [];
|
|
472
|
+
const duration = readCanonicalPositiveInteger(environment.S3_RETENTION_DURATION_SECONDS);
|
|
473
|
+
const intent = {
|
|
474
|
+
mode: environment.S3_RETENTION_MODE,
|
|
475
|
+
policyId: environment.S3_RETENTION_POLICY_ID,
|
|
476
|
+
retentionDurationSeconds: duration,
|
|
477
|
+
};
|
|
478
|
+
const intentErrors = validateObjectStorageRetentionIntent(intent, `${pathArg} intent`);
|
|
479
|
+
errors.push(...intentErrors);
|
|
480
|
+
if (!sha256Pattern.test(environment.S3_RETENTION_INTENT_SHA256)
|
|
481
|
+
|| intentErrors.length === 0
|
|
482
|
+
&& environment.S3_RETENTION_INTENT_SHA256 !== await computeObjectStorageRetentionIntentSha256(
|
|
483
|
+
intent as IObjectStorageRetentionIntent,
|
|
484
|
+
)) {
|
|
485
|
+
errors.push(`${pathArg} S3_RETENTION_INTENT_SHA256 must be the digest of the stated intent`);
|
|
486
|
+
}
|
|
487
|
+
if (!sentinelKeyPattern.test(environment.S3_RETENTION_SENTINEL_KEY)) {
|
|
488
|
+
errors.push(`${pathArg} S3_RETENTION_SENTINEL_KEY must be canonical`);
|
|
489
|
+
}
|
|
490
|
+
const configuredAt = readCanonicalPositiveInteger(environment.S3_RETENTION_CONFIGURED_AT);
|
|
491
|
+
const createdAt = readCanonicalPositiveInteger(environment.S3_RETENTION_SENTINEL_CREATED_AT);
|
|
492
|
+
const retainUntil = readCanonicalPositiveInteger(environment.S3_RETENTION_SENTINEL_RETAIN_UNTIL);
|
|
493
|
+
if (configuredAt === undefined
|
|
494
|
+
|| createdAt === undefined
|
|
495
|
+
|| retainUntil === undefined
|
|
496
|
+
|| createdAt < configuredAt) {
|
|
497
|
+
errors.push(`${pathArg} timestamps must be canonical positive decimals in order`);
|
|
498
|
+
} else if (intentErrors.length === 0) {
|
|
499
|
+
try {
|
|
500
|
+
if (retainUntil !== createObjectStorageRetentionRetainUntil(createdAt, duration as number)) {
|
|
501
|
+
errors.push(`${pathArg} S3_RETENTION_SENTINEL_RETAIN_UNTIL must equal createdAt plus duration`);
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
errors.push(`${pathArg} S3_RETENTION_SENTINEL_RETAIN_UNTIL arithmetic must be safe`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!sha256Pattern.test(environment.S3_RETENTION_SENTINEL_PAYLOAD_SHA256)
|
|
508
|
+
|| !sha256Pattern.test(environment.S3_RETENTION_SENTINEL_METADATA_SHA256)
|
|
509
|
+
|| !etagPattern.test(environment.S3_RETENTION_SENTINEL_ETAG)) {
|
|
510
|
+
errors.push(`${pathArg} sentinel digests and ETag must be canonical`);
|
|
511
|
+
}
|
|
512
|
+
return errors;
|
|
513
|
+
};
|
package/ts/requests/node.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ICluster } from '../data/cluster.js';
|
|
|
4
4
|
import type { IClusterNode, INodeAction, INodeMetricsSample, TNodeActionName } from '../data/clusternode.js';
|
|
5
5
|
import type { IDeployment } from '../data/deployment.js';
|
|
6
6
|
import type { IIdentity } from '../data/user.js';
|
|
7
|
+
import type { IPlatformObjectStorageEndpoint } from '../platform/objectstorage.js';
|
|
7
8
|
import type {
|
|
8
9
|
INodeCredentialMetadata,
|
|
9
10
|
INodeCredentialMutationPreconditions,
|
|
@@ -282,6 +283,38 @@ export interface IReq_Any_Cloudly_SetClusterNodeCorestoreEndpoint extends plugin
|
|
|
282
283
|
};
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
/**
|
|
287
|
+
* An administrator states where a workload on one node reaches the node's Corestore object
|
|
288
|
+
* storage, and the region it signs for: `IClusterNode.data.corestoreObjectStorage`.
|
|
289
|
+
*
|
|
290
|
+
* A request of its own, separate from `setClusterNodeCorestoreEndpoint`, because the two are
|
|
291
|
+
* separate facts with separate readers — the relay dials the control endpoint, a workload dials
|
|
292
|
+
* this one — and setting one never touches the other. A caller that states only the control
|
|
293
|
+
* endpoint leaves this one as it stands.
|
|
294
|
+
*
|
|
295
|
+
* Cloudly validates it with `validatePlatformObjectStorageEndpoint` before it writes and takes the
|
|
296
|
+
* node's own real-write fence inside the writing transaction, as for the control endpoint.
|
|
297
|
+
* `objectStorage: null` clears it, which is the same fact as never having set one: no
|
|
298
|
+
* object-storage environment can be composed for a binding on this node. The endpoint says where,
|
|
299
|
+
* never with what — no credential travels in this request or in the node document.
|
|
300
|
+
*/
|
|
301
|
+
export interface IReq_Any_Cloudly_SetClusterNodeCorestoreObjectStorageEndpoint extends plugins.typedrequestInterfaces.implementsTR<
|
|
302
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
303
|
+
IReq_Any_Cloudly_SetClusterNodeCorestoreObjectStorageEndpoint
|
|
304
|
+
> {
|
|
305
|
+
method: 'setClusterNodeCorestoreObjectStorageEndpoint';
|
|
306
|
+
request: {
|
|
307
|
+
identity: IIdentity;
|
|
308
|
+
nodeId: string;
|
|
309
|
+
/** The node's object-storage endpoint and region, or `null` to state that it has none. */
|
|
310
|
+
objectStorage: IPlatformObjectStorageEndpoint | null;
|
|
311
|
+
};
|
|
312
|
+
response: {
|
|
313
|
+
/** The node as it now stands, so a caller needs no second read to see what it set. */
|
|
314
|
+
node: IClusterNode;
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
285
318
|
export interface IReq_Any_Cloudly_CreateNodeAction extends plugins.typedrequestInterfaces.implementsTR<
|
|
286
319
|
plugins.typedrequestInterfaces.ITypedRequest,
|
|
287
320
|
IReq_Any_Cloudly_CreateNodeAction
|