@serve.zone/interfaces 32.22.0 → 32.24.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.
Files changed (36) hide show
  1. package/changelog.md +76 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/corestorelegacyadoption.d.ts +182 -0
  4. package/dist_ts/data/corestorelegacyadoption.js +292 -0
  5. package/dist_ts/data/index.d.ts +2 -0
  6. package/dist_ts/data/index.js +3 -1
  7. package/dist_ts/data/networkprotection.d.ts +13 -0
  8. package/dist_ts/data/networkprotection.js +8 -2
  9. package/dist_ts/data/runtimelocalstorageclaim.d.ts +73 -10
  10. package/dist_ts/data/runtimelocalstorageclaim.js +89 -2
  11. package/dist_ts/data/service.d.ts +15 -1
  12. package/dist_ts/data/service.js +1 -1
  13. package/dist_ts/data/serviceruntime.d.ts +8 -3
  14. package/dist_ts/data/serviceruntime.js +3 -2
  15. package/dist_ts/data/servicevolumeinitialization.d.ts +91 -0
  16. package/dist_ts/data/servicevolumeinitialization.js +93 -0
  17. package/dist_ts/platform/types.d.ts +45 -0
  18. package/dist_ts/platform/types.js +27 -2
  19. package/dist_ts/requests/corestore.d.ts +60 -0
  20. package/dist_ts/requests/corestore.js +1 -1
  21. package/dist_ts/requests/platform.d.ts +6 -1
  22. package/dist_ts/requests/service.d.ts +23 -0
  23. package/package.json +1 -1
  24. package/readme.md +186 -16
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/data/corestorelegacyadoption.ts +481 -0
  27. package/ts/data/index.ts +2 -0
  28. package/ts/data/networkprotection.ts +20 -1
  29. package/ts/data/runtimelocalstorageclaim.ts +169 -10
  30. package/ts/data/service.ts +16 -0
  31. package/ts/data/serviceruntime.ts +8 -2
  32. package/ts/data/servicevolumeinitialization.ts +159 -0
  33. package/ts/platform/types.ts +75 -0
  34. package/ts/requests/corestore.ts +79 -0
  35. package/ts/requests/platform.ts +6 -0
  36. package/ts/requests/service.ts +28 -0
@@ -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),
@@ -17,6 +17,7 @@ import type {
17
17
  IServiceSecretConfiguration,
18
18
  IServiceSecretDeploymentPlan,
19
19
  } from './secret.js';
20
+ import type { TServiceVolumeInitialization } from './servicevolumeinitialization.js';
20
21
 
