@serve.zone/interfaces 31.7.0 → 31.9.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 (59) hide show
  1. package/changelog.md +77 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/clusterruntime.d.ts +39 -0
  4. package/dist_ts/data/clusterruntime.js +11 -0
  5. package/dist_ts/data/index.d.ts +5 -0
  6. package/dist_ts/data/index.js +6 -1
  7. package/dist_ts/data/networkaddressplan.d.ts +78 -0
  8. package/dist_ts/data/networkaddressplan.js +114 -0
  9. package/dist_ts/data/networkauthority.js +5 -17
  10. package/dist_ts/data/networkcredential.d.ts +10 -4
  11. package/dist_ts/data/networkcredential.js +14 -8
  12. package/dist_ts/data/networkdelivery.d.ts +21 -0
  13. package/dist_ts/data/networkdelivery.js +35 -7
  14. package/dist_ts/data/networklease.d.ts +110 -0
  15. package/dist_ts/data/networklease.js +159 -0
  16. package/dist_ts/data/networknode.d.ts +37 -0
  17. package/dist_ts/data/networknode.js +6 -0
  18. package/dist_ts/data/networkprojection.d.ts +10 -5
  19. package/dist_ts/data/networkprojection.js +15 -22
  20. package/dist_ts/data/networksigning.js +5 -4
  21. package/dist_ts/data/runtimeconfig.js +14 -77
  22. package/dist_ts/data/serviceruntime.d.ts +128 -0
  23. package/dist_ts/data/serviceruntime.js +92 -0
  24. package/dist_ts/private/networkcontract.d.ts +20 -0
  25. package/dist_ts/private/networkcontract.js +45 -1
  26. package/dist_ts/private/producercontract.d.ts +11 -0
  27. package/dist_ts/private/producercontract.js +21 -0
  28. package/dist_ts/private/runtimepolicy.d.ts +31 -0
  29. package/dist_ts/private/runtimepolicy.js +90 -0
  30. package/dist_ts/requests/cluster.d.ts +53 -0
  31. package/dist_ts/requests/cluster.js +1 -1
  32. package/dist_ts/requests/network.d.ts +57 -1
  33. package/dist_ts/requests/runtimesession.d.ts +14 -0
  34. package/dist_ts/requests/service.d.ts +60 -0
  35. package/dist_ts/runtime.networkprojection.d.ts +14 -0
  36. package/dist_ts/runtime.networkprojection.js +68 -19
  37. package/package.json +2 -2
  38. package/readme.md +251 -3
  39. package/ts/00_commitinfo_data.ts +1 -1
  40. package/ts/data/clusterruntime.ts +45 -0
  41. package/ts/data/index.ts +5 -0
  42. package/ts/data/networkaddressplan.ts +175 -0
  43. package/ts/data/networkauthority.ts +4 -16
  44. package/ts/data/networkcredential.ts +19 -9
  45. package/ts/data/networkdelivery.ts +49 -6
  46. package/ts/data/networklease.ts +229 -0
  47. package/ts/data/networknode.ts +37 -0
  48. package/ts/data/networkprojection.ts +28 -26
  49. package/ts/data/networksigning.ts +5 -4
  50. package/ts/data/runtimeconfig.ts +17 -76
  51. package/ts/data/serviceruntime.ts +208 -0
  52. package/ts/private/networkcontract.ts +48 -0
  53. package/ts/private/producercontract.ts +19 -0
  54. package/ts/private/runtimepolicy.ts +105 -0
  55. package/ts/requests/cluster.ts +65 -0
  56. package/ts/requests/network.ts +50 -1
  57. package/ts/requests/runtimesession.ts +17 -0
  58. package/ts/requests/service.ts +81 -0
  59. package/ts/runtime.networkprojection.ts +87 -20
