@serve.zone/interfaces 32.23.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.
@@ -6,17 +6,77 @@ import { snapshotRuntimeSessionBinding } from './runtimesession.js';
6
6
  import { captureNetwork, networkController, networkDigest, networkEqual, networkHash, networkId,
7
7
  networkNodeId, networkRecord, networkReference, networkReferenceOf, networkRevision, requireNetwork,
8
8
  } from '../private/networkcontract.js';
9
+ import { parseCanonicalIpAddress } from '../private/ipaddress.js';
9
10
  import { isRuntimeIdentityNumber } from '../private/runtimepolicy.js';
10
11
  import { createStorageContractReaders } from '../private/storagecontract.js';
11
12
 
13
+ /** A node-local directory the node derives from the claim's identity under a root only the node knows. */
14
+ export type TRuntimeLocalStorageDirectorySource = {
15
+ kind: 'directory';
16
+ };
17
+
18
+ /**
19
+ * A directory on an NFS export. The node mounts `server:export` and uses `subPath` below it.
20
+ *
21
+ * `server` is a lowercase fully qualified hostname or a canonical IP literal, on the standard port.
22
+ * `export` is the canonical absolute path the server exports, `/` included. `subPath` is the
23
+ * volume's directory below the export, as canonical relative segments, or `.` for the export
24
+ * itself. `version` pins one protocol version. NFS authenticates the node's host, not a user, so
25
+ * the source names no credential.
26
+ */
27
+ export type TRuntimeLocalStorageNfsSource = {
28
+ kind: 'nfs';
29
+ server: string;
30
+ export: string;
31
+ subPath: string;
32
+ version: TRuntimeLocalStorageNfsVersion;
33
+ };
34
+
12
35
  /**
13
- * Where a local storage claim's bytes live, named by kind and never by path.
36
+ * A directory on an SMB share. The node mounts `//server/share` and uses `subPath` below it.
14
37
  *
15
- * `directory` is a node-local directory the node derives from the claim's identity under a root
16
- * only the node knows. Network filesystems join this union as further kinds; their credentials
17
- * travel as secrets and never inside a claim.
38
+ * `server` and `subPath` follow the NFS rules; `share` is one share name. `version` pins one
39
+ * dialect. `credential` names the credential authority the node authenticates with, by reference
40
+ * only: the claim never carries a user name, password or domain, and the node resolves the material
41
+ * through the controller's secret delivery.
18
42
  */
19
- export type TRuntimeLocalStorageSource = { kind: 'directory' };
43
+ export type TRuntimeLocalStorageSmbSource = {
44
+ kind: 'smb';
45
+ server: string;
46
+ share: string;
47
+ subPath: string;
48
+ version: TRuntimeLocalStorageSmbVersion;
49
+ credential: IRuntimeAuthorityReference;
50
+ };
51
+
52
+ /**
53
+ * Where a local storage claim's bytes live, named by kind and never by a node's host path.
54
+ *
55
+ * A network source names the remote location and nothing about how to mount it: no mount option,
56
+ * no port and no credential value. The node derives every mount parameter from these fields alone.
57
+ * The kinds are type aliases, as `{ kind: 'directory' }` always was, so a source stays assignable
58
+ * to a plain record.
59
+ */
60
+ export type TRuntimeLocalStorageSource =
61
+ | TRuntimeLocalStorageDirectorySource
62
+ | TRuntimeLocalStorageNfsSource
63
+ | TRuntimeLocalStorageSmbSource;
64
+
65
+ /**
66
+ * The NFS protocol versions a claim may pin, spelled as the kernel's NFS `vers=` parameter spells
67
+ * them. NFS version 4 is always stated with its minor version: the bare `4` negotiates one, which
68
+ * the kernel keeps only for backward compatibility, and a claim must mean the same mount on every
69
+ * node. Version 2 is refused.
70
+ */
71
+ export type TRuntimeLocalStorageNfsVersion = (typeof runtimeLocalStorageClaimContract.nfsVersions)[number];
72
+
73
+ /**
74
+ * The SMB dialects a claim may pin, spelled as the kernel's CIFS `vers=` parameter spells them.
75
+ * Only SMB 3 dialects, the first that encrypt; the kernel refuses 1.0 and 2.0 unless built with
76
+ * insecure legacy dialects. `3` alone negotiates one and is refused for the same reason as a bare
77
+ * NFS `4`.
78
+ */
79
+ export type TRuntimeLocalStorageSmbVersion = (typeof runtimeLocalStorageClaimContract.smbVersions)[number];
20
80
 
21
81
  /** The owner and permission bits a node gives an `empty` volume's root directory when it creates it. */
