@serve.zone/interfaces 31.4.0 → 31.6.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 (39) hide show
  1. package/changelog.md +78 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/backup.d.ts +13 -0
  4. package/dist_ts/data/cluster.d.ts +44 -9
  5. package/dist_ts/data/cluster.js +35 -12
  6. package/dist_ts/data/clusternode.d.ts +49 -0
  7. package/dist_ts/data/clusternode.js +47 -1
  8. package/dist_ts/data/corestore.d.ts +27 -0
  9. package/dist_ts/data/corestore.js +11 -0
  10. package/dist_ts/data/index.d.ts +1 -0
  11. package/dist_ts/data/index.js +2 -1
  12. package/dist_ts/data/isolatedrestore.js +42 -2
  13. package/dist_ts/requests/backup.d.ts +223 -0
  14. package/dist_ts/requests/cluster.d.ts +17 -0
  15. package/dist_ts/requests/corestore.d.ts +57 -0
  16. package/dist_ts/requests/corestore.js +102 -1
  17. package/dist_ts/requests/platform.d.ts +30 -0
  18. package/dist_ts/runtime.corestore.d.ts +7 -10
  19. package/dist_ts/runtime.corestore.js +1 -1
  20. package/dist_ts/runtime.corestore.relay.d.ts +47 -0
  21. package/dist_ts/runtime.corestore.relay.js +57 -0
  22. package/dist_ts/runtime.d.ts +1 -0
  23. package/dist_ts/runtime.js +2 -1
  24. package/package.json +1 -1
  25. package/readme.md +120 -6
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/data/backup.ts +14 -0
  28. package/ts/data/cluster.ts +76 -17
  29. package/ts/data/clusternode.ts +93 -0
  30. package/ts/data/corestore.ts +28 -0
  31. package/ts/data/index.ts +1 -0
  32. package/ts/data/isolatedrestore.ts +53 -1
  33. package/ts/requests/backup.ts +253 -0
  34. package/ts/requests/cluster.ts +21 -0
  35. package/ts/requests/corestore.ts +183 -0
  36. package/ts/requests/platform.ts +36 -0
  37. package/ts/runtime.corestore.relay.ts +111 -0
  38. package/ts/runtime.corestore.ts +7 -8
  39. package/ts/runtime.ts +1 -0
