@serve.zone/interfaces 32.21.0 → 32.22.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 (34) hide show
  1. package/changelog.md +45 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/clusteringress.d.ts +58 -0
  4. package/dist_ts/data/clusteringress.js +96 -2
  5. package/dist_ts/data/clusteringressdesignation.d.ts +64 -0
  6. package/dist_ts/data/clusteringressdesignation.js +84 -0
  7. package/dist_ts/data/index.d.ts +4 -0
  8. package/dist_ts/data/index.js +4 -1
  9. package/dist_ts/data/networkprojection.d.ts +18 -0
  10. package/dist_ts/data/networkprojection.js +31 -2
  11. package/dist_ts/data/networkworkloadingress.d.ts +61 -0
  12. package/dist_ts/data/networkworkloadingress.js +54 -0
  13. package/dist_ts/data/runtimelocalstorageclaim.d.ts +124 -0
  14. package/dist_ts/data/runtimelocalstorageclaim.js +167 -0
  15. package/dist_ts/data/serviceruntime.d.ts +10 -3
  16. package/dist_ts/data/serviceruntime.js +6 -2
  17. package/dist_ts/private/storagecontract.d.ts +1 -0
  18. package/dist_ts/private/storagecontract.js +2 -1
  19. package/dist_ts/requests/clusteringress.d.ts +52 -0
  20. package/dist_ts/requests/clusteringress.js +1 -1
  21. package/dist_ts/requests/runtimesession.d.ts +12 -0
  22. package/package.json +1 -1
  23. package/readme.md +135 -2
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/data/clusteringress.ts +136 -1
  26. package/ts/data/clusteringressdesignation.ts +141 -0
  27. package/ts/data/index.ts +5 -0
  28. package/ts/data/networkprojection.ts +49 -1
  29. package/ts/data/networkworkloadingress.ts +107 -0
  30. package/ts/data/runtimelocalstorageclaim.ts +275 -0
  31. package/ts/data/serviceruntime.ts +13 -2
  32. package/ts/private/storagecontract.ts +1 -0
  33. package/ts/requests/clusteringress.ts +65 -0
  34. package/ts/requests/runtimesession.ts +16 -0
package/readme.md CHANGED
@@ -572,6 +572,41 @@ overlapping current grants reject, and up to 4,096 retained withdrawals carry
572
572
  forward until the caller supplies its exact joined application of the preceding
573
573
  projection.
574
574
 
575
+ The optional `IRuntimeNetworkProjection.workloadIngress` member exists only on
576
+ `cloudly` projections; any other controller kind carrying it rejects. It is how
577
+ the cluster ingress workload reaches the workloads it routes to without a
578
+ published host port: each `grants` entry names the exact lease reference of the
579
+ ingress workload (`source`), the exact lease reference of the target workload
580
+ (`destination`), a protocol, the target's own listening port and an
581
+ `authorization` reference. Unlike the full-mesh workload grants, it needs neither
582
+ the same organization nor a shared private network, it opens exactly one port,
583
+ and it is one-way: nothing flows back except the replies of a connection the
584
+ ingress opened. Both leases must be endpoints of the projection, they must
585
+ differ, and at least one of them must be placed on the projection's own node.
586
+ Entries are unique and strictly ordered by `[source.id, destination.id, protocol,
587
+ port]`; at most 1,024 are allowed (`runtimeNetworkWorkloadIngressContract`). The
588
+ authorization is signed content, not grant identity. A projection that omits the
589
+ member retains its exact canonical bytes, digest and signature.
590
+ `getRuntimeNetworkProjectionWorkloadIngressPacketGrants` validates the complete
591
+ projection and returns detached `IRuntimeNetworkWorkloadIngressPacketGrant`s
592
+ (`{ source, destination: { lease, protocol, port } }`). Their withdrawals follow
593
+ the host rules: an omitted grant needs its exact withdrawal, unknown or current
594
+ denials reject, and up to 4,096 retained withdrawals carry forward until the
595
+ exact joined application of the preceding projection is supplied. Cloudly routes
596
+ a cluster ingress route to the target lease address and port once the grant
597
+ exists.
598
+
599
+ The optional `IRuntimeNetworkProjection.hostPlatformEndpointIds` lists the ids of
600
+ the protected authority's platform endpoints whose address the projection's
601
+ node's own host carries, such as a relay listener or a Corestore beside the node
602
+ on one machine. A node delivers workload flows to those endpoints locally instead
603
+ of forwarding them. The list is sorted, unique, never empty when present, and
604
+ every id must be a current platform endpoint; omitting it keeps the canonical
605
+ bytes and states that no endpoint is local. It grants nothing: a workload still
606
+ reaches only the endpoints it selects. The platform endpoint contract itself
607
+ (`{ id, address, protocol, port }`) names no node, which is why this statement is
608
+ per projection.
609
+
575
610
  `admitRuntimeNetworkProjection` checks exact predecessor/replay identity and
576
611
  rejects changed same-generation content, regressed membership/readiness evidence,
577
612
  changed immutable address/DNS keys, and omitted packet grants without explicit
@@ -811,6 +846,15 @@ named once; an empty `hubs` composes the authority of a runtime network whose cl
811
846
  yet. One hub costs one of the authority's 64 platform endpoints, so 64 clusters fit one
812
847
  authority.
813
848
 
849
+ A single host that carries both a cluster's hub and platform endpoints, for example the relay
850
+ listener and a Corestore, holds more than one address. The hub address sits inside `vpn.hubPrefix`,
851
+ as every hub's does. The plan's own platform endpoints may not: they sit on another address of the
852
+ same host, inside `prefixes`, outside every pool and outside both VPN prefixes. Several endpoints may
853
+ share that one address on distinct ports, because the authority requires only each
854
+ `address:port:protocol` to be unique. For example, with `hubPrefix` `192.0.2.0/28`, the hub binds
855
+ `192.0.2.1` and the plan lists the relay listener and Corestore on `192.0.2.17` with two ports. The
856
+ node's projection then names those endpoints in `hostPlatformEndpointIds`.
857
+
814
858
  `admitRuntimeNetworkAddressPlanChange(previous | null, next)` is the pure decision for a plan write at
