@serve.zone/interfaces 31.7.0 → 31.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/changelog.md +77 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/data/clusterruntime.d.ts +39 -0
  4. package/dist_ts/data/clusterruntime.js +11 -0
  5. package/dist_ts/data/index.d.ts +5 -0
  6. package/dist_ts/data/index.js +6 -1
  7. package/dist_ts/data/networkaddressplan.d.ts +78 -0
  8. package/dist_ts/data/networkaddressplan.js +114 -0
  9. package/dist_ts/data/networkauthority.js +5 -17
  10. package/dist_ts/data/networkcredential.d.ts +10 -4
  11. package/dist_ts/data/networkcredential.js +14 -8
  12. package/dist_ts/data/networkdelivery.d.ts +21 -0
  13. package/dist_ts/data/networkdelivery.js +35 -7
  14. package/dist_ts/data/networklease.d.ts +110 -0
  15. package/dist_ts/data/networklease.js +159 -0
  16. package/dist_ts/data/networknode.d.ts +37 -0
  17. package/dist_ts/data/networknode.js +6 -0
  18. package/dist_ts/data/networkprojection.d.ts +10 -5
  19. package/dist_ts/data/networkprojection.js +15 -22
  20. package/dist_ts/data/networksigning.js +5 -4
  21. package/dist_ts/data/runtimeconfig.js +14 -77
  22. package/dist_ts/data/serviceruntime.d.ts +128 -0
  23. package/dist_ts/data/serviceruntime.js +92 -0
  24. package/dist_ts/private/networkcontract.d.ts +20 -0
  25. package/dist_ts/private/networkcontract.js +45 -1
  26. package/dist_ts/private/producercontract.d.ts +11 -0
  27. package/dist_ts/private/producercontract.js +21 -0
  28. package/dist_ts/private/runtimepolicy.d.ts +31 -0
  29. package/dist_ts/private/runtimepolicy.js +90 -0
  30. package/dist_ts/requests/cluster.d.ts +53 -0
  31. package/dist_ts/requests/cluster.js +1 -1
  32. package/dist_ts/requests/network.d.ts +57 -1
  33. package/dist_ts/requests/runtimesession.d.ts +14 -0
  34. package/dist_ts/requests/service.d.ts +60 -0
  35. package/dist_ts/runtime.networkprojection.d.ts +14 -0
  36. package/dist_ts/runtime.networkprojection.js +68 -19
  37. package/package.json +2 -2
  38. package/readme.md +251 -3
  39. package/ts/00_commitinfo_data.ts +1 -1
  40. package/ts/data/clusterruntime.ts +45 -0
  41. package/ts/data/index.ts +5 -0
  42. package/ts/data/networkaddressplan.ts +175 -0
  43. package/ts/data/networkauthority.ts +4 -16
  44. package/ts/data/networkcredential.ts +19 -9
  45. package/ts/data/networkdelivery.ts +49 -6
  46. package/ts/data/networklease.ts +229 -0
  47. package/ts/data/networknode.ts +37 -0
  48. package/ts/data/networkprojection.ts +28 -26
  49. package/ts/data/networksigning.ts +5 -4
  50. package/ts/data/runtimeconfig.ts +17 -76
  51. package/ts/data/serviceruntime.ts +208 -0
  52. package/ts/private/networkcontract.ts +48 -0
  53. package/ts/private/producercontract.ts +19 -0
  54. package/ts/private/runtimepolicy.ts +105 -0
  55. package/ts/requests/cluster.ts +65 -0
  56. package/ts/requests/network.ts +50 -1
  57. package/ts/requests/runtimesession.ts +17 -0
  58. package/ts/requests/service.ts +81 -0
  59. package/ts/runtime.networkprojection.ts +87 -20
package/readme.md CHANGED
@@ -371,7 +371,8 @@ set. An empty array declares NODATA; absent/unauthorized names remain NXDOMAIN,
371
371
  and IPv4-only service names have AAAA NODATA. The view carries the existing
372
372
  forwarding intersection and zero or one search suffix. Pallet must prove the
373
373
  actual sandbox/veth/source binding, block private/bare-name forwarding, clip