@@ -22,6 +22,10 @@ export interface ICorestoreServiceResources {
22
22
  /**
23
23
  * Corestore inventory of one node (each coreflow node runs its own corestore;
24
24
  * the data is node-local, which is what makes node placement matter).
25
+ *
26
+ * Superseded by `IClusterCorestoreInventory`, which names the node by its
27
+ * cluster-document id instead of by the Swarm hostnames a connection tagged
28
+ * itself with.
25
29
  */
26
30
  export interface ICorestoreNodeInventory {
27
31
  /** Swarm node hostnames of the reporting coreflow. */
@@ -83,6 +87,156 @@ export interface ICoreflowInventoryRegistration {
83
87
  registeredAt: number;
84
88
  }
85
89
 
90
+ /**
91
+ * What one node's Corestore holds, as the cluster relay read it.
92
+ *
93
+ * Node identity is the cluster document's node id: one relay serves every node
94
+ * of its cluster, so which node an inventory describes is stated, never derived
95
+ * from which connection answered. An unreachable node names why — `errorCode`
96
+ * is required whenever `reachable` is false and refused whenever it is true —
97
+ * because a node whose Corestore cannot be reached is a fact the control plane
98
+ * needs, and a silent empty inventory would read as "nothing provisioned",
99
+ * which is the opposite.
100
+ */
101
+ export interface IClusterCorestoreInventory {
102
+ nodeId: string;
103
+ checkedAt: number;
104
+ reachable: boolean;
105
+ errorCode?: 'CORESTORE_ENDPOINT_UNKNOWN' | 'CORESTORE_UNREACHABLE';
106
+ services: ICorestoreServiceResources[];
107
+ }
108
+
109
+ const clusterCorestoreInventoryIdentifierPattern = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
110
+
111
+ const isClusterCorestoreRecord = (
112
+ valueArg: unknown,
113
+ ): valueArg is Record<string, unknown> => (
114
+ Boolean(valueArg)
115
+ && typeof valueArg === 'object'
116
+ && !Array.isArray(valueArg)
117
+ && (Object.getPrototypeOf(valueArg) === Object.prototype
118
+ || Object.getPrototypeOf(valueArg) === null)
119
+ );
120
+
121
+ const hasClusterCorestoreKeys = (
122
+ valueArg: Record<string, unknown>,
123
+ keysArg: readonly string[],
124
+ optionalKeysArg: readonly string[] = [],
125
+ ): boolean => {
126
+ const actualKeys = Object.keys(valueArg);
127
+ return keysArg.every((keyArg) => actualKeys.includes(keyArg))
128
+ && actualKeys.every((keyArg) => keysArg.includes(keyArg) || optionalKeysArg.includes(keyArg));
129
+ };
130
+
131
+ const validateClusterCorestoreServiceResources = (
132
+ valueArg: unknown,
133
+ pathArg: string,
134
+ ): string[] => {
135
+ if (!isClusterCorestoreRecord(valueArg)
136
+ || !hasClusterCorestoreKeys(
137
+ valueArg,
138
+ ['serviceId', 'resources', 'updatedAt'],
139
+ ['serviceName'],
140
+ )) {
141
+ return [`${pathArg} must use its exact schema`];
142
+ }
143
+ const errors: string[] = [];
144
+ if (typeof valueArg.serviceId !== 'string'
145
+ || !clusterCorestoreInventoryIdentifierPattern.test(valueArg.serviceId)) {
146
+ errors.push(`${pathArg} serviceId must be a bounded identifier`);
147
+ }
148
+ if (!Number.isSafeInteger(valueArg.updatedAt) || (valueArg.updatedAt as number) < 0) {
149
+ errors.push(`${pathArg} updatedAt must be a non-negative safe integer`);
150
+ }
151
+ if (!Array.isArray(valueArg.resources)) {
152
+ errors.push(`${pathArg} resources must be an array`);
153
+ return errors;
154
+ }
155
+ for (const [index, resource] of valueArg.resources.entries()) {
156
+ const resourcePath = `${pathArg} resources[${index}]`;
157
+ if (!isClusterCorestoreRecord(resource)
158
+ || !hasClusterCorestoreKeys(
159
+ resource,
160
+ ['capability', 'provider', 'resourceName', 'createdAt'],
161
+ )) {
162
+ errors.push(`${resourcePath} must use its exact schema`);
163
+ continue;
164
+ }
165
+ if (resource.capability !== 'database' && resource.capability !== 'objectstorage') {
166
+ errors.push(`${resourcePath} capability must be database or objectstorage`);
167
+ }
168
+ if (typeof resource.provider !== 'string' || resource.provider.length === 0) {
169
+ errors.push(`${resourcePath} provider must be a non-empty string`);
170
+ }
171
+ if (typeof resource.resourceName !== 'string' || resource.resourceName.length === 0) {
172
+ errors.push(`${resourcePath} resourceName must be a non-empty string`);
173
+ }
174
+ if (!Number.isSafeInteger(resource.createdAt) || (resource.createdAt as number) < 0) {
175
+ errors.push(`${resourcePath} createdAt must be a non-negative safe integer`);
176
+ }
177
+ }
178
+ return errors;
179
+ };
180
+
181
+ /**
182
+ * Validates one cluster Corestore inventory and names every reason it is
183
+ * refused.
184
+ *
185
+ * Shape only. Whether that node belongs to the cluster, and whether a resource
186
+ * should exist, are decisions for the control plane that asked; an inventory
187
+ * that passes here has claimed neither. A reachable inventory that names an
188
+ * error, and an unreachable one that lists resources, are both refused: an
189
+ * answer has to be one of the two things it can be.
190
+ */
191
+ export const validateClusterCorestoreInventory = (
192
+ valueArg: unknown,
193
+ pathArg = 'cluster corestore inventory',
194
+ ): string[] => {
195
+ if (!isClusterCorestoreRecord(valueArg)
196
+ || !hasClusterCorestoreKeys(
197
+ valueArg,
198
+ ['nodeId', 'checkedAt', 'reachable', 'services'],
199
+ ['errorCode'],
200
+ )) {
201
+ return [`${pathArg} must use its exact schema`];
202
+ }
203
+ const errors: string[] = [];
204
+ if (typeof valueArg.nodeId !== 'string'
205
+ || !clusterCorestoreInventoryIdentifierPattern.test(valueArg.nodeId)) {
206
+ errors.push(`${pathArg} nodeId must be a bounded identifier`);
207
+ }
208
+ if (!Number.isSafeInteger(valueArg.checkedAt) || (valueArg.checkedAt as number) < 0) {
209
+ errors.push(`${pathArg} checkedAt must be a non-negative safe integer`);
210
+ }
211
+ if (typeof valueArg.reachable !== 'boolean') {
212
+ errors.push(`${pathArg} reachable must be a boolean`);
213
+ }
214
+ if (valueArg.errorCode !== undefined
215
+ && valueArg.errorCode !== 'CORESTORE_ENDPOINT_UNKNOWN'
216
+ && valueArg.errorCode !== 'CORESTORE_UNREACHABLE') {
217
+ errors.push(`${pathArg} errorCode must name why the node could not be read`);
218
+ }
219
+ if (valueArg.reachable === true && valueArg.errorCode !== undefined) {
220
+ errors.push(`${pathArg} must not carry an errorCode while it reports the node reachable`);
221
+ }
222
+ if (valueArg.reachable === false && valueArg.errorCode === undefined) {
223
+ // An unreachable node with no reason reads like a node holding nothing,
224
+ // which is the one confusion this shape exists to prevent.
225
+ errors.push(`${pathArg} must name why the node could not be read while it reports it unreachable`);
226
+ }
227
+ if (!Array.isArray(valueArg.services)) {
228
+ errors.push(`${pathArg} services must be an array`);
229
+ return errors;
230
+ }
231
+ if (valueArg.reachable === false && valueArg.services.length > 0) {
232
+ errors.push(`${pathArg} must list no services while it reports the node unreachable`);
233
+ }
234
+ for (const [index, service] of valueArg.services.entries()) {
235
+ errors.push(...validateClusterCorestoreServiceResources(service, `${pathArg} services[${index}]`));
236
+ }
237
+ return errors;
238
+ };
239
+
86
240
  export interface IReq_Any_Cloudly_GetCorestoreInventory
87
241
  extends plugins.typedrequestInterfaces.implementsTR<
88
242
  plugins.typedrequestInterfaces.ITypedRequest,
@@ -95,6 +249,35 @@ extends plugins.typedrequestInterfaces.implementsTR<
95
249
  response: ICorestoreInventoryReport;
96
250
  }
97
251
 
252
+ /**
253
+ * Cloudly asks the relay what one exact node of its cluster holds.
254
+ *
255
+ * The node is a request field because one relay serves them all: the connection
256
+ * that answers proves the cluster, never the node. A greenfield cluster answers
257
+ * with an empty `services` list and `reachable: true`, which is the proof that
258
+ * a namespace is blank — distinct from `reachable: false`, which proves
259
+ * nothing at all.
260
+ *
261
+ * Named apart from `getCorestoreInventory` on purpose: that method is Cloudly's
262
+ * own operator-facing read and lives on the other end of this socket. One name
263
+ * on both ends would leave direction as the only thing telling two different
264
+ * bodies apart.
265
+ */
266
+ export interface IReq_Cloudly_Relay_GetClusterCorestoreInventory
267
+ extends plugins.typedrequestInterfaces.implementsTR<
268
+ plugins.typedrequestInterfaces.ITypedRequest,
269
+ IReq_Cloudly_Relay_GetClusterCorestoreInventory
270
+ > {
271
+ method: 'getClusterCorestoreInventory';
272
+ request: {
273
+ nodeId: string;
274
+ };
275
+ response: {
276
+ inventory: IClusterCorestoreInventory;
277
+ };
278
+ }
279
+
280
+ /** Superseded by `getClusterCorestoreInventory`, which names the node. */
98
281
  export interface IReq_Cloudly_Coreflow_GetCorestoreInventory
99
282
  extends plugins.typedrequestInterfaces.implementsTR<
100
283
  plugins.typedrequestInterfaces.ITypedRequest,
@@ -23,6 +23,42 @@ extends plugins.typedrequestInterfaces.implementsTR<
23
23
  };