815
859
  the following revision; any other revision is a contract violation. Leases and the managed VPN
816
860
  outlive a plan revision, so it answers:
@@ -1373,12 +1417,18 @@ phase, settlement, release or authority booleans or evidence.
1373
1417
  `data.IServiceRuntimeStatus { id, specRevision, state, reason, changedAt }` is the producer's verdict
1374
1418
  on one spec revision, and each reason belongs to exactly one state. `converged` and `converging`
1375
1419
  carry `reason: null`. `waiting` carries a `TServiceRuntimeWaitingReason`: `node-network-pending`,
1376
- `conflict` or `failure-backoff`. `refused` carries a `TServiceRuntimeRefusal`:
1420
+ `conflict`, `failure-backoff`, `storage-import-pending` (an `import` local storage claim whose bytes
1421
+ have not arrived on its node) or `bindings-pending` (the secret manifest does not cover every enabled
1422
+ binding of the service yet). `refused` carries a `TServiceRuntimeRefusal`:
1377
1423
  `cluster-runtime-missing`, `organization-missing`, `image-plan-missing`, `platform-unsupported`,
1378
1424
  `secrets-unsupported`, `volumes-unsupported`, `port-protocol-unsupported`, `ports-too-many`,
1379
1425
  `readiness-target-undeclared`, `environment-too-large`, `no-eligible-node`, `replica-limit`,
1380
1426
  `node-authority-unsupported`, `network-policy-missing`, `network-policy-mismatch`,
1381
- `network-workload-pool-missing` or `network-workload-pool-exhausted`. `ports-too-many` is a service declaring more target ports than the
1427
+ `network-workload-pool-missing`, `network-workload-pool-exhausted`, `volume-driver-unsupported`,
1428
+ `volume-options-unsupported`, `volume-shared-unsupported` or `volume-placement-unsupported`. The four
1429
+ volume refusals name what a local storage claim cannot express: a volume driver other than a
1430
+ node-local directory, driver or mount options, one volume mounted by more than one replica or
1431
+ service, and a placement that does not pin each volume to one node. `ports-too-many` is a service declaring more target ports than the
1382
1432
  `runtimeConfigContract.maximumTargetPorts` (64) a config carries, where `port-protocol-unsupported`
1383
1433
  is about one port's protocol; `node-authority-unsupported` is a node whose session resolved fewer
1384
1434
  workload authorities than the spec needs, refused at the admit step rather than handed to a node
@@ -1881,6 +1931,47 @@ storage provider's binding identity/generation, selected policy, matching observ
1881
1931
  join: callers still authenticate Cloudly and the provider, check current readiness and retain the
1882
1932
  native fence.
1883
1933
 
1934
+ ### Runtime local storage claims
1935
+
1936
+ `data.IRuntimeLocalStorageClaim` is a controller's authority for one retained, node-local volume of
1937
+ one service. Cloudly and Onebox issue it alike (`controller.kind` is `cloudly` or `onebox`). It names
1938
+ the claim revision (`id`, `generation`, `previous`), the organization, cluster, node, runtime
1939
+ namespace, service and the service's own `volumeId`, and it states:
1940
+
1941
+ - `source`: `{ kind: 'directory' }`, a node-local directory the node derives from the claim's
1942
+ identity under a root only the node knows. Network filesystems join this union later as further
1943
+ kinds, with their credentials delivered as secrets and never inside a claim;
1944
+ - `initialization`: `empty` creates an empty root with `initialOwnership { uid, gid, mode }`, which
1945
+ an `empty` claim must state, so a workload without `runAs` never meets a root-owned directory by
1946
+ accident; `import` waits for the volume's bytes to be imported and states `initialOwnership: null`,
1947
+ because an imported tree keeps its own ownership. `mode` holds directory permission bits only
1948
+ (`runtimeLocalStorageClaimContract.directoryModeBits`, `0o3777`; setuid is refused);
1949
+ - `mount { mountPath, readOnly }`, judged by the storage request's own mount path rule: canonical,
1950
+ absolute, not `/`, and never overlapping `/run/secrets`;
1951
+ - `accessMode: 'ReadWriteOnce'`, `reclaimPolicy: 'retain'`, and `lifecycle`: `bound` keeps the
1952
+ volume, `purge` orders its bytes deleted and always follows a bound generation of the same claim.
1953
+
1954
+ The claim carries no host path, device, mount option or credential.
1955
+ `snapshotRuntimeLocalStorageClaim` makes a detached exact-schema snapshot,
1956
+ `computeRuntimeLocalStorageClaimDigest` hashes it under `serve.zone/runtime-local-storage-claim`,
1957
+ `validateRuntimeLocalStorageClaim` verifies the stated digest, and
1958
+ `getRuntimeLocalStorageClaimReference` is the `{ id, generation, digest }` an assignment seals in
1959
+ `workload.storage`. `bindRuntimeLocalStorageClaimToAssignment(claim, assignment, expectedClusterId)`
1960
+ checks that reference and the exact controller, organization, node, runtime namespace, service and
1961
+ cluster; a `purge` claim binds to no assignment, because no workload may mount a volume being deleted.
1962
+
1963
+ `requests.runtimesession.IReq_Controller_Pallet_ApplyRuntimeLocalStorageClaim`
1964
+ (`applyRuntimeLocalStorageClaim`) pushes one claim over the node's authenticated session:
1965
+ `{ session, claim }` → `{ status, state, claim }`. `status` is `accepted`, `replay` or `historical`;
1966
+ `state` is `ready`, `import-required`, `purged` or `uncertain` (a volume the node can vouch for
1967
+ neither way, which it neither mounts nor deletes until an operator decides); `claim` is the sent
1968
+ claim's reference. `bindApplyRuntimeLocalStorageClaimRequest(request, trustedSession)` requires the
1969
+ request's session to equal the node's current one and the claim to name its controller, cluster,
1970
+ node and runtime namespace. `bindApplyRuntimeLocalStorageClaimResponse(response, request)` binds the
1971
+ answer to the sent claim and, for `accepted` and `replay`, to a state the claim can be in: a bound
1972
+ claim is `ready`, `import-required` (an `import` claim only) or `uncertain`, a purge is `purged` or
1973
+ `uncertain`. A `historical` answer describes the newer generation the node holds.
1974
+
1884
1975
  `platform.storagemigration` defines the provider-neutral cutover contract for a
