@serve.zone/interfaces 32.15.0 → 32.17.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 +41 -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 +3 -0
- package/dist_ts/data/clusternode.js +7 -1
- package/dist_ts/data/clusterrelaycredential.d.ts +141 -0
- package/dist_ts/data/clusterrelaycredential.js +192 -0
- package/dist_ts/data/fleetcutover.d.ts +65 -0
- package/dist_ts/data/fleetcutover.js +215 -7
- package/dist_ts/data/index.d.ts +2 -0
- package/dist_ts/data/index.js +3 -1
- package/dist_ts/data/sparklegacyretirement.d.ts +137 -0
- package/dist_ts/data/sparklegacyretirement.js +206 -0
- 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 +176 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +147 -17
- package/ts/data/clusternode.ts +13 -0
- package/ts/data/clusterrelaycredential.ts +361 -0
- package/ts/data/fleetcutover.ts +314 -5
- package/ts/data/index.ts +2 -0
- package/ts/data/sparklegacyretirement.ts +352 -0
- package/ts/requests/cluster.ts +69 -0
package/readme.md
CHANGED
|
@@ -1056,6 +1056,24 @@ sequence, observation time and fixed `freshUntil`; assembling a later census can
|
|
|
1056
1056
|
observation. It never transports environment, arbitrary labels, mount paths, volume names,
|
|
1057
1057
|
registry credentials or secret values.
|
|
1058
1058
|
|
|
1059
|
+
`data.computeFleetCutoverDockerServiceSpecDigest` hashes the complete open-schema Docker
|
|
1060
|
+
`Service.Spec`, including response properties unknown to this Interfaces version, under
|
|
1061
|
+
`serve.zone/fleet-cutover-docker-service-spec`. The helper returns only the digest: the full spec,
|
|
1062
|
+
including any environment values or secret references it contains, never belongs in the
|
|
1063
|
+
observation or logs. `data.computeFleetCutoverDockerConstraintSetIdentity` sorts exact constraint
|
|
1064
|
+
strings in UTF-16 code-unit order, preserves duplicates, and binds both the resulting list and its
|
|
1065
|
+
count. `data.snapshotFleetCutoverDockerDesiredTaskCount` admits only the non-negative safe integer
|
|
1066
|
+
from `ServiceStatus.DesiredTasks`; the producer must request service status and must not derive a
|
|
1067
|
+
replacement from task state. These helpers consume only the Docker response fields they name, so
|
|
1068
|
+
additional response properties do not make a newer daemon unreadable.
|
|
1069
|
+
|
|
1070
|
+
The only established historical service-identity ownership label is
|
|
1071
|
+
`data.fleetCutoverDockerLabelNames.serviceId`, whose exact value is `serve.zone.serviceId`.
|
|
1072
|
+
`ownershipLabels.platformOwner: null` means there is no known Docker-label evidence of a platform
|
|
1073
|
+
owner. It does not classify the service as an application, and neither a service name nor an
|
|
1074
|
+
unrecognized label supplies that classification. Cloudly's accepted administrator classification
|
|
1075
|
+
remains the authority for unlabeled legacy platform services.
|
|
1076
|
+
|
|
1059
1077
|
Mount identity has three explicit canonical domains. `serve.zone/fleet-cutover-mount-source-identity`
|
|
1060
1078
|
hashes `{ type, source }`, where `source` is the exact UTF-8 `Mount.Source` returned by Docker,
|
|
1061
1079
|
including an empty string. `serve.zone/fleet-cutover-mount-target-identity` hashes `{ target }`,
|
|
@@ -1068,6 +1086,26 @@ ownership also prevents completion rather than omitting the container. Structura
|
|
|
1068
1086
|
not establish freshness, authentication, manager consensus, ownership or Docker truth; Cloudly
|
|
1069
1087
|
establishes those policy facts while dereferencing the accepted observations.
|
|
1070
1088
|
|
|
1089
|
+
Docker exposes two different volume sources. A service-spec `Mount.Source` is the logical volume
|
|
1090
|
+
name, while a local container `MountPoint.Source` is its physical host path and `MountPoint.Name`
|
|
1091
|
+
is the logical name. `data.createFleetCutoverDockerServiceMountDescriptor` and
|
|
1092
|
+
`data.createFleetCutoverDockerLocalMountDescriptor` therefore keep their exact raw-source mount
|
|
1093
|
+
identities distinct. They never substitute `Name` for `Source`. The local-only
|
|
1094
|
+
`data.createFleetCutoverDockerPhysicalMountEvidence` adds a value-free correlation from the
|
|
1095
|
+
physical `mountIdentityDigest` to the service-spec identity derived from exact `MountPoint.Name`
|
|
1096
|
+
for volumes, or exact `Source` for bind and tmpfs mounts. Its independently domain-separated
|
|
1097
|
+
correlation digest binds that pair. When a physical container states `mountCorrelations`, the list
|
|
1098
|
+
must be sorted, unique, bounded, and a complete one-to-one mapping of its mounts. The property is
|
|
1099
|
+
optional only so observations created before this evidence existed still parse; absence proves no
|
|
1100
|
+
join, and a cutover consumer requiring a physical/service match must refuse it. Unsupported local
|
|
1101
|
+
mount types and volume mounts without a non-empty `Name` cannot produce correlation evidence.
|
|
1102
|
+
|
|
1103
|
+
The shared exact classifier recognizes only a writable
|
|
1104
|
+
`/var/run/docker.sock` to `/var/run/docker.sock` bind as `docker-socket`, a read-only
|
|
1105
|
+
`/sys/fs/cgroup` to `/host/cgroup` bind as `cgroup`, every volume as `persistent-volume`, other
|
|
1106
|
+
binds as `bind`, and tmpfs as `tmpfs`. Other Docker types remain `unknown`; no path normalization
|
|
1107
|
+
or service-name inference is performed.
|
|
1108
|
+
|
|
1071
1109
|
`data.IFleetCutoverScope` is the server-owned authoritative service set. It starts `incomplete`;
|
|
1072
1110
|
every `complete` entry binds an exact Docker service id to the current service authority and an
|
|
1073
1111
|
accepted classification authority. `unknown` cannot appear in a complete structural snapshot.
|
|
@@ -1160,6 +1198,10 @@ source is identified by SHA-256 and may be `retained-inert` or `removed`; deleti
|
|
|
1160
1198
|
erasure are not cutover requirements. Its exact owner generation is retained with that disposition.
|
|
1161
1199
|
Retained recovery evidence is valid only while the released
|
|
1162
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).
|
|
1163
1205
|
|
|
1164
1206
|
Before any post-pallet assignment exists, an administrator calls
|
|
1165
1207
|
`requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
|
|
@@ -1989,12 +2031,13 @@ with `clusterRelayCertificateDomainName(relay)`, which returns the origin's host
|
|
|
1989
2031
|
Cloudly issues the certificate for and owns the A record for. An origin naming an address literal
|
|
1990
2032
|
therefore has no issuable certificate.
|
|
1991
2033
|
|
|
1992
|
-
`data.clusterRelayUndispatchedRefusals` names the
|
|
1993
|
-
custody — `relay-cluster-not-enabled`, `relay-node-foreign
|
|
1994
|
-
with `data.TClusterRelayUndispatchedRefusal` as their union.
|
|
1995
|
-
records and stating that it does not dispatch to this relay, so a
|
|
1996
|
-
it holds for its nodes instead of waiting. A refusal that only
|
|
1997
|
-
relay states nothing about dispatch and is deliberately not in
|
|
2034
|
+
`data.clusterRelayUndispatchedRefusals` names the four registration refusals that end a relay's
|
|
2035
|
+
custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-sequence-not-advancing` and
|
|
2036
|
+
`relay-credential-generation-stale` — with `data.TClusterRelayUndispatchedRefusal` as their union.
|
|
2037
|
+
Each is Cloudly reading its own records and stating that it does not dispatch to this relay, so a
|
|
2038
|
+
relay that hears one drops what it holds for its nodes instead of waiting. A refusal that only
|
|
2039
|
+
says Cloudly could not verify the relay states nothing about dispatch and is deliberately not in
|
|
2040
|
+
the list.
|
|
1998
2041
|
|
|
1999
2042
|
```typescript
|
|
2000
2043
|
const relay: data.IClusterRelay = {
|
|
@@ -2071,6 +2114,133 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
|
|
|
2071
2114
|
How the relay reaches each node's Corestore is part of the same document: see
|
|
2072
2115
|
[Corestore Through The Relay](#corestore-through-the-relay).
|
|
2073
2116
|
|
|
2117
|
+
### Cluster Relay Credential
|
|
2118
|
+
|
|
2119
|
+
A cluster's relay authenticates with its own generation-bearing credential instead of the shared
|
|
2120
|
+
cluster authority every cluster-side component used before it. `IClusterRelayCredential` is public
|
|
2121
|
+
metadata: the cluster, a `generation` that advances by one per accepted mint or rotation, the
|
|
2122
|
+
machine `userId` it authenticates as, and `delivery`, the `ISecretVersionReference` of the
|
|
2123
|
+
immutable encrypted version the bearer was delivered as. No record, receipt, request or response
|
|
2124
|
+
of this contract carries a bearer, a hash or key material, and every reader is exact-keyed, so a
|
|
2125
|
+
peer that adds a secret-looking member is refused rather than copied.
|
|
2126
|
+
|
|
2127
|
+
The credential authenticates the cluster's whole control-plane session, exactly what the shared
|
|
2128
|
+
authority authenticated, so a relay changes one credential and keeps every call it makes. The gain
|
|
2129
|
+
is a generation Cloudly fences each socket against and an authority it can rotate and retire on
|
|
2130
|
+
its own; the scope is not narrower. One cluster has one relay and therefore one credential record,
|
|
2131
|
+
and it never expires by itself: rotation is its lifecycle.
|
|
2132
|
+
|
|
2133
|
+
`mutateClusterRelayCredential { identity, mutation }` takes `TClusterRelayCredentialMutation`.
|
|
2134
|
+
`ensure` requires `expectedGeneration: null` and an absent record; `rotate` requires the exact
|
|
2135
|
+
current generation, never a minimum. The caller retains the complete request and its `mutationId`
|
|
2136
|
+
across retries: the identical body answers with the original result and `replayed: true` without
|
|
2137
|
+
renewing delivery, generation or authority, and a changed body fails closed. The first mint also
|
|
2138
|
+
records the shared authority the cluster still carries as `IClusterLegacyRelayCredential`, so a
|
|
2139
|
+
replacement never exists without a named predecessor. Rotation has no overlap: the previous
|
|
2140
|
+
generation stops authenticating at commit, and a relay still holding it hears
|
|
2141
|
+
`relay-credential-generation-stale`, drops what it holds for its nodes, reads the delivered version
|
|
2142
|
+
and reconnects. There is no revocation of the relay credential, because a rotation already covers
|
|
2143
|
+
a compromised one without a state in which the cluster has no relay authority at all.
|
|
2144
|
+
|
|
2145
|
+
An accepted `registerClusterRelay` answers with two references beside the origin and the offer:
|
|
2146
|
+
`credential`, the generation Cloudly authenticated the socket as, and `acceptedRegistration`, the
|
|
2147
|
+
record that acceptance wrote. The relay states no generation of its own — the registration request
|
|
2148
|
+
is unchanged — because a relay naming its own generation would be asserting its own rank.
|
|
2149
|
+
`IClusterRelayAcceptedRegistration` is that record: the registration facts the served read shape
|
|
2150
|
+
also carries, its own `id`, a `generation` that advances once per acceptance, the `credential`
|
|
2151
|
+
reference and a `digest`. Liveness is not part of it. `IClusterRelayRegistration` keeps exactly its
|
|
2152
|
+
published members, the facts plus `live`.
|
|
2153
|
+
|
|
2154
|
+
`retireLegacyClusterRelayCredential { identity, retirement }` revokes the shared authority, and is
|
|
2155
|
+
the last step of replacing it. `IClusterLegacyRelayCredentialRetirementRequest` carries
|
|
2156
|
+
compare-and-swap inputs only: the exact `expectedLegacyGeneration`, the `replacement` credential
|
|
2157
|
+
reference and the `acceptedRegistration` reference the caller observed. Cloudly dereferences both
|
|
2158
|
+
against its own current records, requires the registration to have been made under that
|
|
2159
|
+
replacement, and requires a verified live socket carrying it; a caller can state no liveness or
|
|
2160
|
+
proof of its own. Acceptance writes the one-shot `IClusterLegacyRelayCredentialInvalidation`
|
|
2161
|
+
(`generation: 1`) and advances the legacy record to `state: 'revoked'` naming it. The invalidation
|
|
2162
|
+
names the legacy record at the generation before the revocation and the record names the
|
|
2163
|
+
invalidation after it, so neither digest covers the other's.
|
|
2164
|
+
`data.clusterRelayCredentialRefusals` names every other answer, with
|
|
2165
|
+
`data.TClusterRelayCredentialRefusal` as their union. `getClusterRelayCredential { identity,
|
|
2166
|
+
clusterId }` reads the chain: the credential, the legacy record and the current accepted
|
|
2167
|
+
registration, each `null` while no record exists.
|
|
2168
|
+
|
|
2169
|
+
Each digest-bearing record has a `snapshot…`, `compute…Digest` and `validate…` function under its
|
|
2170
|
+
own digest domain in `data.clusterRelayCredentialContract` and `data.clusterRelayContract`.
|
|
2171
|
+
`bindClusterLegacyRelayCredentialInvalidation(invalidation, legacyCredential,
|
|
2172
|
+
acceptedRegistration)` joins an invalidation to the legacy record it revoked — the active record
|
|
2173
|
+
it names or that record's revoked successor — and to a registration whose `credential` equals the
|
|
2174
|
+
invalidation's `replacement`. It is a content check; dereferencing each record as current and
|
|
2175
|
+
requiring the live socket remain Cloudly's.
|
|
2176
|
+
|
|
2177
|
+
All three requests read only the verified `IIdentityCredential`, never an identity member a caller
|
|
2178
|
+
states about itself.
|
|
2179
|
+
|
|
2180
|
+
### Spark Legacy Runtime Retirement
|
|
2181
|
+
|
|
2182
|
+
The Spark-owned half of `IFleetCutoverCoreflowRetirementEvidence` — the refused old mode, the
|
|
2183
|
+
configuration compare-and-swap, and the bundle and unit identity — is transported on the Swarm
|
|
2184
|
+
observation a node already posts. There is no route, request or endpoint for it. That is forced
|
|
2185
|
+
rather than preferred: the evidence requires `relay.nodeObservation` and `spark.observation` to be
|
|
2186
|
+
the identical accepted reference, so these facts must reach Cloudly on the very observation that
|
|
2187
|
+
carries the node's Docker census.
|
|
2188
|
+
|
|
2189
|
+
`data.ISparkLegacyRuntimeRetirement` is the optional observation member, available in every
|
|
2190
|
+
observation state because it describes the host and not its Docker daemon. It is
|
|
2191
|
+
`{ refusedMode, bundle, configuration }` and nothing else. It names no node: Cloudly binds it to
|
|
2192
|
+
the reporter it already authenticated, so there is no node identity on it to disagree with. It
|
|
2193
|
+
carries no digest of its own either — `computeSparkSwarmObservationDigest` canonicalizes the whole
|
|
2194
|
+
observation, so a second domain here would only be a second thing to keep in step.
|
|
2195
|
+
|
|
2196
|
+
`refusedMode` is one of `data.sparkLegacyRuntimeModes`, the two runtimes a released Spark could be
|
|
2197
|
+
put into by its `mode` configuration key. `data.sparkLegacyRuntimeModeServices` is the record over
|
|
2198
|
+
that union naming the Docker services each mode owns, so a refusal states a mode and a reader
|
|
2199
|
+
derives the set. `data.ISparkNodeBundleIdentity { version, sourceCommit, manifestSha256, unitName,
|
|
2200
|
+
unitDigest }` is read back from the installed tree and the installed unit file, never from the
|
|
2201
|
+
running process's own build constants: a build can state its own version, it cannot state that the
|
|
2202
|
+
host will start that same code again. `data.ISparkLegacyConfigurationState { ownerGeneration,
|
|
2203
|
+
sourceSha256, disposition }` names the released key-value source by digest alone. No decoded member
|
|
2204
|
+
of that file is representable in any shape here, because it carries the node's released bearer and
|
|
2205
|
+
its jump code; `sourceSha256` covers the whole file, whose smallest unguessable part is a 256-bit
|
|
2206
|
+
token. `disposition` is `retained-inert` or `removed`; erasure is not a cutover requirement.
|
|
2207
|
+
|
|
2208
|
+
Cloudly accepts an observation first and then advances two of its own records, which is what the
|
|
2209
|
+
evidence's two references point at. `data.ISparkLegacyModeRefusal` and
|
|
2210
|
+
`data.ISparkLegacyConfigurationRetirement` are revisions, not one-shot receipts: a node restates
|
|
2211
|
+
both on every observation, a bundle upgrade changes what the boot path is, and a disposition
|
|
2212
|
+
changes once. Both carry the accepted observation they were derived from, its reporter session and
|
|
2213
|
+
its sequence, and both obey the revision rule every record of this package obeys — a first
|
|
2214
|
+
generation has no predecessor and a later one names exactly the generation before it. The
|
|
2215
|
+
configuration record carries two counters on purpose: its own `generation` is contiguous because it
|
|
2216
|
+
is this record's revision, while `configuration.ownerGeneration` is the node's own and may skip,
|
|
2217
|
+
since an observation that never arrived must not look like a skipped revision.
|
|
2218
|
+
|
|
2219
|
+
`advancesSparkLegacyConfigurationState(current, next)` is the compare-and-swap rule, `null` for a
|
|
2220
|
+
node's first accepted state: the same `sourceSha256`, a strictly higher `ownerGeneration`, and no
|
|
2221
|
+
return from `removed` to `retained-inert`. Restating the accepted generation is a replay and
|
|
2222
|
+
therefore not an advance, which is what keeps `acceptedAt` and the named observation from aging
|
|
2223
|
+
forward on a node that has stopped changing. `data.sparkLegacyRetirementRefusals` names every other
|
|
2224
|
+
answer, with `data.TSparkLegacyRetirementRefusal` as their union.
|
|
2225
|
+
`bindSparkLegacyRetirement(modeRefusal, configurationRetirement)` joins the two receipts a
|
|
2226
|
+
retirement cites, requiring the same cluster, node, reporter session, sequence and accepted
|
|
2227
|
+
observation. It is a content check; whether each receipt is its node's current revision remains
|
|
2228
|
+
Cloudly's.
|
|
2229
|
+
|
|
2230
|
+
An omitted member is not a refusal and advances nothing, so the existence of these records is not
|
|
2231
|
+
what makes a retirement honest — the freshness of the observation they name is. Assembly must
|
|
2232
|
+
require that observation to still be inside `fleetCutoverContract.observationFreshnessMs`, exactly
|
|
2233
|
+
as `IFleetCutoverSparkObservationReference` already does, or a node that stopped refusing would go
|
|
2234
|
+
on looking retired.
|
|
2235
|
+
|
|
2236
|
+
Cloudly reads this member before Spark writes it. Every observation validator in this package is
|
|
2237
|
+
exact-key, so a server built against an earlier minor refuses the whole observation rather than
|
|
2238
|
+
ignoring a member it does not know — the peers of a session kind are upgraded before the side that
|
|
2239
|
+
starts depending on the later minor, and the `minimumPeerVersion` of that session kind is raised in
|
|
2240
|
+
the same commit that starts depending on it. A Spark that begins stating this member therefore
|
|
2241
|
+
raises its `sparkSwarmNode` minimum alone, which requires one minimum per session kind rather than
|
|
2242
|
+
one shared across a deployable's offers.
|
|
2243
|
+
|
|
2074
2244
|
### Cluster VPN Hub And Network
|
|
2075
2245
|
|
|
2076
2246
|
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,10 @@ import {
|
|
|
20
20
|
type IFleetCutoverPreparedVolumeHandoffIntent,
|
|
21
21
|
type IFleetCutoverPreparedVolumeHandoffPage,
|
|
22
22
|
} from './fleetcutover.js';
|
|
23
|
+
import {
|
|
24
|
+
validateSparkLegacyRuntimeRetirement,
|
|
25
|
+
type ISparkLegacyRuntimeRetirement,
|
|
26
|
+
} from './sparklegacyretirement.js';
|
|
23
27
|
import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
|
|
24
28
|
import {
|
|
25
29
|
canonicalizeStrictJson,
|
|
@@ -242,6 +246,8 @@ interface ISparkSwarmObservationBase {
|
|
|
242
246
|
observedAt: number;
|
|
243
247
|
/** Available in every observation state; exact page membership proves an empty node subset. */
|
|
244
248
|
fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
|
|
249
|
+
/** Available in every observation state too: this describes the host, not its Docker daemon. */
|
|
250
|
+
fleetCutoverLegacyRetirement?: ISparkLegacyRuntimeRetirement;
|
|
245
251
|
}
|
|
246
252
|
|
|
247
253
|
export type TSparkSwarmObservation =
|
|
@@ -628,6 +634,8 @@ export const validateSparkSwarmObservation = async (
|
|
|
628
634
|
'state',
|
|
629
635
|
...(observation.fleetCutoverHeldVolumeFences === undefined
|
|
630
636
|
? [] : ['fleetCutoverHeldVolumeFences']),
|
|
637
|
+
...(observation.fleetCutoverLegacyRetirement === undefined
|
|
638
|
+
? [] : ['fleetCutoverLegacyRetirement']),
|
|
631
639
|
];
|
|
632
640
|
const expectedKeys = observation.state === 'unknown'
|
|
633
641
|
? [...commonKeys, 'reason']
|
|
@@ -676,6 +684,11 @@ export const validateSparkSwarmObservation = async (
|
|
|
676
684
|
errorArg => `Spark Swarm observation ${errorArg}`,
|
|
677
685
|
));
|
|
678
686
|
}
|
|
687
|
+
if (observation.fleetCutoverLegacyRetirement !== undefined) {
|
|
688
|
+
errors.push(...validateSparkLegacyRuntimeRetirement(
|
|
689
|
+
observation.fleetCutoverLegacyRetirement,
|
|
690
|
+
).map(errorArg => `Spark Swarm observation ${errorArg}`));
|
|
691
|
+
}
|
|
679
692
|
if (observation.state === 'member') {
|
|
680
693
|
if (!isRuntimeIdentifier(observation.localSwarmNodeId)
|
|
681
694
|
|| typeof observation.controlAvailable !== 'boolean') {
|