@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.
@@ -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', 'readiness-target-undeclared',
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
- /** Why the producer admits nothing for this spec revision. A refusal never retires running slots. */
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
+ });
@@ -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. Adapters use it to distinguish
190
- * an idempotent reconciliation from a migration.
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';