1885
1976
  named object-storage binding. Corestore atomically owns and fences the source
1886
1977
  binding after validating a distinct, unfenced active-object snapshot. Onebox
@@ -2440,6 +2531,9 @@ credential is excluded.
2440
2531
  | `registerClusterIngress` | ingress → relay → Cloudly | `{ bearer, version, nodeId, capabilities?, protocol }` → `{ accepted: true, revision \| null, protocol }` |
2441
2532
  | `getClusterIngressRouteTable` | ingress → relay | `{ knownRevision \| null }` → `{ table \| null }` |
2442
2533
  | `getClusterTrafficStatistics` | Cloudly → relay → ingress | `{ fromDayUtc, toDayUtc, cursor?, limit }` → `{ statistics, nextCursor? }` |
2534
+ | `pushClusterIngressRouteTableChanged` | relay → ingress | `{ revision }` → `{}` |
2535
+ | `designateClusterIngress` | admin → Cloudly | `{ identity, designation }` → `{ designation, spec, replayed }` |
2536
+ | `getClusterIngressDesignation` | admin → Cloudly | `{ identity, clusterId }` → `{ designation \| null }` |
2443
2537
 
2444
2538
  `null` for a table means Cloudly has computed none yet — a fact, not an empty table, and an
2445
2539
  ingress must not serve it as one. An ingress announces itself with a **method**, not a connection
@@ -2465,6 +2559,45 @@ the body in the one server order `protocol.readProtocolOffer` states — the off
2465
2559
  before this shape check and before the bearer — so a relay refuses a peer it cannot serve without
2466
2560
  asking Cloudly, and an accepted registration carries the accepting side's own offer back.
2467
2561
 
2562
+ A refused registration is named. A protocol mismatch stays the `IProtocolRefusal` every hop already
2563
+ carries; every other refusal travels as `IClusterIngressRegistrationRefusal { code:
2564
+ 'cluster-ingress-registration-refused', reason, retryAfterMs }` in `TypedResponseError.errorData`,
2565
+ forwarded by the relay untouched. `clusterIngressRegistrationRefusalReasons` lists the reasons in
2566
+ the order Cloudly judges them: `not-ready` (Cloudly cannot judge registrations yet), `registration-invalid`
2567
+ (the body fails `validateClusterIngressRegistration`), `relay-not-connected` (the forwarding relay is
2568
+ not the connection Cloudly elected for its cluster), `ingress-not-designated`, `node-mismatch` (the
2569
+ node is not one the designation lets the ingress run on), `assignment-not-running` (no ready
2570
+ assignment of the ingress service runs on that node under its current session, or it does not carry
2571
+ the designation's current credential), `bearer-refused` and `conflict` (the designation, assignment
2572
+ or session changed while the registration was judged). `retryAfterMs` is how long the ingress waits
2573
+ before it registers again, 1 s to 300 s (`clusterIngressRegistrationContract`).
2574
+ `validateClusterIngressRegistrationRefusal` names every violation and
2575
+ `readClusterIngressRegistrationRefusal(errorData)` answers the refusal or `null`, reading plain data
2576
+ only, as `protocol.readProtocolRefusal` does. No refusal quotes the bearer.
2577
+
2578
+ `pushClusterIngressRouteTableChanged` is the relay's hint that it holds a newer table. It carries the
2579
+ revision alone (`IClusterIngressRouteTableChange`, `validateClusterIngressRouteTableChange`) and
2580
+ nothing secret; the ingress answers it by pulling `getClusterIngressRouteTable` with the revision it
2581
+ already applied and ignores a hint that is not newer, so a lost or repeated hint costs nothing.
2582
+
2583
+ `clusterIngressEnvironmentNames` spells the ingress workload's environment once for both sides:
2584
+ `SERVEZONE_CLUSTER_INGRESS_BEARER` (the registration bearer, delivered as a secret),
2585
+ `SERVEZONE_CLUSTER_RELAY_ORIGIN` (the exact `IClusterRelay.origin`) and `SERVEZONE_CLUSTER_NODE_ID`
2586
+ (the node the assignment runs on). Cloudly writes them; the ingress reads them.
2587
+
2588
+ A cluster born on Pallet has no cutover scope, census or target set, so its ingress is designated by
2589
+ `IClusterIngressDesignation { id, generation, previous, clusterId, runtime, ingressServiceId,
2590
+ preparedSpecRevision, credential, actorId, acceptedAt, digest }` instead of the fleet-cutover
2591
+ designation. `runtime` is the cluster's `pallet` phase reference and `credential`
2592
+ (`IClusterIngressCredentialReference`) the public identity of the managed bearer, never its value.
2593
+ `snapshotClusterIngressDesignation`, `computeClusterIngressDesignationDigest` (domain
2594
+ `serve.zone/cluster-ingress-designation`) and `validateClusterIngressDesignation` judge it, and
2595
+ `snapshotClusterIngressDesignationRequest` the administrator's compare-and-swap intent
2596
+ (`IClusterIngressDesignationRequest { mutationId, clusterId, ingressServiceId, currentRuntime,
2597
+ expectedSpecRevision, expectedDesignation }`). **A cluster holds exactly one kind of designation**:
2598
+ Cloudly refuses to designate a cluster that holds a fleet-cutover ingress designation, and the
2599
+ reverse. Which nodes the ingress runs on is the ingress service's runtime spec placement.
2600
+
2468
2601
  `IClusterTrafficStatistics` is one UTC day of one hostname's traffic on one node —
