@serve.zone/interfaces 32.28.0 → 32.30.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 +81 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/cluster.d.ts +1 -1
- package/dist_ts/data/cluster.js +6 -1
- package/dist_ts/data/networkaddressplan.d.ts +27 -3
- package/dist_ts/data/networkaddressplan.js +38 -2
- package/dist_ts/data/networkprojection.d.ts +74 -3
- package/dist_ts/data/networkprojection.js +21 -2
- package/dist_ts/data/secret.d.ts +11 -0
- package/dist_ts/data/secret.js +13 -1
- package/dist_ts/data/serviceruntime.d.ts +19 -3
- package/dist_ts/data/serviceruntime.js +2 -2
- package/dist_ts/data/settings.d.ts +51 -0
- package/dist_ts/data/settings.js +83 -1
- package/dist_ts/platform/corestoreendpointselection.d.ts +37 -0
- package/dist_ts/platform/corestoreendpointselection.js +45 -0
- package/dist_ts/platform/index.d.ts +3 -1
- package/dist_ts/platform/index.js +4 -2
- package/dist_ts/platform/objectstorage.js +5 -1
- package/dist_ts/private/networkcontract.d.ts +20 -0
- package/dist_ts/private/networkcontract.js +31 -9
- package/dist_ts/requests/network.d.ts +62 -3
- package/dist_ts/requests/settings.d.ts +56 -2
- package/dist_ts/requests/settings.js +91 -1
- package/package.json +3 -3
- package/readme.md +151 -12
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/cluster.ts +5 -0
- package/ts/data/networkaddressplan.ts +66 -4
- package/ts/data/networkprojection.ts +96 -4
- package/ts/data/secret.ts +16 -0
- package/ts/data/serviceruntime.ts +19 -3
- package/ts/data/settings.ts +127 -0
- package/ts/platform/corestoreendpointselection.ts +79 -0
- package/ts/platform/index.ts +3 -0
- package/ts/platform/objectstorage.ts +4 -0
- package/ts/private/networkcontract.ts +43 -4
- package/ts/requests/network.ts +58 -3
- package/ts/requests/settings.ts +162 -2
|
@@ -2,7 +2,7 @@ import type { TSha256Digest } from './immutableimage.js';
|
|
|
2
2
|
import type { IRuntimeAssignmentReference, IRuntimeAuthorityReference, IRuntimeController } from './runtimeassignment.js';
|
|
3
3
|
import { isRuntimeReplicaId, snapshotRuntimeAssignment, validateRuntimeAssignment } from './runtimeassignment.js';
|
|
4
4
|
import type { IRuntimeNetworkHandoffLease, IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
|
|
5
|
-
import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest,
|
|
5
|
+
import { bindRuntimeNetworkHandoffLease, computeRuntimeNetworkProtectedAuthorityDigest, runtimeNetworkAuthorityContract,
|
|
6
6
|
snapshotRuntimeNetworkHandoffLease, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
|
|
7
7
|
import type { IPrivateNetwork, IServicePrivateNetworks, IServicePrivateNetworkDnsPolicy } from './privatenetwork.js';
|
|
8
8
|
import { routerPacketGrantsOf, snapshotRouterProjection,
|
|
@@ -95,7 +95,12 @@ export interface IRuntimeNetworkPublishedPortRequest {
|
|
|
95
95
|
targetPort: number;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Current authorized published-port policy for one service.
|
|
100
|
+
*
|
|
101
|
+
* The published-port half of the service's one policy document; `IServiceNetworkEgress` reads the
|
|
102
|
+
* egress half of the same document, so both carry the same `revision`.
|
|
103
|
+
*/
|
|
99
104
|
export interface IServicePublishedPorts {
|
|
100
105
|
organizationId: string;
|
|
101
106
|
serviceId: string;
|
|
@@ -104,17 +109,82 @@ export interface IServicePublishedPorts {
|
|
|
104
109
|
publishedPorts: IRuntimeNetworkPublishedPortRequest[];
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Exact desired policy. Node-level host-port placement remains Cloudly's decision.
|
|
114
|
+
*
|
|
115
|
+
* One document per service holds its published ports and its egress under one `revision`. The
|
|
116
|
+
* first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
|
|
117
|
+
* with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
|
|
118
|
+
* no published ports. From then on both reads return it, never null. It is an explicit policy: an
|
|
119
|
+
* attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
|
|
120
|
+
* no public address and no platform endpoint; `network-policy-missing` means the service has no
|
|
121
|
+
* document at all.
|
|
122
|
+
*/
|
|
108
123
|
export interface ISetServicePublishedPorts {
|
|
109
124
|
organizationId: string;
|
|
110
125
|
serviceId: string;
|
|
111
126
|
/** Retain the exact body and this identifier across retries. */
|
|
112
127
|
mutationId: string;
|
|
113
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* The revision of the service's one policy document the caller read, shared with its egress: an
|
|
130
|
+
* accepted `ISetServiceNetworkEgress` advances it too. null means the service has never had a
|
|
131
|
+
* policy document, neither published ports nor egress; it is not an empty list.
|
|
132
|
+
*/
|
|
114
133
|
expectedRevision: number | null;
|
|
115
134
|
publishedPorts: IRuntimeNetworkPublishedPortRequest[];
|
|
116
135
|
}
|
|
117
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Current authorized egress policy for one service: whether its attached workloads reach the public
|
|
139
|
+
* internet, and which platform endpoints of the protected authority they reach.
|
|
140
|
+
*
|
|
141
|
+
* The egress half of the service's one policy document; `IServicePublishedPorts` reads the
|
|
142
|
+
* published-port half of the same document, so both carry the same `revision`. Every projection
|
|
143
|
+
* endpoint of the service carries exactly these values (`IRuntimeNetworkEndpoint`), and an attached
|
|
144
|
+
* runtime spec (`TServiceRuntimeNetwork`) is admitted only while it states the same ones: its
|
|
145
|
+
* disagreement is `network-policy-mismatch`, a service without a document `network-policy-missing`.
|
|
146
|
+
* Nothing else grants a workload egress.
|
|
147
|
+
*/
|
|
148
|
+
export interface IServiceNetworkEgress {
|
|
149
|
+
organizationId: string;
|
|
150
|
+
serviceId: string;
|
|
151
|
+
revision: number;
|
|
152
|
+
/** TCP/UDP only; the complete protected union remains denied. */
|
|
153
|
+
publicEgress: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Ids of `IRuntimeNetworkProtectedAuthority.platformEndpoints`, sorted and unique, at most
|
|
156
|
+
* `runtimeNetworkAuthorityContract.maximumPlatformEndpoints`. Empty selects none.
|
|
157
|
+
*/
|
|
158
|
+
platformEndpointIds: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Exact desired egress. The published ports of the same document are carried forward unchanged.
|
|
163
|
+
* Whether each id names a current platform endpoint is Cloudly's decision at acceptance.
|
|
164
|
+
*
|
|
165
|
+
* One document per service holds its published ports and its egress under one `revision`. The
|
|
166
|
+
* first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
|
|
167
|
+
* with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
|
|
168
|
+
* no published ports. From then on both reads return it, never null. It is an explicit policy: an
|
|
169
|
+
* attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
|
|
170
|
+
* no public address and no platform endpoint; `network-policy-missing` means the service has no
|
|
171
|
+
* document at all.
|
|
172
|
+
*/
|
|
173
|
+
export interface ISetServiceNetworkEgress {
|
|
174
|
+
organizationId: string;
|
|
175
|
+
serviceId: string;
|
|
176
|
+
/** Retain the exact body and this identifier across retries. */
|
|
177
|
+
mutationId: string;
|
|
178
|
+
/**
|
|
179
|
+
* The revision of the service's one policy document the caller read, shared with its published
|
|
180
|
+
* ports: an accepted `ISetServicePublishedPorts` advances it too. null means the service has
|
|
181
|
+
* never had a policy document, neither published ports nor egress.
|
|
182
|
+
*/
|
|
183
|
+
expectedRevision: number | null;
|
|
184
|
+
publicEgress: boolean;
|
|
185
|
+
platformEndpointIds: string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
118
188
|
/** Controller-owned readiness evidence. A signature does not prove this evidence's provenance. */
|
|
119
189
|
export interface IRuntimeNetworkEndpoint {
|
|
120
190
|
lease: IRuntimeNetworkWorkloadLease;
|
|
@@ -313,6 +383,28 @@ export const snapshotSetServicePublishedPorts = (value: unknown): ISetServicePub
|
|
|
313
383
|
publishedPorts: publishedPortRequests(item.publishedPorts) };
|
|
314
384
|
});
|
|
315
385
|
|
|
386
|
+
const servicePlatformEndpointIds = (value: unknown): string[] => networkSorted(
|
|
387
|
+
networkList(value, runtimeNetworkAuthorityContract.maximumPlatformEndpoints, networkId), id => id);
|
|
388
|
+
const servicePublicEgress = (value: unknown): boolean => {
|
|
389
|
+
requireNetwork(typeof value === 'boolean'); return value;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** Structural snapshot only; whether each id names a current platform endpoint remains Cloudly's decision. */
|
|
393
|
+
export const snapshotServiceNetworkEgress = (value: unknown): IServiceNetworkEgress => networkSafe(() => {
|
|
394
|
+
const item = networkRecord(value, ['organizationId', 'serviceId', 'revision', 'publicEgress', 'platformEndpointIds']);
|
|
395
|
+
return { organizationId: networkId(item.organizationId), serviceId: networkId(item.serviceId),
|
|
396
|
+
revision: networkCounter(item.revision), publicEgress: servicePublicEgress(item.publicEgress),
|
|
397
|
+
platformEndpointIds: servicePlatformEndpointIds(item.platformEndpointIds) };
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
export const snapshotSetServiceNetworkEgress = (value: unknown): ISetServiceNetworkEgress => networkSafe(() => {
|
|
401
|
+
const item = networkRecord(value, ['organizationId', 'serviceId', 'mutationId', 'expectedRevision', 'publicEgress',
|
|
402
|
+
'platformEndpointIds']);
|
|
403
|
+
return { organizationId: networkId(item.organizationId), serviceId: networkId(item.serviceId),
|
|
404
|
+
mutationId: networkId(item.mutationId), expectedRevision: item.expectedRevision === null ? null : networkCounter(item.expectedRevision),
|
|
405
|
+
publicEgress: servicePublicEgress(item.publicEgress), platformEndpointIds: servicePlatformEndpointIds(item.platformEndpointIds) };
|
|
406
|
+
});
|
|
407
|
+
|
|
316
408
|
const endpoint = (value: unknown): IRuntimeNetworkEndpoint => {
|
|
317
409
|
const hasPublishedPorts = value !== null && typeof value === 'object' && Object.hasOwn(value, 'publishedPorts');
|
|
318
410
|
const item = networkRecord(value, ['lease', 'ready', 'observation', 'publicEgress', 'platformEndpointIds',
|
package/ts/data/secret.ts
CHANGED
|
@@ -1893,6 +1893,22 @@ export const createAppStoreInstallSecretEnvelopeContext = (contextArg: {
|
|
|
1893
1893
|
: null,
|
|
1894
1894
|
});
|
|
1895
1895
|
|
|
1896
|
+
/**
|
|
1897
|
+
* The context a dcrouter admin bootstrap token is sealed under for `bootstrapExternalGatewayCredential`.
|
|
1898
|
+
*
|
|
1899
|
+
* It binds the envelope to the dcrouter URL and the gateway client id the administrator named, so a
|
|
1900
|
+
* sealed token opens only for a request that states the same target, and Cloudly hands it only to
|
|
1901
|
+
* a gateway its own settings name.
|
|
1902
|
+
*/
|
|
1903
|
+
export const createGatewayBootstrapEnvelopeContext = (contextArg: {
|
|
1904
|
+
gatewayUrl: string;
|
|
1905
|
+
gatewayClientId: string;
|
|
1906
|
+
}): Uint8Array => contextBytes({
|
|
1907
|
+
purpose: 'serve.zone/gateway-bootstrap',
|
|
1908
|
+
gatewayUrl: contextArg.gatewayUrl,
|
|
1909
|
+
gatewayClientId: contextArg.gatewayClientId,
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1896
1912
|
export const createResolvedSecretMaterialEnvelopeContext = (contextArg: {
|
|
1897
1913
|
recipientKeyId: string;
|
|
1898
1914
|
organizationId: string;
|
|
@@ -33,7 +33,8 @@ export interface IServiceRuntimeProfile {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* `isolated` gives the workload no network authority. `attached` joins the runtime network with
|
|
36
|
-
* public egress on or off and the platform endpoints it may reach, by id: sorted and unique.
|
|
36
|
+
* public egress on or off and the platform endpoints it may reach, by id: sorted and unique. The
|
|
37
|
+
* service's `IServiceNetworkEgress` must state the same values, or the workload is not admitted.
|
|
37
38
|
*/
|
|
38
39
|
export type TServiceRuntimeNetwork =
|
|
39
40
|
| { mode: 'isolated' }
|
|
@@ -89,7 +90,7 @@ const serviceRuntimeRefusals = [
|
|
|
89
90
|
'volume-driver-unsupported', 'volume-options-unsupported', 'volume-shared-unsupported',
|
|
90
91
|
'volume-placement-unsupported', 'volume-initialization-unknown', 'volume-ownership-unknown',
|
|
91
92
|
'objectstorage-endpoint-missing', 'objectstorage-binding-invalid', 'objectstorage-environment-conflict',
|
|
92
|
-
'database-endpoint-missing',
|
|
93
|
+
'objectstorage-endpoint-unselected', 'database-endpoint-missing', 'database-endpoint-unselected',
|
|
93
94
|
] as const;
|
|
94
95
|
|
|
95
96
|
/**
|
|
@@ -114,7 +115,8 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
|
|
|
114
115
|
* `port-protocol-unsupported` is about one port's protocol — and `node-authority-unsupported` is a
|
|
115
116
|
* node whose session resolved fewer workload authorities than this spec needs, which the admit step
|
|
116
117
|
* refuses rather than handing the node work it would silently park. An attached network needs an
|
|
117
|
-
* explicit matching policy
|
|
118
|
+
* explicit matching policy (`IServiceNetworkEgress`, written by `setServiceNetworkEgress`) and a
|
|
119
|
+
* current workload address pool; neither is defaulted.
|
|
118
120
|
*
|
|
119
121
|
* The volume refusals name what a local storage claim cannot express: `volume-driver-unsupported`
|
|
120
122
|
* a volume of a driver other than a node-local directory, `volume-options-unsupported` driver or
|
|
@@ -133,6 +135,20 @@ export type TServiceRuntimeWaitingReason = typeof serviceRuntimeWaitingReasons[n
|
|
|
133
135
|
* `database-endpoint-missing` is an enabled database binding placed on a node that states no valid
|
|
134
136
|
* `corestoreDatabase`: its credential cannot be addressed to any host a workload reaches, so it is
|
|
135
137
|
* neither granted nor published, and the workload is not started with a guessed one.
|
|
138
|
+
*
|
|
139
|
+
* The two `-unselected` refusals name a binding endpoint the workload's network cannot reach, so a
|
|
140
|
+
* workload is refused rather than started against a connection that stays dark. A workload reaches
|
|
141
|
+
* a platform endpoint only when its spec's attached network selects it (`platformEndpointIds`), and
|
|
142
|
+
* an `isolated` network selects none. `database-endpoint-unselected` is an enabled database binding
|
|
143
|
+
* whose node's valid `corestoreDatabase` is not a selected endpoint: none of the selected
|
|
144
|
+
* `IRuntimeNetworkProtectedAuthority.platformEndpoints` has the endpoint's `host` as its `address`,
|
|
145
|
+
* `tcp` as its `protocol` and the endpoint's `port` as its `port`. `objectstorage-endpoint-unselected`
|
|
146
|
+
* is the same for an enabled object-storage binding's node's valid `corestoreObjectStorage`, whose
|
|
147
|
+
* `endpoint` origin states the host and the port (the scheme's default, 80 for `http:` and 443 for
|
|
148
|
+
* `https:`, when it states none). Platform endpoints are IPv4, so an endpoint whose host is a DNS name
|
|
149
|
+
* or an IPv6 literal is never selected. Each is judged only for an endpoint that is valid: a node
|
|
150
|
+
* stating none, or an invalid one, stays `database-endpoint-missing` or `objectstorage-endpoint-missing`.
|
|
151
|
+
* `platform.findUnselectedCorestoreEndpoints` is this rule; a producer answers what it returns.
|
|
136
152
|
*/
|
|
137
153
|
export type TServiceRuntimeRefusal = typeof serviceRuntimeRefusals[number];
|
|
138
154
|
|
package/ts/data/settings.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TServiceMailSubmissionTlsMode } from './mail.js';
|
|
2
2
|
import type { ISystemSecretOwner, TSecretManagementScope } from './secret.js';
|
|
3
|
+
import { hasExactKeys, readDataProperties } from '../private/dataproperties.js';
|
|
3
4
|
|
|
4
5
|
export interface ICloudlyS3BackupTargetDescriptor {
|
|
5
6
|
type: 's3';
|
|
@@ -109,3 +110,129 @@ export const cloudlySystemSecretKeys = Object.freeze({
|
|
|
109
110
|
coreMailSmtpTlsCertificatePem: 'COREMAIL_SMTP_TLS_CERTIFICATE_PEM',
|
|
110
111
|
coreMailSmtpTlsPrivateKeyPem: 'COREMAIL_SMTP_TLS_PRIVATE_KEY_PEM',
|
|
111
112
|
} as const);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How far Cloudly's enrollment as a dcrouter gateway client has come.
|
|
116
|
+
*
|
|
117
|
+
* - `unconfigured` — no `dcrouterGatewayUrl` is set, so there is nothing to enroll with.
|
|
118
|
+
* - `checking` — Cloudly is validating or provisioning its credential right now.
|
|
119
|
+
* - `ready` — Cloudly holds a validated gateway-client credential whose policy matches its settings.
|
|
120
|
+
* - `degraded` — dcrouter could not be reached; Cloudly retries on its own.
|
|
121
|
+
* - `admin-bootstrap-required` — the held credential is missing, rejected or stale (its policy no
|
|
122
|
+
* longer matches Cloudly's settings or the runtime network address plan's DNS record allowance),
|
|
123
|
+
* and only a dcrouter admin token handed in with `bootstrapExternalGatewayCredential` moves it on.
|
|
124
|
+
*/
|
|
125
|
+
export type TExternalGatewayEnrollmentStatus =
|
|
126
|
+
| 'unconfigured'
|
|
127
|
+
| 'checking'
|
|
128
|
+
| 'ready'
|
|
129
|
+
| 'degraded'
|
|
130
|
+
| 'admin-bootstrap-required';
|
|
131
|
+
|
|
132
|
+
/** Cloudly's gateway-client enrollment as an administrator reads it; it never carries a credential. */
|
|
133
|
+
export interface IExternalGatewayEnrollmentState {
|
|
134
|
+
status: TExternalGatewayEnrollmentStatus;
|
|
135
|
+
/** Why the state is what it is, in Cloudly's words; absent when there is nothing to say. */
|
|
136
|
+
reason?: string;
|
|
137
|
+
/** When Cloudly last finished a check, in milliseconds since the epoch. */
|
|
138
|
+
lastCheckedAt?: number;
|
|
139
|
+
/** When Cloudly retries on its own, for a `degraded` state. */
|
|
140
|
+
nextRetryAt?: number;
|
|
141
|
+
/** Whether Cloudly still holds a validated runtime credential it can serve gateway calls with. */
|
|
142
|
+
cachedValidationProof: boolean;
|
|
143
|
+
/** Whether a provisioned candidate credential still awaits finalization at dcrouter. */
|
|
144
|
+
finalizationPending: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const externalGatewayEnrollmentStatuses = Object.freeze([
|
|
148
|
+
'unconfigured',
|
|
149
|
+
'checking',
|
|
150
|
+
'ready',
|
|
151
|
+
'degraded',
|
|
152
|
+
'admin-bootstrap-required',
|
|
153
|
+
] as const satisfies readonly TExternalGatewayEnrollmentStatus[]);
|
|
154
|
+
|
|
155
|
+
export const externalGatewayBootstrapContract = Object.freeze({
|
|
156
|
+
/**
|
|
157
|
+
* The longest admin bootstrap token Cloudly accepts, in bytes. A dcrouter API token is far
|
|
158
|
+
* shorter; the bound only keeps an unopened envelope from carrying an arbitrary payload.
|
|
159
|
+
*/
|
|
160
|
+
maximumTokenBytes: 1_024,
|
|
161
|
+
/** The longest `reason` an enrollment state carries, in characters. */
|
|
162
|
+
maximumReasonLength: 4_096,
|
|
163
|
+
/** The longest `expectedGatewayUrl` a bootstrap request states, in characters. */
|
|
164
|
+
maximumGatewayUrlLength: 2_048,
|
|
165
|
+
/** The longest `expectedGatewayClientId` a bootstrap request states, in characters. */
|
|
166
|
+
maximumGatewayClientIdLength: 253,
|
|
167
|
+
} as const);
|
|
168
|
+
|
|
169
|
+
/** Every reason Cloudly answers instead of bootstrapping its gateway-client credential. */
|
|
170
|
+
export const externalGatewayBootstrapRefusals = Object.freeze([
|
|
171
|
+
/** Cloudly has no `dcrouterGatewayUrl` or no `dcrouterGatewayClientId` to enroll under. */
|
|
172
|
+
'gateway-unconfigured',
|
|
173
|
+
/**
|
|
174
|
+
* The request's `expectedGatewayUrl` or `expectedGatewayClientId` is not what Cloudly's settings
|
|
175
|
+
* state, so the token would reach a dcrouter or a client the administrator did not name.
|
|
176
|
+
*/
|
|
177
|
+
'gateway-target-mismatch',
|
|
178
|
+
/** The sealed token does not open, or what it opens to breaks the bootstrap token rule. */
|
|
179
|
+
'bootstrap-token-invalid',
|
|
180
|
+
/** dcrouter did not accept the token at all: unknown, revoked, disabled or expired. */
|
|
181
|
+
'bootstrap-token-rejected',
|
|
182
|
+
/** dcrouter accepted the token, but its role is not `admin`, so it cannot provision a client. */
|
|
183
|
+
'bootstrap-token-not-admin',
|
|
184
|
+
/** A candidate credential from an earlier provisioning is still being finalized; retry later. */
|
|
185
|
+
'bootstrap-candidate-pending',
|
|
186
|
+
/** dcrouter could not be reached; the held credential is unchanged and the call may be repeated. */
|
|
187
|
+
'gateway-unreachable',
|
|
188
|
+
/** dcrouter refused provisioning, or the credential it issued failed Cloudly's validation. */
|
|
189
|
+
'bootstrap-provisioning-refused',
|
|
190
|
+
] as const);
|
|
191
|
+
|
|
192
|
+
export type TExternalGatewayBootstrapRefusal = typeof externalGatewayBootstrapRefusals[number];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The bootstrap token rule, judged on the opened bytes: 1 to `maximumTokenBytes` bytes of visible
|
|
196
|
+
* ASCII, with no whitespace or control character. The token is dcrouter's; this only refuses what
|
|
197
|
+
* could never be one, so a pasted line break or an empty pipe never reaches dcrouter.
|
|
198
|
+
*/
|
|
199
|
+
export const isExternalGatewayBootstrapToken = (valueArg: unknown): valueArg is Uint8Array => (
|
|
200
|
+
valueArg instanceof Uint8Array
|
|
201
|
+
&& valueArg.length > 0
|
|
202
|
+
&& valueArg.length <= externalGatewayBootstrapContract.maximumTokenBytes
|
|
203
|
+
&& valueArg.every((byteArg) => byteArg >= 0x21 && byteArg <= 0x7e)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const isEpochMilliseconds = (valueArg: unknown): boolean => (
|
|
207
|
+
Number.isSafeInteger(valueArg) && (valueArg as number) >= 0
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
/** Structural check of an enrollment state as a client receives it; every problem is named. */
|
|
211
|
+
export const validateExternalGatewayEnrollmentState = (valueArg: unknown): string[] => {
|
|
212
|
+
const state = readDataProperties(valueArg);
|
|
213
|
+
if (state === null) return ['external gateway enrollment state must be a plain record'];
|
|
214
|
+
const errors: string[] = [];
|
|
215
|
+
if (!hasExactKeys(state, ['status', 'cachedValidationProof', 'finalizationPending'],
|
|
216
|
+
['reason', 'lastCheckedAt', 'nextRetryAt'])) {
|
|
217
|
+
errors.push('external gateway enrollment state contains fields outside its schema');
|
|
218
|
+
}
|
|
219
|
+
if (!externalGatewayEnrollmentStatuses.some((statusArg) => statusArg === state.status)) {
|
|
220
|
+
errors.push('external gateway enrollment status is unknown');
|
|
221
|
+
}
|
|
222
|
+
if (state.reason !== undefined && (typeof state.reason !== 'string'
|
|
223
|
+
|| state.reason.length === 0
|
|
224
|
+
|| state.reason.length > externalGatewayBootstrapContract.maximumReasonLength)) {
|
|
225
|
+
errors.push('external gateway enrollment reason must be a bounded non-empty string');
|
|
226
|
+
}
|
|
227
|
+
for (const key of ['lastCheckedAt', 'nextRetryAt'] as const) {
|
|
228
|
+
if (state[key] !== undefined && !isEpochMilliseconds(state[key])) {
|
|
229
|
+
errors.push(`external gateway enrollment ${key} must be epoch milliseconds`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const key of ['cachedValidationProof', 'finalizationPending'] as const) {
|
|
233
|
+
if (typeof state[key] !== 'boolean') {
|
|
234
|
+
errors.push(`external gateway enrollment ${key} must be a boolean`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return errors;
|
|
238
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { IRuntimeNetworkProtectedAuthority } from '../data/networkauthority.js';
|
|
2
|
+
import type { TServiceRuntimeNetwork, TServiceRuntimeRefusal } from '../data/serviceruntime.js';
|
|
3
|
+
import { isNetworkIpv4 } from '../private/networkcontract.js';
|
|
4
|
+
import { validatePlatformDatabaseEndpoint, type IPlatformDatabaseEndpoint } from './database.js';
|
|
5
|
+
import { validatePlatformObjectStorageEndpoint, type IPlatformObjectStorageEndpoint } from './objectstorage.js';
|
|
6
|
+
|
|
7
|
+
/** The service runtime refusals a Corestore endpoint the workload's network does not select raises. */
|
|
8
|
+
export type TCorestoreEndpointSelectionRefusal = Extract<TServiceRuntimeRefusal,
|
|
9
|
+
'database-endpoint-unselected' | 'objectstorage-endpoint-unselected'>;
|
|
10
|
+
|
|
11
|
+
/** What `findUnselectedCorestoreEndpoints` judges for one node a service's bindings are placed on. */
|
|
12
|
+
export interface ICorestoreEndpointSelection {
|
|
13
|
+
/**
|
|
14
|
+
* The node's `corestoreDatabase` when an enabled database binding of the service is placed on it,
|
|
15
|
+
* otherwise `null`. A node stating none is `database-endpoint-missing`, never judged here.
|
|
16
|
+
*/
|
|
17
|
+
database: IPlatformDatabaseEndpoint | null;
|
|
18
|
+
/**
|
|
19
|
+
* The node's `corestoreObjectStorage` when an enabled object-storage binding of the service is
|
|
20
|
+
* placed on it, otherwise `null`. A node stating none is `objectstorage-endpoint-missing`.
|
|
21
|
+
*/
|
|
22
|
+
objectStorage: IPlatformObjectStorageEndpoint | null;
|
|
23
|
+
/** The current protected authority's platform endpoints, which the selected ids resolve against. */
|
|
24
|
+
platformEndpoints: IRuntimeNetworkProtectedAuthority['platformEndpoints'];
|
|
25
|
+
/** The runtime spec's network: `attached` selects its `platformEndpointIds`, `isolated` selects none. */
|
|
26
|
+
network: TServiceRuntimeNetwork;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type TPlatformEndpoint = IRuntimeNetworkProtectedAuthority['platformEndpoints'][number];
|
|
30
|
+
|
|
31
|
+
const selectedPlatformEndpoints = (selectionArg: ICorestoreEndpointSelection): TPlatformEndpoint[] => {
|
|
32
|
+
if (selectionArg.network.mode !== 'attached') return [];
|
|
33
|
+
const selectedIds = new Set(selectionArg.network.platformEndpointIds);
|
|
34
|
+
return selectionArg.platformEndpoints.filter((endpointArg) => selectedIds.has(endpointArg.id));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Platform endpoints are IPv4, so only a canonical IPv4 host can be one; a DNS name or IPv6 literal never is. */
|
|
38
|
+
const selectsTcp = (endpointsArg: TPlatformEndpoint[], hostArg: string, portArg: number): boolean => (
|
|
39
|
+
isNetworkIpv4(hostArg) && endpointsArg.some((endpointArg) => endpointArg.address === hostArg
|
|
40
|
+
&& endpointArg.protocol === 'tcp' && endpointArg.port === portArg)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** The origin's port, or the scheme's default (80 for `http:`, 443 for `https:`) when it states none. */
|
|
44
|
+
const objectStorageOriginPort = (originArg: URL): number => (
|
|
45
|
+
originArg.port === '' ? (originArg.protocol === 'https:' ? 443 : 80) : Number(originArg.port)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which of a node's Corestore endpoints a service's workload would dial without its network
|
|
50
|
+
* selecting them, as the service runtime refusals that name them; empty when none.
|
|
51
|
+
*
|
|
52
|
+
* An endpoint is selected when one of the spec's selected platform endpoints has the endpoint's host
|
|
53
|
+
* as its `address`, `tcp` as its `protocol` and the endpoint's port as its `port`: the database
|
|
54
|
+
* endpoint's `host` and `port`, and the object-storage endpoint origin's host and port, or 80 for
|
|
55
|
+
* `http:` and 443 for `https:` when the origin states none. An `isolated` network selects nothing, a
|
|
56
|
+
* selected id the protected authority does not hold selects nothing, and a DNS-name or IPv6 host is
|
|
57
|
+
* never selected. Only a valid endpoint is judged: one `validatePlatformDatabaseEndpoint` or
|
|
58
|
+
* `validatePlatformObjectStorageEndpoint` refuses is the `-missing` refusal's, not this one's. The
|
|
59
|
+
* answer is sorted, so it does not depend on the order of the inputs.
|
|
60
|
+
*/
|
|
61
|
+
export const findUnselectedCorestoreEndpoints = (
|
|
62
|
+
selectionArg: ICorestoreEndpointSelection,
|
|
63
|
+
): TCorestoreEndpointSelectionRefusal[] => {
|
|
64
|
+
const selected = selectedPlatformEndpoints(selectionArg);
|
|
65
|
+
const refusals: TCorestoreEndpointSelectionRefusal[] = [];
|
|
66
|
+
const database = selectionArg.database;
|
|
67
|
+
if (database !== null && validatePlatformDatabaseEndpoint(database).length === 0
|
|
68
|
+
&& !selectsTcp(selected, database.host, database.port)) {
|
|
69
|
+
refusals.push('database-endpoint-unselected');
|
|
70
|
+
}
|
|
71
|
+
const objectStorage = selectionArg.objectStorage;
|
|
72
|
+
if (objectStorage !== null && validatePlatformObjectStorageEndpoint(objectStorage).length === 0) {
|
|
73
|
+
const origin = new URL(objectStorage.endpoint);
|
|
74
|
+
if (!selectsTcp(selected, origin.hostname, objectStorageOriginPort(origin))) {
|
|
75
|
+
refusals.push('objectstorage-endpoint-unselected');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return refusals;
|
|
79
|
+
};
|
package/ts/platform/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ai from './ai.js';
|
|
2
2
|
import * as backup from './backup.js';
|
|
3
|
+
import * as corestoreendpointselection from './corestoreendpointselection.js';
|
|
3
4
|
import * as database from './database.js';
|
|
4
5
|
import * as email from './email.js';
|
|
5
6
|
import * as letter from './letter.js';
|
|
@@ -17,6 +18,7 @@ import * as types from './types.js';
|
|
|
17
18
|
export {
|
|
18
19
|
ai,
|
|
19
20
|
backup,
|
|
21
|
+
corestoreendpointselection,
|
|
20
22
|
database,
|
|
21
23
|
email,
|
|
22
24
|
letter,
|
|
@@ -32,6 +34,7 @@ export {
|
|
|
32
34
|
types,
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
export * from './corestoreendpointselection.js';
|
|
35
38
|
export * from './database.js';
|
|
36
39
|
export * from './storage.js';
|
|
37
40
|
export * from './objectstorage.js';
|
|
@@ -102,6 +102,10 @@ export const validatePlatformObjectStorageEndpoint = (
|
|
|
102
102
|
errors.push(`${pathArg} endpoint must be a canonical origin without a path, query, `
|
|
103
103
|
+ 'fragment, trailing slash or default port');
|
|
104
104
|
}
|
|
105
|
+
// A URL parser refuses a port above 65535 but reads port 0 as stated; no workload dials it.
|
|
106
|
+
if (parsedEndpoint.port !== '' && Number(parsedEndpoint.port) < 1) {
|
|
107
|
+
errors.push(`${pathArg} endpoint port must be from 1 to 65535`);
|
|
108
|
+
}
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
if (typeof valueArg.region !== 'string'
|
|
@@ -9,7 +9,42 @@ export const failNetwork = (): never => { throw new Error('Runtime network proje
|
|
|
9
9
|
export function requireNetwork(value: unknown): asserts value { if (!value) failNetwork(); }
|
|
10
10
|
export const networkCanonical = (value: unknown): string => canonicalizeStrictJson(value, failNetwork);
|
|
11
11
|
export const networkEqual = (left: unknown, right: unknown): boolean => networkCanonical(left) === networkCanonical(right);
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
/** The runtime network address plan's lists that must be stated in their one canonical order. */
|
|
14
|
+
export type TRuntimeNetworkAddressPlanOrderedField = 'prefixes' | 'pools' | 'resolvers' | 'platformEndpoints';
|
|
15
|
+
|
|
16
|
+
/** The structure rules an address plan refusal names; `order` is the only one named so far. */
|
|
17
|
+
export type TRuntimeNetworkAddressPlanStructureRule = 'order';
|
|
18
|
+
|
|
19
|
+
/** Which list of a plan broke which structure rule; never a value from the plan. */
|
|
20
|
+
export interface IRuntimeNetworkAddressPlanStructureViolation {
|
|
21
|
+
field: TRuntimeNetworkAddressPlanOrderedField;
|
|
22
|
+
rule: TRuntimeNetworkAddressPlanStructureRule;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A plan refused for a structure rule the contract names, rather than with the contract's generic
|
|
27
|
+
* rejection. It is the one error `networkSafe` lets through, so a caller can tell an administrator
|
|
28
|
+
* which list to fix; its message names the field and the rule and quotes nothing from the plan.
|
|
29
|
+
*/
|
|
30
|
+
export class RuntimeNetworkAddressPlanStructureError extends Error {
|
|
31
|
+
public readonly violation: IRuntimeNetworkAddressPlanStructureViolation;
|
|
32
|
+
|
|
33
|
+
public constructor(violationArg: IRuntimeNetworkAddressPlanStructureViolation) {
|
|
34
|
+
super(`Runtime network address plan rejected: ${violationArg.field} breaks the ${violationArg.rule} rule.`);
|
|
35
|
+
this.name = 'RuntimeNetworkAddressPlanStructureError';
|
|
36
|
+
this.violation = { field: violationArg.field, rule: violationArg.rule };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const networkSafe = <T>(operation: () => T): T => {
|
|
41
|
+
try {
|
|
42
|
+
return operation();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error instanceof RuntimeNetworkAddressPlanStructureError) throw error;
|
|
45
|
+
return failNetwork();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
13
48
|
|
|
14
49
|
/** Bound decoded transport input before canonicalization; never execute getters/toJSON. */
|
|
15
50
|
export const captureNetwork = (value: unknown): unknown => networkSafe(() => {
|
|
@@ -174,10 +209,14 @@ export const networkHash = async (domain: string, value: { digest: TSha256Digest
|
|
|
174
209
|
const { digest: omitted, ...payload } = value;
|
|
175
210
|
return `sha256:${await createCanonicalJsonSha256Hex(networkCanonical({ domain, payload }), failNetwork)}`;
|
|
176
211
|
};
|
|
177
|
-
|
|
178
|
-
|
|
212
|
+
/** A canonical dotted-quad IPv4 literal: four decimal octets, no leading zeros. */
|
|
213
|
+
export const isNetworkIpv4 = (value: unknown): value is string => {
|
|
214
|
+
if (typeof value !== 'string' || value.length > 15) return false;
|
|
179
215
|
const parts = value.split('.');
|
|
180
|
-
|
|
216
|
+
return parts.length === 4 && parts.every(part => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255);
|
|
217
|
+
};
|
|
218
|
+
export const networkIpv4 = (value: unknown): string => {
|
|
219
|
+
requireNetwork(isNetworkIpv4(value));
|
|
181
220
|
return value;
|
|
182
221
|
};
|
|
183
222
|
export const networkIpv4Number = (value: string): number => value.split('.').reduce((sum, part) => sum * 256 + Number(part), 0);
|
package/ts/requests/network.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
IPrivateNetwork, IServicePrivateNetworks, ISetServicePrivateNetworks,
|
|
7
7
|
TPrivateNetworkMutation,
|
|
8
8
|
} from '../data/privatenetwork.js';
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
IServiceNetworkEgress, IServicePublishedPorts, ISetServiceNetworkEgress, ISetServicePublishedPorts,
|
|
11
|
+
} from '../data/networkprojection.js';
|
|
10
12
|
|
|
11
13
|
/** Current organization picker data. Entries are never cached authorization grants. */
|
|
12
14
|
export interface IReq_Cloudly_ListOwnedOrganizations extends plugins.typedrequestInterfaces.implementsTR<
|
|
@@ -71,7 +73,7 @@ export interface IReq_Cloudly_SetServicePrivateNetworks extends plugins.typedreq
|
|
|
71
73
|
response: { membership: IServicePrivateNetworks; replayed: boolean };
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
/** null means the service has never had
|
|
76
|
+
/** null means the service has never had a policy document, neither published ports nor egress. */
|
|
75
77
|
export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<
|
|
76
78
|
plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_GetServicePublishedPorts
|
|
77
79
|
> {
|
|
@@ -80,6 +82,17 @@ export interface IReq_Cloudly_GetServicePublishedPorts extends plugins.typedrequ
|
|
|
80
82
|
response: { publishedPorts: IServicePublishedPorts | null };
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Writes the published-port half of one service's policy document, carrying its egress forward.
|
|
87
|
+
*
|
|
88
|
+
* One document per service holds its published ports and its egress under one `revision`. The
|
|
89
|
+
* first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
|
|
90
|
+
* with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
|
|
91
|
+
* no published ports. From then on both reads return it, never null. It is an explicit policy: an
|
|
92
|
+
* attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
|
|
93
|
+
* no public address and no platform endpoint; `network-policy-missing` means the service has no
|
|
94
|
+
* document at all.
|
|
95
|
+
*/
|
|
83
96
|
export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequestInterfaces.implementsTR<
|
|
84
97
|
plugins.typedrequestInterfaces.ITypedRequest, IReq_Cloudly_SetServicePublishedPorts
|
|
85
98
|
> {
|
|
@@ -93,6 +106,47 @@ export interface IReq_Cloudly_SetServicePublishedPorts extends plugins.typedrequ
|
|
|
93
106
|
response: { publishedPorts: IServicePublishedPorts; replayed: boolean };
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The egress half of one service's policy document; null means the service has never had a policy
|
|
111
|
+
* document. Only a verified administrator reads it: platform endpoints are platform authority.
|
|
112
|
+
*/
|
|
113
|
+
export interface IReq_Admin_Cloudly_GetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<
|
|
114
|
+
plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_GetServiceNetworkEgress
|
|
115
|
+
> {
|
|
116
|
+
method: 'getServiceNetworkEgress';
|
|
117
|
+
request: { identity: IIdentityCredential; organizationId: string; serviceId: string };
|
|
118
|
+
response: { networkEgress: IServiceNetworkEgress | null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Writes the egress half of one service's policy document, carrying its published ports forward.
|
|
123
|
+
*
|
|
124
|
+
* Only a verified administrator writes it, as only an administrator writes the runtime spec whose
|
|
125
|
+
* attached network must state the same values. `expectedRevision` is the revision of the one
|
|
126
|
+
* document shared with the published ports; any other stored revision refuses the write, and an id
|
|
127
|
+
* that names no current platform endpoint refuses it. A retry of the exact body under the same
|
|
128
|
+
* `mutationId` answers the stored document with `replayed: true`.
|
|
129
|
+
*
|
|
130
|
+
* One document per service holds its published ports and its egress under one `revision`. The
|
|
131
|
+
* first accepted write of either (`expectedRevision: null`) creates it: `setServicePublishedPorts`
|
|
132
|
+
* with egress `publicEgress: false` and `platformEndpointIds: []`, `setServiceNetworkEgress` with
|
|
133
|
+
* no published ports. From then on both reads return it, never null. It is an explicit policy: an
|
|
134
|
+
* attached spec stating `publicEgress: false` and `platformEndpointIds: []` matches it and reaches
|
|
135
|
+
* no public address and no platform endpoint; `network-policy-missing` means the service has no
|
|
136
|
+
* document at all.
|
|
137
|
+
*/
|
|
138
|
+
export interface IReq_Admin_Cloudly_SetServiceNetworkEgress extends plugins.typedrequestInterfaces.implementsTR<
|
|
139
|
+
plugins.typedrequestInterfaces.ITypedRequest, IReq_Admin_Cloudly_SetServiceNetworkEgress
|
|
140
|
+
> {
|
|
141
|
+
method: 'setServiceNetworkEgress';
|
|
142
|
+
request: { identity: IIdentityCredential; mutation: ISetServiceNetworkEgress };
|
|
143
|
+
/**
|
|
144
|
+
* The accepted document including its `revision`; historical on replay. Acceptance is neither an
|
|
145
|
+
* admitted workload nor applied packet state.
|
|
146
|
+
*/
|
|
147
|
+
response: { networkEgress: IServiceNetworkEgress; replayed: boolean };
|
|
148
|
+
}
|
|
149
|
+
|
|
96
150
|
export interface IRequest_Any_Cloudly_GetNetworkNodes {
|
|
97
151
|
method: 'getNetworkNodes';
|
|
98
152
|
request: {};
|
|
@@ -108,7 +162,8 @@ export interface IRequest_Any_Cloudly_GetNetworkNodes {
|
|
|
108
162
|
* validates the plan with `snapshotRuntimeNetworkAddressPlan`, decides the change with
|
|
109
163
|
* `admitRuntimeNetworkAddressPlanChange`, refuses the change while a resolver or platform endpoint
|
|
110
164
|
* it removes or changes is still referenced, and answers with the stored plan: at its new revision,
|
|
111
|
-
* or unchanged.
|
|
165
|
+
* or unchanged. A list stated out of its canonical order (see `IRuntimeNetworkAddressPlan`) is
|
|
166
|
+
* refused `plan-unsorted`, naming the list as the refusal's reference; the plan is never reordered.
|
|
112
167
|
*/
|
|
113
168
|
export interface IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan extends plugins.typedrequestInterfaces.implementsTR<
|
|
114
169
|
plugins.typedrequestInterfaces.ITypedRequest, IReq_Any_Cloudly_SetRuntimeNetworkAddressPlan
|