@serve.zone/interfaces 32.4.0 → 32.6.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 +80 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +4 -5
- package/dist_ts/data/cluster.js +6 -14
- package/dist_ts/data/clusteringress.js +9 -8
- package/dist_ts/data/clusternode.d.ts +6 -1
- package/dist_ts/data/clusternode.js +32 -5
- package/dist_ts/data/clustervpn.d.ts +11 -0
- package/dist_ts/data/clustervpn.js +19 -8
- package/dist_ts/data/isolatedrestore.js +12 -4
- package/dist_ts/data/networkaddressplan.d.ts +21 -15
- package/dist_ts/data/networkaddressplan.js +27 -20
- package/dist_ts/data/networkauthority.js +9 -3
- package/dist_ts/data/networkcredential.d.ts +0 -7
- package/dist_ts/data/networkcredential.js +5 -22
- package/dist_ts/data/networklease.js +3 -3
- package/dist_ts/data/networknode.js +3 -3
- package/dist_ts/data/networkprojection.js +8 -3
- package/dist_ts/data/nodeenrollment.js +4 -2
- package/dist_ts/data/noderetirement.js +3 -3
- package/dist_ts/data/runtimeassignment.d.ts +12 -0
- package/dist_ts/data/runtimeassignment.js +29 -4
- package/dist_ts/data/runtimesession.js +3 -2
- package/dist_ts/data/serviceruntime.js +6 -5
- package/dist_ts/data/sparknodereport.js +6 -3
- package/dist_ts/private/networkcontract.d.ts +2 -0
- package/dist_ts/private/networkcontract.js +8 -2
- package/dist_ts/private/nodeidentifier.d.ts +22 -0
- package/dist_ts/private/nodeidentifier.js +23 -0
- package/dist_ts/requests/corestore.js +4 -4
- package/dist_ts/runtime.js +7 -4
- package/package.json +1 -1
- package/readme.md +60 -24
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +9 -21
- package/ts/data/clusteringress.ts +8 -7
- package/ts/data/clusternode.ts +32 -4
- package/ts/data/clustervpn.ts +28 -9
- package/ts/data/isolatedrestore.ts +12 -3
- package/ts/data/networkaddressplan.ts +32 -25
- package/ts/data/networkauthority.ts +8 -2
- package/ts/data/networkcredential.ts +4 -29
- package/ts/data/networklease.ts +2 -2
- package/ts/data/networknode.ts +2 -2
- package/ts/data/networkprojection.ts +6 -2
- package/ts/data/nodeenrollment.ts +4 -2
- package/ts/data/noderetirement.ts +2 -2
- package/ts/data/runtimeassignment.ts +26 -3
- package/ts/data/runtimesession.ts +3 -2
- package/ts/data/serviceruntime.ts +6 -5
- package/ts/data/sparknodereport.ts +7 -2
- package/ts/private/networkcontract.ts +6 -1
- package/ts/private/nodeidentifier.ts +25 -0
- package/ts/requests/corestore.ts +3 -3
- package/ts/runtime.ts +6 -3
package/ts/data/clustervpn.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { runtimeNetworkVpnControlPrefix } from './networkaddressplan.js';
|
|
2
|
+
import { captureNetwork, failNetwork, networkCanonical, networkContains, networkCounter, networkEqual, networkId, networkIpv4,
|
|
3
|
+
networkList, networkNodeId, networkNoiseKey, networkOverlaps, networkPrivatePrefix, networkRecord, networkSafe,
|
|
4
|
+
networkSorted, requireNetwork } from '../private/networkcontract.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* One workload prefix a node owns inside its cluster's managed VPN, and the policy domain that
|
|
@@ -40,9 +41,16 @@ export interface IClusterVpnNetworkGrant {
|
|
|
40
41
|
* a grant that is missing is withdrawn. `revision` is Cloudly's own counter for this authority and
|
|
41
42
|
* only ever rises; `authorityId` names the authority the hub is bound to, so a network compiled for
|
|
42
43
|
* another cluster is refused rather than applied.
|
|
44
|
+
*
|
|
45
|
+
* `controlPrefix` is the subnet the hub's control plane serves: the address plan's
|
|
46
|
+
* `vpn.controlPrefix`, which the plan contract never lets change, stated here because a hub fixes
|
|
47
|
+
* its control subnet when it binds and no other carrier hands a relay the plan. Without it a relay
|
|
48
|
+
* could only infer a subnet from the members it was sent, which is how a hub comes to disagree with
|
|
49
|
+
* the plan it serves.
|
|
43
50
|
*/
|
|
44
51
|
export interface IClusterVpnNetwork {
|
|
45
52
|
authorityId: string;
|
|
53
|
+
controlPrefix: string;
|
|
46
54
|
revision: number;
|
|
47
55
|
nodes: IClusterVpnNetworkNode[];
|
|
48
56
|
grants: IClusterVpnNetworkGrant[];
|
|
@@ -148,7 +156,7 @@ const prefixOf = (value: unknown): IClusterVpnNetworkPrefix => {
|
|
|
148
156
|
|
|
149
157
|
const nodeOf = (value: unknown): IClusterVpnNetworkNode => {
|
|
150
158
|
const item = networkRecord(value, ['nodeId', 'publicKey', 'controlAddress', 'expiresAt', 'workloadPrefixes']);
|
|
151
|
-
return { nodeId:
|
|
159
|
+
return { nodeId: networkNodeId(item.nodeId), publicKey: networkNoiseKey(item.publicKey), controlAddress: networkIpv4(item.controlAddress),
|
|
152
160
|
expiresAt: networkCounter(item.expiresAt),
|
|
153
161
|
workloadPrefixes: networkSorted(networkList(item.workloadPrefixes, clusterVpnNetworkContract.maximumWorkloadPrefixes, prefixOf),
|
|
154
162
|
prefix => prefix.cidr) };
|
|
@@ -168,20 +176,27 @@ const grantKey = (grant: IClusterVpnNetworkGrant): string => `${grant.sourceDoma
|
|
|
168
176
|
* workload domain this network itself carries, on two different nodes. That last rule is what a
|
|
169
177
|
* per-cluster hub means: a grant whose other end sits on another cluster's hub cannot be expressed
|
|
170
178
|
* here at all, which is why Cloudly refuses it by name while it compiles instead of pushing it.
|
|
179
|
+
*
|
|
180
|
+
* Every control address lies inside `controlPrefix` and no workload prefix meets it — the two rules
|
|
181
|
+
* a hub enforces against the subnet it bound. Checking them while the network is compiled turns a
|
|
182
|
+
* push the hub could only refuse into a network Cloudly never states.
|
|
171
183
|
*/
|
|
172
184
|
export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork => networkSafe(() => {
|
|
173
185
|
const captured = captureNetwork(value);
|
|
174
186
|
requireNetwork(new TextEncoder().encode(networkCanonical(captured)).length <= clusterVpnNetworkContract.maximumCanonicalBytes);
|
|
175
|
-
const item = networkRecord(captured, ['authorityId', 'revision', 'nodes', 'grants']);
|
|
187
|
+
const item = networkRecord(captured, ['authorityId', 'controlPrefix', 'revision', 'nodes', 'grants']);
|
|
188
|
+
const controlPrefix = runtimeNetworkVpnControlPrefix(item.controlPrefix);
|
|
176
189
|
const nodes = networkSorted(networkList(item.nodes, clusterVpnNetworkContract.maximumNodes, nodeOf), node => node.nodeId);
|
|
177
190
|
const grants = networkSorted(networkList(item.grants, clusterVpnNetworkContract.maximumGrants, grantOf), grantKey);
|
|
178
191
|
const domainOwners = new Map<string, string>();
|
|
179
192
|
const addresses = new Set<string>(), keys = new Set<string>(), cidrs = new Set<string>();
|
|
180
193
|
for (const node of nodes) {
|
|
181
|
-
requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress)
|
|
194
|
+
requireNetwork(!keys.has(node.publicKey) && !addresses.has(node.controlAddress)
|
|
195
|
+
&& networkContains(controlPrefix, `${node.controlAddress}/32`));
|
|
182
196
|
keys.add(node.publicKey); addresses.add(node.controlAddress);
|
|
183
197
|
for (const prefix of node.workloadPrefixes) {
|
|
184
|
-
requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain)
|
|
198
|
+
requireNetwork(!cidrs.has(prefix.cidr) && !domainOwners.has(prefix.policyDomain)
|
|
199
|
+
&& !networkOverlaps(controlPrefix, prefix.cidr));
|
|
185
200
|
cidrs.add(prefix.cidr); domainOwners.set(prefix.policyDomain, node.nodeId);
|
|
186
201
|
}
|
|
187
202
|
}
|
|
@@ -189,7 +204,7 @@ export const snapshotClusterVpnNetwork = (value: unknown): IClusterVpnNetwork =>
|
|
|
189
204
|
const source = domainOwners.get(grant.sourceDomain), destination = domainOwners.get(grant.destinationDomain);
|
|
190
205
|
requireNetwork(source && destination && source !== destination);
|
|
191
206
|
}
|
|
192
|
-
return { authorityId: networkId(item.authorityId), revision: networkCounter(item.revision), nodes, grants };
|
|
207
|
+
return { authorityId: networkId(item.authorityId), controlPrefix, revision: networkCounter(item.revision), nodes, grants };
|
|
193
208
|
});
|
|
194
209
|
|
|
195
210
|
export type TClusterVpnNetworkAdmission =
|
|
@@ -208,7 +223,11 @@ export const admitClusterVpnNetwork = (heldValue: unknown | null, nextValue: unk
|
|
|
208
223
|
const next = snapshotClusterVpnNetwork(nextValue);
|
|
209
224
|
if (heldValue === null) return { status: 'accepted' };
|
|
210
225
|
const held = snapshotClusterVpnNetwork(heldValue);
|
|
211
|
-
|
|
226
|
+
// A hub binds one control subnet for its lifetime, so a network that moves the prefix under the
|
|
227
|
+
// same authority is as foreign to this hub as one that names another authority.
|
|
228
|
+
if (held.authorityId !== next.authorityId || held.controlPrefix !== next.controlPrefix) {
|
|
229
|
+
return { status: 'refused', reason: 'vpn-network-authority-foreign' };
|
|
230
|
+
}
|
|
212
231
|
if (next.revision > held.revision) return { status: 'accepted' };
|
|
213
232
|
return networkEqual(held, next) ? { status: 'replay' } : { status: 'refused', reason: 'vpn-network-revision-stale' };
|
|
214
233
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
strictCanonicalJsonRules,
|
|
13
13
|
} from '../private/canonicaljson.js';
|
|
14
14
|
import { hasExactKeys } from '../private/dataproperties.js';
|
|
15
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
15
16
|
|
|
16
17
|
/** Operations carried by a short-lived, single-operation restore grant. */
|
|
17
18
|
export const isolatedRestoreGrantOperations = Object.freeze([
|
|
@@ -521,6 +522,14 @@ const readIdentifier = (valueArg: unknown, fieldNameArg: string): string => {
|
|
|
521
522
|
return valueArg;
|
|
522
523
|
};
|
|
523
524
|
|
|
525
|
+
/** A node of the cluster, read in the node vocabulary the controller named it in. */
|
|
526
|
+
const readNodeId = (valueArg: unknown, fieldNameArg: string): string => {
|
|
527
|
+
if (!isClusterNodeId(valueArg)) {
|
|
528
|
+
return fail(`${fieldNameArg} must be a node id`);
|
|
529
|
+
}
|
|
530
|
+
return valueArg;
|
|
531
|
+
};
|
|
532
|
+
|
|
524
533
|
const readScopedId = (valueArg: unknown, fieldNameArg: string): string => {
|
|
525
534
|
if (typeof valueArg !== 'string' || !canonicalScopedIdPattern.test(valueArg)) {
|
|
526
535
|
return fail(`${fieldNameArg} must be a canonical scoped identifier`);
|
|
@@ -1469,7 +1478,7 @@ export const normalizeIsolatedRestoreNodeControl = <TControlGeneric>(
|
|
|
1469
1478
|
const request = readRecord(valueArg, 'node control request');
|
|
1470
1479
|
assertExactKeys(request, ['nodeId', 'control'], 'node control request');
|
|
1471
1480
|
return Object.freeze({
|
|
1472
|
-
nodeId:
|
|
1481
|
+
nodeId: readNodeId(request.nodeId, 'node control request.nodeId'),
|
|
1473
1482
|
control: normalizeControlArg(request.control),
|
|
1474
1483
|
});
|
|
1475
1484
|
};
|
|
@@ -1656,7 +1665,7 @@ export const normalizeIsolatedRestoreProgress = (
|
|
|
1656
1665
|
);
|
|
1657
1666
|
const preparedAt = readSafeInteger(progress.preparedAt, 'restore progress.preparedAt', 1);
|
|
1658
1667
|
const normalized: IIsolatedRestoreProgress = {
|
|
1659
|
-
nodeId:
|
|
1668
|
+
nodeId: readNodeId(progress.nodeId, 'restore progress.nodeId'),
|
|
1660
1669
|
restoreId: readScopedId(progress.restoreId, 'restore progress.restoreId'),
|
|
1661
1670
|
stagingArchiveId: readScopedId(
|
|
1662
1671
|
progress.stagingArchiveId,
|
|
@@ -1737,7 +1746,7 @@ export const bindIsolatedRestoreProgressToNode = (
|
|
|
1737
1746
|
): Readonly<IIsolatedRestoreProgress> => {
|
|
1738
1747
|
const expectation = readRecord(expectationArg, 'restore progress expectation');
|
|
1739
1748
|
assertExactKeys(expectation, ['nodeId', 'restoreId'], 'restore progress expectation');
|
|
1740
|
-
const nodeId =
|
|
1749
|
+
const nodeId = readNodeId(expectation.nodeId, 'restore progress expectation.nodeId');
|
|
1741
1750
|
const restoreId = readScopedId(
|
|
1742
1751
|
expectation.restoreId,
|
|
1743
1752
|
'restore progress expectation.restoreId',
|
|
@@ -39,14 +39,13 @@ export interface IRuntimeNetworkAddressPlan {
|
|
|
39
39
|
* One cluster's managed VPN hub, as the cluster's relay bound it.
|
|
40
40
|
*
|
|
41
41
|
* The hub is its cluster's own packet relay, so `clusterId` names both the hub and the authority
|
|
42
|
-
* that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`.
|
|
43
|
-
*
|
|
42
|
+
* that may select it, and `address` is an underlay address inside the plan's `vpn.hubPrefix`.
|
|
43
|
+
* `quicPort` is the port it bound for the one transport a managed hub serves.
|
|
44
44
|
*/
|
|
45
45
|
export interface IRuntimeNetworkVpnHub {
|
|
46
46
|
clusterId: string;
|
|
47
47
|
address: string;
|
|
48
|
-
quicPort: number
|
|
49
|
-
websocketPort: number | null;
|
|
48
|
+
quicPort: number;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
/** What an administrator writes; Cloudly supplies the id and the revision. */
|
|
@@ -80,50 +79,60 @@ export const runtimeNetworkAddressPlanContract = Object.freeze({
|
|
|
80
79
|
*/
|
|
81
80
|
maximumControlPrefixLength: 30,
|
|
82
81
|
/**
|
|
83
|
-
* Every transport a hub
|
|
82
|
+
* Every transport a managed hub serves, in the order a node prefers them.
|
|
84
83
|
*
|
|
85
84
|
* One row states all three facts that belong together: the transport a credential names, the
|
|
86
85
|
* suffix its platform endpoint id carries, and the protocol the endpoint is composed with. A hub
|
|
87
|
-
*
|
|
86
|
+
* contributes one endpoint of its address per row, so a transport added here is added once for
|
|
87
|
+
* the id derivation, the composition and the credential binder alike.
|
|
88
|
+
*
|
|
89
|
+
* QUIC is the one row: a managed hub terminates TLS on its QUIC listener alone, so no other
|
|
90
|
+
* transport of it can be dialled over a protected connection.
|
|
88
91
|
*/
|
|
89
92
|
hubTransports: Object.freeze([
|
|
90
93
|
Object.freeze({ transport: 'quic', endpointSuffix: 'quic', protocol: 'udp' }),
|
|
91
|
-
Object.freeze({ transport: 'websocket', endpointSuffix: 'ws', protocol: 'tcp' }),
|
|
92
94
|
]),
|
|
93
95
|
} as const);
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The one reader for a managed VPN control prefix: a canonical private prefix no longer than
|
|
99
|
+
* `maximumControlPrefixLength`. The plan states it for the whole runtime network and a cluster's
|
|
100
|
+
* network repeats it for the hub that binds it, so both read it here and neither can admit a
|
|
101
|
+
* subnet the other would refuse.
|
|
102
|
+
*/
|
|
103
|
+
export const runtimeNetworkVpnControlPrefix = (value: unknown): string => networkSafe(() => {
|
|
104
|
+
const prefix = networkPrivatePrefix(value);
|
|
105
|
+
requireNetwork(Number(prefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
|
|
106
|
+
return prefix;
|
|
107
|
+
});
|
|
108
|
+
|
|
95
109
|
/** How a node reaches its cluster's hub, read off the one table that states the transports. */
|
|
96
110
|
export type TRuntimeNetworkVpnTransport = typeof runtimeNetworkAddressPlanContract.hubTransports[number]['transport'];
|
|
97
111
|
|
|
98
|
-
/** The port a hub bound for one transport
|
|
99
|
-
const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number
|
|
100
|
-
|
|
112
|
+
/** The port a hub bound for one transport; one entry per row of the transport table. */
|
|
113
|
+
const hubPort = (hub: IRuntimeNetworkVpnHub, transport: TRuntimeNetworkVpnTransport): number =>
|
|
114
|
+
({ quic: hub.quicPort })[transport];
|
|
101
115
|
|
|
102
116
|
const byEndpointId = <T extends { id: string }>(left: T, right: T): number =>
|
|
103
117
|
left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
104
118
|
|
|
105
119
|
const hubOf = (value: unknown): IRuntimeNetworkVpnHub => {
|
|
106
|
-
const item = networkRecord(value, ['clusterId', 'address', 'quicPort'
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
quicPort: port(item.quicPort), websocketPort: port(item.websocketPort) };
|
|
110
|
-
// A hub that bound no transport is not reachable, so it is not a hub a node could be sent to.
|
|
111
|
-
requireNetwork(runtimeNetworkAddressPlanContract.hubTransports.some(row => hubPort(hub, row.transport) !== null));
|
|
112
|
-
return hub;
|
|
120
|
+
const item = networkRecord(value, ['clusterId', 'address', 'quicPort']);
|
|
121
|
+
return { clusterId: networkId(item.clusterId), address: networkIpv4(item.address),
|
|
122
|
+
quicPort: networkCounter(item.quicPort, 65535) };
|
|
113
123
|
};
|
|
114
124
|
|
|
115
125
|
/**
|
|
116
|
-
* The platform endpoints one hub contributes to a protected authority: one per transport
|
|
117
|
-
* in id order. Every reader derives the same ids from this one function — the composition
|
|
118
|
-
* the authority, and the router selection that names which of them a node may dial.
|
|
126
|
+
* The platform endpoints one hub contributes to a protected authority: one per transport a managed
|
|
127
|
+
* hub serves, in id order. Every reader derives the same ids from this one function — the composition
|
|
128
|
+
* that seals the authority, and the router selection that names which of them a node may dial.
|
|
119
129
|
*/
|
|
120
130
|
export const runtimeNetworkVpnHubEndpoints = (hubValue: unknown): IRuntimeNetworkProtectedAuthority['platformEndpoints'] =>
|
|
121
131
|
networkSafe(() => {
|
|
122
132
|
const hub = hubOf(captureNetwork(hubValue));
|
|
123
133
|
return runtimeNetworkAddressPlanContract.hubTransports
|
|
124
|
-
.filter(row => hubPort(hub, row.transport) !== null)
|
|
125
134
|
.map(row => ({ id: `${hub.clusterId}:${row.endpointSuffix}`, address: hub.address,
|
|
126
|
-
protocol: row.protocol, port: hubPort(hub, row.transport)
|
|
135
|
+
protocol: row.protocol, port: hubPort(hub, row.transport) }))
|
|
127
136
|
.sort(byEndpointId);
|
|
128
137
|
});
|
|
129
138
|
|
|
@@ -154,9 +163,7 @@ const setsWithHubs = (plan: IRuntimeNetworkAddressPlan, hubs: IRuntimeNetworkVpn
|
|
|
154
163
|
|
|
155
164
|
const vpnOf = (value: unknown): IRuntimeNetworkAddressPlan['vpn'] => {
|
|
156
165
|
const item = networkRecord(value, ['controlPrefix', 'hubPrefix']);
|
|
157
|
-
|
|
158
|
-
requireNetwork(Number(controlPrefix.split('/')[1]) <= runtimeNetworkAddressPlanContract.maximumControlPrefixLength);
|
|
159
|
-
return { controlPrefix, hubPrefix: networkPrefix(item.hubPrefix) };
|
|
166
|
+
return { controlPrefix: runtimeNetworkVpnControlPrefix(item.controlPrefix), hubPrefix: networkPrefix(item.hubPrefix) };
|
|
160
167
|
};
|
|
161
168
|
|
|
162
169
|
/**
|
|
@@ -2,6 +2,7 @@ import type { TSha256Digest } from './immutableimage.js';
|
|
|
2
2
|
import type { IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
|
|
3
3
|
import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
|
|
4
4
|
import { networkPrefix, networkPrivatePrefix } from '../private/networkcontract.js';
|
|
5
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
5
6
|
|
|
6
7
|
/** Controller declaration; a digest does not authenticate completeness or activate a pool. */
|
|
7
8
|
export interface IRuntimeNetworkProtectedAuthority {
|
|
@@ -91,6 +92,11 @@ const id = (value: unknown): string => {
|
|
|
91
92
|
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value));
|
|
92
93
|
return value;
|
|
93
94
|
};
|
|
95
|
+
/** A node id, in the node vocabulary every other contract reads it in. */
|
|
96
|
+
const nodeId = (value: unknown): string => {
|
|
97
|
+
requireValue(isClusterNodeId(value));
|
|
98
|
+
return value as string;
|
|
99
|
+
};
|
|
94
100
|
const integer = (value: unknown, maximum = Number.MAX_SAFE_INTEGER - 1): number => {
|
|
95
101
|
requireValue(typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= maximum);
|
|
96
102
|
return value;
|
|
@@ -185,7 +191,7 @@ export const snapshotRuntimeNetworkProtectedAuthority = (value: unknown): IRunti
|
|
|
185
191
|
requireValue(new Set(allEndpointKeys).size === allEndpointKeys.length);
|
|
186
192
|
const egressAuthorities = sorted(list(item.egressAuthorities, runtimeNetworkAuthorityContract.maximumEgressAuthorities, value => {
|
|
187
193
|
const authority = record(value, ['nodeId', 'runtimeNamespace']);
|
|
188
|
-
return { nodeId:
|
|
194
|
+
return { nodeId: nodeId(authority.nodeId), runtimeNamespace: id(authority.runtimeNamespace) };
|
|
189
195
|
}), value => canonical([value.nodeId, value.runtimeNamespace]));
|
|
190
196
|
requireValue(egressAuthorities.length > 0);
|
|
191
197
|
return { id: identity, generation, controller: controller(item.controller), previous,
|
|
@@ -217,7 +223,7 @@ export const snapshotRuntimeNetworkHandoffLease = (value: unknown): IRuntimeNetw
|
|
|
217
223
|
requireValue(typeof item.conntrackLabel === 'string' && /^[a-f0-9]{32}$/.test(item.conntrackLabel)
|
|
218
224
|
&& item.conntrackLabel !== '0'.repeat(32));
|
|
219
225
|
return { id: id(item.id), generation: integer(item.generation), controller: controller(item.controller),
|
|
220
|
-
nodeId:
|
|
226
|
+
nodeId: nodeId(item.nodeId), runtimeNamespace: id(item.runtimeNamespace), protectedAuthority: reference(item.protectedAuthority),
|
|
221
227
|
routerIncarnationId: id(item.routerIncarnationId), transit: { poolId: id(transit.poolId), prefix: transitPrefix, routerAddress, hostAddress },
|
|
222
228
|
sourcePortRanges, conntrackZone: integer(item.conntrackZone, 65535), conntrackLabel: item.conntrackLabel, digest: digest(item.digest) };
|
|
223
229
|
});
|
|
@@ -19,13 +19,6 @@ export interface IGetRuntimeManagedVpnCredentialResponse extends IGetRuntimeMana
|
|
|
19
19
|
transport: TRuntimeNetworkVpnTransport;
|
|
20
20
|
/** The cluster hub endpoint this node dials, named by the id its signed projection selected. */
|
|
21
21
|
hub: { id: string; address: string; port: number };
|
|
22
|
-
/**
|
|
23
|
-
* The TLS name the hub's WebSocket transport is dialled under, present exactly when `transport`
|
|
24
|
-
* is `websocket` and absent otherwise: QUIC is dialled on the address alone. It is the hub
|
|
25
|
-
* relay's own origin hostname, which the node cross-checks against the relay origin it stores,
|
|
26
|
-
* so a name the node does not already trust ends the handshake before any native effect.
|
|
27
|
-
*/
|
|
28
|
-
serverName?: string;
|
|
29
22
|
authorityId: string;
|
|
30
23
|
serverPublicKey: string;
|
|
31
24
|
clientPublicKey: string;
|
|
@@ -42,21 +35,6 @@ export const snapshotGetRuntimeManagedVpnCredentialRequest = (value: unknown): I
|
|
|
42
35
|
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection) };
|
|
43
36
|
});
|
|
44
37
|
|
|
45
|
-
/**
|
|
46
|
-
* The TLS name a WebSocket hub is dialled under: a lower-case domain name of at least two labels,
|
|
47
|
-
* which is what a relay origin's hostname is and what a certificate can be issued for. An address
|
|
48
|
-
* literal is not one, so a node can never be sent to a name nothing vouches for.
|
|
49
|
-
*/
|
|
50
|
-
const serverName = (value: unknown): string => {
|
|
51
|
-
requireNetwork(typeof value === 'string' && value.length <= 253);
|
|
52
|
-
const labels = (value as string).split('.');
|
|
53
|
-
// The last label starts with a letter, which is what makes this a name and not an address
|
|
54
|
-
// literal: a hub reached by address is reached over QUIC, which needs no name at all.
|
|
55
|
-
requireNetwork(labels.length >= 2 && labels.every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
56
|
-
&& /^[a-z]/.test(labels[labels.length - 1]));
|
|
57
|
-
return value as string;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
38
|
const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
|
|
61
39
|
const row = runtimeNetworkAddressPlanContract.hubTransports.find(candidate => candidate.transport === value);
|
|
62
40
|
requireNetwork(row); return row.transport;
|
|
@@ -64,14 +42,11 @@ const transportOf = (value: unknown): TRuntimeNetworkVpnTransport => {
|
|
|
64
42
|
|
|
65
43
|
export const snapshotGetRuntimeManagedVpnCredentialResponse = (value: unknown): IGetRuntimeManagedVpnCredentialResponse => networkSafe(() => {
|
|
66
44
|
const item = networkRecord(captureNetwork(value), ['session', 'projection', 'transport', 'hub', 'authorityId',
|
|
67
|
-
'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']
|
|
68
|
-
const hub = networkRecord(item.hub, ['id', 'address', 'port'])
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
requireNetwork(Object.hasOwn(item, 'serverName') === (transport === 'websocket'));
|
|
72
|
-
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection), transport,
|
|
45
|
+
'serverPublicKey', 'clientPublicKey', 'clientPrivateKey', 'expiresAt']);
|
|
46
|
+
const hub = networkRecord(item.hub, ['id', 'address', 'port']);
|
|
47
|
+
return { session: snapshotRuntimeSessionBinding(item.session), projection: networkReference(item.projection),
|
|
48
|
+
transport: transportOf(item.transport),
|
|
73
49
|
hub: { id: networkId(hub.id), address: networkIpv4(hub.address), port: networkCounter(hub.port, 65535) },
|
|
74
|
-
...(transport === 'websocket' ? { serverName: serverName(item.serverName) } : {}),
|
|
75
50
|
authorityId: networkId(item.authorityId), serverPublicKey: networkNoiseKey(item.serverPublicKey),
|
|
76
51
|
clientPublicKey: networkNoiseKey(item.clientPublicKey), clientPrivateKey: networkNoiseKey(item.clientPrivateKey),
|
|
77
52
|
expiresAt: networkCounter(item.expiresAt) };
|
package/ts/data/networklease.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
|
|
|
3
3
|
import type { IRuntimeNetworkDnsWindow, IRuntimeNetworkNodeScope } from './networkprojection.js';
|
|
4
4
|
import { snapshotRuntimeNetworkNodeScope, snapshotRuntimeNetworkProjection, validateRuntimeNetworkProjection } from './networkprojection.js';
|
|
5
5
|
import { captureNetwork, failNetwork, networkCounter, networkDigest, networkDnsWindow, networkEffectiveDnsWindow, networkEqual,
|
|
6
|
-
networkHash, networkId, networkList, networkMaximumDnsLeaseMs, networkRecord, networkReference, networkReferenceOf,
|
|
6
|
+
networkHash, networkId, networkList, networkMaximumDnsLeaseMs, networkNodeId, networkRecord, networkReference, networkReferenceOf,
|
|
7
7
|
networkRenewsProjection, networkSafe, networkSameScope, networkScope, networkSignature, requireNetwork } from '../private/networkcontract.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -223,7 +223,7 @@ const heldHorizonMs = (renewals: ISignedRuntimeNetworkDnsLeaseRenewal[]): number
|
|
|
223
223
|
export const snapshotHoldRuntimeNetworkDnsLeaseRenewalsRequest = (value: unknown): IHoldRuntimeNetworkDnsLeaseRenewalsRequest => networkSafe(() => {
|
|
224
224
|
const item = networkRecord(captureNetwork(value), ['nodeId', 'projection', 'renewals']);
|
|
225
225
|
const contract = runtimeNetworkDnsLeaseRenewalContract;
|
|
226
|
-
const request: IHoldRuntimeNetworkDnsLeaseRenewalsRequest = { nodeId:
|
|
226
|
+
const request: IHoldRuntimeNetworkDnsLeaseRenewalsRequest = { nodeId: networkNodeId(item.nodeId), projection: networkReference(item.projection),
|
|
227
227
|
renewals: networkList(item.renewals, contract.maximumEscrowedRenewals, snapshotSignedRuntimeNetworkDnsLeaseRenewal) };
|
|
228
228
|
request.renewals.forEach((signed, index) => requireNetwork(signed.renewal.nodeId === request.nodeId
|
|
229
229
|
&& networkEqual(signed.renewal.projection, request.projection)
|
package/ts/data/networknode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
|
|
2
|
-
import { captureNetwork, networkCounter,
|
|
2
|
+
import { captureNetwork, networkCounter, networkNodeId, networkRecord, networkSafe } from '../private/networkcontract.js';
|
|
3
3
|
|
|
4
4
|
type TEgressOwner = IRuntimeNetworkProtectedAuthority['egressAuthorities'][number];
|
|
5
5
|
|
|
@@ -33,5 +33,5 @@ export interface IRuntimeRouterIncarnation {
|
|
|
33
33
|
|
|
34
34
|
export const snapshotRuntimeRouterIncarnation = (value: unknown): IRuntimeRouterIncarnation => networkSafe(() => {
|
|
35
35
|
const item = networkRecord(captureNetwork(value), ['id', 'incarnation']);
|
|
36
|
-
return { id:
|
|
36
|
+
return { id: networkNodeId(item.id), incarnation: networkCounter(item.incarnation) };
|
|
37
37
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TSha256Digest } from './immutableimage.js';
|
|
2
2
|
import type { IRuntimeAssignmentReference, IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
|
|
3
|
-
import { snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
|
|
3
|
+
import { isRuntimeReplicaId, snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
|
|
4
4
|
import type { IRuntimeNetworkHandoffLease, IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
|
|
5
5
|
import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest,
|
|
6
6
|
snapshotRuntimeNetworkHandoffLease, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
|
|
@@ -191,6 +191,10 @@ export const runtimeNetworkProjectionContract = Object.freeze({
|
|
|
191
191
|
allocationReuse: 'requires-controller-quarantine-and-all-affected-authority-fences',
|
|
192
192
|
} as const);
|
|
193
193
|
|
|
194
|
+
/** The assignment contract owns the replica rule; a lease names the same slot by the same name. */
|
|
195
|
+
const replicaId = (value: unknown): string => {
|
|
196
|
+
requireNetwork(isRuntimeReplicaId(value)); return value as string;
|
|
197
|
+
};
|
|
194
198
|
const ownerKey = (value: { organizationId: string; serviceId: string }): string => networkCanonical([value.organizationId, value.serviceId]);
|
|
195
199
|
const attemptKey = (value: IRuntimeNetworkWorkloadLease): string =>
|
|
196
200
|
networkCanonical([value.organizationId, value.serviceId, value.replicaId, value.attemptId]);
|
|
@@ -220,7 +224,7 @@ export const snapshotRuntimeNetworkWorkloadLease = (value: unknown): IRuntimeNet
|
|
|
220
224
|
requireNetwork(item.dnsServer === gateway);
|
|
221
225
|
return { id: networkId(item.id), generation: networkCounter(item.generation), ...networkScope(item),
|
|
222
226
|
organizationId: networkId(item.organizationId), assignmentId: networkId(item.assignmentId), serviceId: networkId(item.serviceId),
|
|
223
|
-
replicaId:
|
|
227
|
+
replicaId: replicaId(item.replicaId), attemptId: networkId(item.attemptId), protectedAuthority: networkReference(item.protectedAuthority),
|
|
224
228
|
routerIncarnationId: networkId(item.routerIncarnationId), ipv4: { poolId: networkId(ip.poolId), linkSubnet: ip.linkSubnet,
|
|
225
229
|
address, sourcePrefix: ip.sourcePrefix, gateway }, dnsServer: gateway, digest: networkDigest(item.digest) };
|
|
226
230
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
readDataElements, readDataProperty, readExactDataRecord,
|
|
10
10
|
} from '../private/dataproperties.js';
|
|
11
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
11
12
|
|
|
12
13
|
export interface INodeEnrollmentCredentialProposal {
|
|
13
14
|
purpose: TNodeCredentialPurpose;
|
|
@@ -139,9 +140,10 @@ const pair = (valueArg: unknown): [unknown, unknown] => {
|
|
|
139
140
|
requireValue(elements !== null && elements.length === 2);
|
|
140
141
|
return [elements[0], elements[1]];
|
|
141
142
|
};
|
|
143
|
+
/** Enrollment names the node, its installations and its mutations in the node's own vocabulary. */
|
|
142
144
|
const id = (valueArg: unknown): string => {
|
|
143
|
-
requireValue(
|
|
144
|
-
return valueArg;
|
|
145
|
+
requireValue(isClusterNodeId(valueArg));
|
|
146
|
+
return valueArg as string;
|
|
145
147
|
};
|
|
146
148
|
const hash = (valueArg: unknown): string => {
|
|
147
149
|
requireValue(typeof valueArg === 'string' && /^[a-f0-9]{64}$/.test(valueArg));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { captureNetwork, networkCounter, networkId, networkRecord, requireNetwork } from '../private/networkcontract.js';
|
|
1
|
+
import { captureNetwork, networkCounter, networkId, networkNodeId, networkRecord, requireNetwork } from '../private/networkcontract.js';
|
|
2
2
|
import { isOneOf, producerSafe } from '../private/producercontract.js';
|
|
3
3
|
|
|
4
4
|
const nodeRuntimeRetirementStates = ['retiring', 'retired'] as const;
|
|
@@ -58,6 +58,6 @@ export type TNodeRuntimeRetirementRefusal = typeof nodeRuntimeRetirementRefusals
|
|
|
58
58
|
export const snapshotNodeRuntimeRetirement = (value: unknown): INodeRuntimeRetirement => producerSafe(() => {
|
|
59
59
|
const item = networkRecord(captureNetwork(value), ['id', 'clusterId', 'state', 'actorId', 'since']);
|
|
60
60
|
requireNetwork(isOneOf(nodeRuntimeRetirementStates, item.state));
|
|
61
|
-
return { id:
|
|
61
|
+
return { id: networkNodeId(item.id), clusterId: networkId(item.clusterId), state: item.state,
|
|
62
62
|
actorId: networkId(item.actorId), since: networkCounter(item.since) };
|
|
63
63
|
});
|
|
@@ -3,6 +3,7 @@ import { buildDigestPinnedImageReference, isSha256Digest } from './immutableimag
|
|
|
3
3
|
import {
|
|
4
4
|
canonicalizeStrictJson, compareWireTokens, createCanonicalJsonSha256Hex,
|
|
5
5
|
} from '../private/canonicaljson.js';
|
|
6
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
6
7
|
import { isCanonicalTokenList } from '../private/tokenlist.js';
|
|
7
8
|
|
|
8
9
|
export interface IRuntimeController {
|
|
@@ -184,6 +185,27 @@ export const runtimeAssignmentContract = Object.freeze({
|
|
|
184
185
|
const fail: (messageArg: string) => never = (messageArg) => { throw new Error(messageArg); };
|
|
185
186
|
const isIdentifier = (valueArg: unknown): valueArg is string =>
|
|
186
187
|
typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
|
|
188
|
+
/** The index of a replica slot: counted from zero by its producer, never zero-padded. */
|
|
189
|
+
const isReplicaIndex = (valueArg: string): boolean => /^(?:0|[1-9][0-9]{0,8})$/.test(valueArg);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* True when a value is exactly a replica id.
|
|
193
|
+
*
|
|
194
|
+
* A producer names a replica slot after the node that runs it — `<node id>.<index>`, counted from
|
|
195
|
+
* zero — so a replica id that carries an index is read by decomposition: the part before the last
|
|
196
|
+
* dot in the node vocabulary, the index on its own. Reading the composed form as one canonical
|
|
197
|
+
* identifier would refuse every replica of a node whose id begins with `-` or `_`, a node these
|
|
198
|
+
* contracts themselves enrol and register. A name that carries no such index — a zero-padded index
|
|
199
|
+
* among them — is an opaque slot name and is read whole in the canonical identifier vocabulary, as
|
|
200
|
+
* it always was.
|
|
201
|
+
*/
|
|
202
|
+
export const isRuntimeReplicaId = (valueArg: unknown): valueArg is string => {
|
|
203
|
+
if (typeof valueArg !== 'string') return false;
|
|
204
|
+
const separator = valueArg.lastIndexOf('.');
|
|
205
|
+
return separator > 0 && isReplicaIndex(valueArg.slice(separator + 1))
|
|
206
|
+
? isClusterNodeId(valueArg.slice(0, separator))
|
|
207
|
+
: isIdentifier(valueArg);
|
|
208
|
+
};
|
|
187
209
|
const isPositiveInteger = (valueArg: unknown): valueArg is number =>
|
|
188
210
|
typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg > 0;
|
|
189
211
|
const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
|
|
@@ -244,10 +266,11 @@ const assertAssignment = (valueArg: unknown): IRuntimeAssignment => {
|
|
|
244
266
|
|| !['run', 'stop-preserve', 'remove-runtime-preserve-storage'].includes(
|
|
245
267
|
valueArg.disposition as string,
|
|
246
268
|
)) fail('runtime assignment must use its exact schema');
|
|
247
|
-
for (const key of ['organizationId', '
|
|
248
|
-
'serviceId', 'replicaId', 'attemptId']) {
|
|
269
|
+
for (const key of ['organizationId', 'runtimeNamespace', 'assignmentId', 'serviceId', 'attemptId']) {
|
|
249
270
|
if (!isIdentifier(valueArg[key])) fail(`runtime assignment ${key} must be a canonical identifier`);
|
|
250
271
|
}
|
|
272
|
+
if (!isClusterNodeId(valueArg.nodeId)) fail('runtime assignment nodeId must be a node id');
|
|
273
|
+
if (!isRuntimeReplicaId(valueArg.replicaId)) fail('runtime assignment replicaId must be a replica id');
|
|
251
274
|
for (const key of ['workerGroupId', 'siteId']) {
|
|
252
275
|
if (valueArg[key] !== null && !isIdentifier(valueArg[key])) fail('invalid runtime topology ID');
|
|
253
276
|
}
|
|
@@ -357,7 +380,7 @@ export const evaluateRuntimeAssignment = async (
|
|
|
357
380
|
acceptedArg = acceptedArg === null ? null : snapshotRuntimeAssignment(acceptedArg);
|
|
358
381
|
if (!isExactRecord(trustedScopeArg, ['controller', 'organizationId', 'nodeId', 'runtimeNamespace'])
|
|
359
382
|
|| !isController(trustedScopeArg.controller)
|
|
360
|
-
|| !isIdentifier(trustedScopeArg.organizationId) || !
|
|
383
|
+
|| !isIdentifier(trustedScopeArg.organizationId) || !isClusterNodeId(trustedScopeArg.nodeId)
|
|
361
384
|
|| !isIdentifier(trustedScopeArg.runtimeNamespace)) return { status: 'reject', reason: 'invalid' };
|
|
362
385
|
trustedScopeArg = JSON.parse(canonical(trustedScopeArg)) as IRuntimeAssignmentScope;
|
|
363
386
|
} catch {
|
|
@@ -12,6 +12,7 @@ import { negotiateProtocol, readProtocolOffer, type IProtocolOffer } from '../pr
|
|
|
12
12
|
import type { IRuntimeConfig } from './runtimeconfig.js';
|
|
13
13
|
import { bindRuntimeConfigToAssignment, snapshotRuntimeConfig } from './runtimeconfig.js';
|
|
14
14
|
import { canonicalizeStrictJson } from '../private/canonicaljson.js';
|
|
15
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Server-issued public binding for the current authenticated physical connection.
|
|
@@ -178,8 +179,8 @@ const identifier = (valueArg: unknown): string => {
|
|
|
178
179
|
return valueArg;
|
|
179
180
|
};
|
|
180
181
|
const nodeId = (valueArg: unknown): string => {
|
|
181
|
-
requireValue(
|
|
182
|
-
return valueArg;
|
|
182
|
+
requireValue(isClusterNodeId(valueArg));
|
|
183
|
+
return valueArg as string;
|
|
183
184
|
};
|
|
184
185
|
/** One node process, in the same canonical form as the native boot UUID. */
|
|
185
186
|
const nodeInstanceNonce = (valueArg: unknown): string => {
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
} from './runtimeassignment.js';
|
|
4
4
|
import type { IRuntimeConfigLogPolicy, TRuntimeConfigReadiness } from './runtimeconfig.js';
|
|
5
5
|
import { runtimeNetworkAuthorityContract } from './networkauthority.js';
|
|
6
|
-
import { captureNetwork, networkCanonical, networkCounter, networkId, networkList, networkRecord, networkSorted,
|
|
6
|
+
import { captureNetwork, networkCanonical, networkCounter, networkId, networkList, networkNodeId, networkRecord, networkSorted,
|
|
7
7
|
requireNetwork } from '../private/networkcontract.js';
|
|
8
8
|
import { failProducer, isOneOf, producerSafe, variantOf } from '../private/producercontract.js';
|
|
9
9
|
import { assertRuntimeReadiness, isBoundedInteger, isRuntimeCpuMillis, isRuntimeIdentityNumber, isRuntimeLogPolicy,
|
|
@@ -138,8 +138,9 @@ export interface IServiceRuntimeAssignmentView {
|
|
|
138
138
|
delivery: TServiceRuntimeAssignmentDelivery;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
/** Sorted, duplicate-free and read by the vocabulary the ids belong to: a node's, or the canonical one. */
|
|
142
|
+
const sortedIds = (value: unknown, maximum: number, read: (item: unknown) => string): string[] =>
|
|
143
|
+
networkSorted(networkList(value, maximum, read), id => id);
|
|
143
144
|
|
|
144
145
|
const placementOf = (value: unknown): TServiceRuntimePlacement => {
|
|
145
146
|
const kind = variantOf(value, 'kind');
|
|
@@ -148,7 +149,7 @@ const placementOf = (value: unknown): TServiceRuntimePlacement => {
|
|
|
148
149
|
return { kind };
|
|
149
150
|
}
|
|
150
151
|
requireNetwork(kind === 'nodes');
|
|
151
|
-
const nodeIds = sortedIds(networkRecord(value, ['kind', 'nodeIds']).nodeIds, serviceRuntimeContract.maximumPinnedNodes);
|
|
152
|
+
const nodeIds = sortedIds(networkRecord(value, ['kind', 'nodeIds']).nodeIds, serviceRuntimeContract.maximumPinnedNodes, networkNodeId);
|
|
152
153
|
requireNetwork(nodeIds.length > 0);
|
|
153
154
|
return { kind, nodeIds };
|
|
154
155
|
};
|
|
@@ -182,7 +183,7 @@ const networkOf = (value: unknown): TServiceRuntimeNetwork => {
|
|
|
182
183
|
const item = networkRecord(value, ['mode', 'publicEgress', 'platformEndpointIds']);
|
|
183
184
|
requireNetwork(typeof item.publicEgress === 'boolean');
|
|
184
185
|
return { mode, publicEgress: item.publicEgress,
|
|
185
|
-
platformEndpointIds: sortedIds(item.platformEndpointIds, serviceRuntimeContract.maximumPlatformEndpoints) };
|
|
186
|
+
platformEndpointIds: sortedIds(item.platformEndpointIds, serviceRuntimeContract.maximumPlatformEndpoints, networkId) };
|
|
186
187
|
};
|
|
187
188
|
|
|
188
189
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isReleaseVersion } from '../private/releaseversion.js';
|
|
2
2
|
import { readExactDataRecord } from '../private/dataproperties.js';
|
|
3
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
3
4
|
import { protocolRefusalHttpStatus, readProtocolOffer, type IProtocolOffer } from '../protocol.js';
|
|
4
5
|
|
|
5
6
|
/** Host observations only. Container and workload readiness belong to Pallet. */
|
|
@@ -80,6 +81,10 @@ const text = (valueArg: unknown, patternArg: RegExp, maxLengthArg: number): stri
|
|
|
80
81
|
&& valueArg.match(patternArg)?.[0] === valueArg);
|
|
81
82
|
return valueArg;
|
|
82
83
|
};
|
|
84
|
+
// The shared vocabulary, not a copy of it: the id on this wire is the id the node enrolled under.
|
|
85
|
+
const nodeId = (valueArg: unknown): string => (
|
|
86
|
+
isClusterNodeId(valueArg) ? valueArg : fail()
|
|
87
|
+
);
|
|
83
88
|
// The shared grammar, not a copy of it: a stored bundle version has to be one the release
|
|
84
89
|
// comparison can order, and a second rule here would be a second answer to that question.
|
|
85
90
|
const releaseVersion = (valueArg: unknown): string => (
|
|
@@ -131,7 +136,7 @@ export const snapshotSparkNodeReport = (valueArg: unknown): ISparkNodeReport =>
|
|
|
131
136
|
export const snapshotSparkNodeHeartbeatRequest = (valueArg: unknown): ISparkNodeHeartbeatRequest => {
|
|
132
137
|
const value = record(valueArg, ['nodeId', 'nodeToken', 'protocol', 'report']);
|
|
133
138
|
return Object.freeze({
|
|
134
|
-
nodeId:
|
|
139
|
+
nodeId: nodeId(value.nodeId),
|
|
135
140
|
// Both freshly enrolled and explicitly adopted credentials use this bearer format.
|
|
136
141
|
nodeToken: text(value.nodeToken, /^secureToken[a-f0-9]{64}$/, 75),
|
|
137
142
|
protocol: readProtocolOffer(value),
|
|
@@ -144,7 +149,7 @@ export const snapshotSparkNodeHeartbeatReceipt = (valueArg: unknown): ISparkNode
|
|
|
144
149
|
'sessionEpoch', 'acceptedAt', 'protocol']);
|
|
145
150
|
requireValue(value.accepted === true);
|
|
146
151
|
return Object.freeze({
|
|
147
|
-
accepted: true, nodeId:
|
|
152
|
+
accepted: true, nodeId: nodeId(value.nodeId),
|
|
148
153
|
credentialGeneration: integer(value.credentialGeneration, 1),
|
|
149
154
|
sessionEpoch: integer(value.sessionEpoch, 0), acceptedAt: integer(value.acceptedAt, 1),
|
|
150
155
|
protocol: readProtocolOffer(value),
|
|
@@ -3,6 +3,7 @@ import type { IRuntimeAuthorityReference, IRuntimeController } from '../data/run
|
|
|
3
3
|
import type { IRuntimeNetworkDnsLeaseRenewal } from '../data/networklease.js';
|
|
4
4
|
import type { IRuntimeNetworkDnsWindow, IRuntimeNetworkNodeScope, IRuntimeNetworkProjection } from '../data/networkprojection.js';
|
|
5
5
|
import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from './canonicaljson.js';
|
|
6
|
+
import { isClusterNodeId } from './nodeidentifier.js';
|
|
6
7
|
|
|
7
8
|
export const failNetwork = (): never => { throw new Error('Runtime network projection contract rejected.'); };
|
|
8
9
|
export function requireNetwork(value: unknown): asserts value { if (!value) failNetwork(); }
|
|
@@ -79,6 +80,10 @@ export const networkSorted = <T>(values: T[], key: (item: T) => string): T[] =>
|
|
|
79
80
|
export const networkId = (value: unknown): string => {
|
|
80
81
|
requireNetwork(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value)); return value;
|
|
81
82
|
};
|
|
83
|
+
/** A node id is read in the node vocabulary here as everywhere else, never in the canonical one. */
|
|
84
|
+
export const networkNodeId = (value: unknown): string => {
|
|
85
|
+
requireNetwork(isClusterNodeId(value)); return value as string;
|
|
86
|
+
};
|
|
82
87
|
export const networkCounter = (value: unknown, maximum = Number.MAX_SAFE_INTEGER - 1): number => {
|
|
83
88
|
requireNetwork(typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= maximum); return value;
|
|
84
89
|
};
|
|
@@ -106,7 +111,7 @@ export const networkController = (value: unknown): IRuntimeController => {
|
|
|
106
111
|
return { kind: item.kind, id: networkId(item.id), epoch: networkId(item.epoch) };
|
|
107
112
|
};
|
|
108
113
|
export const networkScope = (item: Record<string, unknown>): IRuntimeNetworkNodeScope => ({
|
|
109
|
-
controller: networkController(item.controller), nodeId:
|
|
114
|
+
controller: networkController(item.controller), nodeId: networkNodeId(item.nodeId), runtimeNamespace: networkId(item.runtimeNamespace),
|
|
110
115
|
});
|
|
111
116
|
export const networkSameScope = (left: IRuntimeNetworkNodeScope, right: IRuntimeNetworkNodeScope): boolean =>
|
|
112
117
|
networkEqual(left.controller, right.controller) && left.nodeId === right.nodeId && left.runtimeNamespace === right.runtimeNamespace;
|