2469
2602
  `requests`, `bytesIn`, `bytesOut`, `connections` and the backend failures the proxy can observe
2470
2603
  (`connect`, `handshake`, `request`). Day buckets rather than rates: a rate answers "how busy is it
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.21.0',
6
+ version: '32.22.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -1,7 +1,7 @@
1
1
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
2
2
  import { isUtcCalendarDay } from '../private/utccalendarday.js';
3
3
  import { validateProtocolOfferOn } from '../protocol.js';
4
- import { hasExactKeys } from '../private/dataproperties.js';
4
+ import { hasExactKeys, readExactDataRecord } from '../private/dataproperties.js';
5
5
  import { isClusterNodeId } from '../private/nodeidentifier.js';
6
6
 
7
7
  /**
@@ -424,3 +424,138 @@ export const validateClusterIngressRegistration = (
424
424
  }
425
425
  return errors;
426
426
  };
427
+
428
+ /**
429
+ * The environment variable names of the cluster ingress workload.
430
+ *
431
+ * Cloudly writes them into the ingress workload's assignment environment and the ingress reads
432
+ * them, so both sides spell each name once, here. `bearer` is the credential the ingress presents
433
+ * to `registerClusterIngress`; it is a secret and is delivered as one. `relayOrigin` is the exact
434
+ * `IClusterRelay.origin` of the cluster's relay, and `nodeId` is the node the assignment runs on,
435
+ * which the registration's `nodeId` repeats.
436
+ */
437
+ export const clusterIngressEnvironmentNames = Object.freeze({
438
+ bearer: 'SERVEZONE_CLUSTER_INGRESS_BEARER',
439
+ relayOrigin: 'SERVEZONE_CLUSTER_RELAY_ORIGIN',
440
+ nodeId: 'SERVEZONE_CLUSTER_NODE_ID',
441
+ } as const);
442
+
443
+ /**
444
+ * Every reason Cloudly refuses a cluster ingress registration, in the order it judges them.
445
+ *
446
+ * A protocol mismatch is not among them: it is refused first, as the `IProtocolRefusal` every
447
+ * TypedRequest hop already carries, and a second name for it would be a second answer to one fact.
448
+ */
449
+ export const clusterIngressRegistrationRefusalReasons = Object.freeze([
450
+ /** Cloudly cannot judge registrations yet: it is starting, stopping or missing its environment. */
451
+ 'not-ready',
452
+ /** The body fails `validateClusterIngressRegistration`. */
453
+ 'registration-invalid',
454
+ /** The relay that forwarded the registration is not the connection Cloudly elected for its cluster. */
455
+ 'relay-not-connected',
456
+ /** The cluster holds no current ingress designation. */
457
+ 'ingress-not-designated',
458
+ /** The named node is not one the designation lets the ingress run on. */
459
+ 'node-mismatch',
460
+ /**
461
+ * No ready assignment of the designated ingress service runs on the named node under that node's
462
+ * current session, or the one that runs does not carry the designation's current credential.
463
+ */
464
+ 'assignment-not-running',
465
+ /** The bearer is not the designation's current credential. */
466
+ 'bearer-refused',
467
+ /** The designation, the assignment or the session changed while the registration was judged. */
468
+ 'conflict',
469
+ ] as const);
470
+
471
+ export type TClusterIngressRegistrationRefusalReason = typeof clusterIngressRegistrationRefusalReasons[number];
472
+
473
+ export const clusterIngressRegistrationContract = Object.freeze({
474
+ refusalCode: 'cluster-ingress-registration-refused',
475
+ minimumRetryAfterMs: 1_000,
476
+ maximumRetryAfterMs: 300_000,
477
+ } as const);
478
+
479
+ /**
480
+ * The named refusal of `registerClusterIngress`, carried as `TypedResponseError.errorData` from
481
+ * Cloudly and forwarded by the relay untouched. `retryAfterMs` is how long the ingress waits before
482
+ * it registers again; it never quotes the bearer or anything derived from it.
483
+ */
484
+ export interface IClusterIngressRegistrationRefusal {
485
+ code: 'cluster-ingress-registration-refused';
486
+ reason: TClusterIngressRegistrationRefusalReason;
487
+ retryAfterMs: number;
488
+ }
489
+
490
+ const isRetryAfterMs = (valueArg: unknown): valueArg is number => (
491
+ Number.isSafeInteger(valueArg)
492
+ && (valueArg as number) >= clusterIngressRegistrationContract.minimumRetryAfterMs
493
+ && (valueArg as number) <= clusterIngressRegistrationContract.maximumRetryAfterMs
494
+ );
495
+
496
+ /** Validates one registration refusal and names every reason it is refused. */
497
+ export const validateClusterIngressRegistrationRefusal = (
498
+ valueArg: unknown,
499
+ pathArg = 'cluster ingress registration refusal',
500
+ ): string[] => {
501
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['code', 'reason', 'retryAfterMs'])) {
502
+ return [`${pathArg} must use its exact schema`];
503
+ }
504
+ const errors: string[] = [];
505
+ if (valueArg.code !== clusterIngressRegistrationContract.refusalCode) {
506
+ errors.push(`${pathArg} code must be ${clusterIngressRegistrationContract.refusalCode}`);
507
+ }
508
+ if (!(clusterIngressRegistrationRefusalReasons as readonly unknown[]).includes(valueArg.reason)) {
509
+ errors.push(`${pathArg} reason must be a cluster ingress registration refusal reason`);
510
+ }
511
+ if (!isRetryAfterMs(valueArg.retryAfterMs)) {
512
+ errors.push(`${pathArg} retryAfterMs must be an integer from `
513
+ + `${clusterIngressRegistrationContract.minimumRetryAfterMs} to `
514
+ + `${clusterIngressRegistrationContract.maximumRetryAfterMs}`);
515
+ }
516
+ return errors;
517
+ };
518
+
519
+ /**
520
+ * The refusal inside an error payload, or `null` when the value is not one.
521
+ *
522
+ * It reads a value that survived serialization, the way `protocol.readProtocolRefusal` does, so an
523
+ * ingress tells a named registration refusal apart from a protocol refusal and every other error.
524
+ */
525
+ export const readClusterIngressRegistrationRefusal = (
526
+ valueArg: unknown,
527
+ ): IClusterIngressRegistrationRefusal | null => {
528
+ const refusal = readExactDataRecord(valueArg, ['code', 'reason', 'retryAfterMs']);
529
+ if (refusal === null || validateClusterIngressRegistrationRefusal(refusal).length > 0) {
530
+ return null;
531
+ }
532
+ return Object.freeze({
533
+ code: clusterIngressRegistrationContract.refusalCode,
534
+ reason: refusal.reason as TClusterIngressRegistrationRefusalReason,
535
+ retryAfterMs: refusal.retryAfterMs as number,
536
+ });
537
+ };
538
+
539
+ /**
540
+ * The relay's hint that it holds a newer route table.
541
+ *
542
+ * It carries the revision alone and no route, certificate or key, so it is safe to log; the
543
+ * ingress answers it by pulling `getClusterIngressRouteTable` with the revision it already applied,
544
+ * and ignores a hint whose revision is not greater than that one.
545
+ */
546
+ export interface IClusterIngressRouteTableChange {
547
+ revision: number;
548
+ }
549
+
550
+ /** Validates one route-table change hint. */
551
+ export const validateClusterIngressRouteTableChange = (
552
+ valueArg: unknown,
553
+ pathArg = 'cluster ingress route table change',
554
+ ): string[] => {
555
+ if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['revision'])) {
556
+ return [`${pathArg} must use its exact schema`];
557
+ }
558
+ return isCounter(valueArg.revision)
559
+ ? []
560
+ : [`${pathArg} revision must be a non-negative safe integer`];
561
+ };
@@ -0,0 +1,141 @@
1
+ import type { TSha256Digest } from './immutableimage.js';
2
+ import type { IClusterRuntimeReference } from './clusterruntime.js';
3
+ import { snapshotClusterRuntimeReference } from './clusterruntime.js';
4
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
5
+ import type { ISecretVersionReference } from './secret.js';
6
+ import { captureNetwork, networkCounter, networkDigest, networkHash, networkId, networkRecord, networkReference,
7
+ networkRevision, requireNetwork } from '../private/networkcontract.js';
8
+
9
+ /**
10
+ * The ingress designation of a cluster born on Pallet.
11
+ *
12
+ * A cluster that never ran Swarm has no cutover scope, census or target set, so its ingress is
13
+ * designated by this record instead of `IFleetCutoverIngressDesignation`. A cluster holds exactly
14
+ * one kind of designation at a time: Cloudly refuses to designate a cluster that holds the other
15
+ * kind. Which nodes the ingress runs on is the ingress service's runtime spec placement; the
16
+ * designation names the service and the credential, and nothing else.
17
+ */
18
+
19
+ /** Public identity of the active ingress credential generation; it contains no credential value. */
20
+ export interface IClusterIngressCredentialReference {
21
+ credentialId: string;
22
+ generation: number;
23
+ secretVersion: ISecretVersionReference;
24
+ }
25
+
26
+ /**
27
+ * Cloudly's accepted designation of one service as a Pallet cluster's ingress. It grants no
28
+ * assignment, session or registration: the ingress still registers with the credential it names.
29
+ */
30
+ export interface IClusterIngressDesignation {
31
+ id: string;
32
+ generation: number;
33
+ previous: IRuntimeAuthorityReference | null;
34
+ clusterId: string;
35
+ runtime: IClusterRuntimeReference & { phase: 'pallet' };
36
+ ingressServiceId: string;
37
+ /** The ingress service's runtime spec revision the designation was accepted against. */
38
+ preparedSpecRevision: number;
39
+ credential: IClusterIngressCredentialReference;
40
+ actorId: string;
41
+ acceptedAt: number;
42
+ digest: TSha256Digest;
43
+ }
44
+
45
+ /**
46
+ * Administrator intent to designate one service as the cluster's ingress. The caller supplies only
47
+ * compare-and-swap inputs; Cloudly owns credential generation and acceptance.
48
+ */
49
+ export interface IClusterIngressDesignationRequest {
50
+ mutationId: string;
51
+ clusterId: string;
52
+ ingressServiceId: string;
53
+ currentRuntime: IClusterRuntimeReference & { phase: 'pallet' };
54
+ expectedSpecRevision: number;
55
+ /** Null for creation; the exact current designation for rotation or a change of service. */
56
+ expectedDesignation: IRuntimeAuthorityReference | null;
57
+ }
58
+
59
+ export const clusterIngressDesignationContract = Object.freeze({
60
+ digestDomain: 'serve.zone/cluster-ingress-designation',
61
+ error: 'Cluster ingress designation contract rejected.',
62
+ } as const);
63
+
64
+ const fail = (): never => { throw new Error(clusterIngressDesignationContract.error); };
65
+ const safe = <T>(operationArg: () => T): T => { try { return operationArg(); } catch { return fail(); } };
66
+
67
+ const palletRuntime = (valueArg: unknown, clusterIdArg: string): IClusterRuntimeReference & { phase: 'pallet' } => {
68
+ const runtime = snapshotClusterRuntimeReference(valueArg);
69
+ requireNetwork(runtime.id === clusterIdArg && runtime.phase === 'pallet');
70
+ return { id: runtime.id, phase: 'pallet', generation: runtime.generation };
71
+ };
72
+
73
+ const credentialOf = (valueArg: unknown): IClusterIngressCredentialReference => {
74
+ const value = networkRecord(valueArg, ['credentialId', 'generation', 'secretVersion']);
75
+ const secretVersion = networkRecord(value.secretVersion, ['secretId', 'secretVersionId']);
76
+ return {
77
+ credentialId: networkId(value.credentialId),
78
+ generation: networkCounter(value.generation),
79
+ secretVersion: {
80
+ secretId: networkId(secretVersion.secretId),
81
+ secretVersionId: networkId(secretVersion.secretVersionId),
82
+ },
83
+ };
84
+ };
85
+
86
+ /** Detached exact-schema snapshot of a designation request. */
87
+ export const snapshotClusterIngressDesignationRequest = (
88
+ valueArg: unknown,
89
+ ): IClusterIngressDesignationRequest => safe(() => {
90
+ const value = networkRecord(captureNetwork(valueArg), ['mutationId', 'clusterId', 'ingressServiceId',
91
+ 'currentRuntime', 'expectedSpecRevision', 'expectedDesignation']);
92
+ const clusterId = networkId(value.clusterId);
93
+ return {
94
+ mutationId: networkId(value.mutationId),
95
+ clusterId,
96
+ ingressServiceId: networkId(value.ingressServiceId),
97
+ currentRuntime: palletRuntime(value.currentRuntime, clusterId),
98
+ expectedSpecRevision: networkCounter(value.expectedSpecRevision),
99
+ expectedDesignation: value.expectedDesignation === null ? null : networkReference(value.expectedDesignation),
100
+ };
101
+ });
102
+
103
+ /** Detached exact-schema snapshot. The digest is a separate check. */
104
+ export const snapshotClusterIngressDesignation = (valueArg: unknown): IClusterIngressDesignation => safe(() => {
105
+ const value = networkRecord(captureNetwork(valueArg), ['id', 'generation', 'previous', 'clusterId', 'runtime',
106
+ 'ingressServiceId', 'preparedSpecRevision', 'credential', 'actorId', 'acceptedAt', 'digest']);
107
+ const { id, generation, previous } = networkRevision(value);
108
+ const clusterId = networkId(value.clusterId);
109
+ return {
110
+ id,
111
+ generation,
112
+ previous,
113
+ clusterId,
114
+ runtime: palletRuntime(value.runtime, clusterId),
115
+ ingressServiceId: networkId(value.ingressServiceId),
116
+ preparedSpecRevision: networkCounter(value.preparedSpecRevision),
117
+ credential: credentialOf(value.credential),
118
+ actorId: networkId(value.actorId),
119
+ acceptedAt: networkCounter(value.acceptedAt),
120
+ digest: networkDigest(value.digest),
121
+ };
122
+ });
123
+
124
+ export const computeClusterIngressDesignationDigest = async (valueArg: unknown): Promise<TSha256Digest> => {
125
+ const designation = snapshotClusterIngressDesignation(valueArg);
126
+ try {
127
+ return await networkHash(clusterIngressDesignationContract.digestDomain, designation);
128
+ } catch {
129
+ return fail();
130
+ }
131
+ };
132
+
133
+ export const validateClusterIngressDesignation = async (valueArg: unknown): Promise<string[]> => {
134
+ try {
135
+ const designation = snapshotClusterIngressDesignation(valueArg);
136
+ return designation.digest === await computeClusterIngressDesignationDigest(designation)
137
+ ? [] : ['cluster ingress designation digest does not match'];
138
+ } catch {
139
+ return ['cluster ingress designation must use its exact canonical schema'];
140
+ }
141
+ };
package/ts/data/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './cluster.js';
3
3
  export * from './clusterruntime.js';