@@ -12,6 +12,10 @@ import {
12
12
  import type { IRuntimeAssignmentV1, IRuntimeAuthorityReferenceV1 } from './runtimeassignment.js';
13
13
  import { snapshotRuntimeAssignmentV1, validateRuntimeAssignmentV1 } from './runtimeassignment.js';
14
14
  import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
15
+ import {
16
+ assertRuntimeReadiness, isBoundedInteger, isExactRecord, isRuntimeCpuMillis, isRuntimeIdentityNumber,
17
+ isRuntimeLogPolicy, isRuntimeMemoryBytes, isRuntimePortName, runtimePolicyLimits,
18
+ } from '../private/runtimepolicy.js';
15
19
 
16
20
  /** A container target only; declaration does not publish a host or gateway port. */
17
21
  export interface IRuntimeConfigTargetPortV1 {
@@ -128,28 +132,16 @@ export const runtimeConfigV1Contract = Object.freeze({
128
132
  maximumCanonicalBytes: 262_144,
129
133
  maximumEnvironmentEntries: 128,
130
134
  maximumTargetPorts: 64,
131
- maximumReadinessDurationMs: 3_600_000,
132
- maximumLogRetentionMs: 31_536_000_000,
135
+ maximumReadinessDurationMs: runtimePolicyLimits.maximumReadinessDurationMs,
136
+ maximumLogRetentionMs: runtimePolicyLimits.maximumLogRetentionMs,
133
137
  } as const);
134
138
 
135
139
  const fail: (messageArg: string) => never = (messageArg) => { throw new Error(messageArg); };
136
140
  const encoder = new TextEncoder();
137
- const isInteger = (valueArg: unknown, minimumArg: number, maximumArg: number): valueArg is number =>
138
- typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0)
139
- && valueArg >= minimumArg && valueArg <= maximumArg;
140
141
  const isIdentifier = (valueArg: unknown): valueArg is string =>
141
142
  typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
142
143
  const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
143
144
  typeof valueArg === 'string' && isSha256Digest(valueArg);
144
- const isPortName = (valueArg: unknown): valueArg is string =>
145
- typeof valueArg === 'string' && /^[a-z0-9][a-z0-9_.-]{0,62}$/.test(valueArg);
146
- const isExactRecord = (
147
- valueArg: unknown, keysArg: readonly string[],
148
- ): valueArg is Record<string, unknown> => {
149
- if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
150
- const keys = Object.keys(valueArg);
151
- return keys.length === keysArg.length && keys.every((keyArg) => keysArg.includes(keyArg));
152
- };
153
145
 
154
146
  /**
155
147
  * Bound traversal before canonicalization and copy descriptor values so input
@@ -183,7 +175,7 @@ const snapshotBoundedJson = (valueArg: unknown): unknown => {
183
175
  if (array) {
184
176
  const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
185
177
  const length = lengthDescriptor?.value;
186
- if (!isInteger(length, 0, 256) || keys.length !== length + 1) {
178
+ if (!isBoundedInteger(length, 0, 256) || keys.length !== length + 1) {
187
179
  fail('runtime config requires bounded dense arrays');
188
180
  }
189
181
  const result: unknown[] = [];
@@ -216,60 +208,12 @@ const snapshotBoundedJson = (valueArg: unknown): unknown => {
216
208
  return JSON.parse(canonical);
217
209
  };
218
210
 
219
- const timingKeys = ['initialDelayMs', 'startupGraceMs', 'intervalMs', 'timeoutMs',
220
- 'successThreshold', 'failureThreshold'] as const;
221
- const assertReadiness = (valueArg: unknown, portsArg: IRuntimeConfigTargetPortV1[]): void => {
222
- if (!valueArg || typeof valueArg !== 'object') fail('runtime config readiness is required');
223
- const value = valueArg as Record<string, unknown>;
224
- const keys = value.kind === 'process' ? ['kind', ...timingKeys]
225
- : value.kind === 'tcp' ? ['kind', ...timingKeys, 'targetPort', 'port']
226
- : value.kind === 'http' ? ['kind', ...timingKeys, 'targetPort', 'port',
227
- 'scheme', 'host', 'path', 'expectedStatusCodes', 'followRedirects'] : [];
228
- if (keys.length === 0 || !isExactRecord(value, keys)
229
- || !isInteger(value.initialDelayMs, 0, runtimeConfigV1Contract.maximumReadinessDurationMs)
230
- || !isInteger(value.startupGraceMs, 0, runtimeConfigV1Contract.maximumReadinessDurationMs)
231
- || !isInteger(value.intervalMs, 100, runtimeConfigV1Contract.maximumReadinessDurationMs)
232
- || !isInteger(value.timeoutMs, 1, 60_000)
233
- || !isInteger(value.successThreshold, 1, 100)
234
- || !isInteger(value.failureThreshold, 1, 100)
235
- || value.timeoutMs > value.intervalMs || value.initialDelayMs > value.startupGraceMs) {
236
- fail('runtime config readiness must use explicit bounded timing and exact schema');
237
- }
238
- if (value.kind === 'process') return;
239
- const target = portsArg.find((portArg) => portArg.name === value.targetPort);
240
- if (!isPortName(value.targetPort) || !target || target.protocol !== 'tcp'
241
- || !isInteger(value.port, target.port, target.portEnd)) {
242
- fail('runtime config readiness must select a declared TCP target and port');
243
- }
244
- if (value.kind === 'tcp') return;
245
- if ((value.scheme !== 'http' && value.scheme !== 'https') || value.followRedirects !== false
246
- || typeof value.host !== 'string' || value.host.length > 253
247
- || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(value.host)
248
- || typeof value.path !== 'string' || value.path.length > 4096
249
- || !value.path.startsWith('/') || value.path.startsWith('//')
250
- || /[^\x21-\x7e]|[\\#]/.test(value.path)
251
- || /%(?![0-9A-F]{2})/.test(value.path)
252
- || !Array.isArray(value.expectedStatusCodes) || value.expectedStatusCodes.length === 0
253
- || value.expectedStatusCodes.length > 32) {
254
- fail('runtime config HTTP readiness must use an exact bounded request policy');
255
- }
256
- const parsed = new URL(value.path, 'http://runtime.invalid');
257
- if (`${parsed.pathname}${parsed.search}` !== value.path) fail('runtime config HTTP path must be canonical');
258
- let previousCode = 0;
259
- for (const code of value.expectedStatusCodes) {
260
- if (!isInteger(code, 200, 599) || code <= previousCode) {
261
- fail('runtime config HTTP status expectations must be sorted unique final codes');
262
- }
263
- previousCode = code;
264
- }
265
- };
266
-
267
211
  const assertConfig = (valueArg: unknown): IRuntimeConfigV1 => {
268
212
  if (!isExactRecord(valueArg, ['schemaVersion', 'id', 'generation', 'organizationId', 'serviceId',
269
213
  'image', 'environment', 'workingDirectory', 'cpuMillis', 'memoryBytes', 'readonlyRootfs',
270
214
  'runAsUser', 'runAsGroup', 'targetPorts', 'readiness', 'logs', 'digest'])
271
215
  || valueArg.schemaVersion !== 1 || !isIdentifier(valueArg.id)
272
- || !isInteger(valueArg.generation, 1, Number.MAX_SAFE_INTEGER)
216
+ || !isBoundedInteger(valueArg.generation, 1, Number.MAX_SAFE_INTEGER)
273
217
  || !isIdentifier(valueArg.organizationId) || !isIdentifier(valueArg.serviceId)
274
218
  || !isDigest(valueArg.digest)) fail('runtime config must use its exact v1 schema');
275
219
  const image = valueArg.image;
@@ -297,11 +241,11 @@ const assertConfig = (valueArg: unknown): IRuntimeConfigV1 => {
297
241
  if (typeof directory !== 'string' || !directory.startsWith('/')
298
242
  || encoder.encode(directory).byteLength > 4096 || /[\u0000-\u001f\u007f]/.test(directory)
299
243
  || (directory !== '/' && directory.split('/').slice(1).some((partArg) => !partArg || partArg === '.' || partArg === '..'))
300
- || !(valueArg.cpuMillis === null || isInteger(valueArg.cpuMillis, 1, 1_000_000))
301
- || !isInteger(valueArg.memoryBytes, 16 * 1024 * 1024, 2 ** 50)
244
+ || !isRuntimeCpuMillis(valueArg.cpuMillis)
245
+ || !isRuntimeMemoryBytes(valueArg.memoryBytes)
302
246
  || typeof valueArg.readonlyRootfs !== 'boolean'
303
247
  || !((valueArg.runAsUser === null && valueArg.runAsGroup === null)
304
- || (isInteger(valueArg.runAsUser, 0, 2 ** 31 - 1) && isInteger(valueArg.runAsGroup, 0, 2 ** 31 - 1)))) {
248
+ || (isRuntimeIdentityNumber(valueArg.runAsUser) && isRuntimeIdentityNumber(valueArg.runAsGroup)))) {
305
249
  fail('runtime config requires canonical directory and explicit resource and identity policies');
306
250
  }
307
251
  const ports = valueArg.targetPorts;
@@ -312,8 +256,8 @@ const assertConfig = (valueArg: unknown): IRuntimeConfigV1 => {
312
256
  const validatedPorts: IRuntimeConfigTargetPortV1[] = [];
313
257
  for (const port of ports) {
314
258
  if (!isExactRecord(port, ['name', 'port', 'portEnd', 'protocol'])
315
- || !isPortName(port.name) || port.name <= previousName
316
- || !isInteger(port.port, 1, 65535) || !isInteger(port.portEnd, port.port, 65535)
259
+ || !isRuntimePortName(port.name) || port.name <= previousName
260
+ || !isBoundedInteger(port.port, 1, 65535) || !isBoundedInteger(port.portEnd, port.port, 65535)
317
261
  || (port.protocol !== 'tcp' && port.protocol !== 'udp')
318
262
  || validatedPorts.some((previousArg) => previousArg.protocol === port.protocol
319
263
  && (port.port as number) <= previousArg.portEnd && (port.portEnd as number) >= previousArg.port)) {
@@ -322,13 +266,10 @@ const assertConfig = (valueArg: unknown): IRuntimeConfigV1 => {
322
266
  previousName = port.name;
323
267
  validatedPorts.push(port as unknown as IRuntimeConfigTargetPortV1);
324
268
  }
325
- assertReadiness(valueArg.readiness, validatedPorts);
326
- const logs = valueArg.logs;
327
- if (!isExactRecord(logs, ['maximumLineBytes', 'maximumBufferedBytes', 'retentionMs'])
328
- || !isInteger(logs.maximumLineBytes, 1, 1_048_576)
329
- || !isInteger(logs.maximumBufferedBytes, 1, 67_108_864)
330
- || logs.maximumBufferedBytes < logs.maximumLineBytes
331
- || !isInteger(logs.retentionMs, 1, runtimeConfigV1Contract.maximumLogRetentionMs)) {
269
+ assertRuntimeReadiness(valueArg.readiness, (targetPortArg, portArg) => validatedPorts.some((targetArg) =>
270
+ targetArg.name === targetPortArg && targetArg.protocol === 'tcp'
271
+ && portArg >= targetArg.port && portArg <= targetArg.portEnd), fail);
272
+ if (!isRuntimeLogPolicy(valueArg.logs)) {
332
273
  fail('runtime config logs require explicit bounded line, buffer and retention limits');
333
274
  }
334
275
  return valueArg as unknown as IRuntimeConfigV1;
@@ -0,0 +1,208 @@
1
+ import type {
2
+ IRuntimeAssignmentObservationV1, IRuntimeAssignmentReferenceV1, TRuntimeAssignmentDispositionV1,
3
+ } from './runtimeassignment.js';
4
+ import type { IRuntimeConfigLogPolicyV1, TRuntimeConfigReadinessV1 } from './runtimeconfig.js';
5
+ import { runtimeNetworkAuthorityV1Contract } from './networkauthority.js';
6
+ import { captureNetwork, networkCanonical, networkCounter, networkId, networkList, networkRecord, networkSorted,
7
+ requireNetwork } from '../private/networkcontract.js';
8
+ import { failProducer, isOneOf, producerSafe, variantOf } from '../private/producercontract.js';
9
+ import { assertRuntimeReadiness, isBoundedInteger, isRuntimeCpuMillis, isRuntimeIdentityNumber, isRuntimeLogPolicy,
10
+ isRuntimeMemoryBytes } from '../private/runtimepolicy.js';
11
+
12
+ /** `nodes` pins the service to exactly these nodes: sorted, unique and never empty. */
13
+ export type TServiceRuntimePlacement =
14
+ | { kind: 'every-node' }
15
+ | { kind: 'nodes'; nodeIds: string[] };
16
+
17
+ /**
18
+ * The execution policy a service's runtime configs are built with. It carries everything the
19
+ * runtime config seals except what only the service and its image supply: environment, working
20
+ * directory and target ports. A readiness probe names a target port, which the runtime config then
21
+ * requires the service to declare.
22
+ */
23
+ export interface IServiceRuntimeProfile {
24
+ /** null explicitly requests no CPU quota. */
25
+ cpuMillis: number | null;
26
+ memoryBytes: number;
27
+ readiness: TRuntimeConfigReadinessV1;
28
+ logs: IRuntimeConfigLogPolicyV1;
29
+ readonlyRootfs: boolean;
30
+ /** null runs the image's own OCI user, exactly as a runtime config's null identity pair does. */
31
+ runAs: { user: number; group: number } | null;
32
+ }
33
+
34
+ /**
35
+ * `isolated` gives the workload no network authority. `attached` joins the runtime network with
36
+ * public egress on or off and the platform endpoints it may reach, by id: sorted and unique.
37
+ */
38
+ export type TServiceRuntimeNetwork =
39
+ | { mode: 'isolated' }
40
+ | { mode: 'attached'; publicEgress: boolean; platformEndpointIds: string[] };
41
+
42
+ /**
43
+ * What Cloudly runs for one service on a Pallet cluster. Stored complete, never defaulted at read
44
+ * time, and beside the service document rather than inside it, so a hold written by a cutover
45
+ * never races an operator's service edit.
46
+ */
47
+ export interface IServiceRuntimeSpec {
48
+ /** The service id. */
49
+ id: string;
50
+ /** The service's organization, copied by Cloudly; never caller-chosen. */
51
+ organizationId: string;
52
+ clusterId: string;
53
+ placement: TServiceRuntimePlacement;
54
+ replicasPerNode: number;
55
+ /** While true, the service runs nowhere and every slot keeps its storage. */
56
+ hold: boolean;
57
+ profile: IServiceRuntimeProfile;
58
+ network: TServiceRuntimeNetwork;
59
+ /** Compare-and-swap counter; every write advances it by one. */
60
+ revision: number;
61
+ }
62
+
63
+ /** What an administrator writes; Cloudly supplies the service id, organization and revision. */
64
+ export type TServiceRuntimeSpecWritableData = Omit<IServiceRuntimeSpec, 'id' | 'organizationId' | 'revision'>;
65
+
66
+ export const serviceRuntimeContract = Object.freeze({
67
+ maximumReplicasPerNode: 64,
68
+ /** A service whose placement desires more replicas than this is refused as `replica-limit`. */
69
+ maximumReplicas: 64,
70
+ /** A node can run workloads only as an egress member of the protected authority, which admits at most this many. */
71
+ maximumPinnedNodes: runtimeNetworkAuthorityV1Contract.maximumEgressAuthorities,
72
+ maximumPlatformEndpoints: runtimeNetworkAuthorityV1Contract.maximumPlatformEndpoints,
73
+ /** The largest `limit` `getClusterRuntimeAssignments` accepts; Cloudly refuses a larger one. */
74
+ maximumAssignmentPageSize: 256,
75
+ } as const);
76
+
77
+ const serviceRuntimeWaitingReasons = ['node-network-pending', 'conflict', 'failure-backoff'] as const;
78
+ const serviceRuntimeRefusals = [
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',
82
+ ] as const;
83
+
84
+ /**
85
+ * Why the producer is not converging yet, though nothing needs an administrator:
86
+ * `node-network-pending` waits for a node's network readiness, `conflict` for a sweep over inputs
87
+ * that changed while it ran, `failure-backoff` for a failed replica's backoff to elapse.
88
+ */
89
+ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[number];
90
+
91
+ /** Why the producer admits nothing for this spec revision. A refusal never retires running slots. */
92
+ export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
93
+
94
+ interface IServiceRuntimeStatusBase {
95
+ /** The service id. */
96
+ id: string;
97
+ /** The spec revision this verdict is about. */
98
+ specRevision: number;
99
+ /** Unix milliseconds on Cloudly's clock. Written only when the verdict changes. */
100
+ changedAt: number;
101
+ }
102
+
103
+ /** The producer's verdict on one spec revision. */
104
+ export type IServiceRuntimeStatus = IServiceRuntimeStatusBase & (
105
+ | { state: 'converged' | 'converging'; reason: null }
106
+ | { state: 'waiting'; reason: TServiceRuntimeWaitingReason }
107
+ | { state: 'refused'; reason: TServiceRuntimeRefusal }
108
+ );
109
+
110
+ /**
111
+ * `delivered` once the node's own evidence, an observation or a terminal receipt, cites this exact
112
+ * revision; `undelivered` until then. Cloudly keeps no acknowledgement journal, so a revision the
113
+ * node admitted but has not yet reported on reads as undelivered.
114
+ */
115
+ export type TServiceRuntimeAssignmentDelivery = 'undelivered' | 'delivered';
116
+
117
+ /** One replica slot's current assignment, as an administrator reads it. */
118
+ export interface IServiceRuntimeAssignmentView {
119
+ serviceId: string;
120
+ replicaId: string;
121
+ nodeId: string;
122
+ /** The current revision; its generation is the one the disposition belongs to. */
123
+ assignment: IRuntimeAssignmentReferenceV1;
124
+ disposition: TRuntimeAssignmentDispositionV1;
125
+ /** The latest accepted observation of this attempt, which may cite an earlier revision. */
126
+ observation: Pick<IRuntimeAssignmentObservationV1, 'phase' | 'ready' | 'observedAt' | 'sequence'> | null;
127
+ /** Joined stop and remove receipts of this attempt. */
128
+ terminalReceiptCount: 0 | 1 | 2;
129
+ delivery: TServiceRuntimeAssignmentDelivery;
130
+ }
131
+
132
+ const sortedIds = (value: unknown, maximum: number): string[] =>
133
+ networkSorted(networkList(value, maximum, networkId), id => id);
134
+
135
+ const placementOf = (value: unknown): TServiceRuntimePlacement => {
136
+ const kind = variantOf(value, 'kind');
137
+ if (kind === 'every-node') {
138
+ networkRecord(value, ['kind']);
139
+ return { kind };
140
+ }
141
+ requireNetwork(kind === 'nodes');
142
+ const nodeIds = sortedIds(networkRecord(value, ['kind', 'nodeIds']).nodeIds, serviceRuntimeContract.maximumPinnedNodes);
143
+ requireNetwork(nodeIds.length > 0);
144
+ return { kind, nodeIds };
145
+ };
146
+
147
+ const runAsOf = (value: unknown): IServiceRuntimeProfile['runAs'] => {
148
+ if (value === null) return null;
149
+ const item = networkRecord(value, ['user', 'group']);
150
+ requireNetwork(isRuntimeIdentityNumber(item.user) && isRuntimeIdentityNumber(item.group));
151
+ return { user: item.user, group: item.group };
152
+ };
153
+
154
+ const profileOf = (value: unknown): IServiceRuntimeProfile => {
155
+ const item = networkRecord(value, ['cpuMillis', 'memoryBytes', 'readiness', 'logs', 'readonlyRootfs', 'runAs']);
156
+ requireNetwork(isRuntimeCpuMillis(item.cpuMillis) && isRuntimeMemoryBytes(item.memoryBytes)
157
+ && isRuntimeLogPolicy(item.logs) && typeof item.readonlyRootfs === 'boolean');
158
+ // The profile precedes the service's target ports; the runtime config refuses a target they do not declare.
159
+ assertRuntimeReadiness(item.readiness, () => true, failProducer);
160
+ const { maximumLineBytes, maximumBufferedBytes, retentionMs } = item.logs;
161
+ return { cpuMillis: item.cpuMillis, memoryBytes: item.memoryBytes,
162
+ readiness: JSON.parse(networkCanonical(item.readiness)) as TRuntimeConfigReadinessV1,
163
+ logs: { maximumLineBytes, maximumBufferedBytes, retentionMs }, readonlyRootfs: item.readonlyRootfs, runAs: runAsOf(item.runAs) };
164
+ };
165
+
166
+ const networkOf = (value: unknown): TServiceRuntimeNetwork => {
167
+ const mode = variantOf(value, 'mode');
168
+ if (mode === 'isolated') {
169
+ networkRecord(value, ['mode']);
170
+ return { mode };
171
+ }
172
+ requireNetwork(mode === 'attached');
173
+ const item = networkRecord(value, ['mode', 'publicEgress', 'platformEndpointIds']);
174
+ requireNetwork(typeof item.publicEgress === 'boolean');
175
+ return { mode, publicEgress: item.publicEgress,
176
+ platformEndpointIds: sortedIds(item.platformEndpointIds, serviceRuntimeContract.maximumPlatformEndpoints) };
177
+ };
178
+
179
+ /**
180
+ * Structural snapshot for exact persistence and for a spec write. Whether the cluster, the nodes and
181
+ * the platform endpoints exist, and whether the service declares the readiness target, is Cloudly's.
182
+ */
183
+ export const snapshotServiceRuntimeSpec = (value: unknown): IServiceRuntimeSpec => producerSafe(() => {
184
+ const item = networkRecord(captureNetwork(value), ['id', 'organizationId', 'clusterId', 'placement',
185
+ 'replicasPerNode', 'hold', 'profile', 'network', 'revision']);
186
+ requireNetwork(isBoundedInteger(item.replicasPerNode, 1, serviceRuntimeContract.maximumReplicasPerNode)
187
+ && typeof item.hold === 'boolean');
188
+ return { id: networkId(item.id), organizationId: networkId(item.organizationId), clusterId: networkId(item.clusterId),
189
+ placement: placementOf(item.placement), replicasPerNode: item.replicasPerNode, hold: item.hold,
190
+ profile: profileOf(item.profile), network: networkOf(item.network), revision: networkCounter(item.revision) };
191
+ });
192
+
193
+ /** Structural snapshot for exact persistence: a reason belongs to exactly one state. */
194
+ export const snapshotServiceRuntimeStatus = (value: unknown): IServiceRuntimeStatus => producerSafe(() => {
195
+ const item = networkRecord(captureNetwork(value), ['id', 'specRevision', 'state', 'reason', 'changedAt']);
196
+ const base: IServiceRuntimeStatusBase = { id: networkId(item.id), specRevision: networkCounter(item.specRevision),
197
+ changedAt: networkCounter(item.changedAt) };
198
+ if (item.state === 'converged' || item.state === 'converging') {
199
+ requireNetwork(item.reason === null);
200
+ return { ...base, state: item.state, reason: null };
201
+ }
202
+ if (item.state === 'waiting') {
203
+ requireNetwork(isOneOf(serviceRuntimeWaitingReasons, item.reason));
204
+ return { ...base, state: item.state, reason: item.reason };
205
+ }
206
+ requireNetwork(item.state === 'refused' && isOneOf(serviceRuntimeRefusals, item.reason));
207
+ return { ...base, state: item.state, reason: item.reason };
208
+ });
@@ -1,5 +1,7 @@
1
1
  import type { TSha256Digest } from '../data/immutableimage.js';
2
2
  import type { IRuntimeAuthorityReferenceV1, IRuntimeControllerV1 } from '../data/runtimeassignment.js';
3
+ import type { IRuntimeNetworkDnsLeaseRenewal } from '../data/networklease.js';
4
+ import type { IRuntimeNetworkDnsWindow, IRuntimeNetworkNodeScopeV1, IRuntimeNetworkProjectionV1 } from '../data/networkprojection.js';
3
5
  import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from './canonicaljson.js';
4
6
 
5
7
  export const failNetwork = (): never => { throw new Error('Runtime network projection contract rejected.'); };
@@ -83,17 +85,49 @@ export const networkCounter = (value: unknown, maximum = Number.MAX_SAFE_INTEGER
83
85
  export const networkDigest = (value: unknown): TSha256Digest => {
84
86
  requireNetwork(typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value)); return value as TSha256Digest;
85
87
  };
88
+ /** One encoding for every Ed25519 signature this contract carries: lowercase hex, 64 bytes. */
89
+ export const networkSignature = (value: unknown): string => {
90
+ requireNetwork(typeof value === 'string' && /^[a-f0-9]{128}$/.test(value)); return value;
91
+ };
86
92
  export const networkController = (value: unknown): IRuntimeControllerV1 => {
87
93
  const item = networkRecord(value, ['kind', 'id', 'epoch']);
88
94
  requireNetwork(item.kind === 'cloudly' || item.kind === 'onebox');
89
95
  return { kind: item.kind, id: networkId(item.id), epoch: networkId(item.epoch) };
90
96
  };
97
+ export const networkScope = (item: Record<string, unknown>): IRuntimeNetworkNodeScopeV1 => ({
98
+ controller: networkController(item.controller), nodeId: networkId(item.nodeId), runtimeNamespace: networkId(item.runtimeNamespace),
99
+ });
100
+ export const networkSameScope = (left: IRuntimeNetworkNodeScopeV1, right: IRuntimeNetworkNodeScopeV1): boolean =>
101
+ networkEqual(left.controller, right.controller) && left.nodeId === right.nodeId && left.runtimeNamespace === right.runtimeNamespace;
102
+ export const networkMaximumDnsLeaseMs = 900_000;
103
+ /** One rule for every signed DNS window, projection or renewal: ordered, non-empty, at most fifteen minutes. */
104
+ export const networkDnsWindow = (value: unknown): IRuntimeNetworkDnsWindow => {
105
+ const item = networkRecord(value, ['issuedAt', 'notBefore', 'expiresAt']);
106
+ const window = { issuedAt: networkCounter(item.issuedAt), notBefore: networkCounter(item.notBefore), expiresAt: networkCounter(item.expiresAt) };
107
+ requireNetwork(window.notBefore >= window.issuedAt && window.expiresAt > window.notBefore
108
+ && window.expiresAt - window.issuedAt <= networkMaximumDnsLeaseMs);
109
+ return window;
110
+ };
91
111
  export const networkReference = (value: unknown): IRuntimeAuthorityReferenceV1 => {
92
112
  const item = networkRecord(value, ['id', 'generation', 'digest']);
93
113
  return { id: networkId(item.id), generation: networkCounter(item.generation), digest: networkDigest(item.digest) };
94
114
  };
95
115
  export const networkReferenceOf = (value: IRuntimeAuthorityReferenceV1): IRuntimeAuthorityReferenceV1 =>
96
116
  ({ id: value.id, generation: value.generation, digest: value.digest });
117
+ /** A renewal renews exactly one projection: the same node scope and that projection's exact reference. */
118
+ export const networkRenewsProjection = (renewal: IRuntimeNetworkDnsLeaseRenewal, projection: IRuntimeNetworkProjectionV1): boolean =>
119
+ networkSameScope(renewal, projection) && networkEqual(renewal.projection, networkReferenceOf(projection));
120
+ /**
121
+ * The one rule for the DNS window a node may use under a projection: its admitted renewal's, else
122
+ * the projection's own. Both inputs are already validated caller state; this verifies no signature.
123
+ */
124
+ export const networkEffectiveDnsWindow = (
125
+ projection: IRuntimeNetworkProjectionV1, renewal: IRuntimeNetworkDnsLeaseRenewal | null,
126
+ ): IRuntimeNetworkDnsWindow => {
127
+ if (renewal === null) return projection.dns;
128
+ requireNetwork(networkRenewsProjection(renewal, projection));
129
+ return renewal.dns;
130
+ };
97
131
  export const networkRevision = (item: Record<string, unknown>) => {
98
132
  const id = networkId(item.id), generation = networkCounter(item.generation);
99
133
  const previous = item.previous === null ? null : networkReference(item.previous);
@@ -115,6 +149,20 @@ export const networkPrefixBounds = (value: string): [number, number] => {
115
149
  const [address, bits] = value.split('/'), first = networkIpv4Number(address);
116
150
  return [first, first + 2 ** (32 - Number(bits)) - 1];
117
151
  };
152
+ /** A canonical IPv4 prefix: a dotted quad whose host bits are zero. */
153
+ export const networkPrefix = (value: unknown): string => {
154
+ requireNetwork(typeof value === 'string' && value.length <= 18);
155
+ const parts = value.split('/');
156
+ requireNetwork(parts.length === 2 && /^(?:[0-9]|[12][0-9]|3[0-2])$/.test(parts[1]));
157
+ requireNetwork(networkIpv4Number(networkIpv4(parts[0])) % 2 ** (32 - Number(parts[1])) === 0);
158
+ return value;
159
+ };
160
+ /** A canonical prefix inside the RFC 1918 private ranges. */
161
+ export const networkPrivatePrefix = (value: unknown): string => {
162
+ const prefix = networkPrefix(value);
163
+ requireNetwork(['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'].some(range => networkContains(range, prefix)));
164
+ return prefix;
165
+ };
118
166
  export const networkContains = (outer: string, inner: string): boolean => {
119
167
  const [a, b] = networkPrefixBounds(outer), [c, d] = networkPrefixBounds(inner); return a <= c && b >= d;
120
168
  };
@@ -0,0 +1,19 @@
1
+ import { requireNetwork } from './networkcontract.js';
2
+
3
+ /**
4
+ * The runtime producer records read decoded input with the network family's strict JSON readers,
5
+ * and every producer snapshot runs inside `producerSafe`: whichever rule refused, a caller sees
6
+ * this one rejection and learns nothing about which field failed.
7
+ */
8
+ export const failProducer = (): never => { throw new Error('Runtime producer contract rejected.'); };
9
+ export const producerSafe = <T>(operation: () => T): T => { try { return operation(); } catch { return failProducer(); } };
10
+
11
+ /** Membership in a closed union whose members are listed once, as the array the union is derived from. */
12
+ export const isOneOf = <T extends string>(members: readonly T[], value: unknown): value is T =>
13
+ (members as readonly unknown[]).includes(value);
14
+
15
+ /** The tag of a captured tagged record, read before the record's exact keys are known. */
16
+ export const variantOf = (value: unknown, tag: string): unknown => {
17
+ requireNetwork(value !== null && typeof value === 'object' && !Array.isArray(value));
18
+ return (value as Record<string, unknown>)[tag];
19
+ };
@@ -0,0 +1,105 @@
1
+ import type { IRuntimeConfigLogPolicyV1 } from '../data/runtimeconfig.js';
2
+
3
+ /**
4
+ * The execution policy a runtime config seals and a service runtime profile states ahead of it.
5
+ * Both validators apply these timing, HTTP request, CPU, memory, identity and log rules. Only the
6
+ * service's target ports decide whether a readiness target exists, which a profile cannot know.
7
+ *
8
+ * Every check here reads captured plain data only, the output of `snapshotBoundedJson` or
9
+ * `captureNetwork`: on live input a getter could pass a check and then hand the caller another value.
10
+ */
11
+ export const runtimePolicyLimits = Object.freeze({
12
+ minimumMemoryBytes: 16 * 1024 * 1024,
13
+ maximumMemoryBytes: 2 ** 50,
14
+ maximumCpuMillis: 1_000_000,
15
+ maximumIdentityNumber: 2 ** 31 - 1,
16
+ maximumReadinessDurationMs: 3_600_000,
17
+ maximumLogRetentionMs: 31_536_000_000,
18
+ } as const);
19
+
20
+ export type TRuntimePolicyFailure = (messageArg: string) => never;
21
+
22
+ export const isBoundedInteger = (valueArg: unknown, minimumArg: number, maximumArg: number): valueArg is number =>
23
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0)
24
+ && valueArg >= minimumArg && valueArg <= maximumArg;
25
+ export const isRuntimePortName = (valueArg: unknown): valueArg is string =>
26
+ typeof valueArg === 'string' && /^[a-z0-9][a-z0-9_.-]{0,62}$/.test(valueArg);
27
+ export const isExactRecord = (
28
+ valueArg: unknown, keysArg: readonly string[],
29
+ ): valueArg is Record<string, unknown> => {
30
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
31
+ const keys = Object.keys(valueArg);
32
+ return keys.length === keysArg.length && keys.every((keyArg) => keysArg.includes(keyArg));
33
+ };
34
+
35
+ /** null explicitly requests no CPU quota; zero is never a quota. */
36
+ export const isRuntimeCpuMillis = (valueArg: unknown): valueArg is number | null =>
37
+ valueArg === null || isBoundedInteger(valueArg, 1, runtimePolicyLimits.maximumCpuMillis);
38
+ export const isRuntimeMemoryBytes = (valueArg: unknown): valueArg is number =>
39
+ isBoundedInteger(valueArg, runtimePolicyLimits.minimumMemoryBytes, runtimePolicyLimits.maximumMemoryBytes);
40
+ export const isRuntimeIdentityNumber = (valueArg: unknown): valueArg is number =>
41
+ isBoundedInteger(valueArg, 0, runtimePolicyLimits.maximumIdentityNumber);
42
+
43
+ export const isRuntimeLogPolicy = (valueArg: unknown): valueArg is IRuntimeConfigLogPolicyV1 =>
44
+ isExactRecord(valueArg, ['maximumLineBytes', 'maximumBufferedBytes', 'retentionMs'])
45
+ && isBoundedInteger(valueArg.maximumLineBytes, 1, 1_048_576)
46
+ && isBoundedInteger(valueArg.maximumBufferedBytes, 1, 67_108_864)
47
+ && valueArg.maximumBufferedBytes >= valueArg.maximumLineBytes
48
+ && isBoundedInteger(valueArg.retentionMs, 1, runtimePolicyLimits.maximumLogRetentionMs);
49
+
50
+ const timingKeys = ['initialDelayMs', 'startupGraceMs', 'intervalMs', 'timeoutMs',
51
+ 'successThreshold', 'failureThreshold'] as const;
52
+
53
+ /**
54
+ * Readiness policy. `selectsTcpTargetArg` decides whether a probe's named target and port exist:
55
+ * a config answers from its own target ports, a profile ahead of any config cannot know them.
56
+ */
57
+ export const assertRuntimeReadiness = (
58
+ valueArg: unknown,
59
+ selectsTcpTargetArg: (targetPortArg: string, portArg: number) => boolean,
60
+ failArg: TRuntimePolicyFailure,
61
+ ): void => {
62
+ if (!valueArg || typeof valueArg !== 'object') failArg('runtime config readiness is required');
63
+ const value = valueArg as Record<string, unknown>;
64
+ const keys = value.kind === 'process' ? ['kind', ...timingKeys]
65
+ : value.kind === 'tcp' ? ['kind', ...timingKeys, 'targetPort', 'port']
66
+ : value.kind === 'http' ? ['kind', ...timingKeys, 'targetPort', 'port',
67
+ 'scheme', 'host', 'path', 'expectedStatusCodes', 'followRedirects'] : [];
68
+ const maximumDurationMs = runtimePolicyLimits.maximumReadinessDurationMs;
69
+ if (keys.length === 0 || !isExactRecord(value, keys)
70
+ || !isBoundedInteger(value.initialDelayMs, 0, maximumDurationMs)
71
+ || !isBoundedInteger(value.startupGraceMs, 0, maximumDurationMs)
72
+ || !isBoundedInteger(value.intervalMs, 100, maximumDurationMs)
73
+ || !isBoundedInteger(value.timeoutMs, 1, 60_000)
74
+ || !isBoundedInteger(value.successThreshold, 1, 100)
75
+ || !isBoundedInteger(value.failureThreshold, 1, 100)
76
+ || value.timeoutMs > value.intervalMs || value.initialDelayMs > value.startupGraceMs) {
77
+ failArg('runtime config readiness must use explicit bounded timing and exact schema');
78
+ }
79
+ if (value.kind === 'process') return;
80
+ if (!isRuntimePortName(value.targetPort) || !isBoundedInteger(value.port, 1, 65535)
81
+ || !selectsTcpTargetArg(value.targetPort, value.port)) {
82
+ failArg('runtime config readiness must select a declared TCP target and port');
83
+ }
84
+ if (value.kind === 'tcp') return;
85
+ if ((value.scheme !== 'http' && value.scheme !== 'https') || value.followRedirects !== false
86
+ || typeof value.host !== 'string' || value.host.length > 253
87
+ || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(value.host)
88
+ || typeof value.path !== 'string' || value.path.length > 4096
89
+ || !value.path.startsWith('/') || value.path.startsWith('//')
90
+ || /[^\x21-\x7e]|[\\#]/.test(value.path)
91
+ || /%(?![0-9A-F]{2})/.test(value.path)
92
+ || !Array.isArray(value.expectedStatusCodes) || value.expectedStatusCodes.length === 0
93
+ || value.expectedStatusCodes.length > 32) {
94
+ failArg('runtime config HTTP readiness must use an exact bounded request policy');
95
+ }
96
+ const parsed = new URL(value.path, 'http://runtime.invalid');
97
+ if (`${parsed.pathname}${parsed.search}` !== value.path) failArg('runtime config HTTP path must be canonical');
98
+ let previousCode = 0;
99
+ for (const code of value.expectedStatusCodes) {
100
+ if (!isBoundedInteger(code, 200, 599) || code <= previousCode) {
101
+ failArg('runtime config HTTP status expectations must be sorted unique final codes');
102
+ }
103
+ previousCode = code;
104
+ }
105
+ };
@@ -1,5 +1,8 @@
1
1
  import * as userInterfaces from '../data/user.js';
2
2
  import * as clusterInterfaces from '../data/cluster.js';
3
+ import type { IClusterRuntime, TClusterRuntimeBlocker } from '../data/clusterruntime.js';
4
+ import type { IServiceRuntimeAssignmentView } from '../data/serviceruntime.js';
5
+ import type * as leaseInterfaces from '../data/networklease.js';
3
6
  import * as plugins from '../plugins.js';
4
7
 
5
8
  /**
@@ -146,6 +149,25 @@ export interface IReq_Relay_Cloudly_RegisterClusterRelayV1 extends plugins.typed
146
149
  response: clusterInterfaces.IClusterRelayRegistrationResponseV1;
147
150
  }
148
151
 
152
+ /**
153
+ * Cloudly hands the cluster relay the DNS lease renewals it has signed ahead of time for one node.
154
+ *
155
+ * A cluster is outbound-only, so a node that loses Cloudly loses the only signer of its DNS
156
+ * window. The relay holds this run in memory, replacing whatever it held for that node, and
157
+ * releases from it only the renewal whose window is current (`notBefore <= now < expiresAt`), and
158
+ * only while it has no Cloudly session: a node refuses a window that is not current. It signs nothing and verifies
159
+ * nothing: each renewal is Cloudly's statement and the node verifies it against its own pinned
160
+ * authority.
161
+ */
162
+ export interface IReq_Cloudly_Relay_HoldRuntimeNetworkDnsLeaseRenewals extends plugins.typedrequestInterfaces.implementsTR<
163
+ plugins.typedrequestInterfaces.ITypedRequest,
164
+ IReq_Cloudly_Relay_HoldRuntimeNetworkDnsLeaseRenewals
165
+ > {
166
+ method: 'holdRuntimeNetworkDnsLeaseRenewals';
167
+ request: leaseInterfaces.IHoldRuntimeNetworkDnsLeaseRenewalsRequest;
168
+ response: leaseInterfaces.IHoldRuntimeNetworkDnsLeaseRenewalsResponse;
169
+ }
170
+
149
171
  /**
150
172
  * deletes a cluster
151
173
  */
@@ -162,3 +184,46 @@ export interface IReq_Any_Cloudly_DeleteClusterById extends plugins.typedrequest
162
184
  ok: boolean;
163
185
  };
164
186
  }