24
24
  }
25
25
 
26
+ /**
27
+ * One platform binding and the node its Corestore resources live on.
28
+ *
29
+ * Corestore is node-local, so a binding without a node is a database nobody can
30
+ * place: while every cluster ran one coreflow per node, "this node" was whoever
31
+ * answered, and a cluster relay serving every node has no such implicit answer.
32
+ * The placement is Cloudly's, derived from where the service is assigned.
33
+ */
34
+ export interface IClusterPlatformBindingPlacement {
35
+ nodeId: string;
36
+ binding: IPlatformBinding;
37
+ }
38
+
39
+ /**
40
+ * The desired platform state for the relay's own cluster, node by node.
41
+ *
42
+ * Its session decides which cluster it speaks for, so the request carries no
43
+ * cluster of its own. It replaces `getPlatformDesiredState` for a cluster
44
+ * relay: the same capabilities and provider configs, and bindings that state
45
+ * where they belong instead of leaving it to be inferred.
46
+ */
47
+ export interface IReq_Relay_Cloudly_GetClusterPlatformDesiredState
48
+ extends plugins.typedrequestInterfaces.implementsTR<
49
+ plugins.typedrequestInterfaces.ITypedRequest,
50
+ IReq_Relay_Cloudly_GetClusterPlatformDesiredState
51
+ > {
52
+ method: 'getClusterPlatformDesiredState';
53
+ request: Record<string, never>;
54
+ response: {
55
+ capabilities: IPlatformCapability[];
56
+ providerConfigs: IPlatformProviderConfig[];
57
+ /** Sorted by node, then by binding id, so two reads compare directly. */
58
+ bindings: IClusterPlatformBindingPlacement[];
59
+ };
60
+ }
61
+
26
62
  export interface IReq_Any_Cloudly_GetPlatformCapabilities