4
4
  export * from './clustervpn.js';
5
5
  export * from './clusteringress.js';
6
+ export * from './clusteringressdesignation.js';
6
7
  export * from './clusterrelaycredential.js';
7
8
  export * from './config.js';
8
9
  export * from './corestore.js';
@@ -40,6 +41,9 @@ export * from './networkprojection.js';
40
41
  export type { IRuntimeNetworkRouterPacketGrant, IRuntimeNetworkRouterProjection } from './networkrouter.js';
41
42
  export type { IRuntimeNetworkHostIngress, IRuntimeNetworkHostPacketGrant, IRuntimeNetworkHostProjection } from './networkhost.js';
42
43
  export { runtimeNetworkHostContract } from './networkhost.js';
44
+ export type { IRuntimeNetworkWorkloadIngress, IRuntimeNetworkWorkloadIngressPacketGrant,
45
+ IRuntimeNetworkWorkloadIngressProjection } from './networkworkloadingress.js';
46
+ export { runtimeNetworkWorkloadIngressContract } from './networkworkloadingress.js';
43
47
  export * from './networkprotection.js';
44
48
  export * from './networksigning.js';
45
49
  export * from './privatenetwork.js';
@@ -48,6 +52,7 @@ export * from './releaseversion.js';
48
52
  export * from './runtimeassignment.js';
