@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.
- package/changelog.md +77 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/clusterruntime.d.ts +39 -0
- package/dist_ts/data/clusterruntime.js +11 -0
- package/dist_ts/data/index.d.ts +5 -0
- package/dist_ts/data/index.js +6 -1
- package/dist_ts/data/networkaddressplan.d.ts +78 -0
- package/dist_ts/data/networkaddressplan.js +114 -0
- package/dist_ts/data/networkauthority.js +5 -17
- package/dist_ts/data/networkcredential.d.ts +10 -4
- package/dist_ts/data/networkcredential.js +14 -8
- package/dist_ts/data/networkdelivery.d.ts +21 -0
- package/dist_ts/data/networkdelivery.js +35 -7
- package/dist_ts/data/networklease.d.ts +110 -0
- package/dist_ts/data/networklease.js +159 -0
- package/dist_ts/data/networknode.d.ts +37 -0
- package/dist_ts/data/networknode.js +6 -0
- package/dist_ts/data/networkprojection.d.ts +10 -5
- package/dist_ts/data/networkprojection.js +15 -22
- package/dist_ts/data/networksigning.js +5 -4
- package/dist_ts/data/runtimeconfig.js +14 -77
- package/dist_ts/data/serviceruntime.d.ts +128 -0
- package/dist_ts/data/serviceruntime.js +92 -0
- package/dist_ts/private/networkcontract.d.ts +20 -0
- package/dist_ts/private/networkcontract.js +45 -1
- package/dist_ts/private/producercontract.d.ts +11 -0
- package/dist_ts/private/producercontract.js +21 -0
- package/dist_ts/private/runtimepolicy.d.ts +31 -0
- package/dist_ts/private/runtimepolicy.js +90 -0
- package/dist_ts/requests/cluster.d.ts +53 -0
- package/dist_ts/requests/cluster.js +1 -1
- package/dist_ts/requests/network.d.ts +57 -1
- package/dist_ts/requests/runtimesession.d.ts +14 -0
- package/dist_ts/requests/service.d.ts +60 -0
- package/dist_ts/runtime.networkprojection.d.ts +14 -0
- package/dist_ts/runtime.networkprojection.js +68 -19
- package/package.json +2 -2
- package/readme.md +251 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/clusterruntime.ts +45 -0
- package/ts/data/index.ts +5 -0
- package/ts/data/networkaddressplan.ts +175 -0
- package/ts/data/networkauthority.ts +4 -16
- package/ts/data/networkcredential.ts +19 -9
- package/ts/data/networkdelivery.ts +49 -6
- package/ts/data/networklease.ts +229 -0
- package/ts/data/networknode.ts +37 -0
- package/ts/data/networkprojection.ts +28 -26
- package/ts/data/networksigning.ts +5 -4
- package/ts/data/runtimeconfig.ts +17 -76
- package/ts/data/serviceruntime.ts +208 -0
- package/ts/private/networkcontract.ts +48 -0
- package/ts/private/producercontract.ts +19 -0
- package/ts/private/runtimepolicy.ts +105 -0
- package/ts/requests/cluster.ts +65 -0
- package/ts/requests/network.ts +50 -1
- package/ts/requests/runtimesession.ts +17 -0
- package/ts/requests/service.ts +81 -0
- package/ts/runtime.networkprojection.ts +87 -20
package/ts/data/runtimeconfig.ts
CHANGED
|
@@ -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:
|
|
132
|
-
maximumLogRetentionMs:
|
|
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 (!
|
|
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
|
-
|| !
|
|
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
|
|
301
|
-
|| !
|
|
244
|
+
|| !isRuntimeCpuMillis(valueArg.cpuMillis)
|
|
245
|
+
|| !isRuntimeMemoryBytes(valueArg.memoryBytes)
|
|
302
246
|
|| typeof valueArg.readonlyRootfs !== 'boolean'
|
|
303
247
|
|| !((valueArg.runAsUser === null && valueArg.runAsGroup === null)
|
|
304
|
-
|| (
|
|
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
|
-
|| !
|
|
316
|
-
|| !
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
};
|
package/ts/requests/cluster.ts
CHANGED
|
@@ -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
|
+
}
|