@serve.zone/interfaces 32.68.0 → 32.70.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 +14 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/backup.d.ts +55 -0
- package/dist_ts/data/backup.js +16 -2
- package/dist_ts/data/clusternode.d.ts +15 -2
- package/dist_ts/data/clusternode.js +1 -1
- package/dist_ts/data/index.d.ts +2 -0
- package/dist_ts/data/index.js +3 -1
- package/dist_ts/data/nodeaction.d.ts +439 -0
- package/dist_ts/data/nodeaction.js +434 -0
- package/dist_ts/data/sparkfleettarget.d.ts +80 -0
- package/dist_ts/data/sparkfleettarget.js +71 -0
- package/dist_ts/data/sparknodereport.d.ts +19 -0
- package/dist_ts/data/sparknodereport.js +21 -4
- package/dist_ts/requests/backup.d.ts +8 -0
- package/dist_ts/requests/node.d.ts +100 -0
- package/package.json +1 -1
- package/readme.md +47 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/backup.ts +57 -0
- package/ts/data/clusternode.ts +17 -2
- package/ts/data/index.ts +2 -0
- package/ts/data/nodeaction.ts +755 -0
- package/ts/data/sparkfleettarget.ts +138 -0
- package/ts/data/sparknodereport.ts +42 -3
- package/ts/requests/backup.ts +8 -0
- package/ts/requests/node.ts +118 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { canonicalizeStrictJson, compareWireTokens, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
|
|
2
|
+
import { readDataElements, readDataProperties, readExactDataRecord } from '../private/dataproperties.js';
|
|
3
|
+
import { isClusterNodeId } from '../private/nodeidentifier.js';
|
|
4
|
+
import { isCanonicalTokenList } from '../private/tokenlist.js';
|
|
5
|
+
import { protocolRefusalHttpStatus, readProtocolOffer, type IProtocolOffer } from '../protocol.js';
|
|
6
|
+
import type { TNodeActionName, TNodeActionStatus } from './clusternode.js';
|
|
7
|
+
import { isReleaseVersion } from './releaseversion.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every node action, in canonical token order, so a list of them (a Spark's `actionExecutors`)
|
|
11
|
+
* is compared byte for byte.
|
|
12
|
+
*/
|
|
13
|
+
export const nodeActionNames = Object.freeze([
|
|
14
|
+
'reboot',
|
|
15
|
+
'serveZoneServiceUpdate',
|
|
16
|
+
'systemUpgrade',
|
|
17
|
+
] as const satisfies readonly TNodeActionName[]);
|
|
18
|
+
|
|
19
|
+
/** Fails to compile when `nodeActionNames` leaves out a `TNodeActionName`. */
|
|
20
|
+
type TNodeActionNamesComplete = Exclude<TNodeActionName, typeof nodeActionNames[number]> extends never ? true : never;
|
|
21
|
+
const nodeActionNamesComplete: TNodeActionNamesComplete = true;
|
|
22
|
+
void nodeActionNamesComplete;
|
|
23
|
+
|
|
24
|
+
export const isNodeActionName = (valueArg: unknown): valueArg is TNodeActionName => (
|
|
25
|
+
nodeActionNames.some((nameArg) => nameArg === valueArg)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether each action state is still open or ended.
|
|
30
|
+
*
|
|
31
|
+
* The four values the Swarm-era carrier shipped keep their meaning: `pending` is the requested
|
|
32
|
+
* state of every action, `running` is a Swarm-era action its daemon started, and `succeeded` and
|
|
33
|
+
* `failed` end one. The node runtime's lifecycle (`INodeActionLifecycle`) adds the rest:
|
|
34
|
+
*
|
|
35
|
+
* - `claimed`: the node's Spark took the action (`claim`), stating the boot it took it in.
|
|
36
|
+
* - `executing`, `draining`, `rebooting`: the phase Spark reports, only ever forward
|
|
37
|
+
* (`sparkNodeActionPhaseOrder`).
|
|
38
|
+
* - `verifying`: Cloudly saw the evidence the action changed the node (`nodeActionVerification`)
|
|
39
|
+
* and waits for the node's runtime to come back.
|
|
40
|
+
* - `expired`: never claimed in its claim window, or a Swarm-era record no carrier can deliver
|
|
41
|
+
* (`TNodeActionExpiryReason`).
|
|
42
|
+
* - `cancelled`: an operator withdrew it while it was still `pending`.
|
|
43
|
+
* - `unverified`: its deadline passed before Cloudly could verify it. An operator judges the node;
|
|
44
|
+
* Cloudly never retries it.
|
|
45
|
+
*/
|
|
46
|
+
export const nodeActionStatusKinds = Object.freeze({
|
|
47
|
+
pending: 'open',
|
|
48
|
+
running: 'open',
|
|
49
|
+
claimed: 'open',
|
|
50
|
+
executing: 'open',
|
|
51
|
+
draining: 'open',
|
|
52
|
+
rebooting: 'open',
|
|
53
|
+
verifying: 'open',
|
|
54
|
+
succeeded: 'terminal',
|
|
55
|
+
failed: 'terminal',
|
|
56
|
+
expired: 'terminal',
|
|
57
|
+
cancelled: 'terminal',
|
|
58
|
+
unverified: 'terminal',
|
|
59
|
+
} as const satisfies Record<TNodeActionStatus, 'open' | 'terminal'>);
|
|
60
|
+
|
|
61
|
+
/** Every open state, the ones the one-open-action-per-node rule counts. */
|
|
62
|
+
export const nodeActionOpenStatuses = Object.freeze((Object.keys(nodeActionStatusKinds) as TNodeActionStatus[])
|
|
63
|
+
.filter((statusArg) => nodeActionStatusKinds[statusArg] === 'open'));
|
|
64
|
+
|
|
65
|
+
export const isNodeActionOpen = (statusArg: TNodeActionStatus): boolean => (
|
|
66
|
+
nodeActionStatusKinds[statusArg] === 'open'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The phases Spark reports while it executes a claimed action, in the only order it may report
|
|
71
|
+
* them. A phase may be skipped, never repeated or reported after a later one.
|
|
72
|
+
*/
|
|
73
|
+
export const sparkNodeActionPhaseOrder = Object.freeze(['claimed', 'executing', 'draining', 'rebooting'] as const);
|
|
74
|
+
|
|
75
|
+
/** A phase Spark reports with a `phase` transition. */
|
|
76
|
+
export type TSparkNodeActionPhase = Exclude<typeof sparkNodeActionPhaseOrder[number], 'claimed'>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Time budgets of the node runtime's action lifecycle, in milliseconds.
|
|
80
|
+
*
|
|
81
|
+
* A requested action waits `claimWindowMs` for its node's Spark to claim it, then expires. A claimed
|
|
82
|
+
* action must be verified within its completion budget (`nodeActionCompletionBudgetMs`), measured
|
|
83
|
+
* from the claim, or it ends `unverified`. Each budget is the sum of the steps the action takes. One
|
|
84
|
+
* budget is read from Spark's code; every other one is chosen here, and each member says which.
|
|
85
|
+
*/
|
|
86
|
+
export const nodeActionContract = Object.freeze({
|
|
87
|
+
/** Chosen: how long a requested action waits for its node's Spark to claim it (10 min). */
|
|
88
|
+
claimWindowMs: 600_000,
|
|
89
|
+
/**
|
|
90
|
+
* Spark code: `sparkNodeStopTimeoutSeconds`, the node unit's `TimeoutStopSec=` (522 s), in which
|
|
91
|
+
* Pallet stops every workload it runs.
|
|
92
|
+
*/
|
|
93
|
+
drainMs: 522_000,
|
|
94
|
+
/** Chosen: a host's reboot until Spark reports from the new boot (10 min). */
|
|
95
|
+
bootMs: 600_000,
|
|
96
|
+
/**
|
|
97
|
+
* Chosen: how long Pallet takes to start and register again after the node runtime starts
|
|
98
|
+
* (712 s, from the 2026-11-05 survey; Spark states no start budget of its own).
|
|
99
|
+
*/
|
|
100
|
+
startMs: 712_000,
|
|
101
|
+
/** Chosen: downloading and checking the target's node bundle before anything stops (10 min). */
|
|
102
|
+
downloadMs: 600_000,
|
|
103
|
+
/** Chosen: the host's package upgrade (30 min). */
|
|
104
|
+
hostUpgradeMs: 1_800_000,
|
|
105
|
+
/** Entries an action's timeline keeps; each state is entered at most once. */
|
|
106
|
+
maximumTimelineEntries: 16,
|
|
107
|
+
/** Characters of operator text one timeline entry or failure carries. */
|
|
108
|
+
maximumDetailLength: 512,
|
|
109
|
+
/** Services one impact names; a node holding more is answered `impact-unreadable`. */
|
|
110
|
+
maximumImpactServices: 1024,
|
|
111
|
+
/** Characters of one service name an impact carries. */
|
|
112
|
+
maximumServiceNameLength: 256,
|
|
113
|
+
} as const);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How long a claimed action has until Cloudly must have verified it, from the claim.
|
|
117
|
+
*
|
|
118
|
+
* A reboot drains, boots and starts the node. An update downloads the target, then restarts the
|
|
119
|
+
* node runtime on it: a drain and a start, no boot. A host upgrade runs the package upgrade and,
|
|
120
|
+
* with `reboot`, the reboot after it.
|
|
121
|
+
*/
|
|
122
|
+
export const nodeActionCompletionBudgetMs = (actionNameArg: TNodeActionName, rebootArg: boolean): number => {
|
|
123
|
+
const c = nodeActionContract;
|
|
124
|
+
switch (actionNameArg) {
|
|
125
|
+
case 'reboot':
|
|
126
|
+
return c.drainMs + c.bootMs + c.startMs;
|
|
127
|
+
case 'serveZoneServiceUpdate':
|
|
128
|
+
return c.downloadMs + c.drainMs + c.startMs;
|
|
129
|
+
case 'systemUpgrade':
|
|
130
|
+
return c.hostUpgradeMs + (rebootArg ? c.drainMs + c.bootMs + c.startMs : 0);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Whether an action reboots the host, which is how Cloudly verifies it (`nodeActionVerification`). */
|
|
135
|
+
export const nodeActionReboots = (actionNameArg: TNodeActionName, rebootArg: boolean): boolean => (
|
|
136
|
+
actionNameArg === 'reboot' || (actionNameArg === 'systemUpgrade' && rebootArg)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What Cloudly accepts as evidence that a claimed action changed the node, by what the action does.
|
|
141
|
+
*
|
|
142
|
+
* - `new-boot`: an action that reboots is verified by a heartbeat, accepted after the claim, whose
|
|
143
|
+
* `boot.id` differs from the boot the claim stated.
|
|
144
|
+
* - `target-version`: an update is verified by a heartbeat, accepted after the claim, whose bundle
|
|
145
|
+
* version is the action's target version.
|
|
146
|
+
* - `upgrade-complete`: a host upgrade without a reboot is verified by Spark's `complete` and a
|
|
147
|
+
* heartbeat accepted after it; a heartbeat that states `boot.rebootRequired` fails the action
|
|
148
|
+
* `reboot-required` instead, because applying the upgrade needs a reboot the operator did not
|
|
149
|
+
* request. Cloudly never escalates to one.
|
|
150
|
+
*
|
|
151
|
+
* An action that restarts the node runtime (every action but a host upgrade without a reboot) is
|
|
152
|
+
* then only `succeeded` once the node's Pallet registered a runtime session after the claim and
|
|
153
|
+
* Cloudly accepted an observation of every replica slot on the node after that registration.
|
|
154
|
+
*/
|
|
155
|
+
export type TNodeActionVerification = 'new-boot' | 'target-version' | 'upgrade-complete';
|
|
156
|
+
|
|
157
|
+
export const nodeActionVerification = (
|
|
158
|
+
actionNameArg: TNodeActionName,
|
|
159
|
+
rebootArg: boolean,
|
|
160
|
+
): TNodeActionVerification => nodeActionReboots(actionNameArg, rebootArg) ? 'new-boot'
|
|
161
|
+
: actionNameArg === 'serveZoneServiceUpdate' ? 'target-version' : 'upgrade-complete';
|
|
162
|
+
|
|
163
|
+
/** Whether the action restarts the node runtime and is verified by its return (see `nodeActionVerification`). */
|
|
164
|
+
export const nodeActionRestartsRuntime = (actionNameArg: TNodeActionName, rebootArg: boolean): boolean => (
|
|
165
|
+
nodeActionVerification(actionNameArg, rebootArg) !== 'upgrade-complete'
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Why an action ended `failed`, each with the remedy an operator is told.
|
|
170
|
+
*
|
|
171
|
+
* The first group is stated by Spark (`TSparkNodeActionFailureCode`), the second by Cloudly.
|
|
172
|
+
*/
|
|
173
|
+
export const nodeActionFailureRemedies = Object.freeze({
|
|
174
|
+
'download-failed': 'Spark could not download the target release. Check that the node reaches '
|
|
175
|
+
+ 'code.foss.global, then request the update again.',
|
|
176
|
+
'installer-mismatch': 'The downloaded install.sh does not have the SHA-256 the target states. '
|
|
177
|
+
+ 'Nothing was installed; set the fleet Spark target again, then request the update again.',
|
|
178
|
+
'install-failed': 'The node bundle installer failed and left the installed release in place. '
|
|
179
|
+
+ 'Read the node\'s Spark journal, then request the update again.',
|
|
180
|
+
'drain-failed': 'Pallet did not stop its workloads within the stop budget. Read the node\'s '
|
|
181
|
+
+ 'Pallet journal before requesting the action again.',
|
|
182
|
+
'reboot-failed': 'The host did not accept the reboot. Read the node\'s Spark journal.',
|
|
183
|
+
'host-upgrade-failed': 'The host package upgrade failed. Read the node\'s Spark journal, repair '
|
|
184
|
+
+ 'the package state on the host, then request the upgrade again.',
|
|
185
|
+
'executor-failed': 'Spark could not run the action. Read the node\'s Spark journal.',
|
|
186
|
+
'reboot-required': 'The host upgrade finished and applying it needs a reboot. Request a system '
|
|
187
|
+
+ 'upgrade with reboot, or a reboot, when the node may go down.',
|
|
188
|
+
'target-below-node': 'The node runs a newer Spark than the action\'s target, so the update '
|
|
189
|
+
+ 'would downgrade it. Set the fleet Spark target to the release the node should run.',
|
|
190
|
+
} as const);
|
|
191
|
+
|
|
192
|
+
export type TNodeActionFailureCode = keyof typeof nodeActionFailureRemedies;
|
|
193
|
+
|
|
194
|
+
/** The failure codes Spark may state with a `fail` transition. */
|
|
195
|
+
export const sparkNodeActionFailureCodes = Object.freeze([
|
|
196
|
+
'download-failed',
|
|
197
|
+
'drain-failed',
|
|
198
|
+
'executor-failed',
|
|
199
|
+
'host-upgrade-failed',
|
|
200
|
+
'install-failed',
|
|
201
|
+
'installer-mismatch',
|
|
202
|
+
'reboot-failed',
|
|
203
|
+
] as const satisfies readonly TNodeActionFailureCode[]);
|
|
204
|
+
|
|
205
|
+
export type TSparkNodeActionFailureCode = typeof sparkNodeActionFailureCodes[number];
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Why an action ended `expired`.
|
|
209
|
+
*
|
|
210
|
+
* - `claim-window-passed`: no Spark claimed it within `nodeActionContract.claimWindowMs`.
|
|
211
|
+
* - `carrier-retired`: a Swarm-era record on a node that holds no Swarm-era session, which no
|
|
212
|
+
* carrier delivers: it states none of the frozen parameters and target the node runtime's
|
|
213
|
+
* lifecycle needs. Cloudly keeps the record.
|
|
214
|
+
*/
|
|
215
|
+
export type TNodeActionExpiryReason = 'claim-window-passed' | 'carrier-retired';
|
|
216
|
+
|
|
217
|
+
export interface INodeActionExpiry {
|
|
218
|
+
reason: TNodeActionExpiryReason;
|
|
219
|
+
at: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The Spark release an update moves a node to: the fleet Spark target as it stood when the
|
|
224
|
+
* operator requested the action, frozen into the action.
|
|
225
|
+
*/
|
|
226
|
+
export interface INodeActionTarget {
|
|
227
|
+
/** `MAJOR.MINOR.PATCH`, without `v`. */
|
|
228
|
+
version: string;
|
|
229
|
+
/** The SHA-256 of the release's `install.sh`, as `SHA256SUMS.txt` states it (lowercase hex). */
|
|
230
|
+
installerSha256: string;
|
|
231
|
+
/** The fleet target's revision this copy was taken from. */
|
|
232
|
+
targetRevision: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What an operator confirmed when requesting a node runtime action, frozen into the action.
|
|
237
|
+
*
|
|
238
|
+
* The consent is the typed hostname and the digest of the impact preview the operator read:
|
|
239
|
+
* Cloudly refuses the request when either no longer matches what it reads now.
|
|
240
|
+
*/
|
|
241
|
+
export interface INodeActionConfirmation {
|
|
242
|
+
/** The hostname the operator typed; must equal the node's reported hostname. */
|
|
243
|
+
confirmedHostname: string;
|
|
244
|
+
/** `impactDigest` of the preview the operator confirmed (`computeNodeActionImpactDigest`). */
|
|
245
|
+
impactDigest: string;
|
|
246
|
+
/**
|
|
247
|
+
* Consent to restarting the Corestore the preview names on this node. Required `true` when the
|
|
248
|
+
* impact names one (`corestore`), and never assumed: a Corestore restart is never a default.
|
|
249
|
+
*/
|
|
250
|
+
corestoreRestart: boolean;
|
|
251
|
+
/** `systemUpgrade` only: reboot after the upgrade, as part of this one action. */
|
|
252
|
+
reboot: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* `serveZoneServiceUpdate` only, and required there: the revision of the fleet Spark target the
|
|
255
|
+
* preview showed (`INodeActionPreview.target.targetRevision`). Cloudly refuses the update
|
|
256
|
+
* (`target-changed`) when the target it would freeze into the action has another revision, so the
|
|
257
|
+
* consent names the exact release the node is moved to. Absent for every other action.
|
|
258
|
+
*/
|
|
259
|
+
targetRevision?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** One service the action interrupts, as the impact preview names it. */
|
|
263
|
+
export interface INodeActionImpactService {
|
|
264
|
+
serviceId: string;
|
|
265
|
+
name: string;
|
|
266
|
+
/** Replica slots of this service on the node. */
|
|
267
|
+
slotsOnNode: number;
|
|
268
|
+
/** Replica slots of this service on other nodes; `0` means the service is down while the node is. */
|
|
269
|
+
slotsElsewhere: number;
|
|
270
|
+
/** The service is the cluster's designated ingress. */
|
|
271
|
+
clusterIngress: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* What a node action interrupts, read from Cloudly's database when it is previewed and again when
|
|
276
|
+
* it is requested.
|
|
277
|
+
*
|
|
278
|
+
* - `clusterNodeCount` 1: the node is its cluster, so the action is a full cluster outage.
|
|
279
|
+
* - `relayHost`: the node hosts its cluster's relay, so every inter-node connection of the cluster
|
|
280
|
+
* is interrupted while it is down. The relay never moves for an action.
|
|
281
|
+
* - `corestore`: the node runs a Corestore (`IClusterNode.data.corestore`), which restarts with it.
|
|
282
|
+
* - `services`: every service with a replica slot on the node, sorted by `serviceId`.
|
|
283
|
+
*/
|
|
284
|
+
export interface INodeActionImpact {
|
|
285
|
+
nodeId: string;
|
|
286
|
+
clusterId: string;
|
|
287
|
+
/** The hostname the node reports, which a confirmation types; `null` while it reports none. */
|
|
288
|
+
hostname: string | null;
|
|
289
|
+
clusterNodeCount: number;
|
|
290
|
+
relayHost: boolean;
|
|
291
|
+
corestore: boolean;
|
|
292
|
+
services: INodeActionImpactService[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The SHA-256 (lowercase hex) of one impact's canonical JSON, which a confirmation names. */
|
|
296
|
+
export const computeNodeActionImpactDigest = async (impactArg: INodeActionImpact): Promise<string> => {
|
|
297
|
+
const fail = (reasonArg: string): never => { throw new Error(`Node action impact rejected: ${reasonArg}`); };
|
|
298
|
+
return createCanonicalJsonSha256Hex(canonicalizeStrictJson(impactArg, fail, 'node action impact'), fail);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** When Spark claimed an action, and in which boot of the host. */
|
|
302
|
+
export interface INodeActionClaim {
|
|
303
|
+
claimedAt: number;
|
|
304
|
+
/** The host's `boot.id` the claim stated. */
|
|
305
|
+
bootId: string;
|
|
306
|
+
/** The Spark bundle version the node reported when it claimed. */
|
|
307
|
+
bundleVersion: string | null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Why an action failed, and the remedy the operator was told (`nodeActionFailureRemedies`). */
|
|
311
|
+
export interface INodeActionFailure {
|
|
312
|
+
code: TNodeActionFailureCode;
|
|
313
|
+
remedy: string;
|
|
314
|
+
/** Spark's or Cloudly's own text, bounded by `nodeActionContract.maximumDetailLength`. */
|
|
315
|
+
detail: string | null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Who moved an action into a state. */
|
|
319
|
+
export type TNodeActionActor = 'operator' | 'spark' | 'cloudly';
|
|
320
|
+
|
|
321
|
+
/** One state an action entered, in order. */
|
|
322
|
+
export interface INodeActionTransition {
|
|
323
|
+
status: TNodeActionStatus;
|
|
324
|
+
at: number;
|
|
325
|
+
actor: TNodeActionActor;
|
|
326
|
+
detail: string | null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The node runtime's lifecycle of one action: what was confirmed and frozen at the request, and
|
|
331
|
+
* every state since. Absent on a Swarm-era record, which no carrier but the Swarm-era daemon's
|
|
332
|
+
* delivers.
|
|
333
|
+
*
|
|
334
|
+
* `revision` advances with every state change. Each change is one transaction that matches the
|
|
335
|
+
* action on its expected state and revision, so two writers never both move one action.
|
|
336
|
+
*/
|
|
337
|
+
export interface INodeActionLifecycle {
|
|
338
|
+
clusterId: string;
|
|
339
|
+
revision: number;
|
|
340
|
+
confirmation: INodeActionConfirmation;
|
|
341
|
+
impact: INodeActionImpact;
|
|
342
|
+
/** `serveZoneServiceUpdate` only. */
|
|
343
|
+
target: INodeActionTarget | null;
|
|
344
|
+
/** The claim window's end while `pending`; the completion deadline once claimed. */
|
|
345
|
+
deadlineAt: number;
|
|
346
|
+
claim: INodeActionClaim | null;
|
|
347
|
+
failure: INodeActionFailure | null;
|
|
348
|
+
timeline: INodeActionTransition[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Every reason Cloudly answers instead of admitting, previewing or cancelling a node action. Each
|
|
353
|
+
* travels by name as a `TypedResponseError`, and a refused request changes nothing.
|
|
354
|
+
*/
|
|
355
|
+
export const nodeActionRefusals = Object.freeze([
|
|
356
|
+
/** No node of that id exists. */
|
|
357
|
+
'node-missing',
|
|
358
|
+
/** The node reported through no Spark that carries actions: it never reported at all. */
|
|
359
|
+
'node-action-undeliverable',
|
|
360
|
+
/** The node's Spark does not state that it executes this action (`ISparkNodeReport.actionExecutors`). */
|
|
361
|
+
'node-action-unsupported',
|
|
362
|
+
/**
|
|
363
|
+
* System upgrade on a node runtime node: its Spark does not state the `systemUpgrade` executor,
|
|
364
|
+
* which Spark states only once its host-upgrade executor passed the lab qualification.
|
|
365
|
+
*/
|
|
366
|
+
'system-upgrade-unqualified',
|
|
367
|
+
/** The node already has an open action. */
|
|
368
|
+
'node-action-open',
|
|
369
|
+
/** Another node of the cluster has an open action; a cluster runs one disruptive action at a time. */
|
|
370
|
+
'cluster-action-open',
|
|
371
|
+
/** An update without a fleet Spark target. */
|
|
372
|
+
'spark-target-unset',
|
|
373
|
+
/** An update whose target is below the Spark bundle version the node reports. */
|
|
374
|
+
'spark-target-below-node',
|
|
375
|
+
/** An update of a node that is held at its release (`ISparkNodeTargetHold`). */
|
|
376
|
+
'spark-target-held',
|
|
377
|
+
/** The request states no confirmation, or one that is not well-formed for this action. */
|
|
378
|
+
'confirmation-invalid',
|
|
379
|
+
/** The typed hostname is not the hostname the node reports. */
|
|
380
|
+
'confirmation-hostname-mismatch',
|
|
381
|
+
/** What the action interrupts changed since the preview the confirmation names. */
|
|
382
|
+
'impact-changed',
|
|
383
|
+
/** An update whose confirmation names another fleet Spark target revision than the one Cloudly would freeze. */
|
|
384
|
+
'target-changed',
|
|
385
|
+
/** The impact names a Corestore on the node and the confirmation does not consent to restarting it. */
|
|
386
|
+
'corestore-consent-required',
|
|
387
|
+
/** Cloudly could not read what the action interrupts. */
|
|
388
|
+
'impact-unreadable',
|
|
389
|
+
/** No action of that id on that node. */
|
|
390
|
+
'node-action-missing',
|
|
391
|
+
/** Only a `pending` action is cancelled. */
|
|
392
|
+
'node-action-not-cancellable',
|
|
393
|
+
/** The action moved since the revision the request names. */
|
|
394
|
+
'node-action-revision-stale',
|
|
395
|
+
] as const);
|
|
396
|
+
|
|
397
|
+
export type TNodeActionRefusalCode = typeof nodeActionRefusals[number];
|
|
398
|
+
|
|
399
|
+
/** What requesting one action on one node would do, and whether Cloudly would admit it now. */
|
|
400
|
+
export interface INodeActionPreview {
|
|
401
|
+
nodeId: string;
|
|
402
|
+
actionName: TNodeActionName;
|
|
403
|
+
impact: INodeActionImpact;
|
|
404
|
+
/** What a confirmation names (`INodeActionConfirmation.impactDigest`). */
|
|
405
|
+
impactDigest: string;
|
|
406
|
+
/** The Spark bundle version the node reports; `null` while it reports none. */
|
|
407
|
+
nodeBundleVersion: string | null;
|
|
408
|
+
/** Whether the node's Spark states that the host needs a reboot; `null` while it states nothing. */
|
|
409
|
+
rebootRequired: boolean | null;
|
|
410
|
+
/** `serveZoneServiceUpdate` only: the fleet target the update moves the node to. */
|
|
411
|
+
target: INodeActionTarget | null;
|
|
412
|
+
/** Why a request would be refused now, in `nodeActionRefusals` order; empty when it would be admitted. */
|
|
413
|
+
refusals: TNodeActionRefusalCode[];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const maximumHostnameLength = 253;
|
|
417
|
+
const hostnamePattern = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
|
|
418
|
+
const sha256HexPattern = /^[a-f0-9]{64}$/;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Validates one confirmation for one action and names every reason it is refused.
|
|
422
|
+
*
|
|
423
|
+
* Shape only: an update must state `targetRevision`, every other action must not. Whether it
|
|
424
|
+
* matches the node, the impact and the fleet target Cloudly reads now is Cloudly's decision
|
|
425
|
+
* (`confirmation-hostname-mismatch`, `impact-changed`, `target-changed`, `corestore-consent-required`).
|
|
426
|
+
*/
|
|
427
|
+
export const validateNodeActionConfirmation = (
|
|
428
|
+
valueArg: unknown,
|
|
429
|
+
actionNameArg: TNodeActionName,
|
|
430
|
+
pathArg = 'node action confirmation',
|
|
431
|
+
): string[] => {
|
|
432
|
+
// An update names the target revision it confirmed; no other action does.
|
|
433
|
+
const update = actionNameArg === 'serveZoneServiceUpdate';
|
|
434
|
+
const value = readExactDataRecord(valueArg, ['confirmedHostname', 'impactDigest', 'corestoreRestart', 'reboot',
|
|
435
|
+
...(update ? ['targetRevision'] : [])]);
|
|
436
|
+
if (value === null) {
|
|
437
|
+
return [update
|
|
438
|
+
? `${pathArg} must use its exact schema, with the targetRevision the preview showed`
|
|
439
|
+
: `${pathArg} must use its exact schema, without a targetRevision`];
|
|
440
|
+
}
|
|
441
|
+
const errors: string[] = [];
|
|
442
|
+
if (update && (!Number.isSafeInteger(value.targetRevision) || (value.targetRevision as number) < 1)) {
|
|
443
|
+
errors.push(`${pathArg} targetRevision must be a positive integer`);
|
|
444
|
+
}
|
|
445
|
+
if (typeof value.confirmedHostname !== 'string' || value.confirmedHostname.length > maximumHostnameLength
|
|
446
|
+
|| !hostnamePattern.test(value.confirmedHostname)) {
|
|
447
|
+
errors.push(`${pathArg} confirmedHostname must be a lowercase hostname`);
|
|
448
|
+
}
|
|
449
|
+
if (typeof value.impactDigest !== 'string' || !sha256HexPattern.test(value.impactDigest)) {
|
|
450
|
+
errors.push(`${pathArg} impactDigest must be a lowercase SHA-256 hex digest`);
|
|
451
|
+
}
|
|
452
|
+
if (typeof value.corestoreRestart !== 'boolean') errors.push(`${pathArg} corestoreRestart must be a boolean`);
|
|
453
|
+
if (typeof value.reboot !== 'boolean') {
|
|
454
|
+
errors.push(`${pathArg} reboot must be a boolean`);
|
|
455
|
+
} else if (value.reboot && actionNameArg !== 'systemUpgrade') {
|
|
456
|
+
errors.push(`${pathArg} reboot is stated for systemUpgrade only`);
|
|
457
|
+
}
|
|
458
|
+
return errors;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/** HTTPS POST body asking for the action Cloudly holds for this node. Never log, persist, or return nodeToken. */
|
|
462
|
+
export interface ISparkNodeActionOfferRequest {
|
|
463
|
+
nodeId: string;
|
|
464
|
+
nodeToken: string;
|
|
465
|
+
/** Read and negotiated before the node is authenticated; see `sparkNodeActionContracts`. */
|
|
466
|
+
protocol: IProtocolOffer;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* One action as Spark executes it: the node runtime's view of an open action.
|
|
471
|
+
*
|
|
472
|
+
* `status` is the action's state when Cloudly answered; `revision` is what Spark's next transition
|
|
473
|
+
* names. `claim` states the boot Spark claimed it in, so a Spark that restarted mid-action resumes
|
|
474
|
+
* by replaying the transition it had sent rather than claiming again.
|
|
475
|
+
*/
|
|
476
|
+
export interface ISparkNodeActionAssignment {
|
|
477
|
+
id: string;
|
|
478
|
+
actionName: TNodeActionName;
|
|
479
|
+
status: Extract<TNodeActionStatus, 'pending' | 'claimed' | 'executing' | 'draining' | 'rebooting' | 'verifying'>;
|
|
480
|
+
revision: number;
|
|
481
|
+
deadlineAt: number;
|
|
482
|
+
/** `systemUpgrade` only: reboot after the upgrade. */
|
|
483
|
+
reboot: boolean;
|
|
484
|
+
/** `serveZoneServiceUpdate` only: install exactly this release, after checking its installer's SHA-256. */
|
|
485
|
+
target: INodeActionTarget | null;
|
|
486
|
+
claim: { bootId: string } | null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** What Cloudly answers the offer route: the node's one open action, or `null`. */
|
|
490
|
+
export interface ISparkNodeActionOffer {
|
|
491
|
+
nodeId: string;
|
|
492
|
+
action: ISparkNodeActionAssignment | null;
|
|
493
|
+
/** The controller's offer, so the node judges the answer it acts on. */
|
|
494
|
+
protocol: IProtocolOffer;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* One step Spark states about the action it executes.
|
|
499
|
+
*
|
|
500
|
+
* - `claim`: take a `pending` action, in the boot `bootId` names.
|
|
501
|
+
* - `phase`: the action entered a later phase (`sparkNodeActionPhaseOrder`). A reboot drains and
|
|
502
|
+
* then reboots; an update executes (downloads and installs) and then drains (restarts the node
|
|
503
|
+
* runtime on the new release); a host upgrade executes and, with `reboot`, drains and reboots.
|
|
504
|
+
* - `complete`: a host upgrade without a reboot finished; Cloudly verifies it from the next heartbeat.
|
|
505
|
+
* - `fail`: the action failed with one of `sparkNodeActionFailureCodes`.
|
|
506
|
+
*
|
|
507
|
+
* Every other ending is Cloudly's: it verifies an action from what the node reports
|
|
508
|
+
* (`nodeActionVerification`), and a deadline that passes ends one `unverified`.
|
|
509
|
+
*/
|
|
510
|
+
export type TSparkNodeActionTransition =
|
|
511
|
+
| { kind: 'claim'; bootId: string }
|
|
512
|
+
| { kind: 'phase'; phase: TSparkNodeActionPhase }
|
|
513
|
+
| { kind: 'complete' }
|
|
514
|
+
| { kind: 'fail'; code: TSparkNodeActionFailureCode; detail: string };
|
|
515
|
+
|
|
516
|
+
/** HTTPS POST body stating one transition. Never log, persist, or return nodeToken. */
|
|
517
|
+
export interface ISparkNodeActionTransitionRequest {
|
|
518
|
+
nodeId: string;
|
|
519
|
+
nodeToken: string;
|
|
520
|
+
/** Read and negotiated before the node is authenticated; see `sparkNodeActionContracts`. */
|
|
521
|
+
protocol: IProtocolOffer;
|
|
522
|
+
actionId: string;
|
|
523
|
+
/** The revision of the assignment the transition was decided from. */
|
|
524
|
+
expectedRevision: number;
|
|
525
|
+
transition: TSparkNodeActionTransition;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Why Cloudly did not apply a transition.
|
|
530
|
+
*
|
|
531
|
+
* - `action-unknown`: no action of that id on this node.
|
|
532
|
+
* - `action-closed`: the action ended; `action` states it no more, Spark stops executing it.
|
|
533
|
+
* - `revision-stale`: the action moved since `expectedRevision`; `action` states where it stands.
|
|
534
|
+
* - `transition-invalid`: the action's state or kind does not admit this transition.
|
|
535
|
+
*/
|
|
536
|
+
export type TSparkNodeActionTransitionRefusal = 'action-unknown' | 'action-closed' | 'revision-stale'
|
|
537
|
+
| 'transition-invalid';
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* What Cloudly answers a transition with.
|
|
541
|
+
*
|
|
542
|
+
* `replayed` answers a transition Cloudly had already applied at exactly that revision, so a
|
|
543
|
+
* Spark that lost the answer and sends it again is told it took effect. `action` is the action as
|
|
544
|
+
* it stands after the answer, `null` once it is no longer open.
|
|
545
|
+
*/
|
|
546
|
+
export type TSparkNodeActionTransitionAnswer =
|
|
547
|
+
| { outcome: 'applied' | 'replayed'; action: ISparkNodeActionAssignment | null; protocol: IProtocolOffer }
|
|
548
|
+
| { outcome: 'refused'; reason: TSparkNodeActionTransitionRefusal; action: ISparkNodeActionAssignment | null;
|
|
549
|
+
protocol: IProtocolOffer };
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The node runtime's action routes.
|
|
553
|
+
*
|
|
554
|
+
* Spark asks the offer route after each accepted heartbeat and states every step on the transition
|
|
555
|
+
* route. Both are authenticated with the node's Spark credential exactly like
|
|
556
|
+
* `sparkNodeHeartbeatContract` and judged in the same server order: the offer first, within
|
|
557
|
+
* `maxRequestBytes`, answered with `protocolRefusalStatus` and an `IProtocolRefusal` body on
|
|
558
|
+
* refusal, before the node is authenticated. The server's offer on these routes names, as its
|
|
559
|
+
* minimum, the release that introduced them, so a Spark below it is refused by name and is never
|
|
560
|
+
* handed an action.
|
|
561
|
+
*/
|
|
562
|
+
export const sparkNodeActionContracts = Object.freeze({
|
|
563
|
+
offer: Object.freeze({
|
|
564
|
+
endpoint: '/spark/nodes/action',
|
|
565
|
+
maxRequestBytes: 1024,
|
|
566
|
+
maxResponseBytes: 4096,
|
|
567
|
+
} as const),
|
|
568
|
+
transition: Object.freeze({
|
|
569
|
+
endpoint: '/spark/nodes/action/transition',
|
|
570
|
+
maxRequestBytes: 2048,
|
|
571
|
+
maxResponseBytes: 4096,
|
|
572
|
+
} as const),
|
|
573
|
+
protocolRefusalStatus: protocolRefusalHttpStatus,
|
|
574
|
+
} as const);
|
|
575
|
+
|
|
576
|
+
const fail = (): never => { throw new Error('Spark node action contract rejected.'); };
|
|
577
|
+
function requireValue(valueArg: unknown): asserts valueArg {
|
|
578
|
+
if (!valueArg) fail();
|
|
579
|
+
}
|
|
580
|
+
const record = (valueArg: unknown, keysArg: readonly string[]): Record<string, unknown> => {
|
|
581
|
+
const result = readExactDataRecord(valueArg, keysArg);
|
|
582
|
+
requireValue(result !== null);
|
|
583
|
+
return result;
|
|
584
|
+
};
|
|
585
|
+
const nodeId = (valueArg: unknown): string => (isClusterNodeId(valueArg) ? valueArg : fail());
|
|
586
|
+
const nodeToken = (valueArg: unknown): string => {
|
|
587
|
+
requireValue(typeof valueArg === 'string' && /^secureToken[a-f0-9]{64}$/.test(valueArg));
|
|
588
|
+
return valueArg;
|
|
589
|
+
};
|
|
590
|
+
const integer = (valueArg: unknown, minimumArg: number): number => {
|
|
591
|
+
requireValue(Number.isSafeInteger(valueArg) && !Object.is(valueArg, -0) && (valueArg as number) >= minimumArg);
|
|
592
|
+
return valueArg as number;
|
|
593
|
+
};
|
|
594
|
+
/** An action id as Cloudly mints them: a short URL-safe token. */
|
|
595
|
+
const actionId = (valueArg: unknown): string => {
|
|
596
|
+
requireValue(typeof valueArg === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(valueArg));
|
|
597
|
+
return valueArg;
|
|
598
|
+
};
|
|
599
|
+
/** The kernel's `boot_id`: one lowercase UUID. */
|
|
600
|
+
export const isHostBootId = (valueArg: unknown): valueArg is string => (
|
|
601
|
+
typeof valueArg === 'string'
|
|
602
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(valueArg)
|
|
603
|
+
);
|
|
604
|
+
const bootId = (valueArg: unknown): string => (isHostBootId(valueArg) ? valueArg : fail());
|
|
605
|
+
const operatorText = (valueArg: unknown): string => {
|
|
606
|
+
requireValue(typeof valueArg === 'string' && valueArg.length <= nodeActionContract.maximumDetailLength
|
|
607
|
+
&& /^[\x20-\x7e]*$/.test(valueArg));
|
|
608
|
+
return valueArg;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const snapshotTarget = (valueArg: unknown): INodeActionTarget => {
|
|
612
|
+
const value = record(valueArg, ['version', 'installerSha256', 'targetRevision']);
|
|
613
|
+
requireValue(isReleaseVersion(value.version));
|
|
614
|
+
requireValue(typeof value.installerSha256 === 'string' && sha256HexPattern.test(value.installerSha256));
|
|
615
|
+
return Object.freeze({
|
|
616
|
+
version: value.version, installerSha256: value.installerSha256,
|
|
617
|
+
targetRevision: integer(value.targetRevision, 1),
|
|
618
|
+
});
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const assignmentStatuses = ['pending', 'claimed', 'executing', 'draining', 'rebooting', 'verifying'] as const;
|
|
622
|
+
|
|
623
|
+
/** One assignment, exactly its keys; detached and frozen. */
|
|
624
|
+
export const snapshotSparkNodeActionAssignment = (valueArg: unknown): ISparkNodeActionAssignment => {
|
|
625
|
+
const value = record(valueArg, ['id', 'actionName', 'status', 'revision', 'deadlineAt', 'reboot', 'target', 'claim']);
|
|
626
|
+
requireValue(isNodeActionName(value.actionName));
|
|
627
|
+
const status = assignmentStatuses.find((statusArg) => statusArg === value.status) ?? fail();
|
|
628
|
+
requireValue(typeof value.reboot === 'boolean' && (!value.reboot || value.actionName === 'systemUpgrade'));
|
|
629
|
+
const target = value.target === null ? null : snapshotTarget(value.target);
|
|
630
|
+
requireValue((target !== null) === (value.actionName === 'serveZoneServiceUpdate'));
|
|
631
|
+
const claim = value.claim === null ? null : Object.freeze({ bootId: bootId(record(value.claim, ['bootId']).bootId) });
|
|
632
|
+
requireValue((claim === null) === (status === 'pending'));
|
|
633
|
+
return Object.freeze({
|
|
634
|
+
id: actionId(value.id), actionName: value.actionName, status,
|
|
635
|
+
revision: integer(value.revision, 1), deadlineAt: integer(value.deadlineAt, 1),
|
|
636
|
+
reboot: value.reboot, target, claim,
|
|
637
|
+
});
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
export const snapshotSparkNodeActionOfferRequest = (valueArg: unknown): ISparkNodeActionOfferRequest => {
|
|
641
|
+
const value = record(valueArg, ['nodeId', 'nodeToken', 'protocol']);
|
|
642
|
+
return Object.freeze({
|
|
643
|
+
nodeId: nodeId(value.nodeId), nodeToken: nodeToken(value.nodeToken), protocol: readProtocolOffer(value),
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
export const snapshotSparkNodeActionOffer = (valueArg: unknown): ISparkNodeActionOffer => {
|
|
648
|
+
const value = record(valueArg, ['nodeId', 'action', 'protocol']);
|
|
649
|
+
return Object.freeze({
|
|
650
|
+
nodeId: nodeId(value.nodeId),
|
|
651
|
+
action: value.action === null ? null : snapshotSparkNodeActionAssignment(value.action),
|
|
652
|
+
protocol: readProtocolOffer(value),
|
|
653
|
+
});
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const snapshotTransition = (valueArg: unknown): TSparkNodeActionTransition => {
|
|
657
|
+
const properties = readDataProperties(valueArg);
|
|
658
|
+
requireValue(properties !== null);
|
|
659
|
+
switch (properties.kind) {
|
|
660
|
+
case 'claim':
|
|
661
|
+
return Object.freeze({ kind: 'claim', bootId: bootId(record(valueArg, ['kind', 'bootId']).bootId) });
|
|
662
|
+
case 'phase': {
|
|
663
|
+
const phase = record(valueArg, ['kind', 'phase']).phase;
|
|
664
|
+
const known = sparkNodeActionPhaseOrder.find((phaseArg) => phaseArg !== 'claimed' && phaseArg === phase);
|
|
665
|
+
return Object.freeze({ kind: 'phase', phase: (known ?? fail()) as TSparkNodeActionPhase });
|
|
666
|
+
}
|
|
667
|
+
case 'complete':
|
|
668
|
+
record(valueArg, ['kind']);
|
|
669
|
+
return Object.freeze({ kind: 'complete' });
|
|
670
|
+
case 'fail': {
|
|
671
|
+
const value = record(valueArg, ['kind', 'code', 'detail']);
|
|
672
|
+
const code = sparkNodeActionFailureCodes.find((codeArg) => codeArg === value.code) ?? fail();
|
|
673
|
+
return Object.freeze({ kind: 'fail', code, detail: operatorText(value.detail) });
|
|
674
|
+
}
|
|
675
|
+
default:
|
|
676
|
+
return fail();
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
export const snapshotSparkNodeActionTransitionRequest = (valueArg: unknown): ISparkNodeActionTransitionRequest => {
|
|
681
|
+
const value = record(valueArg, ['nodeId', 'nodeToken', 'protocol', 'actionId', 'expectedRevision', 'transition']);
|
|
682
|
+
return Object.freeze({
|
|
683
|
+
nodeId: nodeId(value.nodeId), nodeToken: nodeToken(value.nodeToken), protocol: readProtocolOffer(value),
|
|
684
|
+
actionId: actionId(value.actionId), expectedRevision: integer(value.expectedRevision, 1),
|
|
685
|
+
transition: snapshotTransition(value.transition),
|
|
686
|
+
});
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const transitionRefusals: readonly TSparkNodeActionTransitionRefusal[] = [
|
|
690
|
+
'action-unknown', 'action-closed', 'revision-stale', 'transition-invalid',
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
export const snapshotSparkNodeActionTransitionAnswer = (valueArg: unknown): TSparkNodeActionTransitionAnswer => {
|
|
694
|
+
const properties = readDataProperties(valueArg);
|
|
695
|
+
requireValue(properties !== null);
|
|
696
|
+
if (properties.outcome === 'refused') {
|
|
697
|
+
const value = record(valueArg, ['outcome', 'reason', 'action', 'protocol']);
|
|
698
|
+
const reason = transitionRefusals.find((reasonArg) => reasonArg === value.reason) ?? fail();
|
|
699
|
+
return Object.freeze({
|
|
700
|
+
outcome: 'refused', reason,
|
|
701
|
+
action: value.action === null ? null : snapshotSparkNodeActionAssignment(value.action),
|
|
702
|
+
protocol: readProtocolOffer(value),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const value = record(valueArg, ['outcome', 'action', 'protocol']);
|
|
706
|
+
requireValue(value.outcome === 'applied' || value.outcome === 'replayed');
|
|
707
|
+
return Object.freeze({
|
|
708
|
+
outcome: value.outcome as 'applied' | 'replayed',
|
|
709
|
+
action: value.action === null ? null : snapshotSparkNodeActionAssignment(value.action),
|
|
710
|
+
protocol: readProtocolOffer(value),
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Whether an open action admits one transition from Spark, judged from its state alone.
|
|
716
|
+
*
|
|
717
|
+
* `claim` takes a `pending` action. A `phase` moves strictly forward in `sparkNodeActionPhaseOrder`
|
|
718
|
+
* from `claimed` or an earlier phase, and only into a phase the action has: `rebooting` only for
|
|
719
|
+
* an action that reboots, `draining` for every action but a host upgrade without a reboot.
|
|
720
|
+
* `complete` ends the execution of a host upgrade without a reboot. `fail` ends any claimed action
|
|
721
|
+
* that Cloudly is not yet verifying.
|
|
722
|
+
*/
|
|
723
|
+
export const admitsSparkNodeActionTransition = (
|
|
724
|
+
actionArg: Pick<ISparkNodeActionAssignment, 'actionName' | 'status' | 'reboot'>,
|
|
725
|
+
transitionArg: TSparkNodeActionTransition,
|
|
726
|
+
): boolean => {
|
|
727
|
+
const executing = (sparkNodeActionPhaseOrder as readonly string[]).indexOf(actionArg.status);
|
|
728
|
+
switch (transitionArg.kind) {
|
|
729
|
+
case 'claim':
|
|
730
|
+
return actionArg.status === 'pending';
|
|
731
|
+
case 'phase': {
|
|
732
|
+
if (executing < 0) return false;
|
|
733
|
+
const next = sparkNodeActionPhaseOrder.indexOf(transitionArg.phase);
|
|
734
|
+
if (next <= executing) return false;
|
|
735
|
+
if (transitionArg.phase === 'rebooting') return nodeActionReboots(actionArg.actionName, actionArg.reboot);
|
|
736
|
+
if (transitionArg.phase === 'draining') return nodeActionRestartsRuntime(actionArg.actionName, actionArg.reboot);
|
|
737
|
+
return actionArg.actionName !== 'reboot';
|
|
738
|
+
}
|
|
739
|
+
case 'complete':
|
|
740
|
+
return executing >= 1 && nodeActionVerification(actionArg.actionName, actionArg.reboot) === 'upgrade-complete';
|
|
741
|
+
case 'fail':
|
|
742
|
+
return executing >= 0;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/** True when a value is a canonical list of action names: known names, strictly increasing, at least one. */
|
|
747
|
+
export const isNodeActionNameList = (valueArg: unknown): valueArg is TNodeActionName[] => {
|
|
748
|
+
const elements = readDataElements(valueArg, nodeActionNames.length);
|
|
749
|
+
return elements !== null && elements.length > 0 && isCanonicalTokenList(elements, nodeActionNames);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
/** Sorts action names into canonical order, so a list built from a set compares byte for byte. */
|
|
753
|
+
export const sortNodeActionNames = (namesArg: Iterable<TNodeActionName>): TNodeActionName[] => (
|
|
754
|
+
[...new Set(namesArg)].sort(compareWireTokens)
|
|
755
|
+
);
|