49
53
  export * from './runtimeconfig.js';
50
54
  export * from './runtimestorageclaim.js';
55
+ export * from './runtimelocalstorageclaim.js';
51
56
  export * from './runtimesession.js';
52
57
  export * from './runtimeterminalreceipt.js';
53
58
  export * from './secret.js';
@@ -9,6 +9,8 @@ import { routerPacketGrantsOf, snapshotRouterProjection,
9
9
  type IRuntimeNetworkRouterPacketGrant, type IRuntimeNetworkRouterProjection } from './networkrouter.js';
10
10
  import { hostPacketGrantsOf, snapshotHostProjection,
11
11
  type IRuntimeNetworkHostPacketGrant, type IRuntimeNetworkHostProjection } from './networkhost.js';
12
+ import { snapshotWorkloadIngressProjection, workloadIngressPacketGrantsOf,
13
+ type IRuntimeNetworkWorkloadIngressPacketGrant, type IRuntimeNetworkWorkloadIngressProjection } from './networkworkloadingress.js';
12
14
  import { buildPrivateNetworkFqdn, composeServicePrivateNetworkDnsPolicy,
13
15
  snapshotPrivateNetwork, snapshotServicePrivateNetworks } from './privatenetwork.js';
14
16
  import { captureNetwork, failNetwork, networkCanonical, networkContains, networkCounter, networkDigest,
@@ -167,6 +169,21 @@ export interface IRuntimeNetworkProjection extends IRuntimeNetworkNodeScope {
167
169
  * host-origin flow; any other controller kind carrying it is refused.
168
170
  */
169
171
  host?: IRuntimeNetworkHostProjection;
172
+ /**
173
+ * One-way workload ingress selections of a `cloudly` projection: the cluster ingress workload dials
174
+ * exact target workload ports across organizations (`IRuntimeNetworkWorkloadIngressPacketGrant`).
175
+ * Omitted, it keeps the canonical bytes and grants no such flow; any other controller kind
176
+ * carrying it is refused.
177
+ */
178
+ workloadIngress?: IRuntimeNetworkWorkloadIngressProjection;
179
+ /**
180
+ * Ids of the protected authority's platform endpoints whose address this projection's node's own
181
+ * host carries, such as a relay listener or a Corestore beside the node on one machine. The node
182
+ * delivers workload flows to them locally instead of forwarding them. Sorted, unique and never
183
+ * empty when present; omitted, it keeps the canonical bytes and states that no endpoint is local.
184
+ * It grants nothing: a workload still reaches only the endpoints it selects.
185
+ */
186
+ hostPlatformEndpointIds?: string[];
170
187
  digest: TSha256Digest;
171
188
  }
172
189
 
@@ -322,9 +339,16 @@ export const snapshotRuntimeNetworkProjection = (value: unknown): IRuntimeNetwor
322
339
  requireNetwork(new TextEncoder().encode(networkCanonical(captured)).length <= runtimeNetworkProjectionContract.maximumCanonicalBytes);
323
340
  const hasRouter = captured !== null && typeof captured === 'object' && Object.hasOwn(captured, 'router');
324
341
  const hasHost = captured !== null && typeof captured === 'object' && Object.hasOwn(captured, 'host');
342
+ const hasWorkloadIngress = captured !== null && typeof captured === 'object' && Object.hasOwn(captured, 'workloadIngress');
343
+ const hasHostPlatformEndpoints = captured !== null && typeof captured === 'object'
344
+ && Object.hasOwn(captured, 'hostPlatformEndpointIds');
325
345
  const item = networkRecord(captured, ['id', 'generation', 'previous', 'controller', 'nodeId', 'runtimeNamespace',
326
346
  'protectedAuthority', 'allocationAuthorities', 'handoff', 'networks', 'memberships', 'endpoints', 'dns', 'withdrawals', 'retiredLeases', 'retiredHandoffs', 'digest',
327
- ...(hasRouter ? ['router'] : []), ...(hasHost ? ['host'] : [])]);
347
+ ...(hasRouter ? ['router'] : []), ...(hasHost ? ['host'] : []), ...(hasWorkloadIngress ? ['workloadIngress'] : []),
348
+ ...(hasHostPlatformEndpoints ? ['hostPlatformEndpointIds'] : [])]);
349
+ const hostPlatformEndpointIds = hasHostPlatformEndpoints
350
+ ? networkSorted(networkList(item.hostPlatformEndpointIds, 64, networkId), value => value) : [];
351
+ requireNetwork(!hasHostPlatformEndpoints || hostPlatformEndpointIds.length > 0);
328
352
  return { ...networkRevision(item), ...networkScope(item),
329
353
  protectedAuthority: snapshotRuntimeNetworkProtectedAuthority(item.protectedAuthority),
330
354
  allocationAuthorities: networkSorted(networkList(item.allocationAuthorities, 16, snapshotRuntimeNetworkProtectedAuthority), refKey),
@@ -335,6 +359,8 @@ export const snapshotRuntimeNetworkProjection = (value: unknown): IRuntimeNetwor
335
359
  withdrawals: networkSorted(networkList(item.withdrawals, 4096, packetGrant), networkCanonical),
336
360
  ...(hasRouter ? { router: snapshotRouterProjection(item.router) } : {}),
337
361
  ...(hasHost ? { host: snapshotHostProjection(item.host) } : {}),
362
+ ...(hasWorkloadIngress ? { workloadIngress: snapshotWorkloadIngressProjection(item.workloadIngress) } : {}),
363
+ ...(hasHostPlatformEndpoints ? { hostPlatformEndpointIds } : {}),
338
364
  retiredLeases: sortedReferences(item.retiredLeases), retiredHandoffs: sortedReferences(item.retiredHandoffs), digest: networkDigest(item.digest) };
