@yadurajfleetos/cli 0.17.1 → 0.18.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/dist/args.js +1 -1
- package/dist/commands/services.js +147 -37
- package/dist/commands/up.js +21 -24
- package/dist/deployment-plan.js +117 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/dist/args.js
CHANGED
|
@@ -43,7 +43,7 @@ export function parseArgs(argv) {
|
|
|
43
43
|
export const KNOWN_FLAGS = new Set([
|
|
44
44
|
// global
|
|
45
45
|
'fleet', 'api', 'json', 'yes', 'y', 'help', 'h', 'version', 'v', 'no-wait',
|
|
46
|
-
'plan', 'dry-run', 'force',
|
|
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
49
|
'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
3
|
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
4
4
|
import { c, table, statusColour, keyValues, relativeTime, mb } from '../render.js';
|
|
5
5
|
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';
|
|
9
10
|
import { planFromManifest, projectNameFor } from '../plan.js';
|
|
10
11
|
import { uploadContext, humanBytes } from '../archive.js';
|
|
11
12
|
import { localSource } from '../source.js';
|
|
@@ -213,10 +214,15 @@ async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
|
|
|
213
214
|
* repository is legitimate for a prebuilt `image:` service, and should not
|
|
214
215
|
* become an error about a file the operator never needed.
|
|
215
216
|
*/
|
|
216
|
-
async function buildContextFor(serviceName) {
|
|
217
|
+
async function buildContextFor(serviceName, manifestPath, baseDir) {
|
|
217
218
|
try {
|
|
218
|
-
const
|
|
219
|
-
|
|
219
|
+
const file = manifestPath ?? (baseDir ? join(baseDir, 'fleet.yaml') : 'fleet.yaml');
|
|
220
|
+
const source = await readFile(file, 'utf8');
|
|
221
|
+
const buildRel = planFromManifest(source).find((s) => s.name === serviceName)?.build;
|
|
222
|
+
if (!buildRel)
|
|
223
|
+
return undefined;
|
|
224
|
+
const dir = baseDir ?? (manifestPath ? dirname(manifestPath) : process.cwd());
|
|
225
|
+
return join(dir, buildRel);
|
|
220
226
|
}
|
|
221
227
|
catch {
|
|
222
228
|
return undefined;
|
|
@@ -227,7 +233,7 @@ export const deployCommand = {
|
|
|
227
233
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
228
234
|
const [name] = args;
|
|
229
235
|
if (!name)
|
|
230
|
-
throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait]', EXIT.usage);
|
|
236
|
+
throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait] [--dir <path>]', EXIT.usage);
|
|
231
237
|
const service = await findService(fleetId, name);
|
|
232
238
|
const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
|
|
233
239
|
const plan = await task('checking deployment plan', async () => deployPlan(fleetId, service));
|
|
@@ -252,12 +258,16 @@ export const deployCommand = {
|
|
|
252
258
|
}
|
|
253
259
|
// A service that builds from source needs its directory sent, or the
|
|
254
260
|
// control plane has nothing to build and says the context does not exist.
|
|
255
|
-
// Read from the manifest here rather than from the service row, because
|
|
256
|
-
// the build path is relative to the file the operator is standing in.
|
|
257
261
|
let contextId;
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
262
|
+
const baseDir = typeof flags.dir === 'string' ? flags.dir : undefined;
|
|
263
|
+
const manifestFile = typeof flags.file === 'string'
|
|
264
|
+
? flags.file
|
|
265
|
+
: typeof flags.manifest === 'string'
|
|
266
|
+
? flags.manifest
|
|
267
|
+
: undefined;
|
|
268
|
+
const buildPath = await buildContextFor(service.name, manifestFile, baseDir);
|
|
269
|
+
if (buildPath) {
|
|
270
|
+
const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, buildPath), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
|
|
261
271
|
contextId = uploaded.contextId;
|
|
262
272
|
}
|
|
263
273
|
const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
|
|
@@ -266,7 +276,16 @@ export const deployCommand = {
|
|
|
266
276
|
onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
|
|
267
277
|
});
|
|
268
278
|
try {
|
|
269
|
-
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
279
|
+
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
280
|
+
body: {
|
|
281
|
+
gitSha,
|
|
282
|
+
contextId,
|
|
283
|
+
// Was in KNOWN_FLAGS and read by nothing, so `--node` was
|
|
284
|
+
// accepted and ignored and the scheduler picked whatever
|
|
285
|
+
// scored highest.
|
|
286
|
+
...(typeof flags.node === 'string' ? { node: flags.node } : {}),
|
|
287
|
+
},
|
|
288
|
+
})).body;
|
|
270
289
|
walker.finish(`scheduled onto ${result.placedOn.name}`);
|
|
271
290
|
return result;
|
|
272
291
|
}
|
|
@@ -440,36 +459,90 @@ export const logsCommand = {
|
|
|
440
459
|
},
|
|
441
460
|
};
|
|
442
461
|
/**
|
|
443
|
-
* The
|
|
462
|
+
* The fleet's nodes, fetched once per process.
|
|
463
|
+
*
|
|
464
|
+
* `init` asks for a node for every database it found, and a repository with
|
|
465
|
+
* Postgres and Redis asked twice for a list that cannot change between the two
|
|
466
|
+
* questions.
|
|
467
|
+
*/
|
|
468
|
+
let nodeCache = null;
|
|
469
|
+
async function fleetNodes(flags) {
|
|
470
|
+
if (nodeCache)
|
|
471
|
+
return nodeCache;
|
|
472
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
473
|
+
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
474
|
+
nodeCache = body.nodes;
|
|
475
|
+
return nodeCache;
|
|
476
|
+
}
|
|
477
|
+
/** Free disk in MB, from whichever pair of numbers the agent reported. */
|
|
478
|
+
function freeDiskMb(n) {
|
|
479
|
+
const used = n.telemetry?.diskUsedMb;
|
|
480
|
+
const total = n.telemetry?.diskTotalMb;
|
|
481
|
+
if (typeof used === 'number' && typeof total === 'number' && total > 0)
|
|
482
|
+
return total - used;
|
|
483
|
+
// node.diskMb is free space already. Falling back to it rather than
|
|
484
|
+
// inventing a capacity: an unavailable metric stays unavailable.
|
|
485
|
+
return typeof n.diskMb === 'number' ? n.diskMb : undefined;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Which node should hold a database's data.
|
|
444
489
|
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* on
|
|
448
|
-
*
|
|
490
|
+
* This replaces a function that answered only when the fleet had exactly one
|
|
491
|
+
* node and gave up otherwise — writing `node: CHANGE_ME` into a file whose very
|
|
492
|
+
* next command then failed on it. A fleet with two nodes is the common case,
|
|
493
|
+
* not the exceptional one, so "I cannot choose" was the usual answer.
|
|
449
494
|
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
495
|
+
* Ranked on what a database actually needs from a machine: it must be able to
|
|
496
|
+
* hold the data, so free disk decides, and free RAM breaks ties. Metrics the
|
|
497
|
+
* agent did not report are left out of the comparison rather than defaulted —
|
|
498
|
+
* a node that reports no disk is not thereby a node with no disk.
|
|
452
499
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* a fleet with several nodes all fall back to the placeholder rather than
|
|
456
|
-
* turning a local command into one that requires the network.
|
|
500
|
+
* Returns undefined only when the fleet has no nodes at all, which is the one
|
|
501
|
+
* case no amount of scoring can fix.
|
|
457
502
|
*/
|
|
458
|
-
async function
|
|
503
|
+
export async function pickNodeForData(flags, what) {
|
|
504
|
+
let nodes;
|
|
459
505
|
try {
|
|
460
|
-
|
|
461
|
-
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
462
|
-
// Offline is fine: a node that is down still holds its disk, and that is
|
|
463
|
-
// what pinning is about. Only an empty fleet has nothing to choose.
|
|
464
|
-
if (body.nodes.length !== 1)
|
|
465
|
-
return undefined;
|
|
466
|
-
const only = body.nodes[0].name;
|
|
467
|
-
console.log(c.dim(` · pinned the database to ${only}, the only node in this fleet`));
|
|
468
|
-
return only;
|
|
506
|
+
nodes = await fleetNodes(flags);
|
|
469
507
|
}
|
|
470
508
|
catch {
|
|
471
509
|
return undefined;
|
|
472
510
|
}
|
|
511
|
+
if (!nodes.length)
|
|
512
|
+
return undefined;
|
|
513
|
+
// An explicit --node is the operator's decision and outranks any scoring.
|
|
514
|
+
// It is still checked, because a typo silently ignored is how the wrong
|
|
515
|
+
// machine ends up holding the data.
|
|
516
|
+
const explicit = typeof flags.node === 'string' ? flags.node : undefined;
|
|
517
|
+
if (explicit) {
|
|
518
|
+
const match = nodes.find((n) => n.name === explicit);
|
|
519
|
+
if (!match) {
|
|
520
|
+
throw new CliError(`No node called "${explicit}" in this fleet. Known: ${nodes.map((n) => n.name).join(', ')}.`, EXIT.usage);
|
|
521
|
+
}
|
|
522
|
+
return { node: match.name, why: 'you named it with --node' };
|
|
523
|
+
}
|
|
524
|
+
if (nodes.length === 1) {
|
|
525
|
+
return { node: nodes[0].name, why: 'the only node in this fleet' };
|
|
526
|
+
}
|
|
527
|
+
// Offline is not disqualifying: a node that is down still holds its disk,
|
|
528
|
+
// and pinning is about where the data lives. It is a tie-breaker, not a gate.
|
|
529
|
+
const ranked = [...nodes].sort((a, b) => {
|
|
530
|
+
const live = Number(b.live ?? false) - Number(a.live ?? false);
|
|
531
|
+
if (live)
|
|
532
|
+
return live;
|
|
533
|
+
const disk = (freeDiskMb(b) ?? -1) - (freeDiskMb(a) ?? -1);
|
|
534
|
+
if (disk)
|
|
535
|
+
return disk;
|
|
536
|
+
return (b.ramMb ?? 0) - (a.ramMb ?? 0);
|
|
537
|
+
});
|
|
538
|
+
const best = ranked[0];
|
|
539
|
+
const disk = freeDiskMb(best);
|
|
540
|
+
const reasons = [
|
|
541
|
+
disk !== undefined ? `${Math.round(disk / 1024)}GB free` : 'free disk not reported',
|
|
542
|
+
best.live ? 'reporting' : 'not reporting',
|
|
543
|
+
];
|
|
544
|
+
console.log(c.dim(` · ${what} pinned to ${best.name} — ${reasons.join(', ')}; change it before applying if that is wrong`));
|
|
545
|
+
return { node: best.name, why: reasons.join(', ') };
|
|
473
546
|
}
|
|
474
547
|
/**
|
|
475
548
|
* A second opinion on the draft, when --ai is given.
|
|
@@ -483,7 +556,16 @@ async function theOnlyNode(flags) {
|
|
|
483
556
|
* appeared with different ports and no explanation is worse than one with a
|
|
484
557
|
* mistake in it -- at least the mistake is yours to find.
|
|
485
558
|
*/
|
|
486
|
-
async function reviewed(draft, flags, services
|
|
559
|
+
async function reviewed(draft, flags, services,
|
|
560
|
+
/**
|
|
561
|
+
* What discovery already settled.
|
|
562
|
+
*
|
|
563
|
+
* Sent so the model is told the facts rather than left to read them back out
|
|
564
|
+
* of the draft it is being asked to correct — a reviewer shown only YAML
|
|
565
|
+
* treats every line as a proposal, including the ones deterministic code
|
|
566
|
+
* already decided and will re-check afterwards regardless.
|
|
567
|
+
*/
|
|
568
|
+
plan) {
|
|
487
569
|
const { repoMap } = await import('../repomap.js');
|
|
488
570
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
489
571
|
// The second pass is a different, smaller question.
|
|
@@ -498,6 +580,7 @@ async function reviewed(draft, flags, services) {
|
|
|
498
580
|
body: {
|
|
499
581
|
draft: base,
|
|
500
582
|
repoMap: map,
|
|
583
|
+
...(plan ? { plan } : {}),
|
|
501
584
|
...(answers ? { answers } : {}),
|
|
502
585
|
...(parts ? { parts } : {}),
|
|
503
586
|
},
|
|
@@ -677,14 +760,41 @@ export const initCommand = {
|
|
|
677
760
|
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
678
761
|
const found = await discover();
|
|
679
762
|
if (found.services.length > 1 || found.databases.length) {
|
|
763
|
+
// Resolved before the plan is built, so the plan can state where the
|
|
764
|
+
// data is going instead of describing a decision still to be made.
|
|
765
|
+
const chosen = found.databases.length
|
|
766
|
+
? await pickNodeForData(flags, found.databases.map((d) => d.name).join(' and '))
|
|
767
|
+
: undefined;
|
|
680
768
|
const drafted = manifestFromDiscovery(found, {
|
|
681
769
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
682
|
-
|
|
683
|
-
|
|
770
|
+
// Resolved here, not deferred into the file. A database needs a node
|
|
771
|
+
// and `pickNodeForData` answers for any fleet that has one, so the
|
|
772
|
+
// placeholder is reachable only when the fleet has no nodes at all.
|
|
773
|
+
node: chosen?.node,
|
|
774
|
+
});
|
|
775
|
+
// Said before the file appears, not after. Everything here is a
|
|
776
|
+
// projection of what discovery already established — nothing is
|
|
777
|
+
// re-detected, and nothing the discovery could not settle is filled in.
|
|
778
|
+
const plan = planFromDiscovery(found, {
|
|
779
|
+
project: projectNameFor(process.cwd()),
|
|
780
|
+
...(chosen ? { node: chosen.node, nodeWhy: chosen.why } : {}),
|
|
684
781
|
});
|
|
782
|
+
for (const line of renderPlan(plan)) {
|
|
783
|
+
console.log(line ? c.dim(line) : '');
|
|
784
|
+
}
|
|
785
|
+
console.log('');
|
|
786
|
+
// Asked before anything is written, because writing is the side effect.
|
|
787
|
+
// `confirm` returns its `ifNoTerminal` answer when there is no tty, so a
|
|
788
|
+
// scripted `fleet init` keeps working exactly as it did.
|
|
789
|
+
if (!flags.yes &&
|
|
790
|
+
!flags.y &&
|
|
791
|
+
!(await confirm('Write this manifest?', { default: true, ifNoTerminal: true }))) {
|
|
792
|
+
console.log(c.dim('Nothing was written.'));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
685
795
|
const questions = drafted.questions;
|
|
686
796
|
const manifest = flags.ai
|
|
687
|
-
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
|
|
797
|
+
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })), toAssistPlan(plan))
|
|
688
798
|
: drafted.manifest;
|
|
689
799
|
await writeFile(path, manifest);
|
|
690
800
|
console.log(`${c.green('created')} ${path}`);
|
|
@@ -823,7 +933,7 @@ export const importCommand = {
|
|
|
823
933
|
// a manifest that must name a node, and on a one-node fleet there is
|
|
824
934
|
// nothing to choose. Without this, import wrote a placeholder and the
|
|
825
935
|
// very next command failed on it.
|
|
826
|
-
node: (
|
|
936
|
+
node: (await pickNodeForData(flags, 'the database'))?.node,
|
|
827
937
|
});
|
|
828
938
|
}
|
|
829
939
|
catch (err) {
|
package/dist/commands/up.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the operator from the loop between them.
|
|
9
9
|
*/
|
|
10
10
|
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
12
|
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
13
13
|
import { c } from '../render.js';
|
|
14
14
|
import { task, glyph } from '../ui.js';
|
|
@@ -20,7 +20,12 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
20
20
|
export const upCommand = {
|
|
21
21
|
async run(args, flags) {
|
|
22
22
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
23
|
-
const
|
|
23
|
+
const rootDir = typeof flags.dir === 'string' ? flags.dir : process.cwd();
|
|
24
|
+
const manifestPath = typeof flags.file === 'string'
|
|
25
|
+
? flags.file
|
|
26
|
+
: typeof flags.manifest === 'string'
|
|
27
|
+
? flags.manifest
|
|
28
|
+
: join(rootDir, 'fleet.yaml');
|
|
24
29
|
// ── Step 1: scaffold if needed ────────────────────────────────────
|
|
25
30
|
let needsApply = false;
|
|
26
31
|
try {
|
|
@@ -29,14 +34,14 @@ export const upCommand = {
|
|
|
29
34
|
catch {
|
|
30
35
|
// No fleet.yaml — run the smart init inline.
|
|
31
36
|
const { detect, manifestTemplate } = await import('../detect.js');
|
|
32
|
-
const d = await task('detecting project framework', async () => detect());
|
|
37
|
+
const d = await task('detecting project framework', async () => detect(rootDir));
|
|
33
38
|
const name = (typeof flags.name === 'string' ? flags.name : '') ||
|
|
34
39
|
args[0] ||
|
|
35
|
-
|
|
40
|
+
rootDir.split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
|
|
36
41
|
'app';
|
|
37
42
|
// Write Dockerfile if generated
|
|
38
43
|
if (d.dockerfile) {
|
|
39
|
-
await writeFile(join(
|
|
44
|
+
await writeFile(join(rootDir, 'Dockerfile'), d.dockerfile);
|
|
40
45
|
console.log(`${glyph.ok} ${c.green('created')} Dockerfile ${c.dim(`(${d.label}, port ${d.port})`)}`);
|
|
41
46
|
}
|
|
42
47
|
// Write manifest
|
|
@@ -47,7 +52,7 @@ export const upCommand = {
|
|
|
47
52
|
// ── Step 2: read and apply the manifest ───────────────────────────
|
|
48
53
|
const manifest = await readFile(manifestPath, 'utf8');
|
|
49
54
|
const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
50
|
-
body: { manifest, project: projectNameFor(
|
|
55
|
+
body: { manifest, project: projectNameFor(rootDir) },
|
|
51
56
|
})).body, {
|
|
52
57
|
done: (b) => b.created.length || b.updated.length
|
|
53
58
|
? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
|
|
@@ -76,13 +81,6 @@ export const upCommand = {
|
|
|
76
81
|
return service;
|
|
77
82
|
});
|
|
78
83
|
// A database that is already serving is left alone.
|
|
79
|
-
//
|
|
80
|
-
// Redeploying one replaces a running container for no reason, and every
|
|
81
|
-
// service that talks to it loses its connections while it restarts. It is
|
|
82
|
-
// in the plan so that a database which is *not* running comes back — which
|
|
83
|
-
// is the case that used to need `fleet up db` by name — not so that every
|
|
84
|
-
// deploy of the stack restarts the database underneath it. Naming it
|
|
85
|
-
// explicitly still redeploys it.
|
|
86
84
|
const skipped = args[0]
|
|
87
85
|
? []
|
|
88
86
|
: resolved.filter((s) => isDatabase.has(s.name) && s.current?.status === 'running');
|
|
@@ -101,6 +99,8 @@ export const upCommand = {
|
|
|
101
99
|
gitSha,
|
|
102
100
|
buildContext: buildContexts.get(service.name),
|
|
103
101
|
wait: !flags['no-wait'],
|
|
102
|
+
...(typeof flags.node === 'string' ? { node: flags.node } : {}),
|
|
103
|
+
rootDir: typeof flags.file === 'string' ? dirname(flags.file) : rootDir,
|
|
104
104
|
});
|
|
105
105
|
deployed.push({ service, url });
|
|
106
106
|
}
|
|
@@ -123,18 +123,11 @@ export const upCommand = {
|
|
|
123
123
|
/**
|
|
124
124
|
* Deploy one service: upload its build context if it has one, run the deploy,
|
|
125
125
|
* and wait for it to report running.
|
|
126
|
-
*
|
|
127
|
-
* Returns the URL the control plane handed back, or null for a service that
|
|
128
|
-
* has none — an internal one, which is reached by name from its neighbours
|
|
129
|
-
* rather than from outside.
|
|
130
126
|
*/
|
|
131
127
|
async function deployOne(service, opts) {
|
|
132
|
-
// A service that builds from source sends its directory first. The control
|
|
133
|
-
// plane then builds it for every architecture the fleet has, which is the
|
|
134
|
-
// part that is easy to get wrong by hand and silent when you do.
|
|
135
128
|
let contextId;
|
|
136
129
|
if (opts.buildContext) {
|
|
137
|
-
const dir = join(
|
|
130
|
+
const dir = join(opts.rootDir, opts.buildContext);
|
|
138
131
|
const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, dir), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
|
|
139
132
|
contextId = uploaded.contextId;
|
|
140
133
|
}
|
|
@@ -145,7 +138,7 @@ async function deployOne(service, opts) {
|
|
|
145
138
|
});
|
|
146
139
|
try {
|
|
147
140
|
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
148
|
-
body: { gitSha: opts.gitSha, contextId },
|
|
141
|
+
body: { gitSha: opts.gitSha, contextId, ...(opts.node ? { node: opts.node } : {}) },
|
|
149
142
|
})).body;
|
|
150
143
|
// Deliberately not walker.finish().
|
|
151
144
|
//
|
|
@@ -197,8 +190,12 @@ async function deployOne(service, opts) {
|
|
|
197
190
|
const line = await request('GET', `/services/${service.id}/progress`)
|
|
198
191
|
.then((r) => r.body)
|
|
199
192
|
.catch(() => null);
|
|
200
|
-
if (line && ['queued', 'building', 'pushing'].includes(line.status)) {
|
|
201
|
-
|
|
193
|
+
if (line && ['queued', 'building', 'pushing', 'deploying'].includes(line.status)) {
|
|
194
|
+
// The agent's own stage once the image leaves the control plane.
|
|
195
|
+
// Before this, everything from "pull" to "health check passed" was
|
|
196
|
+
// one line reading "waiting for the container", which is why a
|
|
197
|
+
// 477-second deploy was indistinguishable from a hang.
|
|
198
|
+
const parts = [line.phase ?? line.status];
|
|
202
199
|
if (line.step && line.ofSteps)
|
|
203
200
|
parts.push(`${line.step}/${line.ofSteps}`);
|
|
204
201
|
if (line.platform)
|
|
@@ -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,9 @@ 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'],
|
|
76
80
|
['--api <url>', 'Control plane URL (default: saved profile)'],
|
|
77
81
|
['--json', 'Machine-readable output on stdout'],
|
|
78
82
|
['--plan, --dry-run', 'Show the deploy placement plan without changing anything'],
|