374
- TTLs and convert the absolute window using its qualified boot/time authority.
374
+ TTLs and convert the effective window from `getRuntimeNetworkEffectiveDnsWindow` (see
375
+ [DNS Lease Renewals](#dns-lease-renewals)) using its qualified boot/time authority.
375
376
  These helpers neither cache queries nor renew a lease.
376
377
 
377
378
  The DNS composer accepts an optional second argument of exact local workload
@@ -454,10 +455,11 @@ capture closed detached JSON shapes and canonical 32-byte key encodings.
454
455
  `bindGetRuntimeManagedVpnCredentialRequestV1(request, projection, trustedSession)`
455
456
  validates the complete projection digest and binds node, controller, namespace,
456
457
  physical-session identity and projection reference. The response binder takes
457
- `(response, request, projection, trustedSession, now)` and additionally requires
458
+ `(response, request, projection, trustedSession, now, renewal = null)` and additionally requires
458
459
  the relay's exact UDP tuple to be selected in `router.platformEndpointIds`.
459
460
  Expiry must be after the caller-qualified time and no later than the referenced
460
- projection's DNS lease; acceptance before that lease starts rejects.
461
+ projection's effective DNS window (see [DNS Lease Renewals](#dns-lease-renewals));
462
+ acceptance before that window starts rejects.
461
463
 
462
464
  These helpers perform content validation. Callers authenticate and fence the
463
465
  current physical peer and projection before and after native work, qualify time
@@ -466,6 +468,188 @@ Noise implementation owns cryptographic validation and authentication. Credentia
466
468
  delivery does not prove a connected VPN, TUN ownership, packet enforcement or
467
469
  workload readiness, and does not replace explicit packet withdrawals.
468
470
 
471
+ ### DNS Lease Renewals
472
+
473
+ A projection's `dns` window lasts at most fifteen minutes and is signed content, so a node's
474
+ private DNS stops when the window ends unless a newer signed statement extends it. Re-issuing the
475
+ projection would extend it too, but a node applies every new projection as new network state,
476
+ which restarts its data plane. `IRuntimeNetworkDnsLeaseRenewal` extends the window and nothing
477
+ else: it carries the node scope (`controller`, `nodeId`, `runtimeNamespace`), the exact
478
+ `projection` reference it renews, a `sequence`, a fresh `dns { issuedAt, notBefore, expiresAt }`
479
+ window and its `digest`. It names the projection by reference, so it never joins the projection
480
+ chain and never changes what the node applied. The new shapes carry no `schemaVersion` and no
481
+ version suffix; the installed package version is the protocol version.
482
+
483
+ `issuedAt` is the start of the window's slot, not the moment of signing: Cloudly signs renewals
484
+ ahead of time on a grid, so the fifteen-minute rule (`maximumDnsLeaseMs`, the same limit a
485
+ projection's window has) is measured from the slot. `computeRuntimeNetworkDnsLeaseRenewalDigest`
486
+ hashes every field except `digest` under its own domain, and `validateRuntimeNetworkDnsLeaseRenewal`
487
+ checks structure and digest only; a digest authenticates nothing.
488
+
489
+ The Node-only `@serve.zone/interfaces/runtime` export signs and verifies renewals with the same
490
+ authority and key that sign the controller's projections, under a separate signature domain.
491
+ `signRuntimeNetworkDnsLeaseRenewal(renewal, authority, privateKey)` produces
492
+ `ISignedRuntimeNetworkDnsLeaseRenewal { authority, renewal, signature }`.
493
+ `verifyRuntimeNetworkDnsLeaseRenewal(signed, trustedAuthority, trustedScope, projectionAuthority)`
494
+ takes the authority and scope from the node's own stored trust, never from the envelope.
495
+ `projectionAuthority` is the reference of the authority revision that signed the projection the
496
+ node admitted, and a renewal verifies only when it was signed under exactly that revision.
497
+ Rotation and revocation end renewals exactly as they end projections: nothing signed under an
498
+ earlier revision verifies after, and a renewal signed under a rotated revision does not renew a
499
+ projection signed under the one before. A renewal never closes a key gap; after rotation, DNS
500
+ continues only with a newly signed projection.
501
+
502
+ `admitRuntimeNetworkDnsLeaseRenewal(renewal, currentProjection, previousRenewal | null, trustedScope, now)`
503
+ is the pure admission decision after verification. The projection and the previous renewal are
504
+ the node's own admitted state, and `now` is its caller-qualified clock, read before the first
505
+ await like every other input. It admits a renewal only when:
506
+
507
+ - the renewal and the projection are in the trusted scope, and the renewal names the current
508
+ projection's exact reference. Once a successor projection is admitted, every renewal of its
509
+ predecessor is refused, and a renewal never introduces a projection the node has not admitted;
510
+ - its `sequence` is higher than the previous renewal's, with gaps allowed, or equal with
511
+ byte-identical content, which is answered as `replay` whatever `now` is, so re-delivery is
512
+ idempotent. The same sequence with any other content is refused;
513
+ - its window is current: `notBefore <= now < expiresAt`. Admitting a window that has not opened
514
+ would displace the coverage the node has now, so a sender delivers a window only once its slot
515
+ has opened; a renewal refused for arriving early is simply delivered again once it has. An
516
+ expired window is refused as well;
517
+ - its window does not move `issuedAt` back and ends later than the previous window. Without a
518
+ previous renewal, the projection's own window is the baseline. Windows need not touch, so a node
519
+ whose lease lapsed can still admit the next renewal it is given.
520
+
521
+ The caller stores the admitted renewal in the same transaction as its trust fence, and deletes it
522
+ in the transaction that admits a successor projection. Admission is not conversion: the node still
523
+ turns the window into a deadline with its qualified clock, and must not use a window before its
524
+ `notBefore`.
525
+
526
+ `requests.runtimesession.IReq_Controller_Pallet_ApplyRuntimeNetworkDnsLeaseRenewal`
527
+ (`applyRuntimeNetworkDnsLeaseRenewal`) delivers one renewal as
528
+ `IApplyRuntimeNetworkDnsLeaseRenewalRequest { session, signed }`.
529
+ `bindApplyRuntimeNetworkDnsLeaseRenewalRequest(request, trustedSession)` requires the node's
530
+ current physical-session binding and a renewal in that session's scope. It does not care who sent
531
+ the request: a cluster relay pushes under the binding the node obtained itself. The answer,
532
+ `IApplyRuntimeNetworkDnsLeaseRenewalResponse { status, renewal }`, repeats
533
+ `IRuntimeNetworkDnsLeaseRenewalReference { projection, sequence, digest }`.
534
+ `bindApplyRuntimeNetworkDnsLeaseRenewalResponse(response, sentRenewal)` compares it with the exact
535
+ renewal that was sent. The ACK asserts durable admission only, not that the node serves DNS.
536
+
537
+ A cluster is outbound-only, so while its relay cannot reach Cloudly nobody can sign a new window.
538
+ Cloudly therefore hands the elected relay a run of renewals it signed ahead of time, with
539
+ `requests.cluster.IReq_Cloudly_Relay_HoldRuntimeNetworkDnsLeaseRenewals`
540
+ (`holdRuntimeNetworkDnsLeaseRenewals`):
541
+ `IHoldRuntimeNetworkDnsLeaseRenewalsRequest { nodeId, projection, renewals }` answered by
542
+ `{ held }`. Each request replaces what the relay held for that node, and an empty run holds
543
+ nothing. The relay keeps the run in memory only, pushes only the renewal whose window is current
544
+ and only while it has no Cloudly session (`escrowRelease`), and holds no key and no trust.
545
+ `snapshotHoldRuntimeNetworkDnsLeaseRenewalsRequest` checks the rules a relay can evaluate without
546
+ the projection itself; the node still measures the first window against its admitted projection,
547
+ and every window against its clock, when it admits it. The run must have:
548
+
549
+ - one node, one projection and one signing authority revision, in one scope;
550
+ - strictly ascending sequences, each window extending the one before;
551
+ - at most `maximumEscrowedRenewals` (32) renewals;
552
+ - at most `maximumEscrowHorizonMs` (four hours) from the first window's `issuedAt` to the last
553
+ window's `expiresAt`. On the intended grid of fifteen-minute windows every ten minutes, the
554
+ horizon allows 23 windows, so it is the limit that binds.
555
+
556
+ `validateHoldRuntimeNetworkDnsLeaseRenewalsRequest` adds every renewal's digest check and
557
+ verifies no signature. `bindHoldRuntimeNetworkDnsLeaseRenewalsResponse(response, sentRequest)`
558
+ requires `held` to equal the number of renewals sent.
559
+
560
+ The horizon is also the revocation latency: a detached cluster keeps DNS authority until its last
561
+ held window ends. So a controller must not release an address or a DNS name before the latest
562
+ `expiresAt` it has signed for that node. Renewals extend DNS only. They grant no packet authority
563
+ and cannot introduce new network state, and they do not extend an already-issued VPN credential;
564
+ a newly issued one may be bound to the renewed window.
565
+
566
+ `getRuntimeNetworkEffectiveDnsWindow(projection, renewal | null)` is the one rule for the window a
567
+ node may use: the admitted renewal's if there is one, else the projection's own. `renewal` is the
568
+ bare `IRuntimeNetworkDnsLeaseRenewal` that admission takes and returns, which is what a node
569
+ stores; Cloudly passes `signed.renewal`. It checks digests and that the renewal names exactly that
570
+ projection, but verifies no signature. Controller and node use it for every limit tied to the
571
+ window, and `bindGetRuntimeManagedVpnCredentialResponseV1` takes the same bare admitted renewal as
572
+ an optional last argument, so a newly issued managed-VPN credential may run to the end of the
573
+ renewed window instead of the projection's original one.
574
+
575
+ ```typescript
576
+ import { data } from '@serve.zone/interfaces';
577
+ import { signRuntimeNetworkDnsLeaseRenewal, verifyRuntimeNetworkDnsLeaseRenewal } from '@serve.zone/interfaces/runtime';
578
+
579
+ const renewal: data.IRuntimeNetworkDnsLeaseRenewal = {
580
+ ...scope,
581
+ projection: { id: projection.id, generation: projection.generation, digest: projection.digest },
582
+ sequence: 1,
583
+ dns: { issuedAt: slotStart, notBefore: slotStart, expiresAt: slotStart + 900_000 },
584
+ digest: `sha256:${'0'.repeat(64)}` as data.TSha256Digest, // well-formed placeholder, replaced below
585
+ };
586
+ renewal.digest = await data.computeRuntimeNetworkDnsLeaseRenewalDigest(renewal);
587
+ const signed = await signRuntimeNetworkDnsLeaseRenewal(renewal, signingAuthority, privateKey);
588
+
589
+ // On the node: stored trust, then the node's own admitted state and qualified clock.
590
+ const verified = await verifyRuntimeNetworkDnsLeaseRenewal(
591
+ signed, storedAuthority, nodeScope, admittedProjectionAuthority);
592
+ const { renewal: admittedRenewal } = await data.admitRuntimeNetworkDnsLeaseRenewal(
593
+ verified.renewal, admittedProjection, previousRenewal, nodeScope, qualifiedNow);
594
+ const window = await data.getRuntimeNetworkEffectiveDnsWindow(admittedProjection, admittedRenewal);
595
+ ```
596
+
597
+ ### Runtime Network Address Plan
598
+
599
+ `data.IRuntimeNetworkAddressPlan` is the address space an administrator gives the runtime network,
600
+ and the single input Cloudly composes the protected authority from:
601
+ `{ id: 'address-plan', revision, prefixes, pools, resolvers, platformEndpoints, vpn: { controlPrefix, relay } }`.
602
+ Pools, resolvers and platform endpoints use the protected authority's own element shapes, and the
603
+ relay uses the managed-VPN credential's `{ id, address, port }`. `platformEndpoints` never lists the
604
+ relay: the composed authority adds it, in id order, as the UDP platform endpoint every node dials.
605
+
606
+ `snapshotRuntimeNetworkAddressPlan` refuses any plan that could describe an authority the authority
607
+ contract refuses. It runs `snapshotRuntimeNetworkProtectedAuthorityV1` twice, once over the plan's
608
+ own sets and once with the relay added, so overlapping pools, more than 16 resolvers, a resolver or
609
+ endpoint outside `prefixes` or inside a pool, and a relay that `prefixes` do not cover are all
610
+ refused by that one validator. On top it requires the VPN `controlPrefix` to be a private prefix no
611
+ longer than /30 (`runtimeNetworkAddressPlanContract.maximumControlPrefixLength`) inside `prefixes`
612
+ that overlaps no pool and holds no resolver or platform endpoint, the relay included. No minimum
613
+ length is stated: the private-range rule already keeps it at /8 or longer.
614
+ `composeRuntimeNetworkProtectedAuthority(plan, frame)` is the composition itself: the producer
615
+ supplies the frame (`TRuntimeNetworkProtectedAuthorityFrame`: authority id, generation, controller,
616
+ previous reference and egress authorities) and receives the complete authority with its digest, so
617
+ the validator and the protection producer can never compose differently.
618
+
619
+ `admitRuntimeNetworkAddressPlanChange(previous | null, next)` is the pure decision for a plan write at
620
+ the following revision; any other revision is a contract violation. Leases and the managed VPN
621
+ outlive a plan revision, so it answers:
622
+
623
+ - `unchanged` when the content equals the stored plan; Cloudly writes nothing;
624
+ - `refused` with `plan-vpn-immutable` when the VPN differs, `plan-pool-removed` when any pool is gone
625
+ or changed its purpose or prefix, and `plan-prefix-shrunk` when the new prefixes, however split,
626
+ no longer cover every address the old ones did;
627
+ - `accepted` with the ids of the resolvers and platform endpoints the change removes or changes. An
628
+ entry that keeps its id but differs in any field counts, so moving it goes through the same
629
+ reference check as removing it.
630
+
631
+ `TRuntimeNetworkAddressPlanRefusal` adds `plan-resolver-referenced` and `plan-endpoint-referenced`,
632
+ which only Cloudly can decide against live references, so every plan refusal has one type.
633
+ `requests.network` carries `setRuntimeNetworkAddressPlan { identity, expectedRevision, plan }`
634
+ (`expectedRevision: null` when no plan exists yet) and `getRuntimeNetworkAddressPlan`, which answers
635
+ `null` until the first plan is set.
636
+
637
+ ### Node Network Readiness
638
+
639
+ `requests.network.getRuntimeNetworkNodeReadiness { identity, nodeId }` answers
640
+ `data.IRuntimeNetworkNodeReadiness`: `ready: true` with nothing missing, or `ready: false` with at
641
+ least one `TRuntimeNetworkNodeReadinessGap`. The gaps follow the node producer's order: the node
642
+ joins the protected authority's egress (`egress-pending`), every egress owner reports current
643
+ protection (`protection-receipts-pending`, naming those `owners`, at least one), a handoff is reserved
644
+ (`handoff-pending`), the router selection carries it (`selection-pending`), the node's acknowledged
645
+ projection carries it (`projection-pending`), and the node is a managed VPN member
646
+ (`vpn-member-pending`).
647
+
648
+ `data.IRuntimeRouterIncarnation { id, incarnation }` is a node's router incarnation, from which its
649
+ handoff and router incarnation ids derive; `snapshotRuntimeRouterIncarnation` checks the record.
650
+ `reincarnateRuntimeNetworkRouter { identity, nodeId, expectedIncarnation }` advances it, so the node
651
+ producer reserves the router a fresh handoff, and answers with the new incarnation.
652
+
469
653
  ### Node-Bound Runtime Assignments
470
654
 
471
655
  The `data` namespace exports `IRuntimeAssignmentV1`, canonical digest helpers,
@@ -599,6 +783,70 @@ only the named Pallet attempt. They do not authorize storage relocation, prove
599
783
  whole-host or Docker/Coreflow absence, grant another controller or organization
600
784
  authority, or replace independently qualified readiness and routing evidence.
601
785
 
786
+ ### Cluster Runtime Phase
787
+
788
+ `data.IClusterRuntime { id, phase, generation, changedAt, changedBy }` records which runtime owns a
789
+ cluster's workloads: `TClusterRuntimePhase` is `swarm`, `draining`, `switching` or `pallet`. It is
790
+ stored beside the cluster document, because a cluster update merges caller data and no caller may
791
+ move a phase with it; `generation` is its compare-and-swap counter. `snapshotClusterRuntime` checks
792
+ the record. Which phase may follow which belongs to the cutover, whose requests ship with it.
793
+
794
+ `requests.cluster.getClusterRuntime { identity, clusterId }` answers `{ runtime, blockers }`. Each
795
+ `TClusterRuntimeBlocker` names what it is about: cluster-wide blockers (`relay-not-enabled`,
796
+ `relay-not-registered`, `address-plan-missing`, `vpn-unconfigured`) name nothing, node blockers
797
+ (`node-without-pallet-credential`, `node-arch-unsupported`) name the `nodeId`, and service blockers
798
+ (`service-multi-cluster-scope`, `service-authority-unsupported`, `platform-binding-present`) name
799
+ the `serviceId`.
800
+
801
+ ### Service Runtime Specs And Status
802
+
803
+ `data.IServiceRuntimeSpec` is what Cloudly runs for one service on a Pallet cluster:
804
+ `{ id, organizationId, clusterId, placement, replicasPerNode, hold, profile, network, revision }`.
805
+ It is stored complete and beside the service document, so a hold written by a cutover never races
806
+ an operator's service edit.
807
+
808
+ - `placement` is `{ kind: 'every-node' }` or `{ kind: 'nodes', nodeIds }`, with `nodeIds` sorted,
809
+ unique, never empty and at most `maximumPinnedNodes`, the protected authority's egress limit.
810
+ - `replicasPerNode` runs from 1 to 64; Cloudly refuses a service desiring more than `maximumReplicas` (64) as `replica-limit`.
811
+ - `profile { cpuMillis, memoryBytes, readiness, logs, readonlyRootfs, runAs }` is the runtime config's
812
+ execution policy ahead of any image: the same readiness and log shapes, and the same CPU, memory
813
+ (at least 16 MiB), identity, log, readiness timing and HTTP request rules, which both validators
814
+ share. `runAs: null` runs the image's own OCI user. Only the service's target ports decide whether
815
+ a readiness probe's target exists: the runtime config requires the service to declare it, and the
816
+ producer refuses a service that does not as `readiness-target-undeclared`.
817
+ - `network` is `{ mode: 'isolated' }` or `{ mode: 'attached', publicEgress, platformEndpointIds }`
818
+ with sorted unique endpoint ids.
819
+
820
+ `snapshotServiceRuntimeSpec` checks all of that; whether the cluster, the nodes and the endpoints
821
+ exist is Cloudly's. `requests.service.setServiceRuntimeSpec { identity, serviceId, expectedRevision, spec }`
822
+ writes `TServiceRuntimeSpecWritableData`, and Cloudly copies the organization from the service.
823
+ `getServiceRuntimeSpec` answers `{ spec, status }`: both `null` before the first spec, and `status`
824
+ `null` until the producer first decides the spec. A status never comes without its spec.
825
+
826
+ `data.IServiceRuntimeStatus { id, specRevision, state, reason, changedAt }` is the producer's verdict
827
+ on one spec revision, and each reason belongs to exactly one state. `converged` and `converging`
828
+ carry `reason: null`. `waiting` carries a `TServiceRuntimeWaitingReason`: `node-network-pending`,
829
+ `conflict` or `failure-backoff`. `refused` carries a `TServiceRuntimeRefusal`:
830
+ `cluster-runtime-missing`, `organization-missing`, `image-plan-missing`, `platform-unsupported`,
831
+ `secrets-unsupported`, `volumes-unsupported`, `port-protocol-unsupported`,
832
+ `readiness-target-undeclared`, `environment-too-large`, `no-eligible-node` or `replica-limit`. A
833
+ refusal never retires running slots. `snapshotServiceRuntimeStatus` enforces the pairing.
834
+
835
+ ### Runtime Assignment Views
836
+
837
+ `data.IServiceRuntimeAssignmentView` is one replica slot's current assignment as an administrator
838
+ reads it: `serviceId`, `replicaId`, `nodeId`, the current `assignment` reference, its `disposition`,
839
+ the latest accepted `observation { phase, ready, observedAt, sequence }` (which may cite an earlier
840
+ revision), `terminalReceiptCount` (0 to 2) and `delivery`. `delivered` means the node's own evidence,
841
+ an observation or a terminal receipt, cites the current revision; until then it is `undelivered`.
842
+
843
+ `requests.service.getServiceRuntimeAssignments { identity, serviceId }` answers every slot of one
844
+ service. `requests.cluster.getClusterRuntimeAssignments { identity, clusterId, cursor, limit }` pages
845
+ through every slot on a cluster's nodes; `limit` runs from 1 to
846
+ `serviceRuntimeContract.maximumAssignmentPageSize` (256), and Cloudly refuses any other.
847
+ `requests.service.pushServiceRuntimeChanged { serviceId }` tells admin UIs that a service's spec,
848
+ status or assignments changed; it carries no state, so a late push never overwrites a newer read.
849
+
602
850
  ### Offline Database Settlement And Packaging
603
851
 
604
852
  The Node-only `@serve.zone/interfaces/runtime` export includes isolated V1
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '31.7.0',
6
+ version: '31.9.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -0,0 +1,45 @@
1
+ import { captureNetwork, networkCounter, networkId, networkRecord, requireNetwork } from '../private/networkcontract.js';
2
+ import { isOneOf, producerSafe } from '../private/producercontract.js';
3
+
4
+ const clusterRuntimePhases = ['swarm', 'draining', 'switching', 'pallet'] as const;
5
+
6
+ /**
7
+ * Which runtime owns a cluster's workloads.
8
+ *
9
+ * `swarm` is Coreflow's; `draining` stops every service while its volumes stay; `switching` lets the
10
+ * cluster's nodes join the runtime network while nothing runs; `pallet` is Pallet's alone.
11
+ */
12
+ export type TClusterRuntimePhase = typeof clusterRuntimePhases[number];
13
+
14
+ /**
15
+ * The runtime phase of one cluster, stored beside the cluster document rather than inside it: a
16
+ * cluster update merges caller data, and no caller may move a phase with it.
17
+ */
18
+ export interface IClusterRuntime {
19
+ /** The cluster id. */
20
+ id: string;
21
+ phase: TClusterRuntimePhase;
22
+ /** Compare-and-swap counter; every phase write advances it by one. */
23
+ generation: number;
24
+ /** Unix milliseconds on Cloudly's clock. */
25
+ changedAt: number;
26
+ /** The administrator or producer that made the change. */
27
+ changedBy: string;
28
+ }
29
+
30
+ /**
31
+ * Why a cluster cannot leave `swarm` yet. Each blocker names the node or service it is about, so an
32
+ * administrator can act on it without a second query.
33
+ */
34
+ export type TClusterRuntimeBlocker =
35
+ | { code: 'relay-not-enabled' | 'relay-not-registered' | 'address-plan-missing' | 'vpn-unconfigured' }
36
+ | { code: 'node-without-pallet-credential' | 'node-arch-unsupported'; nodeId: string }
37
+ | { code: 'service-multi-cluster-scope' | 'service-authority-unsupported' | 'platform-binding-present'; serviceId: string };
38
+
39
+ /** Structural snapshot for exact persistence; which phase may follow which is the cutover's decision. */
40
+ export const snapshotClusterRuntime = (value: unknown): IClusterRuntime => producerSafe(() => {
41
+ const item = networkRecord(captureNetwork(value), ['id', 'phase', 'generation', 'changedAt', 'changedBy']);
42
+ requireNetwork(isOneOf(clusterRuntimePhases, item.phase));
43
+ return { id: networkId(item.id), phase: item.phase, generation: networkCounter(item.generation),
44
+ changedAt: networkCounter(item.changedAt), changedBy: networkId(item.changedBy) };
45
+ });
package/ts/data/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './cloudlyconfig.js';
2
2
  export * from './cluster.js';
3
+ export * from './clusterruntime.js';
3
4
  export * from './clusteringress.js';
4
5
  export * from './config.js';
5
6
  export * from './corestore.js';
@@ -24,9 +25,12 @@ export * from './isolatedrestore.golden.js';
24
25
  export * from './mail.js';
25
26
  export * from './nodecredential.js';
26
27
  export * from './nodeenrollment.js';
28
+ export * from './networkaddressplan.js';
27
29
  export * from './networkauthority.js';
28
30
  export * from './networkcredential.js';
29
31
  export * from './networkdelivery.js';
32
+ export * from './networklease.js';
33
+ export * from './networknode.js';
30
34
  export * from './networkprojection.js';
31
35
  export type { IRuntimeNetworkRouterPacketGrantV1, IRuntimeNetworkRouterProjectionV1 } from './networkrouter.js';
32
36
  export * from './networkprotection.js';
@@ -47,6 +51,7 @@ export * from './sparknodereport.js';
47
51
  export * from './service.js';
48
52
  export * from './servicecredential.js';
49
53
  export * from './serviceports.js';
54
+ export * from './serviceruntime.js';
50
55
  export * from './status.js';
51
56
  export * from './taskexecution.js';
52
57
  export * from './traffic.js';
@@ -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]) {