21
22
  export interface IServiceVolume {
22
23
  /** Stable Docker volume name. If omitted, Coreflow derives one from service id and mount path. */
@@ -32,8 +33,17 @@ export interface IServiceVolume {
32
33
  backup?: boolean;
33
34
  /** Driver-specific options forwarded to Docker's VolumeDriver.Create request. */
34
35
  options?: Record<string, string>;
36
+ /**
37
+ * Server-managed: how the volume's node-local storage claim starts on a Pallet cluster. Only
38
+ * `setServiceVolumeInitialization` writes it; a volume without one is refused as
39
+ * `volume-initialization-unknown` instead of being created empty.
40
+ */
41
+ initialization?: TServiceVolumeInitialization;
35
42
  }
36
43
 
44
+ /** Caller-writable volume declaration. Its initialization has its own fenced request. */
45
+ export type TServiceWritableVolume = Omit<IServiceVolume, 'initialization'>;
46
+
37
47
  const organizationIdRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
38
48
 
39
49
  /** Validates the canonical organization authority used by service operations. */
@@ -255,12 +265,18 @@ export type TServiceWritableData = Omit<
255
265
  | 'secretConfiguration'
256
266
  | 'secretDeployment'
257
267
  | 'mail'
268
+ | 'volumes'
258
269
  > & {
259
270
  /**
260
271
  * Mail configuration stays caller-writable, except for the CoreMail binding
261
272
  * metadata Cloudly derives from reconciliation.
262
273
  */
263
274
  mail?: TServiceMailWritableConfig;
275
+ /**
276
+ * Volumes stay caller-writable, except for their initialization, which Cloudly refuses here.
277
+ * A stored initialization stays with the volume of the same mount path and goes with it.
278
+ */
279
+ volumes?: TServiceWritableVolume[];
264
280
  };
265
281
 
266
282
  /** Caller-writable update data. Null explicitly removes an existing argument vector. */
@@ -78,6 +78,7 @@ export const serviceRuntimeContract = Object.freeze({
78
78
 
79
79
  const serviceRuntimeWaitingReasons = [
80
80
  'node-network-pending', 'conflict', 'failure-backoff', 'storage-import-pending', 'bindings-pending',
81
+ 'legacy-adoption-pending',
81
82
  ] as const;
82
83
  const serviceRuntimeRefusals = [
83
84
  'cluster-runtime-missing', 'organization-missing', 'image-plan-missing', 'platform-unsupported',
@@ -86,7 +87,7 @@ const serviceRuntimeRefusals = [
86
87
  'node-authority-unsupported', 'network-policy-missing', 'network-policy-mismatch',
87
88
  'network-workload-pool-missing', 'network-workload-pool-exhausted',
88
89
  'volume-driver-unsupported', 'volume-options-unsupported', 'volume-shared-unsupported',
89
- 'volume-placement-unsupported',
90
+ 'volume-placement-unsupported', 'volume-initialization-unknown', 'volume-ownership-unknown',
90
91
  ] as const;
91
92
 
92
93
  /**
@@ -94,7 +95,9 @@ const serviceRuntimeRefusals = [
94
95
  * `node-network-pending` waits for a node's network readiness, `conflict` for a sweep over inputs
95
96
  * that changed while it ran, `failure-backoff` for a failed replica's backoff to elapse,
96
97
  * `storage-import-pending` for an `import` local storage claim whose bytes have not arrived on its
97
- * node, and `bindings-pending` for the secret manifest to cover every enabled binding of the service.
98
+ * node, `bindings-pending` for the secret manifest to cover every enabled binding of the service, and
99
+ * `legacy-adoption-pending` for a Corestore binding whose legacy resource waits for the offline
100
+ * adoption Corestore requires (the binding's `statusReason` of the same name).
98
101
  */
99
102
  export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[number];
100
103
 
@@ -112,6 +115,9 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
112
115
  * a volume of a driver other than a node-local directory, `volume-options-unsupported` driver or
113
116
  * mount options, `volume-shared-unsupported` one volume mounted by more than one replica or service,
114
117
  * and `volume-placement-unsupported` a placement that does not pin each volume to one node.
118
+ * `volume-initialization-unknown` is a volume without an initialization and
119
+ * `volume-ownership-unknown` a `run-as` ownership under `runAs: null`
120
+ * (`deriveServiceVolumeClaimInitialization`); neither is defaulted.
115
121
  */
116
122
  export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
117
123
 
@@ -0,0 +1,159 @@
1
+ import type { IRuntimeLocalStorageOwnership } from './runtimelocalstorageclaim.js';
2
+ import { runtimeLocalStorageClaimContract } from './runtimelocalstorageclaim.js';
3
+ import type { IServiceRuntimeProfile } from './serviceruntime.js';
4
+ import { hasExactKeys, isPlainRecord } from '../private/dataproperties.js';
5
+ import { isRuntimeIdentityNumber } from '../private/runtimepolicy.js';
6
+
7
+ /**
8
+ * Where an `import` volume's bytes come from.
9
+ *
10
+ * `docker-volume` is a Docker volume on the host the claim's node runs on, named exactly as Docker
11
+ * lists it; a Corestore-driver volume is named the same way. The operator copies its tree into the
12
+ * node's import directory; the node never reads this reference, and the local storage claim does
13
+ * not carry it.
14
+ */
15
+ export type TServiceVolumeImportSource = { kind: 'docker-volume'; volumeName: string };
16
+
17
+ /**
18
+ * The owner and permission bits an `empty` volume's root directory gets.
19
+ *
20
+ * `explicit` states the numeric owner. `run-as` takes the owner from the runtime spec's
21
+ * `profile.runAs` (user and group) when the claim is first issued; the mode is always stated,
22
+ * because no mode is right for every image.
23
+ */
24
+ export type TServiceVolumeOwnership =
25
+ | { kind: 'explicit'; uid: number; gid: number; mode: number }
26
+ | { kind: 'run-as'; mode: number };
27
+
28
+ /**
29
+ * How a service volume's node-local claim starts: `empty` creates a fresh root with the stated
30
+ * ownership, `import` waits for the bytes of an existing volume.
31
+ */
32
+ export type TServiceVolumeInitialization =
33
+ | { kind: 'empty'; ownership: TServiceVolumeOwnership }
34
+ | { kind: 'import'; source: TServiceVolumeImportSource };
35
+
36
+ /**
37
+ * What a volume's initialization derives for a local storage claim, or why it derives nothing.
38
+ *
39
+ * `volume-initialization-unknown` is a volume with no initialization: Cloudly never assumes
40
+ * `empty`, because an empty volume in front of a service whose data sits in a legacy volume would
41
+ * start that service without its data. `volume-ownership-unknown` is a `run-as` ownership while
42
+ * the runtime spec runs the image's own user (`runAs: null`), whose numeric identity Cloudly does
43
+ * not know.
44
+ */
45
+ export type TServiceVolumeClaimInitialization =
46
+ | { status: 'derived'; initialization: 'empty'; initialOwnership: IRuntimeLocalStorageOwnership }
47
+ | { status: 'derived'; initialization: 'import'; initialOwnership: null }
48
+ | { status: 'refused'; reason: 'volume-initialization-unknown' | 'volume-ownership-unknown' };
49
+
50
+ /**
51
+ * Why `setServiceVolumeInitialization` wrote nothing. `volume-not-found` names no declared volume
52
+ * at that mount path, `conflict` a stored initialization other than `expected`,
53
+ * `volume-initialization-invalid` a value this contract refuses, and
54
+ * `volume-initialization-bound` a change while a bound local storage claim of the volume exists:
55
+ * a claim keeps the initialization and ownership it was issued with for its whole life.
56
+ */
57
+ export type TServiceVolumeInitializationRefusal =
58
+ | 'volume-not-found'
59
+ | 'conflict'
60
+ | 'volume-initialization-invalid'
61
+ | 'volume-initialization-bound';
62
+
63
+ /** The named refusal of `setServiceVolumeInitialization`, sent as `TypedResponseError.errorData`. */
64
+ export interface IServiceVolumeInitializationErrorData {
65
+ code: TServiceVolumeInitializationRefusal;
66
+ retryable: false;
67
+ }
68
+
69
+ export const serviceVolumeInitializationContract = Object.freeze({
70
+ /** Every permission bit an ownership may state, the local storage claim's own bound. */
71
+ directoryModeBits: runtimeLocalStorageClaimContract.directoryModeBits,
72
+ maximumDockerVolumeNameLength: 255,
73
+ } as const);
74
+
75
+ /** Docker's own volume name grammar, `[a-zA-Z0-9][a-zA-Z0-9_.-]`, which needs two characters. */
76
+ const dockerVolumeNamePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]+$/;
77
+
78
+ const isDirectoryMode = (valueArg: unknown): valueArg is number => Number.isSafeInteger(valueArg)
79
+ && (valueArg as number) >= 0
80
+ && ((valueArg as number) & ~serviceVolumeInitializationContract.directoryModeBits) === 0;
81
+
82
+ const validateOwnership = (valueArg: unknown, pathArg: string): string[] => {
83
+ if (!isPlainRecord(valueArg)) return [`${pathArg} must use its exact schema`];
84
+ if (valueArg.kind === 'explicit') {
85
+ if (!hasExactKeys(valueArg, ['kind', 'uid', 'gid', 'mode'])) return [`${pathArg} must use its exact schema`];
86
+ const errors: string[] = [];
87
+ if (!isRuntimeIdentityNumber(valueArg.uid) || !isRuntimeIdentityNumber(valueArg.gid)) {
88
+ errors.push(`${pathArg} uid and gid must be runtime identity numbers`);
89
+ }
90
+ if (!isDirectoryMode(valueArg.mode)) errors.push(`${pathArg} mode must hold directory permission bits only`);
91
+ return errors;
92
+ }
93
+ if (valueArg.kind === 'run-as') {
94
+ if (!hasExactKeys(valueArg, ['kind', 'mode'])) return [`${pathArg} must use its exact schema`];
95
+ return isDirectoryMode(valueArg.mode) ? [] : [`${pathArg} mode must hold directory permission bits only`];
96
+ }
97
+ return [`${pathArg} kind must be explicit or run-as`];
98
+ };
99
+
100
+ const validateImportSource = (valueArg: unknown, pathArg: string): string[] => {
101
+ if (!isPlainRecord(valueArg) || valueArg.kind !== 'docker-volume'
102
+ || !hasExactKeys(valueArg, ['kind', 'volumeName'])) {
103
+ return [`${pathArg} must be an exact docker-volume source`];
104
+ }
105
+ return typeof valueArg.volumeName === 'string'
106
+ && valueArg.volumeName.length <= serviceVolumeInitializationContract.maximumDockerVolumeNameLength
107
+ && dockerVolumeNamePattern.test(valueArg.volumeName)
108
+ ? []
109
+ : [`${pathArg} volumeName must be a Docker volume name`];
110
+ };
111
+
112
+ /** Validates one volume initialization and names every reason it is refused. */
113
+ export const validateServiceVolumeInitialization = (
114
+ valueArg: unknown,
115
+ pathArg = 'service volume initialization',
116
+ ): string[] => {
117
+ if (!isPlainRecord(valueArg)) return [`${pathArg} must use its exact schema`];
118
+ if (valueArg.kind === 'empty') {
119
+ if (!hasExactKeys(valueArg, ['kind', 'ownership'])) return [`${pathArg} must use its exact schema`];
120
+ return validateOwnership(valueArg.ownership, `${pathArg} ownership`);
121
+ }
122
+ if (valueArg.kind === 'import') {
123
+ if (!hasExactKeys(valueArg, ['kind', 'source'])) return [`${pathArg} must use its exact schema`];
124
+ return validateImportSource(valueArg.source, `${pathArg} source`);
125
+ }
126
+ return [`${pathArg} kind must be empty or import`];
127
+ };
128
+
129
+ /**
130
+ * The claim fields a volume's initialization derives under a runtime spec's `runAs`.
131
+ *
132
+ * Absent initialization refuses as `volume-initialization-unknown`; a `run-as` ownership under
133
+ * `runAs: null` refuses as `volume-ownership-unknown`. Nothing is defaulted. The answer is for the
134
+ * first generation of a claim only: later generations carry that claim's own initialization and
135
+ * ownership, whatever `runAs` says by then. A malformed initialization or `runAs` throws.
136
+ */
137
+ export const deriveServiceVolumeClaimInitialization = (
138
+ initializationArg: TServiceVolumeInitialization | undefined,
139
+ runAsArg: IServiceRuntimeProfile['runAs'],
140
+ ): TServiceVolumeClaimInitialization => {
141
+ if (initializationArg === undefined) return { status: 'refused', reason: 'volume-initialization-unknown' };
142
+ const errors = validateServiceVolumeInitialization(initializationArg);
143
+ if (errors.length > 0) throw new Error(errors[0]);
144
+ if (runAsArg !== null && !(isPlainRecord(runAsArg) && hasExactKeys(runAsArg, ['user', 'group'])
145
+ && isRuntimeIdentityNumber(runAsArg.user) && isRuntimeIdentityNumber(runAsArg.group))) {
146
+ throw new Error('runAs must be null or an exact user and group');
147
+ }
148
+ if (initializationArg.kind === 'import') {
149
+ return { status: 'derived', initialization: 'import', initialOwnership: null };
150
+ }
151
+ const ownership = initializationArg.ownership;
152
+ if (ownership.kind === 'explicit') {
153
+ return { status: 'derived', initialization: 'empty',
154
+ initialOwnership: { uid: ownership.uid, gid: ownership.gid, mode: ownership.mode } };
155
+ }
156
+ if (runAsArg === null) return { status: 'refused', reason: 'volume-ownership-unknown' };
157
+ return { status: 'derived', initialization: 'empty',
158
+ initialOwnership: { uid: runAsArg.user, gid: runAsArg.group, mode: ownership.mode } };
159
+ };
@@ -16,6 +16,60 @@ export type TPlatformCapability =
16
16
  export type TPlatformAccessMode = 'rpc' | 'binding' | 'sidecar' | 'internal';
17
17
  export type TPlatformBindingStatus = 'requested' | 'provisioning' | 'ready' | 'degraded' | 'failed' | 'disabled';
18
18
  export type TPlatformDesiredState = 'enabled' | 'disabled';
19
+
20
+ export const platformBindingStatusReasons = [
21
+ 'legacy-adoption-pending',
22
+ 'legacy-adoption-conflict',
23
+ 'legacy-adoption-mismatch',
24
+ ] as const;
25
+
26
+ /**
27
+ * Why a binding holds the status it reports, where the status alone does not say.
28
+ *
29
+ * All three come from Corestore's refusal of a credential binding beside a legacy
30
+ * `/resources/provision` resource (`code` in its 409 body, read with
31
+ * `readCorestoreLegacyAdoptionRefusal`):
32
+ *
33
+ * - `legacy-adoption-pending` (status `provisioning`): the service owns a legacy resource of this
34
+ * capability that no offline adoption authorizes yet. It clears when the operator runs
35
+ * Corestore's adoption with the plan Cloudly produced.
36
+ * - `legacy-adoption-conflict` (status `failed`): the adoption authorizes another binding id, or
37
+ * it was already consumed.
38
+ * - `legacy-adoption-mismatch` (status `failed`): the legacy resource is no longer the one the
39
+ * adoption authorized, its database tenant is absent, or the binding's bucket or retention
40
+ * intent differs from the legacy bucket's.
41
+ */
42
+ export type TPlatformBindingStatusReason = typeof platformBindingStatusReasons[number];
43
+
44
+ const platformBindingStatusOfReason: Readonly<Record<TPlatformBindingStatusReason, TPlatformBindingStatus>> =
45
+ Object.freeze({
46
+ 'legacy-adoption-pending': 'provisioning',
47
+ 'legacy-adoption-conflict': 'failed',
48
+ 'legacy-adoption-mismatch': 'failed',
49
+ });
50
+
51
+ /** The one status each reason is reported with. */
52
+ export const getPlatformBindingStatusOfReason = (
53
+ reasonArg: TPlatformBindingStatusReason,
54
+ ): TPlatformBindingStatus => platformBindingStatusOfReason[reasonArg];
55
+
56
+ /**
57
+ * Validates a reported status and its optional reason: a reason must be a member of the
58
+ * vocabulary and come with its one status. An absent reason is valid with every status.
59
+ */
60
+ export const validatePlatformBindingStatusReason = (
61
+ statusArg: unknown,
62
+ reasonArg: unknown,
63
+ pathArg = 'platform binding statusReason',
64
+ ): string[] => {
65
+ if (reasonArg === undefined) return [];
66
+ if (!(platformBindingStatusReasons as readonly unknown[]).includes(reasonArg)) {
67
+ return [`${pathArg} must be a platform binding status reason`];
68
+ }
69
+ return platformBindingStatusOfReason[reasonArg as TPlatformBindingStatusReason] === statusArg
70
+ ? []
71
+ : [`${pathArg} ${reasonArg as string} requires status ${platformBindingStatusOfReason[reasonArg as TPlatformBindingStatusReason]}`];
72
+ };
19
73
  export type TPlatformEndpointProtocol =
20
74
  | 'typedrequest'
21
75
  | 'http'
@@ -55,6 +109,23 @@ export interface IPlatformServiceEndpoint {
55
109
  port?: number;
56
110
  }
57
111
 
112
+ /**
113
+ * The legacy Corestore resource an object-storage or database binding adopts.
114
+ *
115
+ * Server-managed: only `adoptCorestoreLegacyResources` writes it, in the same write that creates
116
+ * the binding, and Cloudly refuses it (and `objectstorageBucketName`) on `upsertPlatformBinding`.
117
+ * `nodeId` is the node whose Corestore holds the resource, so the binding is placed there and
118
+ * nowhere else; `resourceName` is the legacy database or bucket name; `inventorySha256` is the
119
+ * legacy inventory the adoption was checked against. An adopted bucket's
120
+ * `objectstorageBucketName` is the legacy bucket and its `objectstorageRetention.intent` the legacy
121
+ * intent exactly, or absent when the legacy bucket has none.
122
+ */
123
+ export interface IPlatformBindingCorestoreLegacyAdoption {
124
+ nodeId: string;
125
+ resourceName: string;
126
+ inventorySha256: string;
127
+ }
128
+
58
129
  export interface IPlatformBinding {
59
130
  id: string;
60
131
  serviceId: string;
@@ -69,6 +140,10 @@ export interface IPlatformBinding {
69
140
  objectstorageBucketName?: string;
70
141
  /** Capability-specific value-free immutable-retention intent and evidence. */
71
142
  objectstorageRetention?: IObjectStorageRetentionBinding;
143
+ /** Server-managed: the legacy Corestore resource this binding adopts. */
144
+ corestoreLegacyAdoption?: IPlatformBindingCorestoreLegacyAdoption;
145
+ /** Why the binding holds its status, as the responsible cluster reported it. */
146
+ statusReason?: TPlatformBindingStatusReason;
72
147
  createdAt?: number;
73
148
  updatedAt?: number;
74
149
  }
@@ -1,5 +1,11 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type { IIdentity } from '../data/user.js';
3
+ import type {
4
+ ICorestoreLegacyResourceAdoptionPlanEntry,
5
+ ICorestoreLegacyResourceSelection,
6
+ TClusterCorestoreLegacyResourceInventory,
7
+ } from '../data/corestorelegacyadoption.js';
8
+ import type { IPlatformBinding } from '../platform/types.js';
3
9
  import { hasExactKeys } from '../private/dataproperties.js';
4
10
  import { isClusterNodeId } from '../private/nodeidentifier.js';
5
11
 
@@ -277,3 +283,76 @@ extends plugins.typedrequestInterfaces.implementsTR<
277
283
  inventory: ICorestoreNodeInventory;
278
284
  };
279
285
  }
286
+
287
+ /**
288
+ * Cloudly asks the relay for one exact node's legacy Corestore resources.
289
+ *
290
+ * The relay reads Corestore's `corestoreLegacyAdoptionContract.inventoryPath` with the control
291
+ * token and answers `reachable: true` with its services, or `reachable: false` with the reason, never
292
+ * an empty list for a node it could not read.
293
+ */
294
+ export interface IReq_Cloudly_Relay_GetClusterCorestoreLegacyResources
295
+ extends plugins.typedrequestInterfaces.implementsTR<
296
+ plugins.typedrequestInterfaces.ITypedRequest,
297
+ IReq_Cloudly_Relay_GetClusterCorestoreLegacyResources
298
+ > {
299
+ method: 'getClusterCorestoreLegacyResources';
300
+ request: {
301
+ nodeId: string;
302
+ };
303
+ response: {
304
+ inventory: TClusterCorestoreLegacyResourceInventory;
305
+ };
306
+ }
307
+
308
+ /**
309
+ * An administrator reads one node's legacy Corestore resources through its cluster's relay, with
310
+ * the digest an adoption is checked against (`null` for an unreachable node).
311
+ */
312
+ export interface IReq_Any_Cloudly_GetCorestoreLegacyResources
313
+ extends plugins.typedrequestInterfaces.implementsTR<
314
+ plugins.typedrequestInterfaces.ITypedRequest,
315
+ IReq_Any_Cloudly_GetCorestoreLegacyResources
316
+ > {
317
+ method: 'getCorestoreLegacyResources';
318
+ request: {
319
+ identity: IIdentity;
320
+ clusterId: string;
321
+ nodeId: string;
322
+ };
323
+ response: {
324
+ inventory: TClusterCorestoreLegacyResourceInventory;
325
+ inventorySha256: string | null;
326
+ };
327
+ }
328
+
329
+ /**
330
+ * The only path that creates a binding carrying a legacy bucket name.
331
+ *
332
+ * Cloudly reads the node's legacy inventory again, requires `expectedInventorySha256`, and for
333
+ * each selected resource creates the adopting binding in one transaction: enabled, placed on
334
+ * `nodeId`, with `corestoreLegacyAdoption`, and for a bucket the legacy bucket name and retention
335
+ * intent (`validatePlatformBindingCorestoreLegacyAdoption`). A resource whose adopting binding
336
+ * already exists is answered unchanged. The response lists the adopting bindings and the plan
337
+ * (`createCorestoreLegacyResourceAdoptionPlan`) the operator hands to Corestore's offline
338
+ * `legacy-resource-adoption` migration. Refusals travel as `ICorestoreLegacyAdoptionErrorData`.
339
+ */
340
+ export interface IReq_Any_Cloudly_AdoptCorestoreLegacyResources
341
+ extends plugins.typedrequestInterfaces.implementsTR<
342
+ plugins.typedrequestInterfaces.ITypedRequest,
343
+ IReq_Any_Cloudly_AdoptCorestoreLegacyResources
344
+ > {
345
+ method: 'adoptCorestoreLegacyResources';
346
+ request: {
347
+ identity: IIdentity;
348
+ clusterId: string;
349
+ nodeId: string;
350
+ expectedInventorySha256: string;
351
+ /** Sorted by service then capability (`validateCorestoreLegacyResourceSelections`). */
352
+ resources: ICorestoreLegacyResourceSelection[];
353
+ };
354
+ response: {
355
+ bindings: IPlatformBinding[];
356
+ plan: ICorestoreLegacyResourceAdoptionPlanEntry[];
357
+ };
358
+ }