@serve.zone/interfaces 31.8.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 (40) hide show
  1. package/changelog.md +45 -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 +4 -0
  6. package/dist_ts/data/index.js +5 -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/networknode.d.ts +37 -0
  11. package/dist_ts/data/networknode.js +6 -0
  12. package/dist_ts/data/runtimeconfig.js +14 -77
  13. package/dist_ts/data/serviceruntime.d.ts +128 -0
  14. package/dist_ts/data/serviceruntime.js +92 -0
  15. package/dist_ts/private/networkcontract.d.ts +4 -0
  16. package/dist_ts/private/networkcontract.js +15 -1
  17. package/dist_ts/private/producercontract.d.ts +11 -0
  18. package/dist_ts/private/producercontract.js +21 -0
  19. package/dist_ts/private/runtimepolicy.d.ts +31 -0
  20. package/dist_ts/private/runtimepolicy.js +90 -0
  21. package/dist_ts/requests/cluster.d.ts +37 -0
  22. package/dist_ts/requests/cluster.js +1 -1
  23. package/dist_ts/requests/network.d.ts +57 -1
  24. package/dist_ts/requests/service.d.ts +60 -0
  25. package/package.json +1 -1
  26. package/readme.md +120 -0
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/data/clusterruntime.ts +45 -0
  29. package/ts/data/index.ts +4 -0
  30. package/ts/data/networkaddressplan.ts +175 -0
  31. package/ts/data/networkauthority.ts +4 -16
  32. package/ts/data/networknode.ts +37 -0
  33. package/ts/data/runtimeconfig.ts +17 -76
  34. package/ts/data/serviceruntime.ts +208 -0
  35. package/ts/private/networkcontract.ts +14 -0
  36. package/ts/private/producercontract.ts +19 -0
  37. package/ts/private/runtimepolicy.ts +105 -0
  38. package/ts/requests/cluster.ts +45 -0
  39. package/ts/requests/network.ts +50 -1
  40. package/ts/requests/service.ts +81 -0