187
+
188
+ /**
189
+ * A cluster's runtime phase, and what still blocks it from leaving `swarm`.
190
+ *
191
+ * Cloudly refuses a cluster without a phase record as `cluster-runtime-missing` rather than
192
+ * assuming one. `blockers` is empty once nothing blocks the cutover.
193
+ */
194
+ export interface IReq_Any_Cloudly_GetClusterRuntime extends plugins.typedrequestInterfaces.implementsTR<
195
+ plugins.typedrequestInterfaces.ITypedRequest,
196
+ IReq_Any_Cloudly_GetClusterRuntime
197
+ > {
198
+ method: 'getClusterRuntime';
199
+ request: {
200
+ identity: userInterfaces.IIdentity;
201
+ clusterId: string;
202
+ };
203
+ response: {
204
+ runtime: IClusterRuntime;
205
+ blockers: TClusterRuntimeBlocker[];
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Every current runtime assignment on one cluster's nodes, a page at a time: a cluster carries
211
+ * every node's slots of every service, which no single response bounds. `limit` runs from 1 to
212
+ * `serviceRuntimeContract.maximumAssignmentPageSize`; Cloudly refuses any other.
213
+ */
214
+ export interface IReq_Any_Cloudly_GetClusterRuntimeAssignments extends plugins.typedrequestInterfaces.implementsTR<
215
+ plugins.typedrequestInterfaces.ITypedRequest,
216
+ IReq_Any_Cloudly_GetClusterRuntimeAssignments
217
+ > {
218
+ method: 'getClusterRuntimeAssignments';
219
+ request: {
220
+ identity: userInterfaces.IIdentity;
221
+ clusterId: string;
222
+ cursor: string | null;
223
+ limit: number;
224
+ };
225
+ response: {
226
+ assignments: IServiceRuntimeAssignmentView[];
227
+ nextCursor: string | null;
228
+ };
229
+ }