@serve.zone/interfaces 32.0.2 → 32.2.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 +37 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/noderetirement.d.ts +39 -0
- package/dist_ts/data/noderetirement.js +31 -0
- package/dist_ts/data/serviceruntime.d.ts +10 -2
- package/dist_ts/data/serviceruntime.js +4 -3
- package/dist_ts/platform/storage.d.ts +29 -2
- package/dist_ts/platform/storage.golden.d.ts +31 -0
- package/dist_ts/platform/storage.golden.js +84 -0
- package/dist_ts/platform/storage.js +37 -1
- package/dist_ts/platform/storagemigration.js +11 -287
- package/dist_ts/private/storagecontract.d.ts +40 -0
- package/dist_ts/private/storagecontract.js +399 -0
- package/dist_ts/requests/node.d.ts +25 -0
- package/package.json +1 -1
- package/readme.md +40 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/index.ts +1 -0
- package/ts/data/noderetirement.ts +63 -0
- package/ts/data/serviceruntime.ts +12 -3
- package/ts/platform/storage.golden.ts +97 -0
- package/ts/platform/storage.ts +59 -2
- package/ts/platform/storagemigration.ts +31 -440
- package/ts/private/storagecontract.ts +623 -0
- package/ts/requests/node.ts +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { captureNetwork, networkCounter, networkId, networkRecord, requireNetwork } from '../private/networkcontract.js';
|
|
2
|
+
import { isOneOf, producerSafe } from '../private/producercontract.js';
|
|
3
|
+
|
|
4
|
+
const nodeRuntimeRetirementStates = ['retiring', 'retired'] as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How far a node's retirement has come: `retiring` while the producer still stops and removes the
|
|
8
|
+
* node's slots, the compiler quarantines its handoff and both node credentials are revoked;
|
|
9
|
+
* `retired` once all of that has settled and nothing but the node document is left to delete.
|
|
10
|
+
*/
|
|
11
|
+
export type TNodeRuntimeRetirementState = typeof nodeRuntimeRetirementStates[number];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* That one node leaves its cluster's runtime, stored beside the node document rather than inside
|
|
15
|
+
* it, because a node update merges caller data and no caller may retire a node with it.
|
|
16
|
+
*
|
|
17
|
+
* The producers read the row's presence rather than its state: a node that has one is no longer an
|
|
18
|
+
* egress member and desires no slots. The row is therefore written before anything is taken away,
|
|
19
|
+
* and it stays for as long as the node document does.
|
|
20
|
+
*/
|
|
21
|
+
export interface INodeRuntimeRetirement {
|
|
22
|
+
/** The node id. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** The cluster the node is retiring from, copied by Cloudly; never caller-chosen. */
|
|
25
|
+
clusterId: string;
|
|
26
|
+
state: TNodeRuntimeRetirementState;
|
|
27
|
+
/** The administrator who asked for the retirement. */
|
|
28
|
+
actorId: string;
|
|
29
|
+
/** Unix milliseconds on Cloudly's clock, when the row entered its current state. */
|
|
30
|
+
since: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every reason Cloudly answers instead of retiring a node. Each travels as a `TypedResponseError`,
|
|
35
|
+
* and none of them takes anything away: a refused retirement leaves the node exactly as it was.
|
|
36
|
+
*/
|
|
37
|
+
export const nodeRuntimeRetirementRefusals = Object.freeze([
|
|
38
|
+
/** No node of that id exists, so there is nothing to retire. */
|
|
39
|
+
'node-missing',
|
|
40
|
+
/**
|
|
41
|
+
* The node's cluster runs no Pallet runtime — it has no phase record, or a phase other than
|
|
42
|
+
* `pallet`. Which of the two it is, `getClusterRuntime` says.
|
|
43
|
+
*/
|
|
44
|
+
'cluster-runtime-not-pallet',
|
|
45
|
+
/** The node already carries a retirement row; neither of its states is left by retiring again. */
|
|
46
|
+
'node-already-retired',
|
|
47
|
+
/** `expectedGeneration` is not the cluster runtime's current generation, so a cutover moved underneath the caller. */
|
|
48
|
+
'conflict',
|
|
49
|
+
] as const);
|
|
50
|
+
|
|
51
|
+
/** One of the reasons Cloudly answers instead of a retirement row. */
|
|
52
|
+
export type TNodeRuntimeRetirementRefusal = typeof nodeRuntimeRetirementRefusals[number];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Structural snapshot for exact persistence; whether the node exists and whether its cluster admits
|
|
56
|
+
* the retirement is Cloudly's.
|
|
57
|
+
*/
|
|
58
|
+
export const snapshotNodeRuntimeRetirement = (value: unknown): INodeRuntimeRetirement => producerSafe(() => {
|
|
59
|
+
const item = networkRecord(captureNetwork(value), ['id', 'clusterId', 'state', 'actorId', 'since']);
|
|
60
|
+
requireNetwork(isOneOf(nodeRuntimeRetirementStates, item.state));
|
|
61
|
+
return { id: networkId(item.id), clusterId: networkId(item.clusterId), state: item.state,
|
|
62
|
+
actorId: networkId(item.actorId), since: networkCounter(item.since) };
|
|
63
|
+
});
|
|
@@ -77,8 +77,9 @@ export const serviceRuntimeContract = Object.freeze({
|
|
|
77
77
|
const serviceRuntimeWaitingReasons = ['node-network-pending', 'conflict', 'failure-backoff'] as const;
|
|
78
78
|
const serviceRuntimeRefusals = [
|
|
79
79
|
'cluster-runtime-missing', 'organization-missing', 'image-plan-missing', 'platform-unsupported',
|
|
80
|
-
'secrets-unsupported', 'volumes-unsupported', 'port-protocol-unsupported', '
|
|
81
|
-
'environment-too-large', 'no-eligible-node', 'replica-limit',
|
|
80
|
+
'secrets-unsupported', 'volumes-unsupported', 'port-protocol-unsupported', 'ports-too-many',
|
|
81
|
+
'readiness-target-undeclared', 'environment-too-large', 'no-eligible-node', 'replica-limit',
|
|
82
|
+
'node-authority-unsupported',
|
|
82
83
|
] as const;
|
|
83
84
|
|
|
84
85
|
/**
|
|
@@ -88,7 +89,15 @@ const serviceRuntimeRefusals = [
|
|
|
88
89
|
*/
|
|
89
90
|
export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[number];
|
|
90
91
|
|
|
91
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Why the producer admits nothing for this spec revision. A refusal never retires running slots.
|
|
94
|
+
*
|
|
95
|
+
* The two a reader cannot derive from the name alone: `ports-too-many` is a service declaring more
|
|
96
|
+
* target ports than `runtimeConfigContract.maximumTargetPorts` a config carries — a count, where
|
|
97
|
+
* `port-protocol-unsupported` is about one port's protocol — and `node-authority-unsupported` is a
|
|
98
|
+
* node whose session resolved fewer workload authorities than this spec needs, which the admit step
|
|
99
|
+
* refuses rather than handing the node work it would silently park.
|
|
100
|
+
*/
|
|
92
101
|
export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
|
|
93
102
|
|
|
94
103
|
interface IServiceRuntimeStatusBase {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAppStoreFilesystemStorageRequest,
|
|
3
|
+
IAppStoreObjectStorageRequest,
|
|
4
|
+
} from '../appstore/types.js';
|
|
5
|
+
import { deepFreezeValue } from '../private/canonicaljson.js';
|
|
6
|
+
|
|
7
|
+
export interface IStorageRequestCanonicalDigestGoldenVector<TInput> {
|
|
8
|
+
input: TInput;
|
|
9
|
+
canonicalJson: string;
|
|
10
|
+
sha256: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const filesystemRequest: IAppStoreFilesystemStorageRequest = {
|
|
14
|
+
id: 'app-state',
|
|
15
|
+
kind: 'filesystem',
|
|
16
|
+
storageClass: 'databaseFast',
|
|
17
|
+
accessMode: 'ReadWriteOnce',
|
|
18
|
+
reclaimPolicy: 'delete',
|
|
19
|
+
mountPath: '/var/lib/app',
|
|
20
|
+
capacity: {
|
|
21
|
+
request: '20GiB',
|
|
22
|
+
limit: '40GiB',
|
|
23
|
+
},
|
|
24
|
+
protection: {
|
|
25
|
+
backup: 'required',
|
|
26
|
+
snapshots: 'portable',
|
|
27
|
+
consistency: 'applicationConsistent',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const objectStorageLauncherEnvironmentRequest: IAppStoreObjectStorageRequest = {
|
|
32
|
+
id: 'object-data',
|
|
33
|
+
kind: 'objectStorage',
|
|
34
|
+
storageClass: 'backupCapacity',
|
|
35
|
+
accessMode: 'readWrite',
|
|
36
|
+
reclaimPolicy: 'retain',
|
|
37
|
+
delivery: {
|
|
38
|
+
type: 'launcher-environment',
|
|
39
|
+
keys: {
|
|
40
|
+
endpoint: 'APP_S3_ENDPOINT',
|
|
41
|
+
bucket: 'APP_S3_BUCKET',
|
|
42
|
+
region: 'APP_S3_REGION',
|
|
43
|
+
accessKeyId: 'APP_S3_ACCESS_KEY',
|
|
44
|
+
secretAccessKey: 'APP_S3_SECRET_KEY',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
capacity: {
|
|
48
|
+
request: '500GiB',
|
|
49
|
+
},
|
|
50
|
+
protection: {
|
|
51
|
+
backup: 'required',
|
|
52
|
+
versioning: 'required',
|
|
53
|
+
retentionDays: 30,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const objectStorageFileRequest: IAppStoreObjectStorageRequest = {
|
|
58
|
+
id: 'object-archive',
|
|
59
|
+
kind: 'objectStorage',
|
|
60
|
+
storageClass: 'archiveCapacity',
|
|
61
|
+
accessMode: 'readOnly',
|
|
62
|
+
reclaimPolicy: 'delete',
|
|
63
|
+
delivery: {
|
|
64
|
+
type: 'file',
|
|
65
|
+
targetPath: '/run/secrets/object-archive.json',
|
|
66
|
+
format: 'servezone-object-storage',
|
|
67
|
+
uid: 10_000,
|
|
68
|
+
gid: 10_000,
|
|
69
|
+
mode: 0o400,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The storage request digest, written down for both kinds and both deliveries.
|
|
75
|
+
*
|
|
76
|
+
* A control plane and its consumer compare this value, so it is stated here as bytes rather than
|
|
77
|
+
* as whatever the current normalizer happens to produce: a member that starts or stops
|
|
78
|
+
* participating, an optional member that starts being carried when it is absent, or a delivery
|
|
79
|
+
* mode written as anything but its number would move these digests and be caught here.
|
|
80
|
+
*/
|
|
81
|
+
export const storageRequestCanonicalDigestGoldenVectors = deepFreezeValue({
|
|
82
|
+
filesystem: {
|
|
83
|
+
input: filesystemRequest,
|
|
84
|
+
canonicalJson: '{"accessMode":"ReadWriteOnce","capacity":{"limit":"40GiB","request":"20GiB"},"id":"app-state","kind":"filesystem","mountPath":"/var/lib/app","protection":{"backup":"required","consistency":"applicationConsistent","snapshots":"portable"},"reclaimPolicy":"delete","storageClass":"databaseFast"}',
|
|
85
|
+
sha256: '0f8dc2e480ade7110be1f1d95f666cc56d5b90110d1c4a1b8b8383e2eeda6fae',
|
|
86
|
+
},
|
|
87
|
+
objectStorageLauncherEnvironment: {
|
|
88
|
+
input: objectStorageLauncherEnvironmentRequest,
|
|
89
|
+
canonicalJson: '{"accessMode":"readWrite","capacity":{"request":"500GiB"},"delivery":{"keys":{"accessKeyId":"APP_S3_ACCESS_KEY","bucket":"APP_S3_BUCKET","endpoint":"APP_S3_ENDPOINT","region":"APP_S3_REGION","secretAccessKey":"APP_S3_SECRET_KEY"},"type":"launcher-environment"},"id":"object-data","kind":"objectStorage","protection":{"backup":"required","retentionDays":30,"versioning":"required"},"reclaimPolicy":"retain","storageClass":"backupCapacity"}',
|
|
90
|
+
sha256: '88bd943800bea75b576612b966484e66ddbf7ec9529a7f29dd1eadc9a36e2b79',
|
|
91
|
+
},
|
|
92
|
+
objectStorageFile: {
|
|
93
|
+
input: objectStorageFileRequest,
|
|
94
|
+
canonicalJson: '{"accessMode":"readOnly","delivery":{"format":"servezone-object-storage","gid":10000,"mode":256,"targetPath":"/run/secrets/object-archive.json","type":"file","uid":10000},"id":"object-archive","kind":"objectStorage","reclaimPolicy":"delete","storageClass":"archiveCapacity"}',
|
|
95
|
+
sha256: '13186abbfe733b3917363e0694d367f2d6aa87a4064445532d7c857ee274f795',
|
|
96
|
+
},
|
|
97
|
+
});
|
package/ts/platform/storage.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { TSecretManagementScope } from '../data/secret.js';
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeStrictJson,
|
|
4
|
+
createCanonicalJsonSha256Hex,
|
|
5
|
+
} from '../private/canonicaljson.js';
|
|
6
|
+
import { createStorageContractReaders } from '../private/storagecontract.js';
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Backend-neutral storage vocabulary shared by App Store manifests, control
|
|
@@ -186,8 +191,12 @@ export interface IResolvedStorageBindingBase {
|
|
|
186
191
|
/** Stable App Store storageRequests[].id. */
|
|
187
192
|
requestId: string;
|
|
188
193
|
/**
|
|
189
|
-
* Canonical digest of the normalized request
|
|
190
|
-
*
|
|
194
|
+
* Canonical digest of the normalized request, exactly as
|
|
195
|
+
* `createStorageRequestSha256` states it: bare lowercase hex over the request
|
|
196
|
+
* alone. Adapters use it to distinguish an idempotent reconciliation from a
|
|
197
|
+
* migration, and a consumer re-derives it to recognise the request it asked
|
|
198
|
+
* for, so the control plane and the consumer call that one function rather
|
|
199
|
+
* than each deriving a value the other cannot reproduce.
|
|
191
200
|
*/
|
|
192
201
|
requestDigest: string;
|
|
193
202
|
kind: TStorageResourceKind;
|
|
@@ -232,3 +241,51 @@ extends IResolvedStorageBindingBase {
|
|
|
232
241
|
export type TResolvedStorageBinding =
|
|
233
242
|
| IResolvedFilesystemStorageBinding
|
|
234
243
|
| IResolvedObjectStorageBinding;
|
|
244
|
+
|
|
245
|
+
export class StorageContractError extends Error {
|
|
246
|
+
public constructor(reasonArg: string) {
|
|
247
|
+
super(`storage contract error: ${reasonArg}`);
|
|
248
|
+
this.name = 'StorageContractError';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const fail = (reasonArg: string): never => {
|
|
253
|
+
throw new StorageContractError(reasonArg);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const { normalizeStorageRequest } = createStorageContractReaders(fail);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The exact bytes a storage request's digest is taken over: the request normalized to its contract
|
|
260
|
+
* shape, written as strict canonical JSON — keys in UTF-16 code-unit order, no whitespace, no
|
|
261
|
+
* absent member carried as `null`.
|
|
262
|
+
*
|
|
263
|
+
* Anything that is not a storage request is refused rather than hashed, because a digest of an
|
|
264
|
+
* unjudged body would name a request no adapter could fulfil.
|
|
265
|
+
*/
|
|
266
|
+
export const canonicalizeStorageRequest = (valueArg: unknown): string => {
|
|
267
|
+
return canonicalizeStrictJson(
|
|
268
|
+
normalizeStorageRequest(valueArg, 'storage request'),
|
|
269
|
+
fail,
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The storage request digest: bare lowercase hex SHA-256 over the canonical request, with no
|
|
275
|
+
* prefix and nothing but the request in the hashed value.
|
|
276
|
+
*
|
|
277
|
+
* A control plane issues this value on a binding and a consumer re-derives it from the request it
|
|
278
|
+
* declared; the two only agree because they call this one function. A second derivation elsewhere
|
|
279
|
+
* — a prefix, or a wrapper object around the request — is a different value for the same binding,
|
|
280
|
+
* and the consumer would keep rejecting a binding that is in fact its own.
|
|
281
|
+
*/
|
|
282
|
+
export const createStorageRequestSha256 = async (
|
|
283
|
+
valueArg: unknown,
|
|
284
|
+
): Promise<string> => {
|
|
285
|
+
return createCanonicalJsonSha256Hex(canonicalizeStorageRequest(valueArg), fail);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export {
|
|
289
|
+
storageRequestCanonicalDigestGoldenVectors,
|
|
290
|
+
type IStorageRequestCanonicalDigestGoldenVector,
|
|
291
|
+
} from './storage.golden.js';
|