@@ -0,0 +1,175 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import type { IRuntimeNetworkProtectedAuthorityV1 } from './networkauthority.js';
3
+ import { computeRuntimeNetworkProtectedAuthorityV1Digest, snapshotRuntimeNetworkProtectedAuthorityV1 } from './networkauthority.js';
4
+ import type { IGetRuntimeManagedVpnCredentialResponseV1 } from './networkcredential.js';
5
+ import { captureNetwork, failNetwork, networkContains, networkCounter, networkEqual, networkId, networkIpv4, networkOverlaps,
6
+ networkPrefixBounds, networkPrivatePrefix, networkRecord, networkSafe, requireNetwork } from '../private/networkcontract.js';
7
+
8
+ /**
9
+ * The address space an administrator gives the runtime network, and the one input the protected
10
+ * authority is composed from. Cloudly keeps a single plan; pools and prefixes only grow, and the
11
+ * managed VPN never changes once set.
12
+ */
13
+ export interface IRuntimeNetworkAddressPlan {
14
+ id: 'address-plan';
15
+ /** Compare-and-swap counter; every accepted change advances it by one. */
16
+ revision: number;
17
+ /** Every protected prefix: they cover the pools, resolvers, platform endpoints and the VPN. */
18
+ prefixes: string[];
19
+ pools: IRuntimeNetworkProtectedAuthorityV1['pools'];
20
+ resolvers: IRuntimeNetworkProtectedAuthorityV1['resolvers'];
21
+ /** Without the VPN relay, which the composed authority adds as a UDP platform endpoint. */
22
+ platformEndpoints: IRuntimeNetworkProtectedAuthorityV1['platformEndpoints'];
23
+ vpn: {
24
+ /** The private prefix VPN control addresses are allocated from. */
25
+ controlPrefix: string;
26
+ /** The endpoint every node dials for the managed VPN. */
27
+ relay: IGetRuntimeManagedVpnCredentialResponseV1['relay'];
28
+ };
29
+ }
30
+
31
+ /** What an administrator writes; Cloudly supplies the id and the revision. */
32
+ export type TRuntimeNetworkAddressPlanWritableData = Omit<IRuntimeNetworkAddressPlan, 'id' | 'revision'>;
33
+
34
+ /** What a protection producer fixes around a plan: the authority's identity, revision chain and egress. */
35
+ export type TRuntimeNetworkProtectedAuthorityFrame = Pick<IRuntimeNetworkProtectedAuthorityV1,
36
+ 'id' | 'generation' | 'controller' | 'previous' | 'egressAuthorities'>;
37
+
38
+ /** Refusals the pure change check decides from the two plans alone. */
39
+ export type TRuntimeNetworkAddressPlanChangeRefusal = 'plan-vpn-immutable' | 'plan-pool-removed' | 'plan-prefix-shrunk';
40
+
41
+ /**
42
+ * Every refusal of a plan write. Cloudly adds the two that need live references: a resolver or
43
+ * platform endpoint the change removes or changes while a router selection or a service still names it.
44
+ */
45
+ export type TRuntimeNetworkAddressPlanRefusal =
46
+ | TRuntimeNetworkAddressPlanChangeRefusal | 'plan-resolver-referenced' | 'plan-endpoint-referenced';
47
+
48
+ export type TRuntimeNetworkAddressPlanChange =
49
+ | { status: 'unchanged' }
50
+ /** The ids the change removes or changes, which Cloudly checks against live references before it writes. */
51
+ | { status: 'accepted'; removed: { resolverIds: string[]; platformEndpointIds: string[] } }
52
+ | { status: 'refused'; reason: TRuntimeNetworkAddressPlanChangeRefusal };
53
+
54
+ export const runtimeNetworkAddressPlanContract = Object.freeze({
55
+ id: 'address-plan',
56
+ /**
57
+ * SmartVPN needs its gateway and at least one member inside the control prefix. No minimum length
58
+ * is stated: the private-range rule already keeps the control prefix at /8 or longer.
59
+ */
60
+ maximumControlPrefixLength: 30,
61
+ relayProtocol: 'udp',
62
+ } as const);
63
+
64
+ /**
65
+ * The structural authority validator reads a frame's shape and nothing else, and a plan fixes none of
66
+ * the frame, so every valid frame decides a plan the same way. The digest is a well-formed
67
+ * placeholder: this candidate is never hashed or stored.
68
+ */
69
+ const validationFrame: TRuntimeNetworkProtectedAuthorityFrame = {
70
+ id: 'address-plan', generation: 1, controller: { kind: 'cloudly', id: 'address-plan', epoch: 'address-plan' },
71
+ previous: null, egressAuthorities: [{ nodeId: 'address-plan', runtimeNamespace: 'address-plan' }],
72
+ };
73
+ const unsealedDigest = `sha256:${'0'.repeat(64)}` as TSha256Digest;
74
+
75
+ type TAuthoritySets = Pick<IRuntimeNetworkProtectedAuthorityV1, 'prefixes' | 'pools' | 'resolvers' | 'platformEndpoints'>;
76
+
77
+ /** Unvalidated sets and frame in, the protected authority validator's verdict out. */
78
+ const authorityOf = (
79
+ sets: Record<keyof TAuthoritySets, unknown>, frame: Record<keyof TRuntimeNetworkProtectedAuthorityFrame, unknown>,
80
+ ): IRuntimeNetworkProtectedAuthorityV1 => snapshotRuntimeNetworkProtectedAuthorityV1({ schemaVersion: 1, id: frame.id,
81
+ generation: frame.generation, controller: frame.controller, previous: frame.previous, egressAuthorities: frame.egressAuthorities,
82
+ prefixes: sets.prefixes, pools: sets.pools, resolvers: sets.resolvers, platformEndpoints: sets.platformEndpoints, digest: unsealedDigest });
83
+
84
+ const setsWithRelay = (plan: IRuntimeNetworkAddressPlan): TAuthoritySets => ({
85
+ prefixes: plan.prefixes, pools: plan.pools, resolvers: plan.resolvers,
86
+ platformEndpoints: [...plan.platformEndpoints, { ...plan.vpn.relay, protocol: runtimeNetworkAddressPlanContract.relayProtocol }]
87
+ .sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
88
+ });
89
+
90
+ const vpnOf = (value: unknown): IRuntimeNetworkAddressPlan['vpn'] => {
91
+ const item = networkRecord(value, ['controlPrefix', 'relay']);
92
+ const relay = networkRecord(item.relay, ['id', 'address', 'port']);
93
+ const controlPrefix = networkPrivatePrefix(item.controlPrefix);
94
+ requireNetwork(Number(controlPrefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
95
+ return { controlPrefix, relay: { id: networkId(relay.id), address: networkIpv4(relay.address), port: networkCounter(relay.port, 65535) } };
96
+ };
97
+
98
+ /** The control prefix is protected, and no pool, resolver or platform endpoint, the relay included, sits inside it. */
99
+ const requireVpnProtected = (authority: IRuntimeNetworkProtectedAuthorityV1, controlPrefix: string): void => {
100
+ requireNetwork(authority.prefixes.some(prefix => networkContains(prefix, controlPrefix))
101
+ && authority.pools.every(pool => !networkOverlaps(pool.prefix, controlPrefix))
102
+ && [...authority.resolvers, ...authority.platformEndpoints].every(endpoint => !networkContains(controlPrefix, `${endpoint.address}/32`)));
103
+ };
104
+
105
+ /**
106
+ * Structural snapshot for exact persistence and for a plan write. A plan passes only when both
107
+ * authorities it can describe pass the protected authority's own validator: its sets alone, and
108
+ * its sets with the relay added, so no plan can compose an authority that contract refuses.
109
+ */
110
+ export const snapshotRuntimeNetworkAddressPlan = (value: unknown): IRuntimeNetworkAddressPlan => networkSafe(() => {
111
+ const item = networkRecord(captureNetwork(value), ['id', 'revision', 'prefixes', 'pools', 'resolvers', 'platformEndpoints', 'vpn']);
112
+ requireNetwork(item.id === runtimeNetworkAddressPlanContract.id);
113
+ const own = authorityOf({ prefixes: item.prefixes, pools: item.pools, resolvers: item.resolvers,
114
+ platformEndpoints: item.platformEndpoints }, validationFrame);
115
+ const plan: IRuntimeNetworkAddressPlan = { id: runtimeNetworkAddressPlanContract.id, revision: networkCounter(item.revision),
116
+ prefixes: own.prefixes, pools: own.pools, resolvers: own.resolvers, platformEndpoints: own.platformEndpoints, vpn: vpnOf(item.vpn) };
117
+ requireVpnProtected(authorityOf(setsWithRelay(plan), validationFrame), plan.vpn.controlPrefix);
118
+ return plan;
119
+ });
120
+
121
+ /**
122
+ * The protected authority a plan describes inside a producer's frame, digest included: the plan's
123
+ * sets with the VPN relay as the UDP platform endpoint every node dials. Both inputs are read before
124
+ * the digest is computed.
125
+ */
126
+ export const composeRuntimeNetworkProtectedAuthority = async (
127
+ planValue: unknown, frameValue: unknown,
128
+ ): Promise<IRuntimeNetworkProtectedAuthorityV1> => {
129
+ try {
130
+ const plan = snapshotRuntimeNetworkAddressPlan(planValue);
131
+ const frame = networkRecord(captureNetwork(frameValue), ['id', 'generation', 'controller', 'previous', 'egressAuthorities']);
132
+ const authority = authorityOf(setsWithRelay(plan), frame);
133
+ return { ...authority, digest: await computeRuntimeNetworkProtectedAuthorityV1Digest(authority) };
134
+ } catch { return failNetwork(); }
135
+ };
136
+
137
+ /** Whether the union of `prefixes`, which never overlap one another, covers every address of `prefix`. */
138
+ const coveredBy = (prefixes: string[], prefix: string): boolean => {
139
+ const [first, last] = networkPrefixBounds(prefix);
140
+ const covered = prefixes.reduce((sum, candidate) => {
141
+ const [low, high] = networkPrefixBounds(candidate);
142
+ return sum + Math.max(0, Math.min(last, high) - Math.max(first, low) + 1);
143
+ }, 0);
144
+ return covered === last - first + 1;
145
+ };
146
+
147
+ /**
148
+ * The entries of `previous` that `next` does not hold exactly as they were. An entry changed under its
149
+ * old id counts: whatever still names that id would now reach something else.
150
+ */
151
+ const removedOrChanged = <T>(previous: T[], next: T[]): T[] =>
152
+ previous.filter(item => !next.some(candidate => networkEqual(candidate, item)));
153
+
154
+ const idsOf = (entries: Array<{ id: string }>): string[] => entries.map(entry => entry.id);
155
+
156
+ /**
157
+ * The pure decision for replacing `previousValue`, the stored plan or null, with `nextValue` at the
158
+ * following revision. Leases and the managed VPN outlive a plan revision, so every pool stays exactly
159
+ * as it was, the protected address space only grows, and the VPN never changes. Resolvers and platform
160
+ * endpoints may go or change; `accepted` names every one that did. Equal content is `unchanged`:
161
+ * Cloudly writes nothing and the stored revision stands.
162
+ */
163
+ export const admitRuntimeNetworkAddressPlanChange = (previousValue: unknown | null, nextValue: unknown): TRuntimeNetworkAddressPlanChange =>
164
+ networkSafe(() => {
165
+ const previous = previousValue === null ? null : snapshotRuntimeNetworkAddressPlan(previousValue);
166
+ const next = snapshotRuntimeNetworkAddressPlan(nextValue);
167
+ requireNetwork(next.revision === (previous?.revision ?? 0) + 1);
168
+ if (previous === null) return { status: 'accepted', removed: { resolverIds: [], platformEndpointIds: [] } };
169
+ if (networkEqual({ ...previous, revision: next.revision }, next)) return { status: 'unchanged' };
170
+ if (!networkEqual(previous.vpn, next.vpn)) return { status: 'refused', reason: 'plan-vpn-immutable' };
171
+ if (removedOrChanged(previous.pools, next.pools).length > 0) return { status: 'refused', reason: 'plan-pool-removed' };
172
+ if (!previous.prefixes.every(prefix => coveredBy(next.prefixes, prefix))) return { status: 'refused', reason: 'plan-prefix-shrunk' };
173
+ return { status: 'accepted', removed: { resolverIds: idsOf(removedOrChanged(previous.resolvers, next.resolvers)),
174
+ platformEndpointIds: idsOf(removedOrChanged(previous.platformEndpoints, next.platformEndpoints)) } };
175
+ });
@@ -1,6 +1,7 @@
1
1
  import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeAuthorityReferenceV1, IRuntimeControllerV1 } from './runtimeassignment.js';
3
3
  import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
4
+ import { networkPrefix, networkPrivatePrefix } from '../private/networkcontract.js';
4
5
 
5
6
  /** Controller declaration; a digest does not authenticate completeness or activate a pool. */
6
7
  export interface IRuntimeNetworkProtectedAuthorityV1 {
@@ -129,14 +130,6 @@ const endpointAddress = (value: unknown): string => {
129
130
  requireValue(first > 0 && first < 224 && first !== 127);
130
131
  return result;
131
132
  };
132
- const prefix = (value: unknown): string => {
133
- requireValue(typeof value === 'string' && value.length <= 18);
134
- const parts = value.split('/');
135
- requireValue(parts.length === 2 && /^(?:[0-9]|[12][0-9]|3[0-2])$/.test(parts[1]));
136
- const base = numericAddress(address(parts[0])), width = 2 ** (32 - Number(parts[1]));
137
- requireValue(base % width === 0);
138
- return value;
139
- };
140
133
  const bounds = (value: string): [number, number] => {
141
134
  const [base, bits] = value.split('/'), first = numericAddress(base);
142
135
  return [first, first + 2 ** (32 - Number(bits)) - 1];
@@ -149,11 +142,6 @@ const overlaps = (left: string, right: string): boolean => {
149
142
  const [a, b] = bounds(left), [c, d] = bounds(right);
150
143
  return a <= d && c <= b;
151
144
  };
152
- const privatePrefix = (value: unknown): string => {
153
- const result = prefix(value);
154
- requireValue(['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'].some(range => contains(range, result)));
155
- return result;
156
- };
157
145
  const nonoverlapping = (values: string[]): void => {
158
146
  for (let index = 0; index < values.length; index++) for (let other = 0; other < index; other++) {
159
147
  requireValue(!overlaps(values[index], values[other]));
@@ -170,7 +158,7 @@ export const snapshotRuntimeNetworkProtectedAuthorityV1 = (value: unknown): IRun
170
158
  const identity = id(item.id), generation = integer(item.generation);
171
159
  const previous = item.previous === null ? null : reference(item.previous);
172
160
  requireValue(generation === 1 ? previous === null : previous?.id === identity && previous.generation === generation - 1);
173
- const prefixes = sorted(list(item.prefixes, runtimeNetworkAuthorityV1Contract.maximumPrefixes, prefix), value => value);
161
+ const prefixes = sorted(list(item.prefixes, runtimeNetworkAuthorityV1Contract.maximumPrefixes, networkPrefix), value => value);
174
162
  requireValue(prefixes.length > 0);
175
163
  nonoverlapping(prefixes);
176
164
  const protectedPrefix = (value: string) => requireValue(prefixes.some(range => contains(range, value)));
@@ -178,7 +166,7 @@ export const snapshotRuntimeNetworkProtectedAuthorityV1 = (value: unknown): IRun
178
166
  const pool = record(value, ['id', 'purpose', 'prefix']);
179
167
  requireValue(pool.purpose === 'workload' || pool.purpose === 'transit');
180
168
  const result: IRuntimeNetworkProtectedAuthorityV1['pools'][number] = {
181
- id: id(pool.id), purpose: pool.purpose, prefix: privatePrefix(pool.prefix),
169
+ id: id(pool.id), purpose: pool.purpose, prefix: networkPrivatePrefix(pool.prefix),
182
170
  };
183
171
  protectedPrefix(result.prefix);
184
172
  return result;
@@ -212,7 +200,7 @@ export const snapshotRuntimeNetworkHandoffLeaseV1 = (value: unknown): IRuntimeNe
212
200
  'protectedAuthority', 'routerIncarnationId', 'transit', 'sourcePortRanges', 'conntrackZone', 'conntrackLabel', 'digest']);
213
201
  requireValue(item.schemaVersion === 1);
214
202
  const transit = record(item.transit, ['poolId', 'prefix', 'routerAddress', 'hostAddress']);
215
- const transitPrefix = privatePrefix(transit.prefix), [first, last] = bounds(transitPrefix);
203
+ const transitPrefix = networkPrivatePrefix(transit.prefix), [first, last] = bounds(transitPrefix);
216
204
  const routerAddress = endpointAddress(transit.routerAddress), hostAddress = endpointAddress(transit.hostAddress);
217
205
  requireValue(first !== last && routerAddress !== hostAddress);
218
206
  for (const value of [routerAddress, hostAddress]) {
@@ -0,0 +1,37 @@
1
+ import type { IRuntimeNetworkProtectedAuthorityV1 } from './networkauthority.js';
2
+ import { captureNetwork, networkCounter, networkId, networkRecord, networkSafe } from '../private/networkcontract.js';
3
+
4
+ type TEgressOwner = IRuntimeNetworkProtectedAuthorityV1['egressAuthorities'][number];
5
+
6
+ /**
7
+ * One unmet condition of a node's runtime network readiness, in the order the node producer meets
8
+ * them: the node joins the protected authority's egress, every egress owner reports current
9
+ * protection, a handoff is reserved, the router selection carries it, the node's acknowledged
10
+ * projection carries it, and the node is a managed VPN member. `protection-receipts-pending` names
11
+ * the egress owners whose current receipts are still missing.
12
+ */
13
+ export type TRuntimeNetworkNodeReadinessGap =
14
+ | { condition: 'egress-pending' | 'handoff-pending' | 'selection-pending' | 'projection-pending' | 'vpn-member-pending' }
15
+ | { condition: 'protection-receipts-pending'; owners: [TEgressOwner, ...TEgressOwner[]] };
16
+
17
+ /** A node is ready exactly when nothing is missing. */
18
+ export type IRuntimeNetworkNodeReadiness = { nodeId: string } & (
19
+ | { ready: true; missing: [] }
20
+ | { ready: false; missing: [TRuntimeNetworkNodeReadinessGap, ...TRuntimeNetworkNodeReadinessGap[]] }
21
+ );
22
+
23
+ /**
24
+ * The router incarnation of one node. A handoff's id and router incarnation id derive from it, so
25
+ * advancing it is the only way to give a node's router a fresh handoff.
26
+ */
27
+ export interface IRuntimeRouterIncarnation {
28
+ /** The node id. */
29
+ id: string;
30
+ /** Starts at 1 with the node's first handoff; every reincarnation advances it by one. */
31
+ incarnation: number;
32
+ }
33
+
34
+ export const snapshotRuntimeRouterIncarnation = (value: unknown): IRuntimeRouterIncarnation => networkSafe(() => {
35
+ const item = networkRecord(captureNetwork(value), ['id', 'incarnation']);
36
+ return { id: networkId(item.id), incarnation: networkCounter(item.incarnation) };
37
+ });
@@ -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
+ });