@serve.zone/interfaces 32.51.0 → 32.53.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 +93 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/appstore/catalog.d.ts +92 -12
- package/dist_ts/appstore/catalog.js +119 -5
- package/dist_ts/appstore/types.d.ts +28 -4
- package/dist_ts/appstore/types.js +1 -1
- package/dist_ts/appstore/versionconfig.d.ts +1 -1
- package/dist_ts/data/cluster.d.ts +1 -1
- package/dist_ts/data/clusteringress.d.ts +4 -4
- package/dist_ts/data/clusteringressdesignation.d.ts +2 -2
- package/dist_ts/data/clusternode.d.ts +13 -6
- package/dist_ts/data/clusternode.js +1 -1
- package/dist_ts/data/clusternodehostname.d.ts +63 -0
- package/dist_ts/data/clusternodehostname.js +122 -0
- package/dist_ts/data/clusterrelaycredential.d.ts +3 -3
- package/dist_ts/data/clusterruntime.d.ts +1 -1
- package/dist_ts/data/clustervpn.d.ts +4 -4
- package/dist_ts/data/coremail.d.ts +11 -11
- package/dist_ts/data/coremail.runtime.d.ts +6 -6
- package/dist_ts/data/coremail.runtime.js +1 -1
- package/dist_ts/data/corestorecontroltoken.d.ts +1 -1
- package/dist_ts/data/corestorelegacyadoption.d.ts +2 -2
- package/dist_ts/data/deploymentmachineuser.d.ts +1 -1
- package/dist_ts/data/deploymentpreflight.d.ts +26 -1
- package/dist_ts/data/fleetcutover.d.ts +28 -28
- package/dist_ts/data/hostedapp.d.ts +4 -4
- package/dist_ts/data/immutableimage.d.ts +3 -3
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/isolatedrestore.d.ts +7 -7
- package/dist_ts/data/isolatedrestore.js +1 -1
- package/dist_ts/data/networkaddressplan.d.ts +2 -2
- package/dist_ts/data/networkapplication.d.ts +9 -9
- package/dist_ts/data/networkauthority.d.ts +5 -5
- package/dist_ts/data/networkcredential.js +1 -1
- package/dist_ts/data/networkegressretirement.d.ts +3 -3
- package/dist_ts/data/networkegressretirement.js +1 -1
- package/dist_ts/data/networkhost.d.ts +3 -3
- package/dist_ts/data/networklease.d.ts +9 -9
- package/dist_ts/data/networkprojection.d.ts +7 -7
- package/dist_ts/data/networkprojectionretirement.d.ts +6 -6
- package/dist_ts/data/networkprotection.d.ts +9 -9
- package/dist_ts/data/networksigning.d.ts +6 -6
- package/dist_ts/data/networkworkloadingress.d.ts +3 -3
- package/dist_ts/data/nodeenrollment.d.ts +8 -8
- package/dist_ts/data/noderetirement.d.ts +1 -1
- package/dist_ts/data/noderuntimebinding.d.ts +11 -11
- package/dist_ts/data/privatenetwork.d.ts +3 -3
- package/dist_ts/data/runtimeassignment.d.ts +9 -9
- package/dist_ts/data/runtimeassignment.js +1 -1
- package/dist_ts/data/runtimeassignmentexec.d.ts +2 -2
- package/dist_ts/data/runtimeassignmentlogs.d.ts +4 -4
- package/dist_ts/data/runtimeassignmentstats.d.ts +2 -2
- package/dist_ts/data/runtimeconfig.d.ts +1 -1
- package/dist_ts/data/runtimelocalstorageclaim.d.ts +4 -4
- package/dist_ts/data/runtimelocalvolumearchive.d.ts +5 -5
- package/dist_ts/data/runtimesession.d.ts +5 -5
- package/dist_ts/data/runtimesession.js +1 -1
- package/dist_ts/data/runtimestorageclaim.d.ts +4 -4
- package/dist_ts/data/runtimeterminalreceipt.d.ts +2 -2
- package/dist_ts/data/runtimeterminalreceipt.js +1 -1
- package/dist_ts/data/secret.d.ts +4 -4
- package/dist_ts/data/secret.js +1 -1
- package/dist_ts/data/service.d.ts +10 -3
- package/dist_ts/data/serviceplatformownership.d.ts +1 -1
- package/dist_ts/data/serviceports.js +1 -1
- package/dist_ts/data/serviceruntime.d.ts +3 -3
- package/dist_ts/data/servicevolumeinitialization.d.ts +1 -1
- package/dist_ts/data/settings.d.ts +37 -31
- package/dist_ts/data/settings.js +1 -1
- package/dist_ts/data/sparklegacyretirement.d.ts +4 -4
- package/dist_ts/data/sparknodereport.d.ts +61 -1
- package/dist_ts/data/sparknodereport.js +66 -3
- package/dist_ts/platform/storage.d.ts +1 -1
- package/dist_ts/platform/storagemigration.d.ts +6 -6
- package/dist_ts/platform/storagemigration.golden.d.ts +13 -13
- package/dist_ts/platform/types.d.ts +1 -1
- package/dist_ts/private/canonicaljson.d.ts +6 -6
- package/dist_ts/private/canonicaljson.js +1 -1
- package/dist_ts/private/dataproperties.js +1 -1
- package/dist_ts/private/networkcontract.js +1 -1
- package/dist_ts/private/storagecontract.d.ts +1 -1
- package/dist_ts/private/storagecontract.js +1 -1
- package/dist_ts/protocol.d.ts +1 -1
- package/dist_ts/requests/appstore.d.ts +18 -0
- package/dist_ts/requests/hostedapp.d.ts +6 -0
- package/dist_ts/requests/hostedapp.js +1 -1
- package/dist_ts/requests/node.d.ts +59 -0
- package/dist_ts/runtime.appstorecatalog.d.ts +17 -0
- package/dist_ts/runtime.appstorecatalog.js +84 -12
- package/dist_ts/runtime.assignmentsecret.d.ts +3 -3
- package/dist_ts/runtime.assignmentsecret.js +1 -1
- package/dist_ts/runtime.corestore.d.ts +13 -13
- package/dist_ts/runtime.d.ts +1 -1
- package/dist_ts/runtime.workloadinit.d.ts +11 -11
- package/package.json +4 -3
- package/readme.md +27 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/appstore/catalog.ts +175 -5
- package/ts/appstore/types.ts +25 -0
- package/ts/data/clusternode.ts +8 -0
- package/ts/data/clusternodehostname.ts +153 -0
- package/ts/data/index.ts +1 -0
- package/ts/data/service.ts +10 -2
- package/ts/data/settings.ts +6 -0
- package/ts/data/sparknodereport.ts +112 -2
- package/ts/requests/appstore.ts +22 -0
- package/ts/requests/hostedapp.ts +6 -0
- package/ts/requests/node.ts +67 -0
- package/ts/runtime.appstorecatalog.ts +91 -11
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readExactDataRecord } from '../private/dataproperties.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The static hostname a cluster node runs under, and the scheme Cloudly names a node by default.
|
|
5
|
+
*
|
|
6
|
+
* Cloudly owns the hostname a node should carry (`IClusterNode.data.hostnameIntent`); Spark applies
|
|
7
|
+
* it on the host and reports the hostname it observes, so a node has converged when its report
|
|
8
|
+
* states the intent's hostname. The default scheme is `cloudly-worker-<name>`: `name` is the
|
|
9
|
+
* operator's short name for the node (`alpha`, `bravo`), kept beside the hostname so the node
|
|
10
|
+
* can always be reset to the default however it was renamed.
|
|
11
|
+
*
|
|
12
|
+
* A hostname here is one RFC 1123 label, lowercase: the node's short static hostname, which is
|
|
13
|
+
* what enrollment already requires of a host. A fully qualified name is DNS, not the host's name.
|
|
14
|
+
*/
|
|
15
|
+
export const clusterNodeHostnameContract = Object.freeze({
|
|
16
|
+
/** The default hostname is this prefix followed by the node's name. */
|
|
17
|
+
defaultPrefix: 'cloudly-worker-',
|
|
18
|
+
/** One DNS label. */
|
|
19
|
+
maximumHostnameLength: 63,
|
|
20
|
+
/** What is left of one label after `defaultPrefix`. */
|
|
21
|
+
maximumNameLength: 48,
|
|
22
|
+
/** `requestedBy` is an identity's user id. */
|
|
23
|
+
maximumRequestedByLength: 256,
|
|
24
|
+
error: 'Cluster node hostname contract rejected.',
|
|
25
|
+
} as const);
|
|
26
|
+
|
|
27
|
+
const hostnameLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
28
|
+
|
|
29
|
+
/** True for one lowercase RFC 1123 label of 1 to 63 characters: a node's static hostname. */
|
|
30
|
+
export const isClusterNodeHostname = (valueArg: unknown): valueArg is string => (
|
|
31
|
+
typeof valueArg === 'string'
|
|
32
|
+
&& valueArg.length <= clusterNodeHostnameContract.maximumHostnameLength
|
|
33
|
+
&& hostnameLabel.test(valueArg)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** True for a node name the default scheme can carry: a label of at most 48 characters. */
|
|
37
|
+
export const isClusterNodeName = (valueArg: unknown): valueArg is string => (
|
|
38
|
+
isClusterNodeHostname(valueArg) && valueArg.length <= clusterNodeHostnameContract.maximumNameLength
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** `cloudly-worker-<name>`; throws for a name the scheme cannot carry. */
|
|
42
|
+
export const deriveDefaultClusterNodeHostname = (nameArg: string): string => {
|
|
43
|
+
if (!isClusterNodeName(nameArg)) {
|
|
44
|
+
throw new Error(clusterNodeHostnameContract.error);
|
|
45
|
+
}
|
|
46
|
+
return `${clusterNodeHostnameContract.defaultPrefix}${nameArg}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The node name an observed hostname suggests, or `null` when it suggests none.
|
|
51
|
+
*
|
|
52
|
+
* It reads the first label of the hostname, so a report that states a dotted name proposes its
|
|
53
|
+
* short name. A label already in the default scheme proposes what follows the prefix
|
|
54
|
+
* (`cloudly-worker-bravo` → `bravo`); any other valid label proposes itself unchanged
|
|
55
|
+
* (`cloudlyworkeralpha` stays `cloudlyworkeralpha`). It never guesses a name out of a
|
|
56
|
+
* label in another scheme: an operator states that name when resetting the node to the default.
|
|
57
|
+
*/
|
|
58
|
+
export const proposeClusterNodeName = (hostnameArg: string): string | null => {
|
|
59
|
+
const label = hostnameArg.split('.')[0];
|
|
60
|
+
if (!isClusterNodeHostname(label)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const prefix = clusterNodeHostnameContract.defaultPrefix;
|
|
64
|
+
if (label.startsWith(prefix) && isClusterNodeName(label.slice(prefix.length))) {
|
|
65
|
+
return label.slice(prefix.length);
|
|
66
|
+
}
|
|
67
|
+
return isClusterNodeName(label) ? label : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The hostname Cloudly wants a node to carry; `IClusterNode.data.hostnameIntent`. */
|
|
71
|
+
export interface IClusterNodeHostnameIntent {
|
|
72
|
+
/** The node's name: the input of the default scheme, and what a reset to the default uses. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** The static hostname the node should carry; the default derived from `name` or any other label. */
|
|
75
|
+
hostname: string;
|
|
76
|
+
/** Counts accepted intents from 1; a change names the generation it replaces. */
|
|
77
|
+
generation: number;
|
|
78
|
+
/** Unix milliseconds on Cloudly's clock when this intent was accepted. */
|
|
79
|
+
requestedAt: number;
|
|
80
|
+
/** The administrator who asked for it; absent for the intent enrollment or a migration recorded. */
|
|
81
|
+
requestedBy?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when the intent's hostname is the default its name derives. */
|
|
85
|
+
export const isDefaultClusterNodeHostname = (intentArg: IClusterNodeHostnameIntent): boolean => (
|
|
86
|
+
isClusterNodeName(intentArg.name) && intentArg.hostname === deriveDefaultClusterNodeHostname(intentArg.name)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const fail = (): never => { throw new Error(clusterNodeHostnameContract.error); };
|
|
90
|
+
function requireValue(valueArg: unknown): asserts valueArg {
|
|
91
|
+
if (!valueArg) fail();
|
|
92
|
+
}
|
|
93
|
+
const counter = (valueArg: unknown, minimumArg: number): number => {
|
|
94
|
+
requireValue(typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= minimumArg);
|
|
95
|
+
return valueArg;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Exact, detached and frozen; refuses every key and value this contract does not state. */
|
|
99
|
+
export const snapshotClusterNodeHostnameIntent = (valueArg: unknown): IClusterNodeHostnameIntent => {
|
|
100
|
+
const keys = ['name', 'hostname', 'generation', 'requestedAt'];
|
|
101
|
+
const value = readExactDataRecord(valueArg, keys) ?? readExactDataRecord(valueArg, [...keys, 'requestedBy']);
|
|
102
|
+
requireValue(value !== null);
|
|
103
|
+
requireValue(isClusterNodeName(value.name) && isClusterNodeHostname(value.hostname));
|
|
104
|
+
const intent: IClusterNodeHostnameIntent = {
|
|
105
|
+
name: value.name, hostname: value.hostname,
|
|
106
|
+
generation: counter(value.generation, 1), requestedAt: counter(value.requestedAt, 1),
|
|
107
|
+
};
|
|
108
|
+
if (Object.hasOwn(value, 'requestedBy')) {
|
|
109
|
+
const requestedBy = value.requestedBy;
|
|
110
|
+
requireValue(typeof requestedBy === 'string' && requestedBy.length > 0
|
|
111
|
+
&& requestedBy.length <= clusterNodeHostnameContract.maximumRequestedByLength
|
|
112
|
+
&& /^[\x21-\x7e]+$/.test(requestedBy));
|
|
113
|
+
intent.requestedBy = requestedBy;
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze(intent);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every reason Cloudly answers instead of renaming a node or resetting it to the default. Each
|
|
120
|
+
* travels by name as a `TypedResponseError`, and a refused request changes nothing.
|
|
121
|
+
*/
|
|
122
|
+
export const clusterNodeHostnameRefusals = Object.freeze([
|
|
123
|
+
/** No node of that id exists. */
|
|
124
|
+
'node-missing',
|
|
125
|
+
/** The requested hostname is not one lowercase RFC 1123 label (`isClusterNodeHostname`). */
|
|
126
|
+
'hostname-invalid',
|
|
127
|
+
/**
|
|
128
|
+
* The node would carry no name the default scheme can carry (`isClusterNodeName`). A rename keeps
|
|
129
|
+
* the name of the node's intent; a node without an intent takes
|
|
130
|
+
* `proposeClusterNodeName(<requested hostname>)`, and is refused when that is `null`. A reset
|
|
131
|
+
* names no valid name, or names none while the node has no intent whose name it could reuse.
|
|
132
|
+
*/
|
|
133
|
+
'name-invalid',
|
|
134
|
+
/** Another node of the cluster carries that hostname, as reported or as its intent. */
|
|
135
|
+
'hostname-in-use',
|
|
136
|
+
/** The node's intent already is that hostname under that name. */
|
|
137
|
+
'hostname-unchanged',
|
|
138
|
+
/**
|
|
139
|
+
* Cloudly holds state naming the node by its current hostname that it cannot move to the new
|
|
140
|
+
* hostname with the change, such as a rollout in flight that targets the node.
|
|
141
|
+
*/
|
|
142
|
+
'node-hostname-referenced',
|
|
143
|
+
/** The node does not run the Pallet-era node runtime, the only one that applies a hostname. */
|
|
144
|
+
'node-runtime-unsupported',
|
|
145
|
+
/**
|
|
146
|
+
* `expectedGeneration` is not the node's current intent generation: read the node again. This
|
|
147
|
+
* fence alone serializes changes; an intent the node has not converged to may always be replaced.
|
|
148
|
+
*/
|
|
149
|
+
'conflict',
|
|
150
|
+
] as const);
|
|
151
|
+
|
|
152
|
+
/** One of the reasons Cloudly answers instead of a hostname change. */
|
|
153
|
+
export type TClusterNodeHostnameRefusalCode = typeof clusterNodeHostnameRefusals[number];
|
package/ts/data/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export * from './baremetal.js';
|
|
|
72
72
|
export * from './baseos.js';
|
|
73
73
|
export * from './backup.js';
|
|
74
74
|
export * from './clusternode.js';
|
|
75
|
+
export * from './clusternodehostname.js';
|
|
75
76
|
export * from './settings.js';
|
|
76
77
|
export * from './sparklegacyretirement.js';
|
|
77
78
|
export * from './sparknodereport.js';
|
package/ts/data/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IServiceRessources } from './docker.js';
|
|
2
2
|
import type { IRegistryTarget } from './registry.js';
|
|
3
|
-
import type { IAppStorePublishedPort } from '../appstore/index.js';
|
|
3
|
+
import type { IAppStorePublishedPort, IAppStoreServiceTemplateSnapshot } from '../appstore/index.js';
|
|
4
4
|
import type { IHostedAppLifecycleState } from './hostedapp.js';
|
|
5
5
|
import type { IServiceMailConfig, TServiceMailWritableConfig } from './mail.js';
|
|
6
6
|
import type { IServiceWebPushConfig } from './webpush.js';
|
|
@@ -175,6 +175,12 @@ export interface IService {
|
|
|
175
175
|
appTemplateId?: string;
|
|
176
176
|
appTemplateVersion?: string;
|
|
177
177
|
appStoreUpgradePolicy?: 'manual' | 'notify' | 'auto';
|
|
178
|
+
/**
|
|
179
|
+
* Server-managed snapshot of the App Store version the service runs (`appTemplateId`@
|
|
180
|
+
* `appTemplateVersion`), written at install, upgrade and catalog adoption. Runtime paths read
|
|
181
|
+
* it instead of the live catalog.
|
|
182
|
+
*/
|
|
183
|
+
appStoreTemplate?: IAppStoreServiceTemplateSnapshot;
|
|
178
184
|
hostedAppLifecycle?: IHostedAppLifecycleState;
|
|
179
185
|
reconciliationStatus?: IServiceReconciliationStatus;
|
|
180
186
|
environment: { [key: string]: string };
|
|
@@ -254,11 +260,13 @@ export interface IService {
|
|
|
254
260
|
|
|
255
261
|
/**
|
|
256
262
|
* Caller-writable service data. Immutable rollout state and SecretSet
|
|
257
|
-
* attachments are changed only through their dedicated fenced APIs
|
|
263
|
+
* attachments are changed only through their dedicated fenced APIs, and the
|
|
264
|
+
* App Store template snapshot only by the host's install and upgrade.
|
|
258
265
|
*/
|
|
259
266
|
export type TServiceWritableData = Omit<
|
|
260
267
|
IService['data'],
|
|
261
268
|
| 'organizationId'
|
|
269
|
+
| 'appStoreTemplate'
|
|
262
270
|
| 'immutableImageRequired'
|
|
263
271
|
| 'imageDeployment'
|
|
264
272
|
| 'imageRolloutStatus'
|
package/ts/data/settings.ts
CHANGED
|
@@ -63,6 +63,12 @@ export interface ICloudlySettings {
|
|
|
63
63
|
coreMailDefaultMaxPendingInbound?: number;
|
|
64
64
|
corebuildWorkerUrl?: string;
|
|
65
65
|
corebuildWorkerUrls?: string[];
|
|
66
|
+
/**
|
|
67
|
+
* The npm registries Cloudly loads the App Store catalog from, in order. Absent or empty means
|
|
68
|
+
* `appstore.appStoreCatalogDefaultRegistries`; a value is accepted only when
|
|
69
|
+
* `appstore.validateAppStoreCatalogRegistries` answers no error.
|
|
70
|
+
*/
|
|
71
|
+
appStoreCatalogRegistries?: string[];
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export const cloudlySystemSecretOwner = Object.freeze({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isReleaseVersion } from './releaseversion.js';
|
|
2
|
-
import { readExactDataRecord } from '../private/dataproperties.js';
|
|
2
|
+
import { readDataProperties, readExactDataRecord } from '../private/dataproperties.js';
|
|
3
3
|
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
4
4
|
import { protocolRefusalHttpStatus, readProtocolOffer, type IProtocolOffer } from '../protocol.js';
|
|
5
|
+
import { isClusterNodeHostname } from './clusternodehostname.js';
|
|
5
6
|
|
|
6
7
|
/** Host observations only. Container and workload readiness belong to Pallet. */
|
|
7
8
|
export interface ISparkNodeReport {
|
|
@@ -22,8 +23,32 @@ export interface ISparkNodeReport {
|
|
|
22
23
|
localRuntimeReady: true;
|
|
23
24
|
workloadReadiness: 'unverified';
|
|
24
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The outcome of Spark's last attempt to apply a desired hostname (`sparkNodeHostContract`), when
|
|
28
|
+
* it made one since it started. Whether the node carries the hostname is `runtime.hostname`.
|
|
29
|
+
*/
|
|
30
|
+
hostApply?: ISparkNodeHostApply;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
/** Whether Spark applied a desired hostname: `applied` once the host carries it. */
|
|
34
|
+
export type TSparkNodeHostApplyOutcome = 'applied' | 'failed';
|
|
35
|
+
|
|
36
|
+
/** One attempt to apply the desired hostname of one generation. */
|
|
37
|
+
export interface ISparkNodeHostApply {
|
|
38
|
+
/** The `ISparkNodeDesiredHost.generation` the attempt applied. */
|
|
39
|
+
generation: number;
|
|
40
|
+
/** The hostname it applied. */
|
|
41
|
+
hostname: string;
|
|
42
|
+
outcome: TSparkNodeHostApplyOutcome;
|
|
43
|
+
/** `failed` only, and required there: why, as operator text. */
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bounds of `ISparkNodeHostApply.reason`. */
|
|
48
|
+
export const sparkNodeHostApplyContract = Object.freeze({
|
|
49
|
+
maximumReasonLength: 512,
|
|
50
|
+
} as const);
|
|
51
|
+
|
|
27
52
|
/** HTTPS POST body. Never log, persist, or return nodeToken. */
|
|
28
53
|
export interface ISparkNodeHeartbeatRequest {
|
|
29
54
|
nodeId: string;
|
|
@@ -101,9 +126,28 @@ const integer = (valueArg: unknown, minimumArg: number): number => {
|
|
|
101
126
|
return value;
|
|
102
127
|
};
|
|
103
128
|
|
|
129
|
+
/** One `hostApply`: exactly its keys, a reason exactly when it failed. */
|
|
130
|
+
const snapshotSparkNodeHostApply = (valueArg: unknown): ISparkNodeHostApply => {
|
|
131
|
+
const keys = ['generation', 'hostname', 'outcome'];
|
|
132
|
+
const value = readExactDataRecord(valueArg, keys) ?? record(valueArg, [...keys, 'reason']);
|
|
133
|
+
requireValue(isClusterNodeHostname(value.hostname));
|
|
134
|
+
const generation = integer(value.generation, 1);
|
|
135
|
+
if (value.outcome === 'applied') {
|
|
136
|
+
requireValue(value.reason === undefined);
|
|
137
|
+
return Object.freeze({ generation, hostname: value.hostname, outcome: 'applied' });
|
|
138
|
+
}
|
|
139
|
+
requireValue(value.outcome === 'failed');
|
|
140
|
+
return Object.freeze({ generation, hostname: value.hostname, outcome: 'failed',
|
|
141
|
+
reason: text(value.reason, /^[\x20-\x7e]+$/, sparkNodeHostApplyContract.maximumReasonLength) });
|
|
142
|
+
};
|
|
143
|
+
|
|
104
144
|
/** Exact inert JSON records; detaches and freezes before any consumer awaits. */
|
|
105
145
|
export const snapshotSparkNodeReport = (valueArg: unknown): ISparkNodeReport => {
|
|
106
|
-
const
|
|
146
|
+
const reportKeys = ['observedAt', 'metrics', 'runtime'];
|
|
147
|
+
const properties = readDataProperties(valueArg);
|
|
148
|
+
requireValue(properties !== null);
|
|
149
|
+
const hasHostApply = Object.hasOwn(properties, 'hostApply');
|
|
150
|
+
const value = record(valueArg, hasHostApply ? [...reportKeys, 'hostApply'] : reportKeys);
|
|
107
151
|
const metrics = record(value.metrics, ['cpuUsagePercent', 'memoryUsedMB', 'memoryAvailableMB',
|
|
108
152
|
'diskUsedGB', 'diskAvailableGB']);
|
|
109
153
|
const runtime = record(value.runtime, ['hostname', 'platform', 'arch', 'osRelease', 'bundle',
|
|
@@ -130,6 +174,7 @@ export const snapshotSparkNodeReport = (valueArg: unknown): ISparkNodeReport =>
|
|
|
130
174
|
}),
|
|
131
175
|
localRuntimeReady: true, workloadReadiness: 'unverified',
|
|
132
176
|
}),
|
|
177
|
+
...(hasHostApply ? { hostApply: snapshotSparkNodeHostApply(value.hostApply) } : {}),
|
|
133
178
|
});
|
|
134
179
|
};
|
|
135
180
|
|
|
@@ -155,3 +200,68 @@ export const snapshotSparkNodeHeartbeatReceipt = (valueArg: unknown): ISparkNode
|
|
|
155
200
|
protocol: readProtocolOffer(value),
|
|
156
201
|
});
|
|
157
202
|
};
|
|
203
|
+
|
|
204
|
+
/** HTTPS POST body asking for the hostname Cloudly wants the node to carry. Never log, persist, or return nodeToken. */
|
|
205
|
+
export interface ISparkNodeHostRequest {
|
|
206
|
+
nodeId: string;
|
|
207
|
+
nodeToken: string;
|
|
208
|
+
/** Read and negotiated before the node is authenticated; see `sparkNodeHostContract`. */
|
|
209
|
+
protocol: IProtocolOffer;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The hostname Cloudly wants the node to carry: `IClusterNode.data.hostnameIntent`, as the node
|
|
214
|
+
* applies it.
|
|
215
|
+
*
|
|
216
|
+
* `hostname: null` with `generation: 0` states that Cloudly holds no intent for the node, and Spark
|
|
217
|
+
* leaves the host's hostname as it is. Applying is idempotent: a host that already carries the
|
|
218
|
+
* hostname is left alone, and every answer states the whole desired value rather than a change.
|
|
219
|
+
*/
|
|
220
|
+
export interface ISparkNodeDesiredHost {
|
|
221
|
+
nodeId: string;
|
|
222
|
+
hostname: string | null;
|
|
223
|
+
generation: number;
|
|
224
|
+
/** The controller's offer, so the node judges the answer it acts on. */
|
|
225
|
+
protocol: IProtocolOffer;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The node runtime's desired-host route.
|
|
230
|
+
*
|
|
231
|
+
* Authenticated with the node's Spark credential exactly like `sparkNodeHeartbeatContract`, and
|
|
232
|
+
* judged in the same server order: protocol offer first, against this server's `sparkNode` offer
|
|
233
|
+
* and within `maxRequestBytes`, answered with `protocolRefusalStatus` and an `IProtocolRefusal` body
|
|
234
|
+
* on refusal, before the node is authenticated. Spark reports the outcome of applying the answer as
|
|
235
|
+
* `ISparkNodeReport.hostApply` on its next heartbeat.
|
|
236
|
+
*/
|
|
237
|
+
export const sparkNodeHostContract = Object.freeze({
|
|
238
|
+
endpoint: '/spark/nodes/host',
|
|
239
|
+
maxRequestBytes: 1024,
|
|
240
|
+
maxResponseBytes: 1024,
|
|
241
|
+
protocolRefusalStatus: protocolRefusalHttpStatus,
|
|
242
|
+
} as const);
|
|
243
|
+
|
|
244
|
+
export const snapshotSparkNodeHostRequest = (valueArg: unknown): ISparkNodeHostRequest => {
|
|
245
|
+
const value = record(valueArg, ['nodeId', 'nodeToken', 'protocol']);
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
nodeId: nodeId(value.nodeId),
|
|
248
|
+
nodeToken: text(value.nodeToken, /^secureToken[a-f0-9]{64}$/, 75),
|
|
249
|
+
protocol: readProtocolOffer(value),
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export const snapshotSparkNodeDesiredHost = (valueArg: unknown): ISparkNodeDesiredHost => {
|
|
254
|
+
const value = record(valueArg, ['nodeId', 'hostname', 'generation', 'protocol']);
|
|
255
|
+
const hostname = value.hostname;
|
|
256
|
+
if (hostname === null) {
|
|
257
|
+
requireValue(value.generation === 0);
|
|
258
|
+
} else {
|
|
259
|
+
requireValue(isClusterNodeHostname(hostname));
|
|
260
|
+
}
|
|
261
|
+
return Object.freeze({
|
|
262
|
+
nodeId: nodeId(value.nodeId),
|
|
263
|
+
hostname,
|
|
264
|
+
generation: hostname === null ? 0 : integer(value.generation, 1),
|
|
265
|
+
protocol: readProtocolOffer(value),
|
|
266
|
+
});
|
|
267
|
+
};
|
package/ts/requests/appstore.ts
CHANGED
|
@@ -112,6 +112,28 @@ export interface IReq_Any_RefreshAppStoreCatalog extends plugins.typedrequestInt
|
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Imports a signed catalog envelope an operator carried in (an air-gapped host, or a catalog the
|
|
117
|
+
* registries do not serve yet) and answers the state after it. The host judges it exactly as a
|
|
118
|
+
* fetched one, authentication and succession included, and records it with origin
|
|
119
|
+
* `operatorImport`; the outcome is `status.lastAttempt`, and a refused envelope keeps the active
|
|
120
|
+
* catalog in place and is not a request error.
|
|
121
|
+
*/
|
|
122
|
+
export interface IReq_Any_ImportAppStoreCatalog extends plugins.typedrequestInterfaces.implementsTR<
|
|
123
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
124
|
+
IReq_Any_ImportAppStoreCatalog
|
|
125
|
+
> {
|
|
126
|
+
method: 'importAppStoreCatalog';
|
|
127
|
+
request: {
|
|
128
|
+
identity: IIdentity;
|
|
129
|
+
/** The envelope file's text (`IAppStoreCatalogEnvelope` as JSON), e.g. `catalog.envelope.json`. */
|
|
130
|
+
envelopeText: string;
|
|
131
|
+
};
|
|
132
|
+
response: {
|
|
133
|
+
status: TAppStoreCatalogStatus;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
export interface IReq_Any_GetAppStoreConfig extends plugins.typedrequestInterfaces.implementsTR<
|
|
116
138
|
plugins.typedrequestInterfaces.ITypedRequest,
|
|
117
139
|
IReq_Any_GetAppStoreConfig
|
package/ts/requests/hostedapp.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface IReq_GetHostedAppAccessConfiguration extends plugins.typedreque
|
|
|
32
32
|
roles: data.IHostedAppRoleDefinition[];
|
|
33
33
|
platformOidcSupported: boolean;
|
|
34
34
|
platformOidcRegistration?: data.IHostedAppPlatformOidcRegistration;
|
|
35
|
+
/**
|
|
36
|
+
* Present when the host cannot read this app's roles and platform OIDC capability, e.g. the
|
|
37
|
+
* service holds no App Store template snapshot yet; `roles` is then empty and
|
|
38
|
+
* `platformOidcSupported` false. One such app never fails the whole listing.
|
|
39
|
+
*/
|
|
40
|
+
unavailableReason?: string;
|
|
35
41
|
}>;
|
|
36
42
|
};
|
|
37
43
|
}
|
package/ts/requests/node.ts
CHANGED
|
@@ -150,6 +150,13 @@ export interface IReq_Any_Cloudly_CreateNodeJumpCommand extends plugins.typedreq
|
|
|
150
150
|
role?: IClusterNode['data']['role'];
|
|
151
151
|
nodeType?: IClusterNode['data']['nodeType'];
|
|
152
152
|
ttlMs?: number;
|
|
153
|
+
/**
|
|
154
|
+
* The new node's name (`data.isClusterNodeName`). The setup command names the host
|
|
155
|
+
* `cloudly-worker-<nodeName>` (`data.deriveDefaultClusterNodeHostname`) before it enrolls, so
|
|
156
|
+
* the node enrolls under its default hostname. Absent, the node keeps the hostname it has and
|
|
157
|
+
* its name is proposed from that (`data.proposeClusterNodeName`).
|
|
158
|
+
*/
|
|
159
|
+
nodeName?: string;
|
|
153
160
|
};
|
|
154
161
|
response: {
|
|
155
162
|
jumpCode: string;
|
|
@@ -356,6 +363,66 @@ export interface IReq_Any_Cloudly_SetClusterNodeCorestoreDatabaseEndpoint extend
|
|
|
356
363
|
};
|
|
357
364
|
}
|
|
358
365
|
|
|
366
|
+
/**
|
|
367
|
+
* An administrator renames a node: the static hostname Cloudly wants it to carry
|
|
368
|
+
* (`IClusterNode.data.hostnameIntent.hostname`).
|
|
369
|
+
*
|
|
370
|
+
* The node's name stays as it is: the new intent keeps the name of the node's current intent. A
|
|
371
|
+
* node without an intent takes the name `data.proposeClusterNodeName(hostname)` (`foo` names the
|
|
372
|
+
* node `foo`, `cloudly-worker-alpha` names it `alpha`), and is refused `name-invalid` when
|
|
373
|
+
* that is `null`.
|
|
374
|
+
*
|
|
375
|
+
* Cloudly records the intent and answers at once; Spark applies the intent of the latest
|
|
376
|
+
* generation on the host and the node has converged when its report states the new hostname. A new
|
|
377
|
+
* intent may replace one the node has not converged to, whether it is still applying, failed or
|
|
378
|
+
* never reached the node. `expectedGeneration` is the generation of the node's current intent, `0`
|
|
379
|
+
* when it has none, and serializes concurrent changes. Every refusal is one of
|
|
380
|
+
* `data.clusterNodeHostnameRefusals` and changes nothing.
|
|
381
|
+
*/
|
|
382
|
+
export interface IReq_Any_Cloudly_SetClusterNodeHostname extends plugins.typedrequestInterfaces.implementsTR<
|
|
383
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
384
|
+
IReq_Any_Cloudly_SetClusterNodeHostname
|
|
385
|
+
> {
|
|
386
|
+
method: 'setClusterNodeHostname';
|
|
387
|
+
request: {
|
|
388
|
+
identity: IIdentity;
|
|
389
|
+
nodeId: string;
|
|
390
|
+
expectedGeneration: number;
|
|
391
|
+
/** One lowercase RFC 1123 label (`data.isClusterNodeHostname`). */
|
|
392
|
+
hostname: string;
|
|
393
|
+
};
|
|
394
|
+
response: {
|
|
395
|
+
/** The node as it now stands, carrying the new intent. */
|
|
396
|
+
node: IClusterNode;
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* An administrator resets a node to the default scheme: hostname `cloudly-worker-<name>`
|
|
402
|
+
* (`data.deriveDefaultClusterNodeHostname`).
|
|
403
|
+
*
|
|
404
|
+
* `name` replaces the node's name when stated; absent, the name of the node's current intent is
|
|
405
|
+
* used, and a node without an intent is refused `name-invalid`. Recorded, applied and replaceable
|
|
406
|
+
* exactly like `setClusterNodeHostname`, with the same refusals.
|
|
407
|
+
*/
|
|
408
|
+
export interface IReq_Any_Cloudly_ResetClusterNodeHostname extends plugins.typedrequestInterfaces.implementsTR<
|
|
409
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
410
|
+
IReq_Any_Cloudly_ResetClusterNodeHostname
|
|
411
|
+
> {
|
|
412
|
+
method: 'resetClusterNodeHostname';
|
|
413
|
+
request: {
|
|
414
|
+
identity: IIdentity;
|
|
415
|
+
nodeId: string;
|
|
416
|
+
expectedGeneration: number;
|
|
417
|
+
/** The node's name (`data.isClusterNodeName`). */
|
|
418
|
+
name?: string;
|
|
419
|
+
};
|
|
420
|
+
response: {
|
|
421
|
+
/** The node as it now stands, carrying the new intent. */
|
|
422
|
+
node: IClusterNode;
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
359
426
|
export interface IReq_Any_Cloudly_CreateNodeAction extends plugins.typedrequestInterfaces.implementsTR<
|
|
360
427
|
plugins.typedrequestInterfaces.ITypedRequest,
|
|
361
428
|
IReq_Any_Cloudly_CreateNodeAction
|
|
@@ -62,11 +62,96 @@ const publicKeyObjectOf = (publicKeyArg: string): plugins.crypto.KeyObject =>
|
|
|
62
62
|
key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(publicKeyArg, 'hex').toString('base64url') },
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
/** One PEM block of exactly this label and nothing around it but whitespace. */
|
|
66
|
+
const isSinglePemBlock = (pemArg: string, labelArg: string): boolean =>
|
|
67
|
+
new RegExp(`^-----BEGIN ${labelArg}-----\\r?\\n(?:[A-Za-z0-9+/=]+\\r?\\n)+-----END ${labelArg}-----$`)
|
|
68
|
+
.test(pemArg.trim());
|
|
69
|
+
|
|
70
|
+
const signingKeyOf = async (privateKeyArg: plugins.crypto.KeyObject): Promise<IAppStoreCatalogSigningKey> => {
|
|
71
|
+
const publicKey = rawPublicKeyOf(plugins.crypto.createPublicKey(privateKeyArg));
|
|
72
|
+
return { keyId: await computeAppStoreCatalogSigningKeyId(publicKey), publicKey, privateKey: privateKeyArg };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The Ed25519 private key of a signing key whose `publicKey` it matches; `keyId` is the caller's check. */
|
|
76
|
+
const readSigningPrivateKey = (keyArg: IAppStoreCatalogSigningKey): plugins.crypto.KeyObject => {
|
|
77
|
+
const { privateKey } = keyArg;
|
|
78
|
+
if (!(privateKey instanceof plugins.crypto.KeyObject) || privateKey.type !== 'private'
|
|
79
|
+
|| privateKey.asymmetricKeyType !== 'ed25519') {
|
|
80
|
+
throw new Error('App Store catalog signing key must be an Ed25519 private key');
|
|
81
|
+
}
|
|
82
|
+
if (rawPublicKeyOf(plugins.crypto.createPublicKey(privateKey)) !== keyArg.publicKey) {
|
|
83
|
+
throw new Error(`App Store catalog signing key ${keyArg.keyId} does not match its private key`);
|
|
84
|
+
}
|
|
85
|
+
return privateKey;
|
|
86
|
+
};
|
|
87
|
+
|
|
65
88
|
/** A fresh Ed25519 catalog signing key with its key id. */
|
|
66
|
-
export const createAppStoreCatalogSigningKey = async (): Promise<IAppStoreCatalogSigningKey> =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
export const createAppStoreCatalogSigningKey = async (): Promise<IAppStoreCatalogSigningKey> =>
|
|
90
|
+
await signingKeyOf(plugins.crypto.generateKeyPairSync('ed25519').privateKey);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reads a catalog signing key from the text of a PKCS#8 PEM file holding an unencrypted Ed25519
|
|
94
|
+
* private key (`-----BEGIN PRIVATE KEY-----`, as `openssl genpkey -algorithm ed25519` writes it)
|
|
95
|
+
* and derives its public key and key id. Errors never quote the input.
|
|
96
|
+
*/
|
|
97
|
+
export const importAppStoreCatalogSigningKeyPem = async (pkcs8PemArg: string): Promise<IAppStoreCatalogSigningKey> => {
|
|
98
|
+
if (typeof pkcs8PemArg !== 'string' || !isSinglePemBlock(pkcs8PemArg, 'PRIVATE KEY')) {
|
|
99
|
+
throw new Error('App Store catalog signing key must be one unencrypted PKCS#8 PEM private key block');
|
|
100
|
+
}
|
|
101
|
+
let privateKey: plugins.crypto.KeyObject;
|
|
102
|
+
try {
|
|
103
|
+
privateKey = plugins.crypto.createPrivateKey({ key: pkcs8PemArg, format: 'pem' });
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('App Store catalog signing key is not a readable PKCS#8 PEM private key');
|
|
106
|
+
}
|
|
107
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') {
|
|
108
|
+
throw new Error('App Store catalog signing key must be an Ed25519 private key');
|
|
109
|
+
}
|
|
110
|
+
return await signingKeyOf(privateKey);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The PKCS#8 PEM text of a signing key, for the key file `importAppStoreCatalogSigningKeyPem` reads. */
|
|
114
|
+
export const exportAppStoreCatalogSigningKeyPem = async (keyArg: IAppStoreCatalogSigningKey): Promise<string> => {
|
|
115
|
+
const privateKey = readSigningPrivateKey(keyArg);
|
|
116
|
+
if (await computeAppStoreCatalogSigningKeyId(keyArg.publicKey) !== keyArg.keyId) {
|
|
117
|
+
throw new Error(`App Store catalog signing key ${keyArg.keyId} does not match its public key`);
|
|
118
|
+
}
|
|
119
|
+
return privateKey.export({ format: 'pem', type: 'pkcs8' });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The trusted-key record a consumer ships for a signing key: its public half only. */
|
|
123
|
+
export const getAppStoreCatalogTrustedKey = (keyArg: IAppStoreCatalogSigningKey): IAppStoreCatalogTrustedKey => ({
|
|
124
|
+
keyId: keyArg.keyId,
|
|
125
|
+
publicKey: keyArg.publicKey,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads a trusted key from the text of an SPKI PEM public key (`-----BEGIN PUBLIC KEY-----`, as
|
|
130
|
+
* `openssl pkey -pubout` writes it) holding an Ed25519 key. Errors never quote the input.
|
|
131
|
+
*/
|
|
132
|
+
export const importAppStoreCatalogTrustedKeyPem = async (spkiPemArg: string): Promise<IAppStoreCatalogTrustedKey> => {
|
|
133
|
+
if (typeof spkiPemArg !== 'string' || !isSinglePemBlock(spkiPemArg, 'PUBLIC KEY')) {
|
|
134
|
+
throw new Error('App Store catalog trusted key must be one SPKI PEM public key block');
|
|
135
|
+
}
|
|
136
|
+
let publicKeyObject: plugins.crypto.KeyObject;
|
|
137
|
+
try {
|
|
138
|
+
publicKeyObject = plugins.crypto.createPublicKey({ key: spkiPemArg, format: 'pem' });
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error('App Store catalog trusted key is not a readable SPKI PEM public key');
|
|
141
|
+
}
|
|
142
|
+
if (publicKeyObject.type !== 'public' || publicKeyObject.asymmetricKeyType !== 'ed25519') {
|
|
143
|
+
throw new Error('App Store catalog trusted key must be an Ed25519 public key');
|
|
144
|
+
}
|
|
145
|
+
const publicKey = rawPublicKeyOf(publicKeyObject);
|
|
146
|
+
return { keyId: await computeAppStoreCatalogSigningKeyId(publicKey), publicKey };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The SPKI PEM text of a trusted key whose `keyId` matches its public key. */
|
|
150
|
+
export const exportAppStoreCatalogTrustedKeyPem = async (keyArg: IAppStoreCatalogTrustedKey): Promise<string> => {
|
|
151
|
+
if (await computeAppStoreCatalogSigningKeyId(keyArg.publicKey) !== keyArg.keyId) {
|
|
152
|
+
throw new Error(`App Store catalog trusted key ${keyArg.keyId} does not match its public key`);
|
|
153
|
+
}
|
|
154
|
+
return publicKeyObjectOf(keyArg.publicKey).export({ format: 'pem', type: 'spki' });
|
|
70
155
|
};
|
|
71
156
|
|
|
72
157
|
/**
|
|
@@ -91,13 +176,8 @@ export const signAppStoreCatalog = async (
|
|
|
91
176
|
const payload = createAppStoreCatalogSignaturePayload(catalogDigest);
|
|
92
177
|
const signatures: IAppStoreCatalogSignature[] = [];
|
|
93
178
|
for (const key of signingKeysArg) {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
|| privateKey.asymmetricKeyType !== 'ed25519') {
|
|
97
|
-
throw new Error('App Store catalog signing key must be an Ed25519 private key');
|
|
98
|
-
}
|
|
99
|
-
const publicKey = rawPublicKeyOf(plugins.crypto.createPublicKey(privateKey));
|
|
100
|
-
if (publicKey !== key.publicKey || await computeAppStoreCatalogSigningKeyId(publicKey) !== key.keyId) {
|
|
179
|
+
const privateKey = readSigningPrivateKey(key);
|
|
180
|
+
if (await computeAppStoreCatalogSigningKeyId(key.publicKey) !== key.keyId) {
|
|
101
181
|
throw new Error(`App Store catalog signing key ${key.keyId} does not match its private key`);
|
|
102
182
|
}
|
|
103
183
|
signatures.push({
|