@serve.zone/interfaces 32.29.0 → 32.31.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.
@@ -2,7 +2,7 @@ import type { TSha256Digest } from './immutableimage.js';
2
2
  import type { IRuntimeAssignmentReference, IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
3
3
  import { isRuntimeReplicaId, snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
4
4
  import type { IRuntimeNetworkHandoffLease, IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
5
- import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest,
5
+ import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityContract,
6
6
  snapshotRuntimeNetworkHandoffLease, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
7
7
  import type { IPrivateNetwork, IServicePrivateNetworks, IServicePrivateNetworkDnsPolicy } from './privatenetwork.js';
8
8
  import { routerPacketGrantsOf, snapshotRouterProjection,
@@ -61,15 +61,35 @@ export interface IRuntimeNetworkWorkloadLease extends IRuntimeNetworkNodeScope {
61
61
  }
62
62
 
63
63
  /**
64
- * One node-level published port for a workload. Mirrors `IAppStorePublishedPort`
65
- * without range fields: the runtime publishes exactly one host port per entry.
64
+ * One node-level published port, or one contiguous range of them, for a workload.
65
+ *
66
+ * Without `hostPortEnd` the entry publishes exactly `hostPort` to `targetPort`. With it the entry
67
+ * publishes every port from `hostPort` to `hostPortEnd` inclusive, each to the same port inside the
68
+ * workload, so `targetPort` equals `hostPort`. Every port an entry covers is claimed on its node:
69
+ * two entries of one node and protocol never share a port, and the entries of one endpoint never
70
+ * overlap and cover at most `runtimeNetworkPublishedPortContract.maximumPortsPerService` ports.
66
71
  */
67
72
  export interface IRuntimeNetworkPublishedPort {
68
73
  protocol: 'tcp' | 'udp';
69
- /** 1..65535 and unique per node across the projection for its protocol. */
74
+ /**
75
+ * 1..65535; the first port of a range. Cloudly publishes only ports inside
76
+ * `runtimeNetworkPublishedPortContract.publishablePorts` (`published-port-reserved`).
77
+ */
70
78
  hostPort: number;
71
- /** The workload's own listening port inside its network namespace. */
79
+ /**
80
+ * The last port of a range, above `hostPort` and at most 65535. Omitted, the entry is one port;
81
+ * a one-port range is never written with it.
82
+ */
83
+ hostPortEnd?: number;
84
+ /** The workload's own listening port inside its network namespace; `hostPort` for a range. */
72
85
  targetPort: number;
86
+ /**
87
+ * Outbound flows the workload originates from a published target port leave the node from the
88
+ * matching host port, so a peer sees one address and port in both directions (SIP, RTP). Only
89
+ * `true` is ever stated; omitted, outbound flows keep the handoff lease's translation. It grants
90
+ * no egress: an endpoint carrying a symmetric entry has `publicEgress: true`.
91
+ */
92
+ symmetric?: true;
73
93
  /**
74
94
  * Never the wildcard or loopback. Omitted means the node's uplink address.
75
95
  * The contract cannot prove an uplink fact, so binding the port to that exact
@@ -84,37 +104,121 @@ export interface IRuntimeNetworkPublishedPort {
84
104
  }
85
105
 
86
106
  /**
87
- * Caller-authored published-port request. It carries no `hostIp` (an omitted
88
- * address means the node uplink) and no ranges: exactly one host port per entry.
89
- * The `authorization` reference of the resulting projection entry is derived by
90
- * Cloudly from the policy revision and digest, never supplied by the caller.
107
+ * Caller-authored published-port request. It carries no `hostIp` (an omitted address means the
108
+ * node uplink). `hostPortEnd` and `symmetric` follow the rules of `IRuntimeNetworkPublishedPort`.
109
+ * The `authorization` reference of the resulting projection entry is derived by Cloudly from the
110
+ * policy revision and digest, never supplied by the caller.
91
111
  */
92
112
  export interface IRuntimeNetworkPublishedPortRequest {
93
113
  protocol: 'tcp' | 'udp';
94
114
  hostPort: number;
115
+ /** The last port of a range; `targetPort` then equals `hostPort`. */
116
+ hostPortEnd?: number;
95
117
  targetPort: number;
118
+ /** Same-port outbound; valid only while the service's policy document grants public egress. */
119
+ symmetric?: true;
96
120
  }
97
121
 
98
- /** Current authorized published-port policy for one service. */
122
+ /**
123
+ * Current authorized published-port policy for one service.
124
+ *
125
+ * The published-port half of the service's one policy document; `IServiceNetworkEgress` reads the
126
+ * egress half of the same document, so both carry the same `revision`.
127
+ */
99
128
  export interface IServicePublishedPorts {
100
129
  organizationId: string;
101
130
  serviceId: string;
102
131
  revision: number;
103
- /** Sorted by protocol and host port. Empty means the service publishes nothing. */
132
+ /**
133
+ * Sorted by protocol and host port, never overlapping, at most
134
+ * `runtimeNetworkPublishedPortContract.maximumEntries` entries covering at most its
135
+ * `maximumPortsPerService` ports. Empty means the service publishes nothing.
136
+ */
104
137
  publishedPorts: IRuntimeNetworkPublishedPortRequest[];
105
138
  }
106
139
 
107
- /** Exact desired policy. Node-level host-port placement remains Cloudly's decision. */
140
+ /**
141
+ * Exact desired policy. Node-level host-port placement remains Cloudly's decision.
142
+ *
143
+ * Beyond the shape its snapshot checks, Cloudly refuses a port outside
144
+ * `runtimeNetworkPublishedPortContract.publishablePorts` and a symmetric entry while the document's
145
+ * `publicEgress` is false; `setServiceNetworkEgress` likewise refuses clearing `publicEgress` while a
146
+ * symmetric entry remains.
147
+ *
148
+ * One document per service holds its published ports and its egress under one `revision`. The
149
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
150
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
151
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
152
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
153
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
154
+ * document at all.
155
+ */
108
156
  export interface ISetServicePublishedPorts {
109
157
  organizationId: string;
110
158
  serviceId: string;
111
159
  /** Retain the exact body and this identifier across retries. */
112
160
  mutationId: string;
113
- /** null means the service has never had a published-port document; not an empty list. */
161
+ /**
162
+ * The revision of the service's one policy document the caller read, shared with its egress: an
163
+ * accepted `ISetServiceNetworkEgress` advances it too. null means the service has never had a
164
+ * policy document, neither published ports nor egress; it is not an empty list.
165
+ */
114
166
  expectedRevision: number | null;
115
167
  publishedPorts: IRuntimeNetworkPublishedPortRequest[];
116
168
  }
117
169
 
170
+ /**
171
+ * Current authorized egress policy for one service: whether its attached workloads reach the public
172
+ * internet, and which platform endpoints of the protected authority they reach.
173
+ *
174
+ * The egress half of the service's one policy document; `IServicePublishedPorts` reads the
175
+ * published-port half of the same document, so both carry the same `revision`. Every projection
176
+ * endpoint of the service carries exactly these values (`IRuntimeNetworkEndpoint`), and an attached
177
+ * runtime spec (`TServiceRuntimeNetwork`) is admitted only while it states the same ones: its
178
+ * disagreement is `network-policy-mismatch`, a service without a document `network-policy-missing`.
179
+ * Nothing else grants a workload egress.
180
+ */
181
+ export interface IServiceNetworkEgress {
182
+ organizationId: string;
183
+ serviceId: string;
184
+ revision: number;
185
+ /** TCP/UDP only; the complete protected union remains denied. */
186
+ publicEgress: boolean;
187
+ /**
188
+ * Ids of `IRuntimeNetworkProtectedAuthority.platformEndpoints`, sorted and unique, at most
189
+ * `runtimeNetworkAuthorityContract.maximumPlatformEndpoints`. Empty selects none.
190
+ */
191
+ platformEndpointIds: string[];
192
+ }
193
+
194
+ /**
195
+ * Exact desired egress. The published ports of the same document are carried forward unchanged.
196
+ * Whether each id names a current platform endpoint is Cloudly's decision at acceptance, and so is
197
+ * the refusal of `publicEgress: false` while a carried published port is `symmetric`.
198
+ *
199
+ * One document per service holds its published ports and its egress under one `revision`. The
200
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
201
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
202
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
203
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
204
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
205
+ * document at all.
206
+ */
207
+ export interface ISetServiceNetworkEgress {
208
+ organizationId: string;
209
+ serviceId: string;
210
+ /** Retain the exact body and this identifier across retries. */
211
+ mutationId: string;
212
+ /**
213
+ * The revision of the service's one policy document the caller read, shared with its published
214
+ * ports: an accepted `ISetServicePublishedPorts` advances it too. null means the service has
215
+ * never had a policy document, neither published ports nor egress.
216
+ */
217
+ expectedRevision: number | null;
218
+ publicEgress: boolean;
219
+ platformEndpointIds: string[];
220
+ }
221
+
118
222
  /** Controller-owned readiness evidence. A signature does not prove this evidence's provenance. */
119
223
  export interface IRuntimeNetworkEndpoint {
120
224
  lease: IRuntimeNetworkWorkloadLease;
@@ -128,7 +232,11 @@ export interface IRuntimeNetworkEndpoint {
128
232
  /** TCP/UDP only; the complete protected union remains denied. */
129
233
  publicEgress: boolean;
130
234
  platformEndpointIds: string[];
131
- /** Sorted by protocol and host port. Absent publishes nothing; a present member is never empty. */
235
+ /**
236
+ * Sorted by protocol and host port and never overlapping, within the bounds of
237
+ * `runtimeNetworkPublishedPortContract`. Absent publishes nothing; a present member is never
238
+ * empty. A `symmetric` entry is admitted only while `publicEgress` is true.
239
+ */
132
240
  publishedPorts?: IRuntimeNetworkPublishedPort[];
133
241
  }
134
242
 
@@ -216,6 +324,23 @@ export const runtimeNetworkProjectionContract = Object.freeze({
216
324
  allocationReuse: 'requires-controller-quarantine-and-all-affected-authority-fences',
217
325
  } as const);
218
326
 
327
+ /**
328
+ * The bounds of one service's published ports, the same on its policy document and on each of its
329
+ * projection endpoints.
330
+ *
331
+ * `maximumEntries` bounds the list and `maximumPortsPerService` the ports its entries cover
332
+ * together, a range counting every port in it; the snapshots refuse either excess.
333
+ * `publishablePorts` is Cloudly's placement rule, not a snapshot rule: the ports above it are
334
+ * `runtimeNetworkHandoffSnatPortRange`, which a handoff lease translates outbound flows to, so
335
+ * Cloudly refuses a policy with a port outside it as `published-port-reserved`
336
+ * (`isRuntimeNetworkPublishedPortReserved`). A policy written before this rule stays readable.
337
+ */
338
+ export const runtimeNetworkPublishedPortContract = Object.freeze({
339
+ maximumEntries: 64,
340
+ maximumPortsPerService: 1024,
341
+ publishablePorts: Object.freeze({ first: 1, last: 49_151 } as const),
342
+ } as const);
343
+
219
344
  /** The assignment contract owns the replica rule; a lease names the same slot by the same name. */
220
345
  const replicaId = (value: unknown): string => {
221
346
  requireNetwork(isRuntimeReplicaId(value)); return value as string;
@@ -276,28 +401,59 @@ const packetGrant = (value: unknown): IRuntimeNetworkPacketGrant => {
276
401
  return { source: networkReference(item.source), destination: { kind, id: networkId(destination.id), address: networkIpv4(destination.address),
277
402
  protocol: destination.protocol, port: networkCounter(destination.port, 65535) } };
278
403
  };
279
- const publishedPortKey = (value: { protocol: 'tcp' | 'udp'; hostPort: number }): string =>
404
+ type TPublishedPortSpan = Pick<IRuntimeNetworkPublishedPortRequest, 'protocol' | 'hostPort' | 'hostPortEnd'>;
405
+ const publishedPortKey = (value: TPublishedPortSpan): string =>
280
406
  `${value.protocol}/${String(value.hostPort).padStart(5, '0')}`;
281
- const publishedPort = (value: unknown): IRuntimeNetworkPublishedPort => {
282
- const hasHostIp = value !== null && typeof value === 'object' && Object.hasOwn(value, 'hostIp');
283
- const item = networkRecord(value, ['protocol', 'hostPort', 'targetPort', 'authorization', ...(hasHostIp ? ['hostIp'] : [])]);
407
+ const publishedPortLast = (value: TPublishedPortSpan): number => value.hostPortEnd ?? value.hostPort;
408
+
409
+ /**
410
+ * Whether any port an entry covers lies outside `runtimeNetworkPublishedPortContract.publishablePorts`:
411
+ * Cloudly's `published-port-reserved`. It judges an entry its snapshot already accepted.
412
+ */
413
+ export const isRuntimeNetworkPublishedPortReserved = (value: TPublishedPortSpan): boolean =>
414
+ value.hostPort < runtimeNetworkPublishedPortContract.publishablePorts.first
415
+ || publishedPortLast(value) > runtimeNetworkPublishedPortContract.publishablePorts.last;
416
+
417
+ /** The members every published-port entry shares; an optional member is stated only when present. */
418
+ const publishedPortSpan = (item: Record<string, unknown>): IRuntimeNetworkPublishedPortRequest => {
284
419
  requireNetwork(item.protocol === 'tcp' || item.protocol === 'udp');
285
- const hostIp = hasHostIp ? networkIpv4(item.hostIp) : null;
420
+ const hostPort = networkCounter(item.hostPort, 65535), targetPort = networkCounter(item.targetPort, 65535);
421
+ const hasEnd = Object.hasOwn(item, 'hostPortEnd'), hasSymmetric = Object.hasOwn(item, 'symmetric');
422
+ const hostPortEnd = hasEnd ? networkCounter(item.hostPortEnd, 65535) : hostPort;
423
+ // A range maps each host port to the same workload port; a one-port range has one encoding, none.
424
+ requireNetwork(!hasEnd || (hostPortEnd > hostPort && targetPort === hostPort));
425
+ requireNetwork(!hasSymmetric || item.symmetric === true);
426
+ return { protocol: item.protocol, hostPort, ...(hasEnd ? { hostPortEnd } : {}), targetPort,
427
+ ...(hasSymmetric ? { symmetric: true as const } : {}) };
428
+ };
429
+ /** Sorted, never overlapping within a protocol, and bounded in entries and in covered ports. */
430
+ const publishedPortList = <T extends TPublishedPortSpan>(value: unknown, read: (item: unknown) => T): T[] => {
431
+ const ports = networkSorted(networkList(value, runtimeNetworkPublishedPortContract.maximumEntries, read), publishedPortKey);
432
+ let covered = 0;
433
+ ports.forEach((port, index) => {
434
+ const previous = index === 0 ? null : ports[index - 1];
435
+ requireNetwork(previous === null || previous.protocol !== port.protocol || publishedPortLast(previous) < port.hostPort);
436
+ covered += publishedPortLast(port) - port.hostPort + 1;
437
+ });
438
+ requireNetwork(covered <= runtimeNetworkPublishedPortContract.maximumPortsPerService);
439
+ return ports;
440
+ };
441
+ const publishedPort = (value: unknown): IRuntimeNetworkPublishedPort => {
442
+ const item = networkRecord(value, ['protocol', 'hostPort', 'targetPort', 'authorization'], ['hostIp', 'hostPortEnd', 'symmetric']);
443
+ const { symmetric, ...span } = publishedPortSpan(item);
444
+ const hostIp = Object.hasOwn(item, 'hostIp') ? networkIpv4(item.hostIp) : null;
286
445
  requireNetwork(hostIp === null || (hostIp !== '0.0.0.0' && !networkContains('127.0.0.0/8', `${hostIp}/32`)));
287
- return { protocol: item.protocol, hostPort: networkCounter(item.hostPort, 65535), targetPort: networkCounter(item.targetPort, 65535),
288
- ...(hostIp === null ? {} : { hostIp }), authorization: networkReference(item.authorization) };
446
+ return { ...span, ...(hostIp === null ? {} : { hostIp }), ...(symmetric ? { symmetric } : {}),
447
+ authorization: networkReference(item.authorization) };
289
448
  };
290
449
  const publishedPorts = (value: unknown): IRuntimeNetworkPublishedPort[] => {
291
- const ports = networkSorted(networkList(value, 64, publishedPort), publishedPortKey);
450
+ const ports = publishedPortList(value, publishedPort);
292
451
  requireNetwork(ports.length > 0); return ports;
293
452
  };
294
- const publishedPortRequest = (value: unknown): IRuntimeNetworkPublishedPortRequest => {
295
- const item = networkRecord(value, ['protocol', 'hostPort', 'targetPort']);
296
- requireNetwork(item.protocol === 'tcp' || item.protocol === 'udp');
297
- return { protocol: item.protocol, hostPort: networkCounter(item.hostPort, 65535), targetPort: networkCounter(item.targetPort, 65535) };
298
- };
453
+ const publishedPortRequest = (value: unknown): IRuntimeNetworkPublishedPortRequest =>
454
+ publishedPortSpan(networkRecord(value, ['protocol', 'hostPort', 'targetPort'], ['hostPortEnd', 'symmetric']));
299
455
  const publishedPortRequests = (value: unknown): IRuntimeNetworkPublishedPortRequest[] =>
300
- networkSorted(networkList(value, 64, publishedPortRequest), publishedPortKey);
456
+ publishedPortList(value, publishedPortRequest);
301
457
 
302
458
  /** Structural snapshot only; node placement, quota and authorization remain Cloudly's decision. */
303
459
  export const snapshotServicePublishedPorts = (value: unknown): IServicePublishedPorts => networkSafe(() => {
@@ -313,6 +469,28 @@ export const snapshotSetServicePublishedPorts = (value: unknown): ISetServicePub
313
469
  publishedPorts: publishedPortRequests(item.publishedPorts) };
314
470
  });
315
471
 
472
+ const servicePlatformEndpointIds = (value: unknown): string[] => networkSorted(
473
+ networkList(value, runtimeNetworkAuthorityContract.maximumPlatformEndpoints, networkId), id => id);
474
+ const servicePublicEgress = (value: unknown): boolean => {
475
+ requireNetwork(typeof value === 'boolean'); return value;
476
+ };
477
+
478
+ /** Structural snapshot only; whether each id names a current platform endpoint remains Cloudly's decision. */
479
+ export const snapshotServiceNetworkEgress = (value: unknown): IServiceNetworkEgress => networkSafe(() => {
480
+ const item = networkRecord(value, ['organizationId', 'serviceId', 'revision', 'publicEgress', 'platformEndpointIds']);
481
+ return { organizationId: networkId(item.organizationId), serviceId: networkId(item.serviceId),
482
+ revision: networkCounter(item.revision), publicEgress: servicePublicEgress(item.publicEgress),
483
+ platformEndpointIds: servicePlatformEndpointIds(item.platformEndpointIds) };
484
+ });
485
+
486
+ export const snapshotSetServiceNetworkEgress = (value: unknown): ISetServiceNetworkEgress => networkSafe(() => {
487
+ const item = networkRecord(value, ['organizationId', 'serviceId', 'mutationId', 'expectedRevision', 'publicEgress',
488
+ 'platformEndpointIds']);
489
+ return { organizationId: networkId(item.organizationId), serviceId: networkId(item.serviceId),
490
+ mutationId: networkId(item.mutationId), expectedRevision: item.expectedRevision === null ? null : networkCounter(item.expectedRevision),
491
+ publicEgress: servicePublicEgress(item.publicEgress), platformEndpointIds: servicePlatformEndpointIds(item.platformEndpointIds) };
492
+ });
493
+
316
494
  const endpoint = (value: unknown): IRuntimeNetworkEndpoint => {
317
495
  const hasPublishedPorts = value !== null && typeof value === 'object' && Object.hasOwn(value, 'publishedPorts');
318
496
  const item = networkRecord(value, ['lease', 'ready', 'observation', 'publicEgress', 'platformEndpointIds',
@@ -328,9 +506,12 @@ const endpoint = (value: unknown): IRuntimeNetworkEndpoint => {
328
506
  reporterSessionId: networkId(observed.reporterSessionId), sequence: networkCounter(observed.sequence), digest: networkDigest(observed.digest) };
329
507
  }
330
508
  requireNetwork(!item.ready || (observation !== null && observation.assignment.generation === 1));
509
+ const ports = hasPublishedPorts ? publishedPorts(item.publishedPorts) : null;
510
+ // Same-port outbound rides the public egress grant; it never grants egress of its own.
511
+ requireNetwork(item.publicEgress || !(ports ?? []).some(port => port.symmetric));
331
512
  return { lease, ready: item.ready, observation, publicEgress: item.publicEgress,
332
513
  platformEndpointIds: networkSorted(networkList(item.platformEndpointIds, 64, networkId), value => value),
333
- ...(hasPublishedPorts ? { publishedPorts: publishedPorts(item.publishedPorts) } : {}) };
514
+ ...(ports === null ? {} : { publishedPorts: ports }) };
334
515
  };
335
516
 
336
517
  /** Structural snapshot only; use validate/signature/admission helpers before accepting authority. */
@@ -435,7 +616,8 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
435
616
  requireNetwork(!aliases.has(name)); aliases.add(name);
436
617
  }
437
618
  }
438
- const assignments = new Set<string>(), attempts = new Set<string>(), readyReplicas = new Set<string>(), hostPorts = new Set<string>();
619
+ const assignments = new Set<string>(), attempts = new Set<string>(), readyReplicas = new Set<string>();
620
+ const hostPortClaims: Array<{ node: string; first: number; last: number }> = [];
439
621
  for (const endpoint of projection.endpoints) {
440
622
  const lease = endpoint.lease, authority = allocationAuthority(lease.protectedAuthority);
441
623
  // A controller on the node itself has no other node: every endpoint it projects is local.
@@ -454,12 +636,16 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
454
636
  pool.id === lease.ipv4.poolId && pool.purpose === 'workload' && networkContains(pool.prefix, lease.ipv4.linkSubnet)));
455
637
  if (networkSameScope(lease, projection)) requireNetwork(projection.handoff !== null && lease.routerIncarnationId === projection.handoff.routerIncarnationId);
456
638
  for (const id of endpoint.platformEndpointIds) requireNetwork(current.platformEndpoints.some(platform => platform.id === id));
457
- for (const port of endpoint.publishedPorts ?? []) {
458
- const claim = networkCanonical([lease.nodeId, port.protocol, port.hostPort]);
459
- requireNetwork(!hostPorts.has(claim)); hostPorts.add(claim);
460
- }
639
+ for (const port of endpoint.publishedPorts ?? []) hostPortClaims.push({ node: networkCanonical([lease.nodeId, port.protocol]),
640
+ first: port.hostPort, last: publishedPortLast(port) });
461
641
  requireNetwork(!projection.retiredLeases.some(retired => networkEqual(retired, networkReferenceOf(lease))));
462
642
  }
643
+ // Every port of every entry is claimed on its node: ordered by start, no claim reaches the next one.
644
+ hostPortClaims.sort((left, right) => left.node < right.node ? -1 : left.node > right.node ? 1 : left.first - right.first);
645
+ for (let index = 1; index < hostPortClaims.length; index++) {
646
+ const previous = hostPortClaims[index - 1], current = hostPortClaims[index];
647
+ requireNetwork(previous.node !== current.node || previous.last < current.first);
648
+ }
463
649
  for (const id of projection.hostPlatformEndpointIds ?? []) requireNetwork(current.platformEndpoints.some(platform => platform.id === id));
464
650
  for (let index = 0; index < projection.endpoints.length; index++) for (let previous = 0; previous < index; previous++) {
465
651
  requireNetwork(!networkOverlaps(projection.endpoints[index].lease.ipv4.linkSubnet, projection.endpoints[previous].lease.ipv4.linkSubnet));
@@ -33,7 +33,8 @@ export interface IServiceRuntimeProfile {
33
33
 
34
34
  /**
35
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.
36
+ * public egress on or off and the platform endpoints it may reach, by id: sorted and unique. The
37
+ * service's `IServiceNetworkEgress` must state the same values, or the workload is not admitted.
37
38
  */
38
39
  export type TServiceRuntimeNetwork =
39
40
  | { mode: 'isolated' }
@@ -89,7 +90,8 @@ const serviceRuntimeRefusals = [
89
90
  'volume-driver-unsupported', 'volume-options-unsupported', 'volume-shared-unsupported',
90
91
  'volume-placement-unsupported', 'volume-initialization-unknown', 'volume-ownership-unknown',
91
92
  'objectstorage-endpoint-missing', 'objectstorage-binding-invalid', 'objectstorage-environment-conflict',
92
- 'database-endpoint-missing',
93
+ 'objectstorage-endpoint-unselected', 'database-endpoint-missing', 'database-endpoint-unselected',
94
+ 'published-port-reserved', 'published-port-symmetric-without-egress',
93
95
  ] as const;
94
96
 
95
97
  /**
@@ -114,7 +116,8 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
114
116
  * `port-protocol-unsupported` is about one port's protocol — and `node-authority-unsupported` is a
115
117
  * node whose session resolved fewer workload authorities than this spec needs, which the admit step
116
118
  * refuses rather than handing the node work it would silently park. An attached network needs an
117
- * explicit matching policy and a current workload address pool; neither is defaulted.
119
+ * explicit matching policy (`IServiceNetworkEgress`, written by `setServiceNetworkEgress`) and a
120
+ * current workload address pool; neither is defaulted.
118
121
  *
119
122
  * The volume refusals name what a local storage claim cannot express: `volume-driver-unsupported`
120
123
  * a volume of a driver other than a node-local directory, `volume-options-unsupported` driver or
@@ -133,6 +136,28 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
133
136
  * `database-endpoint-missing` is an enabled database binding placed on a node that states no valid
134
137
  * `corestoreDatabase`: its credential cannot be addressed to any host a workload reaches, so it is
135
138
  * neither granted nor published, and the workload is not started with a guessed one.
139
+ *
140
+ * The two `-unselected` refusals name a binding endpoint the workload's network cannot reach, so a
141
+ * workload is refused rather than started against a connection that stays dark. A workload reaches
142
+ * a platform endpoint only when its spec's attached network selects it (`platformEndpointIds`), and
143
+ * an `isolated` network selects none. `database-endpoint-unselected` is an enabled database binding
144
+ * whose node's valid `corestoreDatabase` is not a selected endpoint: none of the selected
145
+ * `IRuntimeNetworkProtectedAuthority.platformEndpoints` has the endpoint's `host` as its `address`,
146
+ * `tcp` as its `protocol` and the endpoint's `port` as its `port`. `objectstorage-endpoint-unselected`
147
+ * is the same for an enabled object-storage binding's node's valid `corestoreObjectStorage`, whose
148
+ * `endpoint` origin states the host and the port (the scheme's default, 80 for `http:` and 443 for
149
+ * `https:`, when it states none). Platform endpoints are IPv4, so an endpoint whose host is a DNS name
150
+ * or an IPv6 literal is never selected. Each is judged only for an endpoint that is valid: a node
151
+ * stating none, or an invalid one, stays `database-endpoint-missing` or `objectstorage-endpoint-missing`.
152
+ * `platform.findUnselectedCorestoreEndpoints` is this rule; a producer answers what it returns.
153
+ *
154
+ * The two `published-port-` refusals name a service policy document whose published ports no
155
+ * projection may carry, so its endpoints are not compiled rather than published on some ports only.
156
+ * `published-port-reserved` is an entry covering a port outside
157
+ * `runtimeNetworkPublishedPortContract.publishablePorts` (`isRuntimeNetworkPublishedPortReserved`),
158
+ * which the handoff leases' `runtimeNetworkHandoffSnatPortRange` owns; a document written before
159
+ * that rule can hold one. `published-port-symmetric-without-egress` is a `symmetric` entry in a
160
+ * document whose `publicEgress` is false, which a projection endpoint refuses.
136
161
  */
137
162
  export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
138
163
 
@@ -0,0 +1,79 @@
1
+ import type { IRuntimeNetworkProtectedAuthority } from '../data/networkauthority.js';
2
+ import type { TServiceRuntimeNetwork, TServiceRuntimeRefusal } from '../data/serviceruntime.js';
3
+ import { isNetworkIpv4 } from '../private/networkcontract.js';
4
+ import { validatePlatformDatabaseEndpoint, type IPlatformDatabaseEndpoint } from './database.js';
5
+ import { validatePlatformObjectStorageEndpoint, type IPlatformObjectStorageEndpoint } from './objectstorage.js';
6
+
7
+ /** The service runtime refusals a Corestore endpoint the workload's network does not select raises. */
8
+ export type TCorestoreEndpointSelectionRefusal = Extract<TServiceRuntimeRefusal,
9
+ 'database-endpoint-unselected' | 'objectstorage-endpoint-unselected'>;
10
+
11
+ /** What `findUnselectedCorestoreEndpoints` judges for one node a service's bindings are placed on. */
12
+ export interface ICorestoreEndpointSelection {
13
+ /**
14
+ * The node's `corestoreDatabase` when an enabled database binding of the service is placed on it,
15
+ * otherwise `null`. A node stating none is `database-endpoint-missing`, never judged here.
16
+ */
17
+ database: IPlatformDatabaseEndpoint | null;
18
+ /**
19
+ * The node's `corestoreObjectStorage` when an enabled object-storage binding of the service is
20
+ * placed on it, otherwise `null`. A node stating none is `objectstorage-endpoint-missing`.
21
+ */
22
+ objectStorage: IPlatformObjectStorageEndpoint | null;
23
+ /** The current protected authority's platform endpoints, which the selected ids resolve against. */
24
+ platformEndpoints: IRuntimeNetworkProtectedAuthority['platformEndpoints'];
25
+ /** The runtime spec's network: `attached` selects its `platformEndpointIds`, `isolated` selects none. */
26
+ network: TServiceRuntimeNetwork;
27
+ }
28
+
29
+ type TPlatformEndpoint = IRuntimeNetworkProtectedAuthority['platformEndpoints'][number];
30
+
31
+ const selectedPlatformEndpoints = (selectionArg: ICorestoreEndpointSelection): TPlatformEndpoint[] => {
32
+ if (selectionArg.network.mode !== 'attached') return [];
33
+ const selectedIds = new Set(selectionArg.network.platformEndpointIds);
34
+ return selectionArg.platformEndpoints.filter((endpointArg) => selectedIds.has(endpointArg.id));
35
+ };
36
+
37
+ /** Platform endpoints are IPv4, so only a canonical IPv4 host can be one; a DNS name or IPv6 literal never is. */
38
+ const selectsTcp = (endpointsArg: TPlatformEndpoint[], hostArg: string, portArg: number): boolean => (
39
+ isNetworkIpv4(hostArg) && endpointsArg.some((endpointArg) => endpointArg.address === hostArg
40
+ && endpointArg.protocol === 'tcp' && endpointArg.port === portArg)
41
+ );
42
+
43
+ /** The origin's port, or the scheme's default (80 for `http:`, 443 for `https:`) when it states none. */
44
+ const objectStorageOriginPort = (originArg: URL): number => (
45
+ originArg.port === '' ? (originArg.protocol === 'https:' ? 443 : 80) : Number(originArg.port)
46
+ );
47
+
48
+ /**
49
+ * Which of a node's Corestore endpoints a service's workload would dial without its network
50
+ * selecting them, as the service runtime refusals that name them; empty when none.
51
+ *
52
+ * An endpoint is selected when one of the spec's selected platform endpoints has the endpoint's host
53
+ * as its `address`, `tcp` as its `protocol` and the endpoint's port as its `port`: the database
54
+ * endpoint's `host` and `port`, and the object-storage endpoint origin's host and port, or 80 for
55
+ * `http:` and 443 for `https:` when the origin states none. An `isolated` network selects nothing, a
56
+ * selected id the protected authority does not hold selects nothing, and a DNS-name or IPv6 host is
57
+ * never selected. Only a valid endpoint is judged: one `validatePlatformDatabaseEndpoint` or
58
+ * `validatePlatformObjectStorageEndpoint` refuses is the `-missing` refusal's, not this one's. The
59
+ * answer is sorted, so it does not depend on the order of the inputs.
60
+ */
61
+ export const findUnselectedCorestoreEndpoints = (
62
+ selectionArg: ICorestoreEndpointSelection,
63
+ ): TCorestoreEndpointSelectionRefusal[] => {
64
+ const selected = selectedPlatformEndpoints(selectionArg);
65
+ const refusals: TCorestoreEndpointSelectionRefusal[] = [];
66
+ const database = selectionArg.database;
67
+ if (database !== null && validatePlatformDatabaseEndpoint(database).length === 0
68
+ && !selectsTcp(selected, database.host, database.port)) {
69
+ refusals.push('database-endpoint-unselected');
70
+ }
71
+ const objectStorage = selectionArg.objectStorage;
72
+ if (objectStorage !== null && validatePlatformObjectStorageEndpoint(objectStorage).length === 0) {
73
+ const origin = new URL(objectStorage.endpoint);
74
+ if (!selectsTcp(selected, origin.hostname, objectStorageOriginPort(origin))) {
75
+ refusals.push('objectstorage-endpoint-unselected');
76
+ }
77
+ }
78
+ return refusals;
79
+ };
@@ -1,5 +1,6 @@
1
1
  import * as ai from './ai.js';
2
2
  import * as backup from './backup.js';
3
+ import * as corestoreendpointselection from './corestoreendpointselection.js';
3
4
  import * as database from './database.js';
4
5
  import * as email from './email.js';
5
6
  import * as letter from './letter.js';
@@ -17,6 +18,7 @@ import * as types from './types.js';
17
18
  export {
18
19
  ai,
19
20
  backup,
21
+ corestoreendpointselection,
20
22
  database,
21
23
  email,
22
24
  letter,
@@ -32,6 +34,7 @@ export {
32
34
  types,
33
35
  };
34
36
 
37
+ export * from './corestoreendpointselection.js';
35
38
  export * from './database.js';
36
39
  export * from './storage.js';
37
40
  export * from './objectstorage.js';
@@ -102,6 +102,10 @@ export const validatePlatformObjectStorageEndpoint = (
102
102
  errors.push(`${pathArg} endpoint must be a canonical origin without a path, query, `
103
103
  + 'fragment, trailing slash or default port');
104
104
  }
105
+ // A URL parser refuses a port above 65535 but reads port 0 as stated; no workload dials it.
106
+ if (parsedEndpoint.port !== '' && Number(parsedEndpoint.port) < 1) {
107
+ errors.push(`${pathArg} endpoint port must be from 1 to 65535`);
108
+ }
105
109
  }
106
110
  }
107
111
  if (typeof valueArg.region !== 'string'
@@ -209,10 +209,14 @@ export const networkHash = async (domain: string, value: { digest: TSha256Digest
209
209
  const { digest: omitted, ...payload } = value;
210
210
  return `sha256:${await createCanonicalJsonSha256Hex(networkCanonical({ domain, payload }), failNetwork)}`;
211
211
  };
212
- export const networkIpv4 = (value: unknown): string => {
213
- requireNetwork(typeof value === 'string' && value.length <= 15);
212
+ /** A canonical dotted-quad IPv4 literal: four decimal octets, no leading zeros. */
213
+ export const isNetworkIpv4 = (value: unknown): value is string => {
214
+ if (typeof value !== 'string' || value.length > 15) return false;
214
215
  const parts = value.split('.');
215
- requireNetwork(parts.length === 4 && parts.every(part => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255));
216
+ return parts.length === 4 && parts.every(part => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255);
217
+ };
218
+ export const networkIpv4 = (value: unknown): string => {
219
+ requireNetwork(isNetworkIpv4(value));
216
220
  return value;
217
221
  };
218
222
  export const networkIpv4Number = (value: string): number => value.split('.').reduce((sum, part) => sum * 256 + Number(part), 0);
@@ -6,7 +6,9 @@ import type {
6
6
  IPrivateNetwork, IServicePrivateNetworks, ISetServicePrivateNetworks,
7
7
  TPrivateNetworkMutation,
8
8
  } from '../data/privatenetwork.js';
9
- import type { IServicePublishedPorts, ISetServicePublishedPorts } from '../data/networkprojection.js';
9
+ import type {
10
+ IServiceNetworkEgress, IServicePublishedPorts, ISetServiceNetworkEgress, ISetServicePublishedPorts,
11
+ } from '../data/networkprojection.js';
10
12
 
11
13
  /** Current organization picker data. Entries are never cached authorization grants. */
12
14
  export interface IReq_Cloudly_ListOwnedOrganizations extends plugins.typedrequestInterfaces.implementsTR<
@@ -71,7 +73,7 @@ export interface IReq_Cloudly_SetServicePrivateNetworks extends plugins.typedreq
71
73
  response: { membership: IServicePrivateNetworks; replayed: boolean };
72
74
  }
73
75
 
74
- /** null means the service has never had an authorized published-port policy. */
76
+ /** null means the service has never had a policy document, neither published ports nor egress. */
75
77
  export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<
76
78
  plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_GetServicePublishedPorts
77
79
  > {
@@ -80,6 +82,17 @@ export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequ
80
82
  response: { publishedPorts: IServicePublishedPorts | null };
81
83
  }
82
84
 
85
+ /**
86
+ * Writes the published-port half of one service's policy document, carrying its egress forward.
87
+ *
88
+ * One document per service holds its published ports and its egress under one `revision`. The
89
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
90
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
91
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
92
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
93
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
94
+ * document at all.
95
+ */
83
96
  export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<
84
97
  plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_SetServicePublishedPorts
85
98
  > {
@@ -93,6 +106,47 @@ export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequ
93
106
  response: { publishedPorts: IServicePublishedPorts; replayed: boolean };
94
107
  }
95
108
 
109
+ /**
110
+ * The egress half of one service's policy document; null means the service has never had a policy
111
+ * document. Only a verified administrator reads it: platform endpoints are platform authority.
112
+ */
113
+ export interface IReq_Admin_Cloudly_GetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<
114
+ plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_GetServiceNetworkEgress
115
+ > {
116
+ method: 'getServiceNetworkEgress';
117
+ request: { identity: IIdentityCredential; organizationId: string; serviceId: string };
118
+ response: { networkEgress: IServiceNetworkEgress | null };
119
+ }
120
+
121
+ /**
122
+ * Writes the egress half of one service's policy document, carrying its published ports forward.
123
+ *
124
+ * Only a verified administrator writes it, as only an administrator writes the runtime spec whose
125
+ * attached network must state the same values. `expectedRevision` is the revision of the one
126
+ * document shared with the published ports; any other stored revision refuses the write, and an id
127
+ * that names no current platform endpoint refuses it. A retry of the exact body under the same
128
+ * `mutationId` answers the stored document with `replayed: true`.
129
+ *
130
+ * One document per service holds its published ports and its egress under one `revision`. The
131
+ * first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
132
+ * with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
133
+ * no published ports. From then on both reads return it, never null. It is an explicit policy: an
134
+ * attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
135
+ * no public address and no platform endpoint; `network-policy-missing` means the service has no
136
+ * document at all.
137
+ */
138
+ export interface IReq_Admin_Cloudly_SetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<
139
+ plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_SetServiceNetworkEgress
140
+ > {
141
+ method: 'setServiceNetworkEgress';
142
+ request: { identity: IIdentityCredential; mutation: ISetServiceNetworkEgress };
143
+ /**
144
+ * The accepted document including its `revision`; historical on replay. Acceptance is neither an
145
+ * admitted workload nor applied packet state.
146
+ */
147
+ response: { networkEgress: IServiceNetworkEgress; replayed: boolean };
148
+ }
149
+
96
150
  export interface IRequest_Any_Cloudly_GetNetworkNodes {
97
151
  method: 'getNetworkNodes';
98
152
  request: {};