@yadurajfleetos/cli 0.17.2 → 0.18.1
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/args.js +1 -1
- package/dist/commands/services.js +197 -59
- package/dist/commands/up.js +21 -25
- package/dist/config.js +1 -1
- package/dist/deploy-wait.js +105 -0
- package/dist/deployment-plan.js +117 -0
- package/dist/index.js +8 -0
- package/package.json +1 -1
package/dist/args.js
CHANGED
|
@@ -46,7 +46,7 @@ export const KNOWN_FLAGS = new Set([
|
|
|
46
46
|
'plan', 'dry-run', 'force', 'dir', 'file', 'manifest',
|
|
47
47
|
// per command
|
|
48
48
|
'ai', 'all', 'apply', 'channel', 'deploy', 'email', 'events', 'f', 'follow', 'limit',
|
|
49
|
-
'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
|
|
49
|
+
'name', 'node', 'only', 'out', 'password', 'project', 'secret', 'service', 'sha',
|
|
50
50
|
'since', 'terminal', 'to', 'token', 'url',
|
|
51
51
|
]);
|
|
52
52
|
/** The closest known flag to a mistyped one, or null when nothing is close. */
|
|
@@ -6,6 +6,8 @@ import { task, glyph } from '../ui.js';
|
|
|
6
6
|
import { withLadder } from '../ladder.js';
|
|
7
7
|
import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
|
|
8
8
|
import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
|
|
9
|
+
import { planFromDiscovery, renderPlan, toAssistPlan } from '../deployment-plan.js';
|
|
10
|
+
import { requireRunning } from '../deploy-wait.js';
|
|
9
11
|
import { planFromManifest, projectNameFor } from '../plan.js';
|
|
10
12
|
import { uploadContext, humanBytes } from '../archive.js';
|
|
11
13
|
import { localSource } from '../source.js';
|
|
@@ -131,13 +133,51 @@ export const servicesCommand = {
|
|
|
131
133
|
}
|
|
132
134
|
},
|
|
133
135
|
};
|
|
134
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The service a name refers to, in a fleet where a name is no longer unique.
|
|
138
|
+
*
|
|
139
|
+
* Services are identified by (fleet, project, name), so two projects may both
|
|
140
|
+
* have a "backend". This used to take the first match, which meant a command
|
|
141
|
+
* could act on another project's service without saying so — the same class of
|
|
142
|
+
* mistake as the apply that silently took one over.
|
|
143
|
+
*
|
|
144
|
+
* Resolved in the order the operator's intent is clearest:
|
|
145
|
+
*
|
|
146
|
+
* --project an explicit answer, and an error if it does not match
|
|
147
|
+
* the directory the project a manifest here would apply as, which is how
|
|
148
|
+
* the service got its project in the first place
|
|
149
|
+
* otherwise refuse, and list the projects to choose between
|
|
150
|
+
*
|
|
151
|
+
* An id always wins: it identifies a row on its own and needs no project.
|
|
152
|
+
*/
|
|
153
|
+
/** `--project`, when the operator gave one. */
|
|
154
|
+
const projectFlag = (flags) => typeof flags.project === 'string' ? flags.project : undefined;
|
|
155
|
+
async function findService(fleetId, name, project) {
|
|
135
156
|
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
157
|
+
const byId = body.services.find((s) => s.id === name);
|
|
158
|
+
if (byId)
|
|
159
|
+
return byId;
|
|
160
|
+
const matches = body.services.filter((s) => s.name === name);
|
|
161
|
+
if (!matches.length) {
|
|
138
162
|
throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
|
|
139
163
|
}
|
|
140
|
-
|
|
164
|
+
if (matches.length === 1)
|
|
165
|
+
return matches[0];
|
|
166
|
+
if (project) {
|
|
167
|
+
const scoped = matches.find((s) => s.project === project);
|
|
168
|
+
if (scoped)
|
|
169
|
+
return scoped;
|
|
170
|
+
throw new CliError(`No service called "${name}" in project "${project}". ` +
|
|
171
|
+
`It exists in: ${matches.map((s) => s.project).join(', ')}.`, EXIT.usage);
|
|
172
|
+
}
|
|
173
|
+
// The project this directory would apply as. Not a guess: it is the same
|
|
174
|
+
// value `fleet apply` uses, so it names the service this directory owns.
|
|
175
|
+
const here = projectNameFor(process.cwd());
|
|
176
|
+
const local = matches.find((s) => s.project === here);
|
|
177
|
+
if (local)
|
|
178
|
+
return local;
|
|
179
|
+
throw new CliError(`"${name}" exists in more than one project: ${matches.map((s) => s.project).join(', ')}. ` +
|
|
180
|
+
`Say which with --project <name>.`, EXIT.usage);
|
|
141
181
|
}
|
|
142
182
|
async function deployPlan(fleetId, service) {
|
|
143
183
|
return (await request('GET', `/services/${service.id}/placement-preview`)).body.decision;
|
|
@@ -184,27 +224,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
184
224
|
* follows it to conclusion rather than reporting "scheduled" and leaving the
|
|
185
225
|
* operator to guess.
|
|
186
226
|
*/
|
|
187
|
-
async function waitUntilRunning(fleetId, name,
|
|
227
|
+
async function waitUntilRunning(fleetId, name, project) {
|
|
228
|
+
const service = await findService(fleetId, name, project);
|
|
188
229
|
await task(`waiting for ${c.bold(name)} to come up`, async (s) => {
|
|
189
230
|
s.hints([
|
|
190
231
|
'the agent picks up desired state on its next poll',
|
|
191
|
-
|
|
232
|
+
"a cold image pull takes as long as the node's uplink does",
|
|
192
233
|
'this clears once the agent reports the container running',
|
|
193
234
|
]);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
|
|
207
|
-
}, { done: () => `${c.bold(name)} is running` });
|
|
235
|
+
// The shared waiter, on the shared deadline. This used to be its own
|
|
236
|
+
// loop with its own three-minute deadline, which failed a deploy that
|
|
237
|
+
// reported running three seconds later.
|
|
238
|
+
await requireRunning(fleetId, service.id, name, {
|
|
239
|
+
onPoll: (status, elapsedMs) => {
|
|
240
|
+
if (!status)
|
|
241
|
+
return;
|
|
242
|
+
s.update(`${c.bold(name)} · ${status} · ${Math.round(elapsedMs / 1000)}s`);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}, { done: () => 'running' });
|
|
208
246
|
}
|
|
209
247
|
/**
|
|
210
248
|
* The build context a service declares, if any, read from the local manifest.
|
|
@@ -233,7 +271,7 @@ export const deployCommand = {
|
|
|
233
271
|
const [name] = args;
|
|
234
272
|
if (!name)
|
|
235
273
|
throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait] [--dir <path>]', EXIT.usage);
|
|
236
|
-
const service = await findService(fleetId, name);
|
|
274
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
237
275
|
const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
|
|
238
276
|
const plan = await task('checking deployment plan', async () => deployPlan(fleetId, service));
|
|
239
277
|
const viable = plan.outcome === 'placed' && Boolean(plan.nodeName);
|
|
@@ -275,7 +313,16 @@ export const deployCommand = {
|
|
|
275
313
|
onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
|
|
276
314
|
});
|
|
277
315
|
try {
|
|
278
|
-
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
316
|
+
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
317
|
+
body: {
|
|
318
|
+
gitSha,
|
|
319
|
+
contextId,
|
|
320
|
+
// Was in KNOWN_FLAGS and read by nothing, so `--node` was
|
|
321
|
+
// accepted and ignored and the scheduler picked whatever
|
|
322
|
+
// scored highest.
|
|
323
|
+
...(typeof flags.node === 'string' ? { node: flags.node } : {}),
|
|
324
|
+
},
|
|
325
|
+
})).body;
|
|
279
326
|
walker.finish(`scheduled onto ${result.placedOn.name}`);
|
|
280
327
|
return result;
|
|
281
328
|
}
|
|
@@ -294,7 +341,7 @@ export const deployCommand = {
|
|
|
294
341
|
if (body.url)
|
|
295
342
|
console.log(`${glyph.info} ${c.cyan(body.url)}`);
|
|
296
343
|
if (!flags['no-wait'])
|
|
297
|
-
await waitUntilRunning(fleetId, service.name);
|
|
344
|
+
await waitUntilRunning(fleetId, service.name, service.project);
|
|
298
345
|
},
|
|
299
346
|
};
|
|
300
347
|
export const whereCommand = {
|
|
@@ -303,7 +350,7 @@ export const whereCommand = {
|
|
|
303
350
|
const [name] = args;
|
|
304
351
|
if (!name)
|
|
305
352
|
throw new CliError('usage: fleet where <service>', EXIT.usage);
|
|
306
|
-
const service = await findService(fleetId, name);
|
|
353
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
307
354
|
const { body } = await request('GET', `/services/${service.id}/placement-preview`);
|
|
308
355
|
const d = body.decision;
|
|
309
356
|
if (flags.json)
|
|
@@ -336,7 +383,7 @@ export const rescheduleCommand = {
|
|
|
336
383
|
const [name] = args;
|
|
337
384
|
if (!name)
|
|
338
385
|
throw new CliError('usage: fleet reschedule <service>', EXIT.usage);
|
|
339
|
-
const service = await findService(fleetId, name);
|
|
386
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
340
387
|
const { body } = await request('POST', `/services/${service.id}/reschedule`);
|
|
341
388
|
console.log(`${c.green('moved')} ${service.name} → ${c.bold(body.movedTo.name)}`);
|
|
342
389
|
},
|
|
@@ -365,7 +412,7 @@ export const deploymentsCommand = {
|
|
|
365
412
|
const [name] = args;
|
|
366
413
|
if (!name)
|
|
367
414
|
throw new CliError('usage: fleet deployments <service>', EXIT.usage);
|
|
368
|
-
const service = await findService(fleetId, name);
|
|
415
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
369
416
|
const { body } = await request('GET', `/services/${service.id}/deployments`);
|
|
370
417
|
if (flags.json)
|
|
371
418
|
return console.log(JSON.stringify(body.deployments, null, 2));
|
|
@@ -386,13 +433,13 @@ export const restartCommand = {
|
|
|
386
433
|
const [name] = args;
|
|
387
434
|
if (!name)
|
|
388
435
|
throw new CliError('usage: fleet restart <service>', EXIT.usage);
|
|
389
|
-
const service = await findService(fleetId, name);
|
|
436
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
390
437
|
const { body } = await request('POST', `/services/${service.id}/restart`, { body: {} });
|
|
391
438
|
if (flags.json)
|
|
392
439
|
return console.log(JSON.stringify(body, null, 2));
|
|
393
440
|
console.log(`${glyph.ok} ${c.green('restart scheduled')} ${service.name} ${c.dim(body.deployment.id.slice(0, 8))}`);
|
|
394
441
|
if (!flags['no-wait'])
|
|
395
|
-
await waitUntilRunning(fleetId, service.name);
|
|
442
|
+
await waitUntilRunning(fleetId, service.name, service.project);
|
|
396
443
|
},
|
|
397
444
|
};
|
|
398
445
|
export const rollbackCommand = {
|
|
@@ -401,7 +448,7 @@ export const rollbackCommand = {
|
|
|
401
448
|
const [name, deploymentId] = args;
|
|
402
449
|
if (!name)
|
|
403
450
|
throw new CliError('usage: fleet rollback <service> [deployment-id]', EXIT.usage);
|
|
404
|
-
const service = await findService(fleetId, name);
|
|
451
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
405
452
|
if (!flags.yes && !flags.y && !(await confirmDeploy())) {
|
|
406
453
|
console.log(c.dim('Rollback cancelled.'));
|
|
407
454
|
return;
|
|
@@ -411,7 +458,7 @@ export const rollbackCommand = {
|
|
|
411
458
|
return console.log(JSON.stringify(body, null, 2));
|
|
412
459
|
console.log(`${glyph.ok} ${c.green('rollback scheduled')} ${service.name} ← ${c.dim(body.rolledBackTo.slice(0, 8))}`);
|
|
413
460
|
if (!flags['no-wait'])
|
|
414
|
-
await waitUntilRunning(fleetId, service.name);
|
|
461
|
+
await waitUntilRunning(fleetId, service.name, service.project);
|
|
415
462
|
},
|
|
416
463
|
};
|
|
417
464
|
export const logsCommand = {
|
|
@@ -420,7 +467,7 @@ export const logsCommand = {
|
|
|
420
467
|
const [name] = args;
|
|
421
468
|
if (!name)
|
|
422
469
|
throw new CliError('usage: fleet logs <service> [--follow] [--since 1h]', EXIT.usage);
|
|
423
|
-
const service = await findService(fleetId, name);
|
|
470
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
424
471
|
if (flags.since)
|
|
425
472
|
console.error(c.dim('note: agent log tails are live snapshots; --since is limited to the current retained tail.'));
|
|
426
473
|
let previous = '';
|
|
@@ -449,36 +496,90 @@ export const logsCommand = {
|
|
|
449
496
|
},
|
|
450
497
|
};
|
|
451
498
|
/**
|
|
452
|
-
* The
|
|
499
|
+
* The fleet's nodes, fetched once per process.
|
|
453
500
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
|
|
501
|
+
* `init` asks for a node for every database it found, and a repository with
|
|
502
|
+
* Postgres and Redis asked twice for a list that cannot change between the two
|
|
503
|
+
* questions.
|
|
504
|
+
*/
|
|
505
|
+
let nodeCache = null;
|
|
506
|
+
async function fleetNodes(flags) {
|
|
507
|
+
if (nodeCache)
|
|
508
|
+
return nodeCache;
|
|
509
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
510
|
+
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
511
|
+
nodeCache = body.nodes;
|
|
512
|
+
return nodeCache;
|
|
513
|
+
}
|
|
514
|
+
/** Free disk in MB, from whichever pair of numbers the agent reported. */
|
|
515
|
+
function freeDiskMb(n) {
|
|
516
|
+
const used = n.telemetry?.diskUsedMb;
|
|
517
|
+
const total = n.telemetry?.diskTotalMb;
|
|
518
|
+
if (typeof used === 'number' && typeof total === 'number' && total > 0)
|
|
519
|
+
return total - used;
|
|
520
|
+
// node.diskMb is free space already. Falling back to it rather than
|
|
521
|
+
// inventing a capacity: an unavailable metric stays unavailable.
|
|
522
|
+
return typeof n.diskMb === 'number' ? n.diskMb : undefined;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Which node should hold a database's data.
|
|
526
|
+
*
|
|
527
|
+
* This replaces a function that answered only when the fleet had exactly one
|
|
528
|
+
* node and gave up otherwise — writing `node: CHANGE_ME` into a file whose very
|
|
529
|
+
* next command then failed on it. A fleet with two nodes is the common case,
|
|
530
|
+
* not the exceptional one, so "I cannot choose" was the usual answer.
|
|
458
531
|
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
532
|
+
* Ranked on what a database actually needs from a machine: it must be able to
|
|
533
|
+
* hold the data, so free disk decides, and free RAM breaks ties. Metrics the
|
|
534
|
+
* agent did not report are left out of the comparison rather than defaulted —
|
|
535
|
+
* a node that reports no disk is not thereby a node with no disk.
|
|
461
536
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
* a fleet with several nodes all fall back to the placeholder rather than
|
|
465
|
-
* turning a local command into one that requires the network.
|
|
537
|
+
* Returns undefined only when the fleet has no nodes at all, which is the one
|
|
538
|
+
* case no amount of scoring can fix.
|
|
466
539
|
*/
|
|
467
|
-
async function
|
|
540
|
+
export async function pickNodeForData(flags, what) {
|
|
541
|
+
let nodes;
|
|
468
542
|
try {
|
|
469
|
-
|
|
470
|
-
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
471
|
-
// Offline is fine: a node that is down still holds its disk, and that is
|
|
472
|
-
// what pinning is about. Only an empty fleet has nothing to choose.
|
|
473
|
-
if (body.nodes.length !== 1)
|
|
474
|
-
return undefined;
|
|
475
|
-
const only = body.nodes[0].name;
|
|
476
|
-
console.log(c.dim(` · pinned the database to ${only}, the only node in this fleet`));
|
|
477
|
-
return only;
|
|
543
|
+
nodes = await fleetNodes(flags);
|
|
478
544
|
}
|
|
479
545
|
catch {
|
|
480
546
|
return undefined;
|
|
481
547
|
}
|
|
548
|
+
if (!nodes.length)
|
|
549
|
+
return undefined;
|
|
550
|
+
// An explicit --node is the operator's decision and outranks any scoring.
|
|
551
|
+
// It is still checked, because a typo silently ignored is how the wrong
|
|
552
|
+
// machine ends up holding the data.
|
|
553
|
+
const explicit = typeof flags.node === 'string' ? flags.node : undefined;
|
|
554
|
+
if (explicit) {
|
|
555
|
+
const match = nodes.find((n) => n.name === explicit);
|
|
556
|
+
if (!match) {
|
|
557
|
+
throw new CliError(`No node called "${explicit}" in this fleet. Known: ${nodes.map((n) => n.name).join(', ')}.`, EXIT.usage);
|
|
558
|
+
}
|
|
559
|
+
return { node: match.name, why: 'you named it with --node' };
|
|
560
|
+
}
|
|
561
|
+
if (nodes.length === 1) {
|
|
562
|
+
return { node: nodes[0].name, why: 'the only node in this fleet' };
|
|
563
|
+
}
|
|
564
|
+
// Offline is not disqualifying: a node that is down still holds its disk,
|
|
565
|
+
// and pinning is about where the data lives. It is a tie-breaker, not a gate.
|
|
566
|
+
const ranked = [...nodes].sort((a, b) => {
|
|
567
|
+
const live = Number(b.live ?? false) - Number(a.live ?? false);
|
|
568
|
+
if (live)
|
|
569
|
+
return live;
|
|
570
|
+
const disk = (freeDiskMb(b) ?? -1) - (freeDiskMb(a) ?? -1);
|
|
571
|
+
if (disk)
|
|
572
|
+
return disk;
|
|
573
|
+
return (b.ramMb ?? 0) - (a.ramMb ?? 0);
|
|
574
|
+
});
|
|
575
|
+
const best = ranked[0];
|
|
576
|
+
const disk = freeDiskMb(best);
|
|
577
|
+
const reasons = [
|
|
578
|
+
disk !== undefined ? `${Math.round(disk / 1024)}GB free` : 'free disk not reported',
|
|
579
|
+
best.live ? 'reporting' : 'not reporting',
|
|
580
|
+
];
|
|
581
|
+
console.log(c.dim(` · ${what} pinned to ${best.name} — ${reasons.join(', ')}; change it before applying if that is wrong`));
|
|
582
|
+
return { node: best.name, why: reasons.join(', ') };
|
|
482
583
|
}
|
|
483
584
|
/**
|
|
484
585
|
* A second opinion on the draft, when --ai is given.
|
|
@@ -492,7 +593,16 @@ async function theOnlyNode(flags) {
|
|
|
492
593
|
* appeared with different ports and no explanation is worse than one with a
|
|
493
594
|
* mistake in it -- at least the mistake is yours to find.
|
|
494
595
|
*/
|
|
495
|
-
async function reviewed(draft, flags, services
|
|
596
|
+
async function reviewed(draft, flags, services,
|
|
597
|
+
/**
|
|
598
|
+
* What discovery already settled.
|
|
599
|
+
*
|
|
600
|
+
* Sent so the model is told the facts rather than left to read them back out
|
|
601
|
+
* of the draft it is being asked to correct — a reviewer shown only YAML
|
|
602
|
+
* treats every line as a proposal, including the ones deterministic code
|
|
603
|
+
* already decided and will re-check afterwards regardless.
|
|
604
|
+
*/
|
|
605
|
+
plan) {
|
|
496
606
|
const { repoMap } = await import('../repomap.js');
|
|
497
607
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
498
608
|
// The second pass is a different, smaller question.
|
|
@@ -507,6 +617,7 @@ async function reviewed(draft, flags, services) {
|
|
|
507
617
|
body: {
|
|
508
618
|
draft: base,
|
|
509
619
|
repoMap: map,
|
|
620
|
+
...(plan ? { plan } : {}),
|
|
510
621
|
...(answers ? { answers } : {}),
|
|
511
622
|
...(parts ? { parts } : {}),
|
|
512
623
|
},
|
|
@@ -686,14 +797,41 @@ export const initCommand = {
|
|
|
686
797
|
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
687
798
|
const found = await discover();
|
|
688
799
|
if (found.services.length > 1 || found.databases.length) {
|
|
800
|
+
// Resolved before the plan is built, so the plan can state where the
|
|
801
|
+
// data is going instead of describing a decision still to be made.
|
|
802
|
+
const chosen = found.databases.length
|
|
803
|
+
? await pickNodeForData(flags, found.databases.map((d) => d.name).join(' and '))
|
|
804
|
+
: undefined;
|
|
689
805
|
const drafted = manifestFromDiscovery(found, {
|
|
690
806
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
691
|
-
|
|
692
|
-
|
|
807
|
+
// Resolved here, not deferred into the file. A database needs a node
|
|
808
|
+
// and `pickNodeForData` answers for any fleet that has one, so the
|
|
809
|
+
// placeholder is reachable only when the fleet has no nodes at all.
|
|
810
|
+
node: chosen?.node,
|
|
693
811
|
});
|
|
812
|
+
// Said before the file appears, not after. Everything here is a
|
|
813
|
+
// projection of what discovery already established — nothing is
|
|
814
|
+
// re-detected, and nothing the discovery could not settle is filled in.
|
|
815
|
+
const plan = planFromDiscovery(found, {
|
|
816
|
+
project: projectNameFor(process.cwd()),
|
|
817
|
+
...(chosen ? { node: chosen.node, nodeWhy: chosen.why } : {}),
|
|
818
|
+
});
|
|
819
|
+
for (const line of renderPlan(plan)) {
|
|
820
|
+
console.log(line ? c.dim(line) : '');
|
|
821
|
+
}
|
|
822
|
+
console.log('');
|
|
823
|
+
// Asked before anything is written, because writing is the side effect.
|
|
824
|
+
// `confirm` returns its `ifNoTerminal` answer when there is no tty, so a
|
|
825
|
+
// scripted `fleet init` keeps working exactly as it did.
|
|
826
|
+
if (!flags.yes &&
|
|
827
|
+
!flags.y &&
|
|
828
|
+
!(await confirm('Write this manifest?', { default: true, ifNoTerminal: true }))) {
|
|
829
|
+
console.log(c.dim('Nothing was written.'));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
694
832
|
const questions = drafted.questions;
|
|
695
833
|
const manifest = flags.ai
|
|
696
|
-
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
|
|
834
|
+
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })), toAssistPlan(plan))
|
|
697
835
|
: drafted.manifest;
|
|
698
836
|
await writeFile(path, manifest);
|
|
699
837
|
console.log(`${c.green('created')} ${path}`);
|
|
@@ -758,7 +896,7 @@ export const removeServiceCommand = {
|
|
|
758
896
|
if (!name)
|
|
759
897
|
throw new CliError('usage: fleet rm <service> [--yes]', EXIT.usage);
|
|
760
898
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
761
|
-
const service = await findService(fleetId, name);
|
|
899
|
+
const service = await findService(fleetId, name, projectFlag(flags));
|
|
762
900
|
const confirmed = flags.yes === true || flags.y === true;
|
|
763
901
|
if (!confirmed) {
|
|
764
902
|
if (!process.stdin.isTTY) {
|
|
@@ -832,7 +970,7 @@ export const importCommand = {
|
|
|
832
970
|
// a manifest that must name a node, and on a one-node fleet there is
|
|
833
971
|
// nothing to choose. Without this, import wrote a placeholder and the
|
|
834
972
|
// very next command failed on it.
|
|
835
|
-
node: (
|
|
973
|
+
node: (await pickNodeForData(flags, 'the database'))?.node,
|
|
836
974
|
});
|
|
837
975
|
}
|
|
838
976
|
catch (err) {
|
|
@@ -871,7 +1009,7 @@ export const explainCommand = {
|
|
|
871
1009
|
const name = args[0];
|
|
872
1010
|
if (!name)
|
|
873
1011
|
throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
|
|
874
|
-
const service = await findService(id, name);
|
|
1012
|
+
const service = await findService(id, name, projectFlag(flags));
|
|
875
1013
|
const { body } = await request('GET', `/services/${service.id}/deployments`);
|
|
876
1014
|
// The most recent failure, which is what someone asking "why did that
|
|
877
1015
|
// fail" means — not the most recent deployment, which may since have
|
package/dist/commands/up.js
CHANGED
|
@@ -14,6 +14,7 @@ import { c } from '../render.js';
|
|
|
14
14
|
import { task, glyph } from '../ui.js';
|
|
15
15
|
import { withLadder } from '../ladder.js';
|
|
16
16
|
import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
|
|
17
|
+
import { requireRunning } from '../deploy-wait.js';
|
|
17
18
|
import { planFromManifest, deployOrder, projectNameFor } from '../plan.js';
|
|
18
19
|
import { uploadContext, humanBytes } from '../archive.js';
|
|
19
20
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -99,6 +100,7 @@ export const upCommand = {
|
|
|
99
100
|
gitSha,
|
|
100
101
|
buildContext: buildContexts.get(service.name),
|
|
101
102
|
wait: !flags['no-wait'],
|
|
103
|
+
...(typeof flags.node === 'string' ? { node: flags.node } : {}),
|
|
102
104
|
rootDir: typeof flags.file === 'string' ? dirname(flags.file) : rootDir,
|
|
103
105
|
});
|
|
104
106
|
deployed.push({ service, url });
|
|
@@ -137,7 +139,7 @@ async function deployOne(service, opts) {
|
|
|
137
139
|
});
|
|
138
140
|
try {
|
|
139
141
|
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
140
|
-
body: { gitSha: opts.gitSha, contextId },
|
|
142
|
+
body: { gitSha: opts.gitSha, contextId, ...(opts.node ? { node: opts.node } : {}) },
|
|
141
143
|
})).body;
|
|
142
144
|
// Deliberately not walker.finish().
|
|
143
145
|
//
|
|
@@ -178,19 +180,22 @@ async function deployOne(service, opts) {
|
|
|
178
180
|
// building afterwards, so this is the window in which a multi-arch
|
|
179
181
|
// build has to finish - and an arm64 build emulated on an amd64 host
|
|
180
182
|
// is measured in tens of minutes, not minutes.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
// The same waiter `fleet deploy` uses, on the same deadline. Two
|
|
184
|
+
// commands watching the same thing in two loops is how one of them
|
|
185
|
+
// ended up giving up at three minutes.
|
|
186
|
+
await requireRunning(opts.fleetId, service.id, service.name, {
|
|
187
|
+
onPoll: async () => {
|
|
188
|
+
// What the builder is doing, rather than a hint about what it
|
|
189
|
+
// might be doing. Every field here has been reaching /progress
|
|
190
|
+
// since the phase writer was added and nothing asked for it.
|
|
191
|
+
// Failures are swallowed: this is a label, and losing it must not
|
|
192
|
+
// end a deploy that is going fine.
|
|
193
|
+
const line = await request('GET', `/services/${service.id}/progress`)
|
|
194
|
+
.then((r) => r.body)
|
|
195
|
+
.catch(() => null);
|
|
196
|
+
if (!line || !['queued', 'building', 'pushing', 'deploying'].includes(line.status))
|
|
197
|
+
return;
|
|
198
|
+
const parts = [line.phase ?? line.status];
|
|
194
199
|
if (line.step && line.ofSteps)
|
|
195
200
|
parts.push(`${line.step}/${line.ofSteps}`);
|
|
196
201
|
if (line.platform)
|
|
@@ -200,17 +205,8 @@ async function deployOne(service, opts) {
|
|
|
200
205
|
s.update(`${c.bold(service.name)} · ${parts.join(' · ')}`);
|
|
201
206
|
if (line.detail)
|
|
202
207
|
s.hints([line.detail]);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const current = body.services.find((s) => s.id === service.id)?.current;
|
|
206
|
-
if (current?.status === 'running')
|
|
207
|
-
return;
|
|
208
|
-
if (current?.status === 'failed') {
|
|
209
|
-
throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
|
|
210
|
-
}
|
|
211
|
-
await sleep(2000);
|
|
212
|
-
}
|
|
213
|
-
throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
214
210
|
}, { done: () => `${c.bold(service.name)} is running` });
|
|
215
211
|
}
|
|
216
212
|
return deployResult.url;
|
package/dist/config.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function loadProfile() {
|
|
|
23
23
|
catch {
|
|
24
24
|
// The public control plane is the useful default for a first-time install.
|
|
25
25
|
// Self-hosters and CI can always override it with FLEET_API or --api.
|
|
26
|
-
return { api: fromEnv.api || 'https://fleetapi.plastikworld.xyz'
|
|
26
|
+
return { ...fromEnv, api: fromEnv.api || 'https://fleetapi.plastikworld.xyz' };
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
export async function saveProfile(profile) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting for a deploy to reach a terminal state, once, for every command.
|
|
3
|
+
*
|
|
4
|
+
* `fleet up` and `fleet deploy` both watched a deploy and drifted apart while
|
|
5
|
+
* doing it. `up` was widened to 45 minutes when it took over the build, because
|
|
6
|
+
* an arm64 image emulated on an amd64 control plane is measured in tens of
|
|
7
|
+
* minutes. `deploy` kept a three-minute deadline nobody revisited.
|
|
8
|
+
*
|
|
9
|
+
* A real deploy of landing-page started at 15:44:54 and reported running at
|
|
10
|
+
* 15:47:57 — 183 seconds. `fleet deploy` gave up at 181 and told the operator
|
|
11
|
+
* the service "was scheduled but has not reported running", about a service
|
|
12
|
+
* that was running. Two seconds of drift produced a false failure, and the
|
|
13
|
+
* message sent somebody to debug a healthy container.
|
|
14
|
+
*
|
|
15
|
+
* So the deadline is one constant and the loop is one function. Two commands
|
|
16
|
+
* waiting for the same thing in two places is how the drift happened, and
|
|
17
|
+
* sharing the constant without sharing the loop would only slow it down.
|
|
18
|
+
*/
|
|
19
|
+
import { request, CliError, EXIT } from './api.js';
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
/**
|
|
22
|
+
* How long a deploy may take before the CLI stops watching.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately longer than the control plane's own BUILD_TIMEOUT_MS (20
|
|
25
|
+
* minutes): the client must not call a deploy dead while the server is still
|
|
26
|
+
* legitimately working on it. The server owns "this build is stuck"; this
|
|
27
|
+
* number only owns "nobody is watching any more".
|
|
28
|
+
*/
|
|
29
|
+
export const DEPLOY_READY_TIMEOUT_MS = 45 * 60_000;
|
|
30
|
+
/** The service's current deployment status, or null when it cannot be read. */
|
|
31
|
+
const liveStatus = (fleetId, serviceId) => request('GET', `/fleets/${fleetId}/services`)
|
|
32
|
+
.then((r) => r.body.services.find((s) => s.id === serviceId)?.current ?? null)
|
|
33
|
+
// Unreadable is not the same as failed: a blip must not end a deploy that
|
|
34
|
+
// is going fine, so this reads as "no answer yet" and the loop continues.
|
|
35
|
+
.catch(() => null);
|
|
36
|
+
/**
|
|
37
|
+
* Wait until a service is running, has failed, or the deadline passes.
|
|
38
|
+
*
|
|
39
|
+
* Three behaviours the previous loops did not have:
|
|
40
|
+
*
|
|
41
|
+
* A service that is ALREADY running returns immediately. `fleet deploy` on an
|
|
42
|
+
* unchanged service creates no new deployment, so waiting for a transition
|
|
43
|
+
* meant waiting for something that could not happen.
|
|
44
|
+
*
|
|
45
|
+
* The deadline triggers one final authoritative read before failing. The bug
|
|
46
|
+
* this file exists for was a state change three seconds the wrong side of the
|
|
47
|
+
* deadline; a poll loop that gives up without looking one more time will always
|
|
48
|
+
* be able to miss by a second.
|
|
49
|
+
*
|
|
50
|
+
* And a timeout is reported as a timeout, carrying the last status seen —
|
|
51
|
+
* never as "has not reported running", which reads as a verdict on the service
|
|
52
|
+
* rather than on the waiting.
|
|
53
|
+
*/
|
|
54
|
+
export async function waitForRunning(fleetId, serviceId, opts = {}) {
|
|
55
|
+
const timeoutMs = opts.timeoutMs ?? DEPLOY_READY_TIMEOUT_MS;
|
|
56
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
57
|
+
const currentOf = opts.read ?? liveStatus;
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
const deadline = startedAt + timeoutMs;
|
|
60
|
+
let last = null;
|
|
61
|
+
// Before waiting at all. A deploy that changed nothing has nothing to wait
|
|
62
|
+
// for, and the desired state is already the current one.
|
|
63
|
+
const already = await currentOf(fleetId, serviceId);
|
|
64
|
+
if (already?.status === 'running')
|
|
65
|
+
return { state: 'running' };
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
await opts.onPoll?.(last, Date.now() - startedAt);
|
|
68
|
+
const current = await currentOf(fleetId, serviceId);
|
|
69
|
+
last = current?.status ?? last;
|
|
70
|
+
if (current?.status === 'running')
|
|
71
|
+
return { state: 'running' };
|
|
72
|
+
if (current?.status === 'failed') {
|
|
73
|
+
return { state: 'failed', reason: current.failureReason ?? null };
|
|
74
|
+
}
|
|
75
|
+
await sleep(pollMs);
|
|
76
|
+
}
|
|
77
|
+
// One last look. The deadline is a decision to stop waiting, not evidence
|
|
78
|
+
// about the service, and the two are only ever a poll interval apart.
|
|
79
|
+
const settled = await currentOf(fleetId, serviceId);
|
|
80
|
+
if (settled?.status === 'running')
|
|
81
|
+
return { state: 'running' };
|
|
82
|
+
if (settled?.status === 'failed') {
|
|
83
|
+
return { state: 'failed', reason: settled.failureReason ?? null };
|
|
84
|
+
}
|
|
85
|
+
return { state: 'timeout', last: settled?.status ?? last, elapsedMs: Date.now() - startedAt };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The same wait, as an exception for callers that want one.
|
|
89
|
+
*
|
|
90
|
+
* Keeps the outcome type available to anything that would rather branch than
|
|
91
|
+
* catch, while giving the two commands the single line they had before.
|
|
92
|
+
*/
|
|
93
|
+
export async function requireRunning(fleetId, serviceId, name, opts = {}) {
|
|
94
|
+
const outcome = await waitForRunning(fleetId, serviceId, opts);
|
|
95
|
+
if (outcome.state === 'running')
|
|
96
|
+
return;
|
|
97
|
+
if (outcome.state === 'failed') {
|
|
98
|
+
throw new CliError(`"${name}" did not start${outcome.reason ? `: ${outcome.reason}` : '.'} ` +
|
|
99
|
+
`\`fleet deployments ${name}\` has the reason.`, EXIT.healthCheckFailed);
|
|
100
|
+
}
|
|
101
|
+
const minutes = Math.round(outcome.elapsedMs / 60_000);
|
|
102
|
+
throw new CliError(`Stopped watching "${name}" after ${minutes} minute${minutes === 1 ? '' : 's'}; ` +
|
|
103
|
+
`it was last ${outcome.last ?? 'not reporting a status'}. The deploy may still be running — ` +
|
|
104
|
+
`\`fleet deployments ${name}\` has the current state.`, EXIT.healthCheckFailed);
|
|
105
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project the discovery model onto the plan.
|
|
3
|
+
*
|
|
4
|
+
* Pure, and takes an already-resolved node rather than looking one up: node
|
|
5
|
+
* selection talks to the control plane and belongs to the caller, which leaves
|
|
6
|
+
* this testable without a fleet.
|
|
7
|
+
*/
|
|
8
|
+
export function planFromDiscovery(discovery, opts) {
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const svc of discovery.services) {
|
|
11
|
+
entries.push({
|
|
12
|
+
name: svc.name,
|
|
13
|
+
kind: 'service',
|
|
14
|
+
what: svc.detection.label,
|
|
15
|
+
ramMb: svc.ramMb,
|
|
16
|
+
// Discovery sizes a GPU service differently and gives everything else one
|
|
17
|
+
// default. That is a recommendation, not a measurement, and labelling it
|
|
18
|
+
// otherwise would be the invented precision this plan exists to avoid.
|
|
19
|
+
ramFrom: 'recommended',
|
|
20
|
+
placement: 'flexible',
|
|
21
|
+
// The engines this service's OWN dependencies imply. Discovery is already
|
|
22
|
+
// careful here — a frontend beside a backend must not claim to use the
|
|
23
|
+
// database — so this is a projection, not a second inference.
|
|
24
|
+
dependsOn: discovery.databases.filter((d) => svc.engines.includes(d.engine)).map((d) => d.name),
|
|
25
|
+
persistent: false,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
for (const db of discovery.databases) {
|
|
29
|
+
entries.push({
|
|
30
|
+
name: db.name,
|
|
31
|
+
kind: 'database',
|
|
32
|
+
what: db.engine,
|
|
33
|
+
// Matches what `manifestFromDiscovery` writes, so the plan cannot
|
|
34
|
+
// describe a manifest different from the one produced.
|
|
35
|
+
ramMb: 512,
|
|
36
|
+
ramFrom: 'recommended',
|
|
37
|
+
placement: 'pinned',
|
|
38
|
+
...(opts.node ? { node: opts.node } : {}),
|
|
39
|
+
...(opts.nodeWhy ? { nodeWhy: opts.nodeWhy } : {}),
|
|
40
|
+
dependsOn: [],
|
|
41
|
+
// A database holds data by definition; that is why it is pinned at all.
|
|
42
|
+
persistent: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const limits = [];
|
|
46
|
+
if (discovery.databases.length) {
|
|
47
|
+
// Answered honestly rather than invented. `volume:` names a volume and
|
|
48
|
+
// `backup:` schedules a copy; neither carries a size, so a plan printing
|
|
49
|
+
// "20 GB" would describe a field the manifest cannot hold.
|
|
50
|
+
limits.push('storage size: unknown — a manifest can name a volume and a backup schedule, but has no field for its size');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
project: opts.project,
|
|
54
|
+
entries,
|
|
55
|
+
secrets: [...new Set(discovery.services.flatMap((s) => s.secrets))].sort(),
|
|
56
|
+
totalRamMb: entries.reduce((sum, e) => sum + e.ramMb, 0),
|
|
57
|
+
limits,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const size = (mb) => mb >= 1024 ? `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 2)} GiB` : `${mb} MiB`;
|
|
61
|
+
/**
|
|
62
|
+
* The plan as lines, for the terminal.
|
|
63
|
+
*
|
|
64
|
+
* Returns them rather than printing, so a test can read exactly what a user
|
|
65
|
+
* would see.
|
|
66
|
+
*/
|
|
67
|
+
export function renderPlan(plan) {
|
|
68
|
+
const out = [`Deployment plan · ${plan.project}`];
|
|
69
|
+
const services = plan.entries.filter((e) => e.kind === 'service');
|
|
70
|
+
const databases = plan.entries.filter((e) => e.kind === 'database');
|
|
71
|
+
if (services.length) {
|
|
72
|
+
out.push('', 'Services');
|
|
73
|
+
for (const s of services) {
|
|
74
|
+
out.push(` ${s.name} ${s.what} · ${size(s.ramMb)} · ${s.placement}`);
|
|
75
|
+
if (s.dependsOn.length)
|
|
76
|
+
out.push(` uses ${s.dependsOn.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (databases.length) {
|
|
80
|
+
out.push('', 'Databases');
|
|
81
|
+
for (const d of databases) {
|
|
82
|
+
// An unresolved node is stated, not hidden. It is the one thing that
|
|
83
|
+
// stops the manifest deploying, and the reader should meet it here
|
|
84
|
+
// rather than three commands later.
|
|
85
|
+
const where = d.node ? `→ ${d.node}` : '→ no node chosen yet';
|
|
86
|
+
out.push(` ${d.name} ${d.what} · ${size(d.ramMb)} · pinned ${where}`);
|
|
87
|
+
if (d.nodeWhy)
|
|
88
|
+
out.push(` ${d.nodeWhy}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (plan.secrets.length) {
|
|
92
|
+
out.push('', `Secrets · ${plan.secrets.length} required`);
|
|
93
|
+
out.push(` ${plan.secrets.join(', ')}`);
|
|
94
|
+
out.push(' values are never read or written into the manifest');
|
|
95
|
+
}
|
|
96
|
+
out.push('', `Memory · ${size(plan.totalRamMb)} across ${plan.entries.length} containers`);
|
|
97
|
+
out.push(' every figure is a starting point, not a measurement');
|
|
98
|
+
for (const limit of plan.limits)
|
|
99
|
+
out.push(` ${limit}`);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
export function toAssistPlan(plan) {
|
|
103
|
+
return {
|
|
104
|
+
project: plan.project,
|
|
105
|
+
entries: plan.entries.map((e) => ({
|
|
106
|
+
name: e.name,
|
|
107
|
+
kind: e.kind,
|
|
108
|
+
what: e.what,
|
|
109
|
+
ramMb: e.ramMb,
|
|
110
|
+
placement: e.placement,
|
|
111
|
+
...(e.node ? { node: e.node } : {}),
|
|
112
|
+
dependsOn: e.dependsOn,
|
|
113
|
+
persistent: e.persistent,
|
|
114
|
+
})),
|
|
115
|
+
secrets: plan.secrets,
|
|
116
|
+
};
|
|
117
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const GROUPS = [
|
|
|
18
18
|
['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
|
|
19
19
|
['init', 'Read the repository — monorepo, databases, secrets — and write a fleet.yaml'],
|
|
20
20
|
['init --ai', 'The same, then have the control plane review the draft against the repository'],
|
|
21
|
+
['init --node <name>', 'Pin discovered databases to a specific node instead of the best-scoring one'],
|
|
21
22
|
['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
|
|
22
23
|
['config show', 'Show the saved control plane and selected fleet'],
|
|
23
24
|
['use <fleet>', 'Select the default fleet for later commands'],
|
|
@@ -73,6 +74,13 @@ const GROUPS = [
|
|
|
73
74
|
];
|
|
74
75
|
const OPTIONS = [
|
|
75
76
|
['--fleet <id>', 'Operate on a specific fleet'],
|
|
77
|
+
// Documented because it silently did nothing on `up` and `deploy` for a long
|
|
78
|
+
// time, and a flag that validates but is ignored is worse than one that errors.
|
|
79
|
+
['--node <name>', 'Deploy onto this node, or say why the service cannot go there'],
|
|
80
|
+
// Needed since a service became (fleet, project, name): two projects may
|
|
81
|
+
// both have a "backend", and picking the first match silently acts on
|
|
82
|
+
// somebody else's.
|
|
83
|
+
['--project <name>', 'Disambiguate when two projects have a service of the same name'],
|
|
76
84
|
['--api <url>', 'Control plane URL (default: saved profile)'],
|
|
77
85
|
['--json', 'Machine-readable output on stdout'],
|
|
78
86
|
['--plan, --dry-run', 'Show the deploy placement plan without changing anything'],
|