@serve.zone/interfaces 32.16.0 → 32.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +60 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +50 -7
- package/dist_ts/data/cluster.js +72 -11
- package/dist_ts/data/clusternode.d.ts +24 -1
- package/dist_ts/data/clusternode.js +19 -1
- package/dist_ts/data/clusterrelaycredential.d.ts +141 -0
- package/dist_ts/data/clusterrelaycredential.js +192 -0
- package/dist_ts/data/index.d.ts +3 -0
- package/dist_ts/data/index.js +4 -1
- package/dist_ts/{private → data}/releaseversion.js +1 -1
- package/dist_ts/data/sparklegacyretirement.d.ts +186 -0
- package/dist_ts/data/sparklegacyretirement.js +244 -0
- package/dist_ts/data/sparknodereport.js +2 -2
- package/dist_ts/protocol.js +2 -2
- package/dist_ts/requests/cluster.d.ts +54 -0
- package/dist_ts/requests/cluster.js +1 -1
- package/package.json +1 -1
- package/readme.md +178 -7
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +147 -17
- package/ts/data/clusternode.ts +50 -1
- package/ts/data/clusterrelaycredential.ts +361 -0
- package/ts/data/index.ts +3 -0
- package/ts/data/sparklegacyretirement.ts +436 -0
- package/ts/data/sparknodereport.ts +1 -1
- package/ts/protocol.ts +1 -1
- package/ts/requests/cluster.ts +69 -0
- /package/dist_ts/{private → data}/releaseversion.d.ts +0 -0
- /package/ts/{private → data}/releaseversion.ts +0 -0
package/readme.md
CHANGED
|
@@ -1198,6 +1198,10 @@ source is identified by SHA-256 and may be `retained-inert` or `removed`; deleti
|
|
|
1198
1198
|
erasure are not cutover requirements. Its exact owner generation is retained with that disposition.
|
|
1199
1199
|
Retained recovery evidence is valid only while the released
|
|
1200
1200
|
owner cannot consume it into live mode and the old credential remains revoked.
|
|
1201
|
+
`relay.credential`, `relay.acceptedRegistration`, `legacy.credential` and `legacy.invalidation`
|
|
1202
|
+
reference the records of [Cluster Relay Credential](#cluster-relay-credential). The old-mode
|
|
1203
|
+
refusal, the configuration CAS and the Spark bundle and unit identity are Spark-owned and come
|
|
1204
|
+
from [Spark Legacy Runtime Retirement](#spark-legacy-runtime-retirement).
|
|
1201
1205
|
|
|
1202
1206
|
Before any post-pallet assignment exists, an administrator calls
|
|
1203
1207
|
`requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
|
|
@@ -1462,6 +1466,11 @@ untouched.
|
|
|
1462
1466
|
|
|
1463
1467
|
Versions are `MAJOR.MINOR.PATCH` and nothing else — no prerelease, no build metadata, no `v`
|
|
1464
1468
|
prefix, no leading zeros — and are compared per numeric component, so `32.10.0` is above `32.9.0`.
|
|
1469
|
+
That grammar is public as `data.IReleaseVersion`, `data.parseReleaseVersion`,
|
|
1470
|
+
`data.isReleaseVersion`, `data.compareReleaseVersions` and `data.maximumReleaseVersionLength`: any
|
|
1471
|
+
policy that has to order two released versions — a minimum bundle a server believes, a supported
|
|
1472
|
+
floor — reads them here instead of splitting strings of its own, which is how a comparison ends up
|
|
1473
|
+
saying `32.9.0` is above `32.10.0`.
|
|
1465
1474
|
`minimumPeerVersion` is in the same major as `interfacesVersion` and never above it. Each
|
|
1466
1475
|
deployable owns its minimums, one per session kind it serves, and raises one only in the commit
|
|
1467
1476
|
that starts depending on a later interfaces minor, for that session kind alone.
|
|
@@ -1553,7 +1562,12 @@ Coreflow connection:
|
|
|
1553
1562
|
exact next sequence with a strictly advancing `observedAt`, while an exact
|
|
1554
1563
|
same-sequence/same-digest retry returns the byte-equivalent persisted receipt
|
|
1555
1564
|
before age checks. It must reject conflicting, stale, skipped,
|
|
1556
|
-
retired-session, old, future, or non-advancing reports.
|
|
1565
|
+
retired-session, old, future, or non-advancing reports. `evidence-refused` is the
|
|
1566
|
+
one rejection about content rather than sequencing: it names an evidence member
|
|
1567
|
+
this server refuses for this body and every resend of it, so the node must change
|
|
1568
|
+
the body instead of retrying it, while a server-side condition a later attempt
|
|
1569
|
+
could pass stays `acceptance-race` — telling those two apart while it accepts
|
|
1570
|
+
evidence is the consumer's own work. A reused session ID
|
|
1557
1571
|
may be treated as new only after it leaves the bounded retired window, and its
|
|
1558
1572
|
sequence-one observation must still advance the permanent timestamp
|
|
1559
1573
|
high-water mark. The consumer must persist acceptance state and its receipt
|
|
@@ -2027,12 +2041,13 @@ with `clusterRelayCertificateDomainName(relay)`, which returns the origin's host
|
|
|
2027
2041
|
Cloudly issues the certificate for and owns the A record for. An origin naming an address literal
|
|
2028
2042
|
therefore has no issuable certificate.
|
|
2029
2043
|
|
|
2030
|
-
`data.clusterRelayUndispatchedRefusals` names the
|
|
2031
|
-
custody — `relay-cluster-not-enabled`, `relay-node-foreign
|
|
2032
|
-
with `data.TClusterRelayUndispatchedRefusal` as their union.
|
|
2033
|
-
records and stating that it does not dispatch to this relay, so a
|
|
2034
|
-
it holds for its nodes instead of waiting. A refusal that only
|
|
2035
|
-
relay states nothing about dispatch and is deliberately not in
|
|
2044
|
+
`data.clusterRelayUndispatchedRefusals` names the four registration refusals that end a relay's
|
|
2045
|
+
custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-sequence-not-advancing` and
|
|
2046
|
+
`relay-credential-generation-stale` — with `data.TClusterRelayUndispatchedRefusal` as their union.
|
|
2047
|
+
Each is Cloudly reading its own records and stating that it does not dispatch to this relay, so a
|
|
2048
|
+
relay that hears one drops what it holds for its nodes instead of waiting. A refusal that only
|
|
2049
|
+
says Cloudly could not verify the relay states nothing about dispatch and is deliberately not in
|
|
2050
|
+
the list.
|
|
2036
2051
|
|
|
2037
2052
|
```typescript
|
|
2038
2053
|
const relay: data.IClusterRelay = {
|
|
@@ -2109,6 +2124,162 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
|
|
|
2109
2124
|
How the relay reaches each node's Corestore is part of the same document: see
|
|
2110
2125
|
[Corestore Through The Relay](#corestore-through-the-relay).
|
|
2111
2126
|
|
|
2127
|
+
### Cluster Relay Credential
|
|
2128
|
+
|
|
2129
|
+
A cluster's relay authenticates with its own generation-bearing credential instead of the shared
|
|
2130
|
+
cluster authority every cluster-side component used before it. `IClusterRelayCredential` is public
|
|
2131
|
+
metadata: the cluster, a `generation` that advances by one per accepted mint or rotation, the
|
|
2132
|
+
machine `userId` it authenticates as, and `delivery`, the `ISecretVersionReference` of the
|
|
2133
|
+
immutable encrypted version the bearer was delivered as. No record, receipt, request or response
|
|
2134
|
+
of this contract carries a bearer, a hash or key material, and every reader is exact-keyed, so a
|
|
2135
|
+
peer that adds a secret-looking member is refused rather than copied.
|
|
2136
|
+
|
|
2137
|
+
The credential authenticates the cluster's whole control-plane session, exactly what the shared
|
|
2138
|
+
authority authenticated, so a relay changes one credential and keeps every call it makes. The gain
|
|
2139
|
+
is a generation Cloudly fences each socket against and an authority it can rotate and retire on
|
|
2140
|
+
its own; the scope is not narrower. One cluster has one relay and therefore one credential record,
|
|
2141
|
+
and it never expires by itself: rotation is its lifecycle.
|
|
2142
|
+
|
|
2143
|
+
`mutateClusterRelayCredential { identity, mutation }` takes `TClusterRelayCredentialMutation`.
|
|
2144
|
+
`ensure` requires `expectedGeneration: null` and an absent record; `rotate` requires the exact
|
|
2145
|
+
current generation, never a minimum. The caller retains the complete request and its `mutationId`
|
|
2146
|
+
across retries: the identical body answers with the original result and `replayed: true` without
|
|
2147
|
+
renewing delivery, generation or authority, and a changed body fails closed. The first mint also
|
|
2148
|
+
records the shared authority the cluster still carries as `IClusterLegacyRelayCredential`, so a
|
|
2149
|
+
replacement never exists without a named predecessor. Rotation has no overlap: the previous
|
|
2150
|
+
generation stops authenticating at commit, and a relay still holding it hears
|
|
2151
|
+
`relay-credential-generation-stale`, drops what it holds for its nodes, reads the delivered version
|
|
2152
|
+
and reconnects. There is no revocation of the relay credential, because a rotation already covers
|
|
2153
|
+
a compromised one without a state in which the cluster has no relay authority at all.
|
|
2154
|
+
|
|
2155
|
+
An accepted `registerClusterRelay` answers with two references beside the origin and the offer:
|
|
2156
|
+
`credential`, the generation Cloudly authenticated the socket as, and `acceptedRegistration`, the
|
|
2157
|
+
record that acceptance wrote. The relay states no generation of its own — the registration request
|
|
2158
|
+
is unchanged — because a relay naming its own generation would be asserting its own rank.
|
|
2159
|
+
`IClusterRelayAcceptedRegistration` is that record: the registration facts the served read shape
|
|
2160
|
+
also carries, its own `id`, a `generation` that advances once per acceptance, the `credential`
|
|
2161
|
+
reference and a `digest`. Liveness is not part of it. `IClusterRelayRegistration` keeps exactly its
|
|
2162
|
+
published members, the facts plus `live`.
|
|
2163
|
+
|
|
2164
|
+
`retireLegacyClusterRelayCredential { identity, retirement }` revokes the shared authority, and is
|
|
2165
|
+
the last step of replacing it. `IClusterLegacyRelayCredentialRetirementRequest` carries
|
|
2166
|
+
compare-and-swap inputs only: the exact `expectedLegacyGeneration`, the `replacement` credential
|
|
2167
|
+
reference and the `acceptedRegistration` reference the caller observed. Cloudly dereferences both
|
|
2168
|
+
against its own current records, requires the registration to have been made under that
|
|
2169
|
+
replacement, and requires a verified live socket carrying it; a caller can state no liveness or
|
|
2170
|
+
proof of its own. Acceptance writes the one-shot `IClusterLegacyRelayCredentialInvalidation`
|
|
2171
|
+
(`generation: 1`) and advances the legacy record to `state: 'revoked'` naming it. The invalidation
|
|
2172
|
+
names the legacy record at the generation before the revocation and the record names the
|
|
2173
|
+
invalidation after it, so neither digest covers the other's.
|
|
2174
|
+
`data.clusterRelayCredentialRefusals` names every other answer, with
|
|
2175
|
+
`data.TClusterRelayCredentialRefusal` as their union. `getClusterRelayCredential { identity,
|
|
2176
|
+
clusterId }` reads the chain: the credential, the legacy record and the current accepted
|
|
2177
|
+
registration, each `null` while no record exists.
|
|
2178
|
+
|
|
2179
|
+
Each digest-bearing record has a `snapshot…`, `compute…Digest` and `validate…` function under its
|
|
2180
|
+
own digest domain in `data.clusterRelayCredentialContract` and `data.clusterRelayContract`.
|
|
2181
|
+
`bindClusterLegacyRelayCredentialInvalidation(invalidation, legacyCredential,
|
|
2182
|
+
acceptedRegistration)` joins an invalidation to the legacy record it revoked — the active record
|
|
2183
|
+
it names or that record's revoked successor — and to a registration whose `credential` equals the
|
|
2184
|
+
invalidation's `replacement`. It is a content check; dereferencing each record as current and
|
|
2185
|
+
requiring the live socket remain Cloudly's.
|
|
2186
|
+
|
|
2187
|
+
All three requests read only the verified `IIdentityCredential`, never an identity member a caller
|
|
2188
|
+
states about itself.
|
|
2189
|
+
|
|
2190
|
+
### Spark Legacy Runtime Retirement
|
|
2191
|
+
|
|
2192
|
+
The Spark-owned half of `IFleetCutoverCoreflowRetirementEvidence` — the refused old mode, the
|
|
2193
|
+
configuration compare-and-swap, and the bundle and unit identity — is transported on the Swarm
|
|
2194
|
+
observation a node already posts. There is no route, request or endpoint for it. That is forced
|
|
2195
|
+
rather than preferred: the evidence requires `relay.nodeObservation` and `spark.observation` to be
|
|
2196
|
+
the identical accepted reference, so these facts must reach Cloudly on the very observation that
|
|
2197
|
+
carries the node's Docker census.
|
|
2198
|
+
|
|
2199
|
+
`data.ISparkLegacyRuntimeRetirement` is the optional observation member, available in every
|
|
2200
|
+
observation state because it describes the host and not its Docker daemon. Accepting it is
|
|
2201
|
+
state-independent; completing the evidence is not. `IFleetCutoverCoreflowRetirementEvidence`
|
|
2202
|
+
completes only from a `member` observation of the relay host, because `relay.nodeObservation` and
|
|
2203
|
+
`spark.observation` must be the identical accepted reference and that reference has to carry the
|
|
2204
|
+
relay host's local Docker snapshot, which no other observation state can state. It is
|
|
2205
|
+
`{ refusedMode, bundle, configuration }` and nothing else. It names no node: Cloudly binds it to
|
|
2206
|
+
the reporter it already authenticated, so there is no node identity on it to disagree with. It
|
|
2207
|
+
carries no digest of its own either — `computeSparkSwarmObservationDigest` canonicalizes the whole
|
|
2208
|
+
observation, so a second domain here would only be a second thing to keep in step.
|
|
2209
|
+
|
|
2210
|
+
`refusedMode` is one of `data.sparkLegacyRuntimeModes`, the two runtimes a released Spark could be
|
|
2211
|
+
put into by its `mode` configuration key. `data.sparkLegacyRuntimeModeServices` is the record over
|
|
2212
|
+
that union naming the Docker services each mode owns, so a refusal states a mode and a reader
|
|
2213
|
+
derives the set. `data.ISparkNodeBundleIdentity { version, sourceCommit, manifestSha256, unitName,
|
|
2214
|
+
unitDigest }` is read back from the installed tree and the installed unit file, never from the
|
|
2215
|
+
running process's own build constants: a build can state its own version, it cannot state that the
|
|
2216
|
+
host will start that same code again. `data.ISparkLegacyConfigurationState { ownerGeneration,
|
|
2217
|
+
sourceSha256, disposition }` names the released key-value source by digest alone. No decoded member
|
|
2218
|
+
of that file is representable in any shape here, because it carries the node's released bearer and
|
|
2219
|
+
its jump code; `sourceSha256` covers the whole file, whose smallest unguessable part is a 256-bit
|
|
2220
|
+
token. `disposition` is `retained-inert` or `removed` — the two of
|
|
2221
|
+
`data.sparkLegacyConfigurationDispositions`, with `data.TSparkLegacyConfigurationDisposition` as
|
|
2222
|
+
their union; erasure is not a cutover requirement.
|
|
2223
|
+
|
|
2224
|
+
Cloudly accepts an observation first and then advances two of its own records, which is what the
|
|
2225
|
+
evidence's two references point at. `data.ISparkLegacyModeRefusal` and
|
|
2226
|
+
`data.ISparkLegacyConfigurationRetirement` are revisions, not one-shot receipts: a node restates
|
|
2227
|
+
both on every observation, a bundle upgrade changes what the boot path is, and a disposition
|
|
2228
|
+
changes once. Both carry the accepted observation they were derived from, its reporter session and
|
|
2229
|
+
its sequence, and both obey the revision rule every record of this package obeys — a first
|
|
2230
|
+
generation has no predecessor and a later one names exactly the generation before it. The
|
|
2231
|
+
configuration record carries two counters on purpose: its own `generation` is contiguous because it
|
|
2232
|
+
is this record's revision, while `configuration.ownerGeneration` is the node's own and may skip,
|
|
2233
|
+
since an observation that never arrived must not look like a skipped revision.
|
|
2234
|
+
|
|
2235
|
+
`advancesSparkLegacyConfigurationState(current, next)` is the compare-and-swap rule, `null` for a
|
|
2236
|
+
node's first accepted state: the same `sourceSha256`, a strictly higher `ownerGeneration`, and no
|
|
2237
|
+
return from `removed` to `retained-inert`. Restating the accepted generation is a replay and
|
|
2238
|
+
therefore not an advance, which is what keeps `acceptedAt` and the named observation from aging
|
|
2239
|
+
forward on a node that has stopped changing. `data.sparkLegacyRetirementRefusals` names every other
|
|
2240
|
+
answer, with `data.TSparkLegacyRetirementRefusal` as their union.
|
|
2241
|
+
`bindSparkLegacyRetirement(modeRefusal, configurationRetirement)` joins the two receipts a
|
|
2242
|
+
retirement cites, requiring the same cluster, node, reporter session, sequence and accepted
|
|
2243
|
+
observation. It is a content check; whether each receipt is its node's current revision remains
|
|
2244
|
+
Cloudly's.
|
|
2245
|
+
|
|
2246
|
+
The answer travels on the observation's own accepted receipt, as
|
|
2247
|
+
`ISparkSwarmObservationAcceptedReceipt.fleetCutoverLegacyRetirement`, a
|
|
2248
|
+
`data.TSparkLegacyRetirementOutcome`: `{ state: 'accepted', generation }` for the revision this
|
|
2249
|
+
observation advanced both records to, `{ state: 'restated', generation }` for the replay that
|
|
2250
|
+
renewed nothing, and `{ state: 'refused', refusal, retryable: false }` naming one of the refusals
|
|
2251
|
+
above. A refused statement does not reject the observation. The statement is judged against
|
|
2252
|
+
records rather than against the message — a malformed one is already refused by
|
|
2253
|
+
`validateSparkLegacyRuntimeRetirement` — so everything left is a fact about this node's history,
|
|
2254
|
+
while the observation's liveness and census are true either way and the cluster's target set
|
|
2255
|
+
depends on their freshness. A refusal is never retryable: it answers one exact statement, so
|
|
2256
|
+
resending it cannot change the answer, and a genuine server-side race is the observation's own
|
|
2257
|
+
`acceptance-race` one level up. The flag is carried anyway, so a node meeting a refusal name a
|
|
2258
|
+
later minor added still learns it must stop.
|
|
2259
|
+
`validateSparkSwarmObservationResponse` binds the answer to the statement in both directions: an
|
|
2260
|
+
accepted receipt that answers a statement the observation never made, or leaves one it did make
|
|
2261
|
+
unanswered, is refused. A rejected receipt carries no outcome at all, because an observation that
|
|
2262
|
+
was not accepted never reached the statement.
|
|
2263
|
+
`data.snapshotSparkLegacyRetirementOutcome` and `data.validateSparkLegacyRetirementOutcome` read
|
|
2264
|
+
it exact-key per state, like every other shape here.
|
|
2265
|
+
|
|
2266
|
+
An omitted member is not a refusal and advances nothing, so the existence of these records is not
|
|
2267
|
+
what makes a retirement honest — the freshness of the observation they name is. Assembly must
|
|
2268
|
+
require that observation to still be inside `fleetCutoverContract.observationFreshnessMs`, exactly
|
|
2269
|
+
as `IFleetCutoverSparkObservationReference` already does, or a node that stopped refusing would go
|
|
2270
|
+
on looking retired.
|
|
2271
|
+
|
|
2272
|
+
Cloudly reads this member before Spark writes it. Every observation validator in this package is
|
|
2273
|
+
exact-key, so a server built against an earlier minor refuses the whole observation rather than
|
|
2274
|
+
ignoring a member it does not know — the peers of a session kind are upgraded before the side that
|
|
2275
|
+
starts depending on the later minor, and the `minimumPeerVersion` of that session kind is raised in
|
|
2276
|
+
the same commit that starts depending on it. A Spark that begins stating this member therefore
|
|
2277
|
+
raises its `sparkSwarmNode` minimum alone, which requires one minimum per session kind rather than
|
|
2278
|
+
one shared across a deployable's offers. The duty runs both ways: a receipt is read exact-key too,
|
|
2279
|
+
so a Cloudly that begins answering with `fleetCutoverLegacyRetirement` raises its own
|
|
2280
|
+
`sparkSwarmNode` minimum in the same commit, or an older Spark refuses the receipt for a member it
|
|
2281
|
+
does not know.
|
|
2282
|
+
|
|
2112
2283
|
### Cluster VPN Hub And Network
|
|
2113
2284
|
|
|
2114
2285
|
A cluster's relay is also that cluster's managed VPN hub: every node of the cluster dials it, and
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/data/cluster.ts
CHANGED
|
@@ -2,11 +2,23 @@ import * as plugins from '../plugins.js';
|
|
|
2
2
|
|
|
3
3
|
import { type IDockerRegistryInfo } from '../data/docker.js';
|
|
4
4
|
import type { IClusterNode } from './clusternode.js';
|
|
5
|
+
import type { IFleetCutoverAcceptedEvidenceReference } from './fleetcutover.js';
|
|
6
|
+
import type { TSha256Digest } from './immutableimage.js';
|
|
7
|
+
import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
|
|
5
8
|
import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
|
|
6
9
|
import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
7
10
|
import { hasExactKeys } from '../private/dataproperties.js';
|
|
8
11
|
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
9
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
captureNetwork,
|
|
14
|
+
networkCounter,
|
|
15
|
+
networkDigest,
|
|
16
|
+
networkHash,
|
|
17
|
+
networkId,
|
|
18
|
+
networkNoiseKey,
|
|
19
|
+
networkRecord,
|
|
20
|
+
networkReference,
|
|
21
|
+
} from '../private/networkcontract.js';
|
|
10
22
|
|
|
11
23
|
export interface ICluster {
|
|
12
24
|
id: string;
|
|
@@ -137,6 +149,16 @@ export interface IRegisterClusterRelayResponse {
|
|
|
137
149
|
origin: string;
|
|
138
150
|
/** Cloudly's own offer, so an accepted relay judges the session it just registered into. */
|
|
139
151
|
protocol: IProtocolOffer;
|
|
152
|
+
/**
|
|
153
|
+
* The relay credential generation Cloudly authenticated this socket as.
|
|
154
|
+
*
|
|
155
|
+
* Read from Cloudly's own record and never from the registration: a relay that stated its own
|
|
156
|
+
* generation would be asserting its own rank. It is how a relay learns the exact generation its
|
|
157
|
+
* readiness is bound to.
|
|
158
|
+
*/
|
|
159
|
+
credential: IRuntimeAuthorityReference;
|
|
160
|
+
/** The accepted registration record this answer wrote, as a retirement record later cites it. */
|
|
161
|
+
acceptedRegistration: IFleetCutoverAcceptedEvidenceReference;
|
|
140
162
|
}
|
|
141
163
|
|
|
142
164
|
/**
|
|
@@ -155,6 +177,13 @@ export const clusterRelayUndispatchedRefusals = Object.freeze([
|
|
|
155
177
|
'relay-node-foreign',
|
|
156
178
|
/** A registration of this relay identity at the same or a higher sequence is already recorded. */
|
|
157
179
|
'relay-sequence-not-advancing',
|
|
180
|
+
/**
|
|
181
|
+
* The credential this socket authenticated with is no longer the cluster's current generation.
|
|
182
|
+
*
|
|
183
|
+
* Rotation has no overlap, so another relay may already hold the current one: this relay is not
|
|
184
|
+
* the one Cloudly dispatches to, which `relay-identity-unverified` never states.
|
|
185
|
+
*/
|
|
186
|
+
'relay-credential-generation-stale',
|
|
158
187
|
] as const);
|
|
159
188
|
|
|
160
189
|
/** One of the reasons Cloudly states that it does not dispatch to a relay. */
|
|
@@ -342,14 +371,12 @@ export const validateRegisterClusterRelayRequest = (
|
|
|
342
371
|
};
|
|
343
372
|
|
|
344
373
|
/**
|
|
345
|
-
*
|
|
374
|
+
* What one accepted relay registration states about the relay that sent it.
|
|
346
375
|
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
|
|
350
|
-
* time, which is what orders two registrations of one cluster.
|
|
376
|
+
* The part the served read shape and the accepted record share: no record identity, no digest and
|
|
377
|
+
* no liveness, so neither shape restates the other's fields.
|
|
351
378
|
*/
|
|
352
|
-
export interface
|
|
379
|
+
export interface IClusterRelayRegistrationFacts {
|
|
353
380
|
clusterId: string;
|
|
354
381
|
nodeId: string;
|
|
355
382
|
listenAddress: string;
|
|
@@ -357,6 +384,17 @@ export interface IClusterRelayRegistration {
|
|
|
357
384
|
version: string;
|
|
358
385
|
registrationSequence: number;
|
|
359
386
|
acceptedAt: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One cluster's relay as Cloudly serves it to an administrator.
|
|
391
|
+
*
|
|
392
|
+
* This is the read shape, not the registration a relay sends: `IRegisterClusterRelayRequest` is
|
|
393
|
+
* what the relay puts on the wire, and this is what Cloudly answers with after it accepted one.
|
|
394
|
+
* `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
|
|
395
|
+
* time, which is what orders two registrations of one cluster.
|
|
396
|
+
*/
|
|
397
|
+
export interface IClusterRelayRegistration extends IClusterRelayRegistrationFacts {
|
|
360
398
|
/**
|
|
361
399
|
* Whether a verified socket carries this relay at this instant.
|
|
362
400
|
*
|
|
@@ -367,6 +405,26 @@ export interface IClusterRelayRegistration {
|
|
|
367
405
|
live: boolean;
|
|
368
406
|
}
|
|
369
407
|
|
|
408
|
+
const clusterRelayRegistrationFactKeys = [
|
|
409
|
+
'clusterId', 'nodeId', 'listenAddress', 'listenPort', 'version', 'registrationSequence',
|
|
410
|
+
'acceptedAt',
|
|
411
|
+
] as const;
|
|
412
|
+
|
|
413
|
+
/** The one format rule of every registration fact, whichever shape carries it. */
|
|
414
|
+
const validateClusterRelayRegistrationFacts = (
|
|
415
|
+
valueArg: Record<string, unknown>,
|
|
416
|
+
pathArg: string,
|
|
417
|
+
): string[] => [
|
|
418
|
+
...validateClusterRelayIdentifier(valueArg.clusterId, `${pathArg} clusterId`),
|
|
419
|
+
...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
|
|
420
|
+
...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
|
|
421
|
+
...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
|
|
422
|
+
...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
|
|
423
|
+
...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
|
|
424
|
+
`${pathArg} registrationSequence`),
|
|
425
|
+
...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
|
|
426
|
+
];
|
|
427
|
+
|
|
370
428
|
/**
|
|
371
429
|
* Validates one served relay registration and names every reason it is refused.
|
|
372
430
|
*
|
|
@@ -378,23 +436,95 @@ export const validateClusterRelayRegistration = (
|
|
|
378
436
|
valueArg: unknown,
|
|
379
437
|
pathArg = 'cluster relay registration',
|
|
380
438
|
): string[] => {
|
|
381
|
-
if (!isRecord(valueArg)
|
|
382
|
-
|
|
439
|
+
if (!isRecord(valueArg)
|
|
440
|
+
|| !hasExactKeys(valueArg, [...clusterRelayRegistrationFactKeys, 'live'])) {
|
|
383
441
|
return [`${pathArg} must use its exact schema`];
|
|
384
442
|
}
|
|
385
443
|
return [
|
|
386
|
-
...
|
|
387
|
-
...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
|
|
388
|
-
...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
|
|
389
|
-
...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
|
|
390
|
-
...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
|
|
391
|
-
...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
|
|
392
|
-
`${pathArg} registrationSequence`),
|
|
393
|
-
...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
|
|
444
|
+
...validateClusterRelayRegistrationFacts(valueArg, pathArg),
|
|
394
445
|
...(typeof valueArg.live === 'boolean' ? [] : [`${pathArg} live must be a boolean`]),
|
|
395
446
|
];
|
|
396
447
|
};
|
|
397
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Cloudly's accepted registration record: the thing an evidence reference names.
|
|
451
|
+
*
|
|
452
|
+
* `credential` is the exact relay credential generation the registering socket authenticated as,
|
|
453
|
+
* so a reader can tell a registration made under the current credential from one made under a
|
|
454
|
+
* superseded one. Liveness is deliberately absent: a live socket is not a fact a record can hold,
|
|
455
|
+
* and a digest that covered it would change without anything having been registered.
|
|
456
|
+
*/
|
|
457
|
+
export interface IClusterRelayAcceptedRegistration extends IClusterRelayRegistrationFacts {
|
|
458
|
+
id: string;
|
|
459
|
+
/** Advances by one per accepted registration, so a reference to a superseded one fails closed. */
|
|
460
|
+
generation: number;
|
|
461
|
+
credential: IRuntimeAuthorityReference;
|
|
462
|
+
digest: TSha256Digest;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export const clusterRelayContract = Object.freeze({
|
|
466
|
+
acceptedRegistrationDigestDomain: 'serve.zone/cluster-relay-accepted-registration',
|
|
467
|
+
} as const);
|
|
468
|
+
|
|
469
|
+
const failClusterRelayRecord = (): never => {
|
|
470
|
+
throw new Error('Cluster relay contract rejected.');
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/** The facts of an already exact-keyed record, refused unless each passes its one format rule. */
|
|
474
|
+
const readClusterRelayRegistrationFacts = (
|
|
475
|
+
valueArg: Record<string, unknown>,
|
|
476
|
+
): IClusterRelayRegistrationFacts => {
|
|
477
|
+
if (validateClusterRelayRegistrationFacts(valueArg, 'cluster relay registration').length > 0) {
|
|
478
|
+
failClusterRelayRecord();
|
|
479
|
+
}
|
|
480
|
+
const {
|
|
481
|
+
clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
|
|
482
|
+
} = valueArg;
|
|
483
|
+
// Each member passed its format rule above, which is what the assertion states.
|
|
484
|
+
return {
|
|
485
|
+
clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
|
|
486
|
+
} as IClusterRelayRegistrationFacts;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/** Structural copy only; whether the record is the cluster's current one is Cloudly's to decide. */
|
|
490
|
+
export const snapshotClusterRelayAcceptedRegistration = (
|
|
491
|
+
valueArg: unknown,
|
|
492
|
+
): IClusterRelayAcceptedRegistration => {
|
|
493
|
+
try {
|
|
494
|
+
const value = networkRecord(captureNetwork(valueArg), [
|
|
495
|
+
...clusterRelayRegistrationFactKeys, 'id', 'generation', 'credential', 'digest',
|
|
496
|
+
]);
|
|
497
|
+
return {
|
|
498
|
+
...readClusterRelayRegistrationFacts(value),
|
|
499
|
+
id: networkId(value.id),
|
|
500
|
+
generation: networkCounter(value.generation),
|
|
501
|
+
credential: networkReference(value.credential),
|
|
502
|
+
digest: networkDigest(value.digest),
|
|
503
|
+
};
|
|
504
|
+
} catch {
|
|
505
|
+
return failClusterRelayRecord();
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export const computeClusterRelayAcceptedRegistrationDigest = async (
|
|
510
|
+
valueArg: unknown,
|
|
511
|
+
): Promise<TSha256Digest> => networkHash(
|
|
512
|
+
clusterRelayContract.acceptedRegistrationDigestDomain,
|
|
513
|
+
snapshotClusterRelayAcceptedRegistration(valueArg),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
export const validateClusterRelayAcceptedRegistration = async (
|
|
517
|
+
valueArg: unknown,
|
|
518
|
+
): Promise<string[]> => {
|
|
519
|
+
try {
|
|
520
|
+
const value = snapshotClusterRelayAcceptedRegistration(valueArg);
|
|
521
|
+
return value.digest === await computeClusterRelayAcceptedRegistrationDigest(value)
|
|
522
|
+
? [] : ['cluster relay accepted registration digest does not match'];
|
|
523
|
+
} catch {
|
|
524
|
+
return ['cluster relay accepted registration must use its exact canonical schema'];
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
398
528
|
/**
|
|
399
529
|
* The name the relay's certificate is issued for, and the A record Cloudly
|
|
400
530
|
* owns for it. Derived from the origin so every consumer derives it
|
package/ts/data/clusternode.ts
CHANGED
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
type IFleetCutoverPreparedVolumeHandoffIntent,
|
|
21
21
|
type IFleetCutoverPreparedVolumeHandoffPage,
|
|
22
22
|
} from './fleetcutover.js';
|
|
23
|
+
import {
|
|
24
|
+
validateSparkLegacyRetirementOutcome,
|
|
25
|
+
validateSparkLegacyRuntimeRetirement,
|
|
26
|
+
type ISparkLegacyRuntimeRetirement,
|
|
27
|
+
type TSparkLegacyRetirementOutcome,
|
|
28
|
+
} from './sparklegacyretirement.js';
|
|
23
29
|
import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
24
30
|
import {
|
|
25
31
|
canonicalizeStrictJson,
|
|
@@ -242,6 +248,8 @@ interface ISparkSwarmObservationBase {
|
|
|
242
248
|
observedAt: number;
|
|
243
249
|
/** Available in every observation state; exact page membership proves an empty node subset. */
|
|
244
250
|
fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
|
|
251
|
+
/** Available in every observation state too: this describes the host, not its Docker daemon. */
|
|
252
|
+
fleetCutoverLegacyRetirement?: ISparkLegacyRuntimeRetirement;
|
|
245
253
|
}
|
|
246
254
|
|
|
247
255
|
export type TSparkSwarmObservation =
|
|
@@ -280,7 +288,17 @@ export type TSparkSwarmObservationRejectionCode =
|
|
|
280
288
|
| 'observation-too-old'
|
|
281
289
|
| 'observation-from-future'
|
|
282
290
|
| 'observation-time-not-advancing'
|
|
283
|
-
| 'acceptance-race'
|
|
291
|
+
| 'acceptance-race'
|
|
292
|
+
/**
|
|
293
|
+
* An evidence member of this observation is refused, for this body and every resend of it.
|
|
294
|
+
*
|
|
295
|
+
* The whole observation is rejected because the refused content is what the body states, and a
|
|
296
|
+
* node that keeps resending it would never be recorded. It is the answer for a member whose
|
|
297
|
+
* content this server has decided against; a server-side condition that a later attempt could
|
|
298
|
+
* pass is `acceptance-race` instead, and telling the two apart while accepting evidence is the
|
|
299
|
+
* server's own work.
|
|
300
|
+
*/
|
|
301
|
+
| 'evidence-refused';
|
|
284
302
|
|
|
285
303
|
export const sparkSwarmObservationContract = Object.freeze({
|
|
286
304
|
digestDomain: 'serve.zone/spark-swarm-observation',
|
|
@@ -297,6 +315,7 @@ export const sparkSwarmObservationContract = Object.freeze({
|
|
|
297
315
|
'observation-from-future': false,
|
|
298
316
|
'observation-time-not-advancing': false,
|
|
299
317
|
'acceptance-race': true,
|
|
318
|
+
'evidence-refused': false,
|
|
300
319
|
} satisfies Record<TSparkSwarmObservationRejectionCode, boolean>),
|
|
301
320
|
} as const);
|
|
302
321
|
|
|
@@ -344,6 +363,15 @@ export interface ISparkSwarmObservationAcceptedReceipt {
|
|
|
344
363
|
acceptedAt: number;
|
|
345
364
|
/** Runtime target generation observed immediately after this acceptance. */
|
|
346
365
|
targetGenerationAtAcceptance: number;
|
|
366
|
+
/**
|
|
367
|
+
* What the server did with `fleetCutoverLegacyRetirement`, present exactly when it was stated.
|
|
368
|
+
*
|
|
369
|
+
* A statement about the node's own released runtime is judged against records rather than
|
|
370
|
+
* against this message, so its refusal is answered here instead of rejecting an observation
|
|
371
|
+
* whose liveness and census are true either way. Absent on the rejected receipt: an observation
|
|
372
|
+
* that was not accepted never reached the statement.
|
|
373
|
+
*/
|
|
374
|
+
fleetCutoverLegacyRetirement?: TSparkLegacyRetirementOutcome;
|
|
347
375
|
/** The answering server's offer; see `sparkSwarmNodeContracts`. */
|
|
348
376
|
protocol: IProtocolOffer;
|
|
349
377
|
}
|
|
@@ -628,6 +656,8 @@ export const validateSparkSwarmObservation = async (
|
|
|
628
656
|
'state',
|
|
629
657
|
...(observation.fleetCutoverHeldVolumeFences === undefined
|
|
630
658
|
? [] : ['fleetCutoverHeldVolumeFences']),
|
|
659
|
+
...(observation.fleetCutoverLegacyRetirement === undefined
|
|
660
|
+
? [] : ['fleetCutoverLegacyRetirement']),
|
|
631
661
|
];
|
|
632
662
|
const expectedKeys = observation.state === 'unknown'
|
|
633
663
|
? [...commonKeys, 'reason']
|
|
@@ -676,6 +706,11 @@ export const validateSparkSwarmObservation = async (
|
|
|
676
706
|
errorArg => `Spark Swarm observation ${errorArg}`,
|
|
677
707
|
));
|
|
678
708
|
}
|
|
709
|
+
if (observation.fleetCutoverLegacyRetirement !== undefined) {
|
|
710
|
+
errors.push(...validateSparkLegacyRuntimeRetirement(
|
|
711
|
+
observation.fleetCutoverLegacyRetirement,
|
|
712
|
+
).map(errorArg => `Spark Swarm observation ${errorArg}`));
|
|
713
|
+
}
|
|
679
714
|
if (observation.state === 'member') {
|
|
680
715
|
if (!isRuntimeIdentifier(observation.localSwarmNodeId)
|
|
681
716
|
|| typeof observation.controlAvailable !== 'boolean') {
|
|
@@ -955,6 +990,8 @@ export const validateSparkSwarmObservationResponse = async (
|
|
|
955
990
|
'observationDigest',
|
|
956
991
|
'acceptedAt',
|
|
957
992
|
'targetGenerationAtAcceptance',
|
|
993
|
+
...(responseArg.fleetCutoverLegacyRetirement === undefined
|
|
994
|
+
? [] : ['fleetCutoverLegacyRetirement']),
|
|
958
995
|
'protocol',
|
|
959
996
|
];
|
|
960
997
|
const rejectedKeys = [
|
|
@@ -1004,6 +1041,18 @@ export const validateSparkSwarmObservationResponse = async (
|
|
|
1004
1041
|
|| (responseArg.targetGenerationAtAcceptance as number) < 0) {
|
|
1005
1042
|
errors.push('Spark Swarm observation accepted receipt fields must be canonical');
|
|
1006
1043
|
}
|
|
1044
|
+
// Stated and unanswered, or answered and never stated: both are a server that judged
|
|
1045
|
+
// something other than the observation this receipt binds.
|
|
1046
|
+
if ((responseArg.fleetCutoverLegacyRetirement !== undefined)
|
|
1047
|
+
!== (expectedRequestArg.observation.fleetCutoverLegacyRetirement !== undefined)) {
|
|
1048
|
+
errors.push(
|
|
1049
|
+
'Spark Swarm observation accepted receipt must answer the legacy retirement it was sent',
|
|
1050
|
+
);
|
|
1051
|
+
} else if (responseArg.fleetCutoverLegacyRetirement !== undefined) {
|
|
1052
|
+
errors.push(...validateSparkLegacyRetirementOutcome(
|
|
1053
|
+
responseArg.fleetCutoverLegacyRetirement,
|
|
1054
|
+
).map(errorArg => `Spark Swarm observation response ${errorArg}`));
|
|
1055
|
+
}
|
|
1007
1056
|
} else {
|
|
1008
1057
|
const retryability = sparkSwarmObservationContract.rejectionRetryability;
|
|
1009
1058
|
if (!isPositiveSafeInteger(responseArg.rejectedAt)
|