22
82
  export interface IRuntimeLocalStorageOwnership {
@@ -39,15 +99,19 @@ export interface IRuntimeLocalStorageMount {
39
99
  export type TRuntimeLocalStorageLifecycle = 'bound' | 'purge';
40
100
 
41
101
  /**
42
- * A controller's authority for one retained, node-local volume of one service.
102
+ * A controller's authority for one retained volume of one service, mounted on one node.
43
103
  *
44
- * Cloudly and Onebox issue it alike. It names the volume by identity only: no host path, no
45
- * device, no mount option and no credential. The node resolves the identity to its own storage.
104
+ * Cloudly and Onebox issue it alike. It never names a host path, a device, a mount option or a
105
+ * credential value. A `directory` source names the volume by identity only, and the node resolves
106
+ * it to its own storage; a network source names the remote directory and, for SMB, the credential
107
+ * by reference.
46
108
  *
47
109
  * `initialization` says what the node does when the volume does not exist yet. `empty` creates an
48
110
  * empty root with `initialOwnership`, which it must state, so a workload that runs as the image's
49
111
  * own user never meets a root-owned directory by accident. `import` waits for the volume's bytes to
50
- * be imported and states no ownership: an imported tree keeps the ownership it carries.
112
+ * be imported and states no ownership: an imported tree keeps the ownership it carries. SMB carries
113
+ * no POSIX ownership, so the node presents an SMB volume with `initialOwnership`, and an `smb`
114
+ * source is always `empty`.
51
115
  */
52
116
  export interface IRuntimeLocalStorageClaim {
53
117
  id: string;
@@ -104,6 +168,16 @@ export const runtimeLocalStorageClaimContract = Object.freeze({
104
168
  reclaimPolicy: 'retain',
105
169
  /** Every permission bit a claim may state: setgid, sticky and the rwx triplets. */
106
170
  directoryModeBits: 0o3777,
171
+ nfsVersions: ['3', '4.0', '4.1', '4.2'],
172
+ smbVersions: ['3.0', '3.02', '3.1.1'],
173
+ /** The longest hostname a network source may name, in characters. */
174
+ maximumServerLength: 253,
175
+ /** The longest `export` or `subPath`, in characters; every character is printable ASCII. */
176
+ maximumPathLength: 1024,
177
+ /** The longest segment of an `export` or `subPath`. */
178
+ maximumSegmentLength: 255,
179
+ /** The longest SMB share name. */
180
+ maximumShareLength: 80,
107
181
  } as const);
108
182
 
109
183
  const fail = (): never => { throw new Error(runtimeLocalStorageClaimContract.error); };
@@ -112,7 +186,90 @@ const { readMountPath } = createStorageContractReaders(fail);
112
186
  const claimStates = ['ready', 'import-required', 'purged', 'uncertain'] as const;
113
187
  const responseStatuses = ['accepted', 'replay', 'historical'] as const;
114
188
 
189
+ const hostnameLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
190
+ /**
191
+ * Printable ASCII without a space. One spelling per name, so two claims cannot name one directory
192
+ * two ways, and nothing a mount source or an option string reads as a separator.
193
+ */
194
+ const printableAsciiPattern = /^[\x21-\x7e]+$/;
195
+ /** A comma or `=` would read as a mount option; the rest have meaning in a mount source or an SMB name. */
196
+ const pathSegmentForbiddenPattern = /["*,/:<=>?\\|]/;
197
+ /** SMB refuses these in a share name, beyond what it refuses in a path segment. */
198
+ const shareForbiddenPattern = /[+;\[\]]/;
199
+
200
+ /**
201
+ * A lowercase fully qualified hostname of two or more labels whose last label is not numeric, or a
202
+ * canonical IP literal. One label would resolve through each node's own search domains; a numeric
203
+ * last label would let a non-canonical address pass as a hostname. There is no port and no
204
+ * userinfo: the node connects to the protocol's standard port.
205
+ */
206
+ const serverOf = (valueArg: unknown): string => {
207
+ requireNetwork(typeof valueArg === 'string');
208
+ if (parseCanonicalIpAddress(valueArg) !== null) return valueArg;
209
+ const labels = valueArg.split('.');
210
+ requireNetwork(valueArg.length <= runtimeLocalStorageClaimContract.maximumServerLength && labels.length >= 2
211
+ && labels.every((labelArg) => hostnameLabelPattern.test(labelArg))
212
+ && !/^[0-9]+$/.test(labels[labels.length - 1]));
213
+ return valueArg;
214
+ };
215
+
216
+ const isPathSegment = (valueArg: string): boolean =>
217
+ valueArg.length <= runtimeLocalStorageClaimContract.maximumSegmentLength
218
+ && printableAsciiPattern.test(valueArg) && !pathSegmentForbiddenPattern.test(valueArg)
219
+ && valueArg !== '.' && valueArg !== '..';
220
+
221
+ const isRelativeSegments = (valueArg: string): boolean =>
222
+ valueArg.length <= runtimeLocalStorageClaimContract.maximumPathLength
223
+ && valueArg.split('/').every(isPathSegment);
224
+
225
+ /** `/`, or `/` followed by canonical segments without a trailing slash. */
226
+ const exportOf = (valueArg: unknown): string => {
227
+ requireNetwork(typeof valueArg === 'string' && valueArg.length <= runtimeLocalStorageClaimContract.maximumPathLength
228
+ && valueArg.startsWith('/') && (valueArg === '/' || isRelativeSegments(valueArg.slice(1))));
229
+ return valueArg;
230
+ };
231
+
232
+ /** `.`, or canonical relative segments. */
233
+ const subPathOf = (valueArg: unknown): string => {
234
+ requireNetwork(typeof valueArg === 'string' && (valueArg === '.' || isRelativeSegments(valueArg)));
235
+ return valueArg;
236
+ };
237
+
238
+ const shareOf = (valueArg: unknown): string => {
239
+ requireNetwork(typeof valueArg === 'string' && valueArg.length <= runtimeLocalStorageClaimContract.maximumShareLength
240
+ && isPathSegment(valueArg) && !shareForbiddenPattern.test(valueArg));
241
+ return valueArg;
242
+ };
243
+
244
+ const literalOf = <TLiteral extends string>(valueArg: unknown, allowedArg: readonly TLiteral[]): TLiteral => {
245
+ requireNetwork((allowedArg as readonly unknown[]).includes(valueArg));
246
+ return valueArg as TLiteral;
247
+ };
248
+
115
249
  const sourceOf = (valueArg: unknown): TRuntimeLocalStorageSource => {
250
+ requireNetwork(valueArg !== null && typeof valueArg === 'object');
251
+ const { kind } = valueArg as Record<string, unknown>;
252
+ if (kind === 'nfs') {
253
+ const value = networkRecord(valueArg, ['kind', 'server', 'export', 'subPath', 'version']);
254
+ return {
255
+ kind: 'nfs',
256
+ server: serverOf(value.server),
257
+ export: exportOf(value.export),
258
+ subPath: subPathOf(value.subPath),
259
+ version: literalOf(value.version, runtimeLocalStorageClaimContract.nfsVersions),
260
+ };
261
+ }
262
+ if (kind === 'smb') {
263
+ const value = networkRecord(valueArg, ['kind', 'server', 'share', 'subPath', 'version', 'credential']);
264
+ return {
265
+ kind: 'smb',
266
+ server: serverOf(value.server),
267
+ share: shareOf(value.share),
268
+ subPath: subPathOf(value.subPath),
269
+ version: literalOf(value.version, runtimeLocalStorageClaimContract.smbVersions),
270
+ credential: networkReference(value.credential),
271
+ };
272
+ }
116
273
  const value = networkRecord(valueArg, ['kind']);
117
274
  requireNetwork(value.kind === 'directory');
118
275
  return { kind: 'directory' };
@@ -142,6 +299,8 @@ export const snapshotRuntimeLocalStorageClaim = (valueArg: unknown): IRuntimeLoc
142
299
  || (value.initialization === 'import' && value.initialOwnership === null));
143
300
  requireNetwork(value.accessMode === 'ReadWriteOnce' && value.reclaimPolicy === 'retain');
144
301
  requireNetwork(value.lifecycle === 'bound' || (value.lifecycle === 'purge' && previous !== null));
302
+ const source = sourceOf(value.source);
303
+ requireNetwork(source.kind !== 'smb' || value.initialization === 'empty');
145
304
  return {
146
305
  id, generation, previous,
147
306
  controller: networkController(value.controller),
@@ -151,7 +310,7 @@ export const snapshotRuntimeLocalStorageClaim = (valueArg: unknown): IRuntimeLoc
151
310
  runtimeNamespace: networkId(value.runtimeNamespace),
152
311
  serviceId: networkId(value.serviceId),
153
312
  volumeId: networkId(value.volumeId),
154
- source: sourceOf(value.source),
313
+ source,
155
314
  initialization: value.initialization as IRuntimeLocalStorageClaim['initialization'],
156
315
  initialOwnership: value.initialOwnership === null ? null : ownershipOf(value.initialOwnership),
157
316
  mount: mountOf(value.mount),
@@ -5,6 +5,7 @@ import * as email from './email.js';
5
5
  import * as letter from './letter.js';
6
6
  import * as logging from './logging.js';
7
7
  import * as objectstorage from './objectstorage.js';
8
+ import * as objectstorageenvironment from './objectstorageenvironment.js';
8
9
  import * as objectstorageretention from './objectstorageretention.js';
9
10
  import * as pushnotification from './pushnotification.js';
10
11
  import * as sip from './sip.js';
@@ -21,6 +22,7 @@ export {
21
22
  letter,
22
23
  logging,
23
24
  objectstorage,
25
+ objectstorageenvironment,
24
26
  objectstorageretention,
25
27
  pushnotification,
26
28
  sip,
@@ -32,6 +34,7 @@ export {
32
34
 
33
35
  export * from './storage.js';
34
36
  export * from './objectstorage.js';
37
+ export * from './objectstorageenvironment.js';
35
38
  export * from './objectstorageretention.js';
36
39
  export * from './storagemigration.js';
37
40
  export * from './types.js';
@@ -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
+ };