339
365
  });
340
366
 
@@ -434,6 +460,7 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
434
460
  }
435
461
  requireNetwork(!projection.retiredLeases.some(retired => networkEqual(retired, networkReferenceOf(lease))));
436
462
  }
463
+ for (const id of projection.hostPlatformEndpointIds ?? []) requireNetwork(current.platformEndpoints.some(platform => platform.id === id));
437
464
  for (let index = 0; index < projection.endpoints.length; index++) for (let previous = 0; previous < index; previous++) {
438
465
  requireNetwork(!networkOverlaps(projection.endpoints[index].lease.ipv4.linkSubnet, projection.endpoints[previous].lease.ipv4.linkSubnet));
439
466
  }
@@ -444,6 +471,8 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
444
471
  requireNetwork((projection.router?.withdrawals ?? []).every(grant => !routerGrants.has(networkCanonical(grant))));
445
472
  const hostGrants = new Set(hostPacketGrantsOf(projection).map(networkCanonical));
446
473
  requireNetwork((projection.host?.withdrawals ?? []).every(grant => !hostGrants.has(networkCanonical(grant))));
474
+ const workloadIngressGrants = new Set(workloadIngressPacketGrantsOf(projection).map(networkCanonical));
475
+ requireNetwork((projection.workloadIngress?.withdrawals ?? []).every(grant => !workloadIngressGrants.has(networkCanonical(grant))));
447
476
  requireNetwork(projection.digest === await computeRuntimeNetworkProjectionDigest(projection));