27
63
  extends plugins.typedrequestInterfaces.implementsTR<
28
64
  plugins.typedrequestInterfaces.ITypedRequest,
@@ -0,0 +1,111 @@
1
+ import type { ICorestoreDatabaseBackupReceipt } from './runtime.corestore.js';
2
+ import {
3
+ corestoreDatabaseBackupRuntimeLimits,
4
+ normalizeCorestoreDatabaseBackupReceipt,
5
+ } from './runtime.corestore.js';
6
+
7
+ /**
8
+ * Carrying a Corestore database closure through the cluster relay.
9
+ *
10
+ * A cluster is outbound-only, so Cloudly never reads Corestore itself: the
11
+ * relay exports the closure from its cluster's Corestore and streams it to
12
+ * Cloudly, and streams it back for a restore. The bytes travel as a
13
+ * VirtualStream on the relay's own session; this descriptor is what the parent
14
+ * TypedRequest carries about them.
15
+ *
16
+ * No version suffix and no schema field: the protocol version is the installed
17
+ * `@serve.zone/interfaces` version.
18
+ */
19
+
20
+ /**
21
+ * What a peer states about the closure it is about to send or serve.
22
+ *
23
+ * `size` is mandatory, and it is the reason this descriptor exists at all:
24
+ * Corestore's database backup restore requires one exact `Content-Length`,
25
+ * identity transfer encoding and no content encoding, and it refuses a body
26
+ * whose byte length differs from its receipt. A stream that only learned its
27
+ * length at EOF could therefore never be restored — the receiver has to open
28
+ * the Corestore request before the first byte arrives.
29
+ *
30
+ * `sha256` is the digest of those exact bytes, and `receipt` is Corestore's own
31
+ * closure receipt, which binds the backup, the source service, the snapshot and
32
+ * the closure's identity. The two are cross-checked here rather than trusted
33
+ * separately: a descriptor whose `size` or `sha256` disagrees with its receipt
34
+ * describes a closure nobody can restore.
35
+ */
36
+ export interface ICorestoreDatabaseClosureDescriptor {
37
+ /** Exact byte length of the closure, as Corestore's `Content-Length` needs it. */
38
+ size: number;
39
+ /** Lowercase hex SHA-256 over those exact bytes. */
40
+ sha256: string;
41
+ /** Corestore's own receipt for this closure; its identity and integrity authority. */
42
+ receipt: ICorestoreDatabaseBackupReceipt;
43
+ }
44
+
45
+ const bareSha256Pattern = /^[a-f0-9]{64}$/;
46
+
47
+ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => (
48
+ Boolean(valueArg)
49
+ && typeof valueArg === 'object'
50
+ && !Array.isArray(valueArg)
51
+ && (Object.getPrototypeOf(valueArg) === Object.prototype
52
+ || Object.getPrototypeOf(valueArg) === null)
53
+ );
54
+
55
+ const hasExactKeys = (
56
+ valueArg: Record<string, unknown>,
57
+ keysArg: readonly string[],
58
+ ): boolean => {
59
+ const actualKeys = Object.keys(valueArg).sort();
60
+ const expectedKeys = [...keysArg].sort();
61
+ return actualKeys.length === expectedKeys.length
62
+ && actualKeys.every((keyArg, indexArg) => keyArg === expectedKeys[indexArg]);
63
+ };
64
+
65
+ /**
66
+ * Validates one closure descriptor and names every reason it is refused.
67
+ *
68
+ * Shape and self-consistency only: whether the closure exists, whether the
69
+ * backup is this cluster's and whether the target database may be replaced are
70
+ * decisions for the party that holds them. A descriptor that passes here has
71
+ * claimed none of them. No reason quotes closure bytes, because a reason
72
+ * travels into logs and the closure is tenant data.
73
+ */
74
+ export const validateCorestoreDatabaseClosureDescriptor = (
75
+ valueArg: unknown,
76
+ pathArg = 'corestore database closure descriptor',
77
+ ): string[] => {
78
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['size', 'sha256', 'receipt'])) {
79
+ return [`${pathArg} must use its exact schema`];
80
+ }
81
+ const errors: string[] = [];
82
+ const maximumBytes = corestoreDatabaseBackupRuntimeLimits.maximumClosureBytes;
83
+ if (!Number.isSafeInteger(valueArg.size)
84
+ || (valueArg.size as number) < 1
85
+ || (valueArg.size as number) > maximumBytes) {
86
+ errors.push(`${pathArg} size must be an integer from 1 to ${maximumBytes}`);
87
+ }
88
+ if (typeof valueArg.sha256 !== 'string' || !bareSha256Pattern.test(valueArg.sha256)) {
89
+ errors.push(`${pathArg} sha256 must be a lowercase hex SHA-256 digest`);
90
+ }
91
+ let receipt: ICorestoreDatabaseBackupReceipt | undefined;
92
+ try {
93
+ receipt = normalizeCorestoreDatabaseBackupReceipt(valueArg.receipt);
94
+ } catch (error) {
95
+ // Corestore's own normalizer names the field it refused and never the
96
+ // material behind it, so its reason is worth more than its class.
97
+ errors.push(`${pathArg} receipt is not canonical: ${(error as Error).message}`);
98
+ }
99
+ if (!receipt || errors.length > 0) {
100
+ return errors;
101
+ }
102
+ if (valueArg.size !== receipt.closure.archiveBytes) {
103
+ errors.push(
104
+ `${pathArg} size must equal the ${receipt.closure.archiveBytes} bytes its receipt states`,
105
+ );
106
+ }
107
+ if (valueArg.sha256 !== receipt.closure.sha256) {
108
+ errors.push(`${pathArg} sha256 must equal the digest its receipt states`);
109
+ }
110
+ return errors;
111
+ };
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type {
3
3
  IActiveSecretRecipientMetadata,
4
+ ICorestoreDatabaseAllocationReference,
4
5
  IIdentityCredential,
5
6
  ISecretVersionReference,
6
7
  } from './data/index.js';
