@yadurajfleetos/cli 0.1.7 → 0.1.9
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/dist/commands/down.js +23 -4
- package/dist/commands/index.js +7 -1
- package/dist/commands/nodes.js +15 -1
- package/dist/commands/secrets.js +117 -0
- package/dist/commands/services.js +77 -12
- package/dist/commands/unpair.js +189 -0
- package/dist/commands/up.js +20 -9
- package/dist/index.js +5 -0
- package/dist/ladder.js +290 -0
- package/dist/mark.js +30 -10
- package/dist/progress.js +188 -0
- package/dist/prompt.js +205 -0
- package/dist/render.js +57 -0
- package/dist/ui.js +115 -30
- package/package.json +2 -2
package/dist/ladder.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A progress ladder: several named steps sharing one redraw region, each
|
|
3
|
+
* settling in place as it completes.
|
|
4
|
+
*
|
|
5
|
+
* This exists because a stack of independent spinners cannot answer the only
|
|
6
|
+
* question an operator has during a four-minute deploy — where am I. Completed
|
|
7
|
+
* steps stay on screen, the active step carries the clock, and the steps still to
|
|
8
|
+
* come are listed from the start, so the shape of the whole operation is legible
|
|
9
|
+
* before it has finished.
|
|
10
|
+
*
|
|
11
|
+
* The two rules from ui.ts still hold. Everything here goes to stderr, so `--json`
|
|
12
|
+
* on stdout stays pipeable into jq. And every animated form has a plain-line
|
|
13
|
+
* transcript equivalent, so a CI log reads as a sequence of events rather than as
|
|
14
|
+
* a smear of cursor escapes.
|
|
15
|
+
*/
|
|
16
|
+
import { c, cursor, glyphs, truncate } from './render.js';
|
|
17
|
+
import { MARK_HEIGHT, markFrame, PEER_COUNT } from './mark.js';
|
|
18
|
+
import { animated, claimRegion, duration, elapsed, glyph, hookCursorRestore, isQuiet, releaseRegion, setInterruptHandler, tickFor, width, } from './ui.js';
|
|
19
|
+
const err = process.stderr;
|
|
20
|
+
/**
|
|
21
|
+
* The live ladder, if there is one. Prompts need to find it in order to step
|
|
22
|
+
* aside for it; threading it through every call site instead would mean every
|
|
23
|
+
* command that can prompt has to know whether it is inside a ladder.
|
|
24
|
+
*/
|
|
25
|
+
let current = null;
|
|
26
|
+
export const activeLadder = () => current;
|
|
27
|
+
const screenRows = () => err.rows || process.stdout.rows || 24;
|
|
28
|
+
export function ladder(steps, opts = {}) {
|
|
29
|
+
const rows = steps.map((step) => ({ ...step, state: 'todo' }));
|
|
30
|
+
const at = (key) => rows.find((row) => row.key === key);
|
|
31
|
+
// The mark is the first thing to go when the terminal is short: the steps carry
|
|
32
|
+
// the information, the mesh only carries the brand.
|
|
33
|
+
const withMark = Boolean(opts.mark) && screenRows() >= MARK_HEIGHT + rows.length + 5;
|
|
34
|
+
// One spare row for the detail line that appears under the active step.
|
|
35
|
+
const height = (withMark ? MARK_HEIGHT + 1 : 0) + rows.length + 1;
|
|
36
|
+
const indent = withMark ? ' ' : '';
|
|
37
|
+
const owner = {};
|
|
38
|
+
// claimRegion mutates, so it stays last: a ladder that is too tall to redraw
|
|
39
|
+
// must not take ownership on its way to the transcript fallback.
|
|
40
|
+
const live = animated() && !isQuiet() && screenRows() >= height + 2 && claimRegion(owner);
|
|
41
|
+
const tick = tickFor(height);
|
|
42
|
+
let ticks = 0;
|
|
43
|
+
let phase = 0;
|
|
44
|
+
let painted = [];
|
|
45
|
+
let timer;
|
|
46
|
+
let closed = false;
|
|
47
|
+
let lastTranscriptDetail = 0;
|
|
48
|
+
const took = (row) => row.state === 'active' ? elapsed(row.startedAt ?? Date.now()) : duration(row.took ?? 0);
|
|
49
|
+
const render = (row, spin) => {
|
|
50
|
+
const marker = row.state === 'active'
|
|
51
|
+
? c.signal(spin)
|
|
52
|
+
: row.state === 'done'
|
|
53
|
+
? glyph.ok
|
|
54
|
+
: row.state === 'fail'
|
|
55
|
+
? glyph.fail
|
|
56
|
+
: glyph.pending;
|
|
57
|
+
const dimmed = row.state === 'todo' || row.state === 'skip';
|
|
58
|
+
const label = dimmed ? c.dim(row.label) : row.label;
|
|
59
|
+
const summary = row.summary ? ` ${c.dim(row.summary)}` : '';
|
|
60
|
+
return `${marker} ${label}${summary}${took(row)}`;
|
|
61
|
+
};
|
|
62
|
+
const frame = () => {
|
|
63
|
+
const spin = glyphs.frames[ticks % glyphs.frames.length];
|
|
64
|
+
const body = rows.flatMap((row) => {
|
|
65
|
+
const out = [`${indent}${render(row, spin)}`];
|
|
66
|
+
if (row.state === 'active' && row.detail)
|
|
67
|
+
out.push(`${indent} ${c.dim(`${glyphs.branch} ${row.detail}`)}`);
|
|
68
|
+
return out;
|
|
69
|
+
});
|
|
70
|
+
const head = withMark
|
|
71
|
+
? [
|
|
72
|
+
...markFrame(phase).map((line, i) => ` ${line}${i === 2 && opts.title ? ` ${c.bold(opts.title)}` : ''}`),
|
|
73
|
+
'',
|
|
74
|
+
]
|
|
75
|
+
: [];
|
|
76
|
+
return [...head, ...body].map((line) => truncate(line, width()));
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Rewrite only the rows that moved, batched into a single write. A full repaint
|
|
80
|
+
* of a nine-row region twelve times a second is visible as flicker over SSH,
|
|
81
|
+
* and most frames change one row.
|
|
82
|
+
*/
|
|
83
|
+
const paint = (lines) => {
|
|
84
|
+
if (!lines.length)
|
|
85
|
+
return;
|
|
86
|
+
if (painted.length !== lines.length) {
|
|
87
|
+
// The row count changed, so a partial rewrite would orphan rows below.
|
|
88
|
+
err.write((painted.length ? cursor.up(painted.length) + '\r' + cursor.clearBelow() : '') +
|
|
89
|
+
lines.join('\n') +
|
|
90
|
+
'\n');
|
|
91
|
+
painted = lines;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let first = -1;
|
|
95
|
+
let last = -1;
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
if (lines[i] !== painted[i]) {
|
|
98
|
+
if (first < 0)
|
|
99
|
+
first = i;
|
|
100
|
+
last = i;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Nothing moved: leave the terminal completely alone.
|
|
104
|
+
if (first < 0)
|
|
105
|
+
return;
|
|
106
|
+
let out = cursor.up(lines.length - first);
|
|
107
|
+
for (let i = first; i <= last; i++) {
|
|
108
|
+
if (lines[i] !== painted[i])
|
|
109
|
+
out += cursor.clearLine() + lines[i];
|
|
110
|
+
out += '\n';
|
|
111
|
+
}
|
|
112
|
+
// Back to where the region ends, without a newline that could scroll it.
|
|
113
|
+
const below = lines.length - 1 - last;
|
|
114
|
+
if (below > 0)
|
|
115
|
+
out += cursor.down(below);
|
|
116
|
+
err.write(out);
|
|
117
|
+
painted = lines;
|
|
118
|
+
};
|
|
119
|
+
const erase = () => {
|
|
120
|
+
if (!painted.length)
|
|
121
|
+
return;
|
|
122
|
+
err.write(cursor.up(painted.length) + '\r' + cursor.clearBelow());
|
|
123
|
+
painted = [];
|
|
124
|
+
};
|
|
125
|
+
const advance = () => {
|
|
126
|
+
ticks += 1;
|
|
127
|
+
// Scale the pulse by the tick so it travels at the same speed on a slow link.
|
|
128
|
+
phase = (phase + 0.25 * (tick / 80)) % PEER_COUNT;
|
|
129
|
+
paint(frame());
|
|
130
|
+
};
|
|
131
|
+
const start = () => {
|
|
132
|
+
timer = setInterval(advance, tick);
|
|
133
|
+
timer.unref?.();
|
|
134
|
+
};
|
|
135
|
+
/** One transcript line per transition — the same rule the spinner follows. */
|
|
136
|
+
const transcript = (line) => {
|
|
137
|
+
if (!isQuiet())
|
|
138
|
+
err.write(`${line}\n`);
|
|
139
|
+
};
|
|
140
|
+
const api = {
|
|
141
|
+
begin(key, label) {
|
|
142
|
+
const row = at(key);
|
|
143
|
+
if (!row)
|
|
144
|
+
return;
|
|
145
|
+
if (label)
|
|
146
|
+
row.label = label;
|
|
147
|
+
row.state = 'active';
|
|
148
|
+
row.startedAt = Date.now();
|
|
149
|
+
row.detail = undefined;
|
|
150
|
+
if (live)
|
|
151
|
+
paint(frame());
|
|
152
|
+
else
|
|
153
|
+
transcript(`${c.dim(glyphs.stepActive)} ${row.label}…`);
|
|
154
|
+
},
|
|
155
|
+
detail(key, text) {
|
|
156
|
+
const row = at(key);
|
|
157
|
+
if (!row || row.state !== 'active' || row.detail === text)
|
|
158
|
+
return;
|
|
159
|
+
row.detail = text;
|
|
160
|
+
if (live) {
|
|
161
|
+
paint(frame());
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Without a redraw region every sub-step would be its own line, and a build
|
|
165
|
+
// emits hundreds. Keep a captured log alive without flooding it.
|
|
166
|
+
if (Date.now() - lastTranscriptDetail < 10_000)
|
|
167
|
+
return;
|
|
168
|
+
lastTranscriptDetail = Date.now();
|
|
169
|
+
transcript(` ${c.dim(`${glyphs.branch} ${text}`)}`);
|
|
170
|
+
},
|
|
171
|
+
done(key, summary) {
|
|
172
|
+
const row = at(key);
|
|
173
|
+
if (!row)
|
|
174
|
+
return;
|
|
175
|
+
row.took = Date.now() - (row.startedAt ?? Date.now());
|
|
176
|
+
row.state = 'done';
|
|
177
|
+
row.summary = summary;
|
|
178
|
+
row.detail = undefined;
|
|
179
|
+
if (live)
|
|
180
|
+
paint(frame());
|
|
181
|
+
else
|
|
182
|
+
transcript(`${glyph.ok} ${row.label}${summary ? ` — ${summary}` : ''}${took(row)}`);
|
|
183
|
+
},
|
|
184
|
+
skip(key, why) {
|
|
185
|
+
const row = at(key);
|
|
186
|
+
if (!row)
|
|
187
|
+
return;
|
|
188
|
+
row.state = 'skip';
|
|
189
|
+
row.summary = why ?? 'skipped';
|
|
190
|
+
row.detail = undefined;
|
|
191
|
+
if (live)
|
|
192
|
+
paint(frame());
|
|
193
|
+
else
|
|
194
|
+
transcript(`${glyph.pending} ${c.dim(`${row.label} — ${row.summary}`)}`);
|
|
195
|
+
},
|
|
196
|
+
fail(key, why) {
|
|
197
|
+
const row = at(key);
|
|
198
|
+
if (!row)
|
|
199
|
+
return;
|
|
200
|
+
row.took = Date.now() - (row.startedAt ?? Date.now());
|
|
201
|
+
row.state = 'fail';
|
|
202
|
+
row.summary = why;
|
|
203
|
+
row.detail = undefined;
|
|
204
|
+
if (live)
|
|
205
|
+
paint(frame());
|
|
206
|
+
else
|
|
207
|
+
transcript(`${glyph.fail} ${row.label}${why ? ` — ${why}` : ''}${took(row)}`);
|
|
208
|
+
},
|
|
209
|
+
failActive(why) {
|
|
210
|
+
for (const row of rows)
|
|
211
|
+
if (row.state === 'active')
|
|
212
|
+
api.fail(row.key, why);
|
|
213
|
+
},
|
|
214
|
+
note(line) {
|
|
215
|
+
if (!live) {
|
|
216
|
+
transcript(line);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
erase();
|
|
220
|
+
err.write(`${line}\n`);
|
|
221
|
+
paint(frame());
|
|
222
|
+
},
|
|
223
|
+
suspend() {
|
|
224
|
+
if (!live)
|
|
225
|
+
return;
|
|
226
|
+
clearInterval(timer);
|
|
227
|
+
erase();
|
|
228
|
+
err.write(cursor.show());
|
|
229
|
+
releaseRegion(owner);
|
|
230
|
+
},
|
|
231
|
+
resume() {
|
|
232
|
+
if (!live || closed || !claimRegion(owner))
|
|
233
|
+
return;
|
|
234
|
+
err.write(cursor.hide());
|
|
235
|
+
paint(frame());
|
|
236
|
+
start();
|
|
237
|
+
},
|
|
238
|
+
close() {
|
|
239
|
+
if (closed)
|
|
240
|
+
return;
|
|
241
|
+
closed = true;
|
|
242
|
+
clearInterval(timer);
|
|
243
|
+
setInterruptHandler(null);
|
|
244
|
+
releaseRegion(owner);
|
|
245
|
+
if (current === api)
|
|
246
|
+
current = null;
|
|
247
|
+
if (!live)
|
|
248
|
+
return;
|
|
249
|
+
erase();
|
|
250
|
+
// Reprint the settled steps as static lines so scrollback keeps the summary.
|
|
251
|
+
// The mesh does not survive: a frozen loading animation in scrollback says
|
|
252
|
+
// nothing that a settled step list does not say better.
|
|
253
|
+
const settled = rows
|
|
254
|
+
.filter((row) => row.state !== 'todo')
|
|
255
|
+
.map((row) => truncate(render(row, glyphs.stepActive), width()));
|
|
256
|
+
err.write((settled.length ? `${settled.join('\n')}\n` : '') + cursor.show());
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
if (live) {
|
|
260
|
+
current = api;
|
|
261
|
+
hookCursorRestore();
|
|
262
|
+
setInterruptHandler(() => {
|
|
263
|
+
erase();
|
|
264
|
+
if (opts.onCancel)
|
|
265
|
+
err.write(`${glyph.warn} ${opts.onCancel}\n`);
|
|
266
|
+
});
|
|
267
|
+
err.write(cursor.hide());
|
|
268
|
+
paint(frame());
|
|
269
|
+
start();
|
|
270
|
+
}
|
|
271
|
+
return api;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Run work under a ladder, closing it on either outcome. Whichever step was in
|
|
275
|
+
* flight when the work threw is marked failed, so the transcript shows where it
|
|
276
|
+
* stopped rather than only that it stopped.
|
|
277
|
+
*/
|
|
278
|
+
export async function withLadder(steps, run, opts = {}) {
|
|
279
|
+
const l = ladder(steps, opts);
|
|
280
|
+
try {
|
|
281
|
+
return await run(l);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
l.failActive(error instanceof Error ? error.message : undefined);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
l.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
package/dist/mark.js
CHANGED
|
@@ -6,14 +6,28 @@
|
|
|
6
6
|
* The mark is a fixed character grid rather than a string per state, because
|
|
7
7
|
* the loading animation lights individual spokes and needs to address cells.
|
|
8
8
|
*/
|
|
9
|
-
import { c, colourDepth, rgb } from './render.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
import { c, colourDepth, rgb, unicode } from './render.js';
|
|
10
|
+
/**
|
|
11
|
+
* Both grids are deliberately the same 17×5 shape. Every cell coordinate below,
|
|
12
|
+
* and the cursor arithmetic in the progress UI above, is written against those
|
|
13
|
+
* dimensions — an ASCII fallback of a different size would silently break the
|
|
14
|
+
* redraw rather than merely look plainer.
|
|
15
|
+
*/
|
|
16
|
+
const GRID = unicode
|
|
17
|
+
? [
|
|
18
|
+
' ○ ○ ○ ',
|
|
19
|
+
' ╲ │ ╱ ',
|
|
20
|
+
'○───────◉───────○',
|
|
21
|
+
' ╱ │ ╲ ',
|
|
22
|
+
' ○ ○ ○ ',
|
|
23
|
+
]
|
|
24
|
+
: [
|
|
25
|
+
' o o o ',
|
|
26
|
+
' \\ | / ',
|
|
27
|
+
'o-------@-------o',
|
|
28
|
+
' / | \\ ',
|
|
29
|
+
' o o o ',
|
|
30
|
+
];
|
|
17
31
|
export const MARK_WIDTH = 17;
|
|
18
32
|
export const MARK_HEIGHT = GRID.length;
|
|
19
33
|
/** Where the hub sits. Always lit — a fleet with no control plane is not a fleet. */
|
|
@@ -82,6 +96,8 @@ export function markFrame(phase) {
|
|
|
82
96
|
/** The resting mark: hub live, peers quiet. */
|
|
83
97
|
export const mark = () => markFrame(-1);
|
|
84
98
|
const WORDMARK = ['█▀▀ █ █▀▀ █▀▀ ▀█▀', '█▀ █ █▀ █▀ █ ', '▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ '];
|
|
99
|
+
/** The hub, read back out of the grid so the two glyph sets cannot drift. */
|
|
100
|
+
const hubGlyph = GRID[HUB[0]][HUB[1]];
|
|
85
101
|
/**
|
|
86
102
|
* Mark and wordmark side by side, with the tagline tucked under the wordmark so
|
|
87
103
|
* the block stays rectangular. Falls back to a single line when the terminal is
|
|
@@ -91,11 +107,15 @@ export function banner(subtitle) {
|
|
|
91
107
|
// `columns` is 0, not undefined, on some pseudo-terminals — `??` would miss it.
|
|
92
108
|
const columns = process.stdout.columns || 80;
|
|
93
109
|
if (columns < 46)
|
|
94
|
-
return `${c.signal(
|
|
110
|
+
return `${c.signal(hubGlyph)} ${c.bold('fleet')}${subtitle ? c.dim(` ${subtitle}`) : ''}`;
|
|
95
111
|
// The wordmark sits against the middle three rows of the mark; the tagline
|
|
96
112
|
// takes the last. Every mark row is exactly MARK_WIDTH visible columns, so a
|
|
97
113
|
// fixed gutter aligns them without measuring around the colour codes.
|
|
98
|
-
|
|
114
|
+
// Without block glyphs the drawn wordmark is unreadable, so ASCII terminals
|
|
115
|
+
// get the name set once against the hub row instead of a row of mojibake.
|
|
116
|
+
const right = unicode
|
|
117
|
+
? ['', ...WORDMARK.map(c.bold), subtitle ? c.dim(subtitle) : '']
|
|
118
|
+
: ['', '', c.bold('F L E E T'), subtitle ? c.dim(subtitle) : '', ''];
|
|
99
119
|
return mark()
|
|
100
120
|
.map((line, i) => ` ${line}${right[i] ? ` ${right[i]}` : ''}`.trimEnd())
|
|
101
121
|
.join('\n');
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following a deploy.
|
|
3
|
+
*
|
|
4
|
+
* `POST /services/:id/deploy` is a single request that can take minutes: it builds
|
|
5
|
+
* for every architecture in the fleet, pushes to the registry, allocates a port
|
|
6
|
+
* and hands the container to an agent. The control plane now walks the deployment
|
|
7
|
+
* row through those phases and publishes the build's own sub-step alongside it
|
|
8
|
+
* (control-plane/src/api/deploy-progress.ts), so the CLI can poll for them while
|
|
9
|
+
* it waits on the request it already has in flight, and show where the work has
|
|
10
|
+
* actually got to instead of guessing.
|
|
11
|
+
*
|
|
12
|
+
* Progress is decoration, never the result: a poll that fails is swallowed and the
|
|
13
|
+
* deploy carries on. Only `awaitRunning`, where a poll *is* the mechanism, treats
|
|
14
|
+
* a persistently unreachable control plane as an error.
|
|
15
|
+
*/
|
|
16
|
+
import { request, CliError, EXIT } from './api.js';
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
/** A failure reason can be a build log tail; a one-line error gets one line of it. */
|
|
19
|
+
export const firstLine = (text) => text.split('\n')[0].trim().slice(0, 200);
|
|
20
|
+
export async function fetchProgress(serviceId) {
|
|
21
|
+
const { body } = await request('GET', `/services/${serviceId}/progress`);
|
|
22
|
+
return body.progress;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The build's sub-step as one line. `linux/arm64` is shortened because the arch is
|
|
26
|
+
* the informative half and the redraw region is narrow.
|
|
27
|
+
*/
|
|
28
|
+
export function progressLine(p) {
|
|
29
|
+
if (!p.detail)
|
|
30
|
+
return undefined;
|
|
31
|
+
const counter = p.step && p.ofSteps ? `${p.step}/${p.ofSteps} ` : '';
|
|
32
|
+
const platform = p.platform ? `${p.platform.replace(/^linux\//, '')} ` : '';
|
|
33
|
+
return `${counter}${platform}${p.detail}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Poll `/progress` in the background while something else is being awaited.
|
|
37
|
+
*
|
|
38
|
+
* The shape is the device-flow race in auth.ts: the answer comes from one promise
|
|
39
|
+
* while a second keeps the display honest until it lands. `stop()` interrupts the
|
|
40
|
+
* sleep rather than waiting it out, so the last frame is not held back by a poll
|
|
41
|
+
* interval that is no longer needed.
|
|
42
|
+
*/
|
|
43
|
+
export function follow(serviceId, sink, opts = {}) {
|
|
44
|
+
const interval = opts.intervalMs ?? 800;
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let misses = 0;
|
|
47
|
+
let wake = null;
|
|
48
|
+
const rest = (ms) => new Promise((resolve) => {
|
|
49
|
+
const timer = setTimeout(resolve, ms);
|
|
50
|
+
wake = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
const loop = (async () => {
|
|
56
|
+
while (!stopped) {
|
|
57
|
+
await rest(interval);
|
|
58
|
+
if (stopped)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const progress = await fetchProgress(serviceId);
|
|
62
|
+
misses = 0;
|
|
63
|
+
if (progress)
|
|
64
|
+
sink(progress);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A control plane that predates the endpoint answers 404 every time, so
|
|
68
|
+
// give up rather than spend the whole build asking again.
|
|
69
|
+
if (++misses >= 3) {
|
|
70
|
+
opts.onUnavailable?.();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
return {
|
|
77
|
+
stop: async () => {
|
|
78
|
+
stopped = true;
|
|
79
|
+
wake?.();
|
|
80
|
+
await loop.catch(() => { });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Ladder steps for a deploy, in the order the control plane reports them. */
|
|
85
|
+
export const DEPLOY_STEPS = [
|
|
86
|
+
{ key: 'place', label: 'choosing a node' },
|
|
87
|
+
{ key: 'build', label: 'building the image' },
|
|
88
|
+
{ key: 'push', label: 'pushing to the fleet registry' },
|
|
89
|
+
{ key: 'schedule', label: 'scheduling onto the node' },
|
|
90
|
+
{ key: 'health', label: 'waiting for the container' },
|
|
91
|
+
];
|
|
92
|
+
/** Which ladder step each server-reported phase corresponds to. */
|
|
93
|
+
const STEP_OF = {
|
|
94
|
+
queued: 0,
|
|
95
|
+
building: 1,
|
|
96
|
+
pushing: 2,
|
|
97
|
+
scheduling: 3,
|
|
98
|
+
deploying: 4,
|
|
99
|
+
running: 5,
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Drive a ladder from the server's phases.
|
|
103
|
+
*
|
|
104
|
+
* Forward only: a poll can arrive out of order, and re-beginning a step would
|
|
105
|
+
* restart its clock. Steps the deploy never entered are marked skipped rather than
|
|
106
|
+
* done — a service deployed from a prebuilt image goes straight from `queued` to
|
|
107
|
+
* `scheduling`, and settling build and push as complete would claim work that
|
|
108
|
+
* never happened.
|
|
109
|
+
*/
|
|
110
|
+
export function phaseWalker(l, steps = DEPLOY_STEPS) {
|
|
111
|
+
let at = 0;
|
|
112
|
+
l.begin(steps[0].key);
|
|
113
|
+
const advance = (target, summary) => {
|
|
114
|
+
if (target <= at)
|
|
115
|
+
return;
|
|
116
|
+
for (let i = at; i < target; i++) {
|
|
117
|
+
if (i === at)
|
|
118
|
+
l.done(steps[i].key, summary);
|
|
119
|
+
else
|
|
120
|
+
l.skip(steps[i].key, 'not needed');
|
|
121
|
+
}
|
|
122
|
+
at = target;
|
|
123
|
+
if (target < steps.length)
|
|
124
|
+
l.begin(steps[target].key);
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
advance,
|
|
128
|
+
finish: (summary) => advance(steps.length, summary),
|
|
129
|
+
get at() {
|
|
130
|
+
return at;
|
|
131
|
+
},
|
|
132
|
+
apply(p) {
|
|
133
|
+
if (p.status === 'failed') {
|
|
134
|
+
l.failActive(p.failureReason ? firstLine(p.failureReason) : undefined);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const target = STEP_OF[p.status];
|
|
138
|
+
if (target !== undefined) {
|
|
139
|
+
// Which node was chosen is the one summary worth carrying over from a
|
|
140
|
+
// poll, and it belongs on the step that decided it.
|
|
141
|
+
advance(target, at === 0 ? (p.nodeName ?? undefined) : undefined);
|
|
142
|
+
}
|
|
143
|
+
const line = progressLine(p);
|
|
144
|
+
if (line && at < steps.length)
|
|
145
|
+
l.detail(steps[at].key, line);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Follow a service to `running`.
|
|
151
|
+
*
|
|
152
|
+
* The deploy request returns once the image exists and a node has been chosen; the
|
|
153
|
+
* container actually starting is the agent's job and happens afterwards, so the
|
|
154
|
+
* CLI follows it to a conclusion rather than reporting "scheduled" and leaving the
|
|
155
|
+
* operator to guess. One indexed row per poll, not the whole service list.
|
|
156
|
+
*/
|
|
157
|
+
export async function awaitRunning(service, opts = {}) {
|
|
158
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 180_000);
|
|
159
|
+
let misses = 0;
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
let progress = null;
|
|
162
|
+
try {
|
|
163
|
+
progress = await fetchProgress(service.id);
|
|
164
|
+
misses = 0;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
// Here a poll is the mechanism, not decoration. Reporting a timeout when
|
|
168
|
+
// the control plane simply stopped answering would blame the wrong thing.
|
|
169
|
+
if (++misses >= 5)
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
if (progress?.status === 'running')
|
|
173
|
+
return;
|
|
174
|
+
if (progress?.status === 'failed') {
|
|
175
|
+
const why = progress.failureReason ? `: ${firstLine(progress.failureReason)}` : '';
|
|
176
|
+
throw new CliError(`"${service.name}" did not start${why}. \`fleet deployments ${service.name}\` has the detail.`, EXIT.healthCheckFailed);
|
|
177
|
+
}
|
|
178
|
+
if (progress?.status === 'pinned_unavailable') {
|
|
179
|
+
throw new CliError(`"${service.name}" is pinned to a node that is not available. ` +
|
|
180
|
+
`\`fleet where ${service.name}\` explains why.`, EXIT.noEligibleNode);
|
|
181
|
+
}
|
|
182
|
+
if (progress)
|
|
183
|
+
opts.onProgress?.(progress);
|
|
184
|
+
await sleep(2000);
|
|
185
|
+
}
|
|
186
|
+
throw new CliError(`"${service.name}" was scheduled but has not reported running. ` +
|
|
187
|
+
`\`fleet deployments ${service.name}\` has the detail.`, EXIT.healthCheckFailed);
|
|
188
|
+
}
|