448
477
  return projection;
449
478
  } catch { return failNetwork(); }
@@ -461,6 +490,12 @@ export const getRuntimeNetworkProjectionRouterPacketGrants = async (value: unkno
461
490
  export const getRuntimeNetworkProjectionHostPacketGrants = async (value: unknown): Promise<IRuntimeNetworkHostPacketGrant[]> =>
462
491
  hostPacketGrantsOf(await validateRuntimeNetworkProjection(value));
463
492
 
493
+ /** One-way workload ingress authority comes only from explicit signed `cloudly` selections of exact target ports. */
494
+ export const getRuntimeNetworkProjectionWorkloadIngressPacketGrants = async (
495
+ value: unknown,
496
+ ): Promise<IRuntimeNetworkWorkloadIngressPacketGrant[]> =>
497
+ workloadIngressPacketGrantsOf(await validateRuntimeNetworkProjection(value));
498
+
464
499
  /** Complete offline DNS material, including authorized names never previously queried. */
465
500
  /** Select exact local receivers before expansion; all targets still come from
466
501
  * the complete validated projection. Selection supplies no attachment authority. */
@@ -526,6 +561,7 @@ export const admitRuntimeNetworkProjection = async (
526
561
  if (previousInput === null) {
527
562
  requireNetwork(projection.generation === 1 && applied === null && projection.withdrawals.length === 0
528
563
  && (projection.router?.withdrawals.length ?? 0) === 0 && (projection.host?.withdrawals.length ?? 0) === 0
564
+ && (projection.workloadIngress?.withdrawals.length ?? 0) === 0
529
565
  && projection.retiredLeases.length === 0 && projection.retiredHandoffs.length === 0);
530
566
  return { status: 'accepted', projection };
531
567
  }
@@ -567,6 +603,15 @@ export const admitRuntimeNetworkProjection = async (
567
603
  for (const grant of previousHostGrants) requireNetwork(hostGrants.has(networkCanonical(grant)) || hostWithdrawals.has(networkCanonical(grant)));
568
604
  const knownHostWithdrawals = new Set([...previousHostGrants, ...(previous.host?.withdrawals ?? [])].map(networkCanonical));
569
605
  requireNetwork((projection.host?.withdrawals ?? []).every(grant => knownHostWithdrawals.has(networkCanonical(grant))));
606
+ const workloadIngressGrants = new Set(workloadIngressPacketGrantsOf(projection).map(networkCanonical));
607
+ const workloadIngressWithdrawals = new Set((projection.workloadIngress?.withdrawals ?? []).map(networkCanonical));
608
+ const previousWorkloadIngressGrants = workloadIngressPacketGrantsOf(previous);
609
+ for (const grant of previousWorkloadIngressGrants) requireNetwork(workloadIngressGrants.has(networkCanonical(grant))
610
+ || workloadIngressWithdrawals.has(networkCanonical(grant)));
611
+ const knownWorkloadIngressWithdrawals = new Set([...previousWorkloadIngressGrants,
612
+ ...(previous.workloadIngress?.withdrawals ?? [])].map(networkCanonical));
613
+ requireNetwork((projection.workloadIngress?.withdrawals ?? []).every(grant =>
614
+ knownWorkloadIngressWithdrawals.has(networkCanonical(grant))));
570
615
  const knownLeases = [...previous.endpoints.map(endpoint => networkReferenceOf(endpoint.lease)), ...previous.retiredLeases];
571
616
  const knownHandoffs = [...(previous.handoff ? [networkReferenceOf(previous.handoff)] : []), ...previous.retiredHandoffs];
572
617
  requireNetwork(projection.retiredLeases.every(retired => knownLeases.some(known => networkEqual(known, retired)))
@@ -578,6 +623,9 @@ export const admitRuntimeNetworkProjection = async (
578
623
  for (const grant of previous.withdrawals) requireNetwork(withdrawn.has(networkCanonical(grant)));
579
624
  for (const grant of previous.router?.withdrawals ?? []) requireNetwork(routerWithdrawals.has(networkCanonical(grant)));
580
625
  for (const grant of previous.host?.withdrawals ?? []) requireNetwork(hostWithdrawals.has(networkCanonical(grant)));
626
+ for (const grant of previous.workloadIngress?.withdrawals ?? []) {
627
+ requireNetwork(workloadIngressWithdrawals.has(networkCanonical(grant)));
628
+ }
581
629
  requireCarried(previous.retiredLeases, projection.retiredLeases); requireCarried(previous.retiredHandoffs, projection.retiredHandoffs);
582
630
  }
583
631
  for (const old of previous.endpoints) {