@@ -1281,14 +1282,12 @@ export const corestoreDatabaseBackupRuntimeLimits = Object.freeze({
1281
1282
  maximumServiceNameBytes: 1024,
1282
1283
  });
1283
1284
 
1284
- export interface ICorestoreDatabaseAllocationReference {
1285
- purpose: 'scratch-rehearsal';
1286
- rehearsalId: string;
1287
- allocationId: string;
1288
- generation: number;
1289
- bindingSha256: string;
1290
- receiptSha256: string;
1291
- }
1285
+ /**
1286
+ * Declared in the data tree, because an isolated restore's canonical resource
1287
+ * mappings bind the exact same reference and that tree carries no runtime
1288
+ * import. Re-exported here so this module's readers keep one import site.
1289
+ */
1290
+ export type { ICorestoreDatabaseAllocationReference };
1292
1291
 
1293
1292
  export interface ICorestoreDatabaseBackupSnapshotTags {
1294
1293
  corestore: 'resource';
package/ts/runtime.ts CHANGED
@@ -42,6 +42,7 @@ export * from './runtime.workloadinit.js';
42
42
  export * from './runtime.networkprojection.js';
43
43
  export * from './runtime.corestore.js';
44
44
  export * from './runtime.corestore.databaseexportpackage.js';
45
+ export * from './runtime.corestore.relay.js';
45
46
  export * from './runtime.cloudlylegacydeploymentsettlement.js';
46
47
  export * from './runtime.cloudlylegacydeploymentsettlement.offlineexport.js';
47
48