@yadurajfleetos/cli 0.17.2 → 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/commands/services.js +128 -27
- package/dist/commands/up.js +8 -3
- package/dist/deployment-plan.js +117 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ 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';
|
|
@@ -275,7 +276,16 @@ export const deployCommand = {
|
|
|
275
276
|
onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
|
|
276
277
|
});
|
|
277
278
|
try {
|
|
278
|
-
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;
|
|
279
289
|
walker.finish(`scheduled onto ${result.placedOn.name}`);
|
|
280
290
|
return result;
|
|
281
291
|
}
|
|
@@ -449,36 +459,90 @@ export const logsCommand = {
|
|
|
449
459
|
},
|
|
450
460
|
};
|
|
451
461
|
/**
|
|
452
|
-
* The
|
|
462
|
+
* The fleet's nodes, fetched once per process.
|
|
453
463
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
|
|
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.
|
|
458
489
|
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
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.
|
|
461
494
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
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.
|
|
499
|
+
*
|
|
500
|
+
* Returns undefined only when the fleet has no nodes at all, which is the one
|
|
501
|
+
* case no amount of scoring can fix.
|
|
466
502
|
*/
|
|
467
|
-
async function
|
|
503
|
+
export async function pickNodeForData(flags, what) {
|
|
504
|
+
let nodes;
|
|
468
505
|
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;
|
|
506
|
+
nodes = await fleetNodes(flags);
|
|
478
507
|
}
|
|
479
508
|
catch {
|
|
480
509
|
return undefined;
|
|
481
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(', ') };
|
|
482
546
|
}
|
|
483
547
|
/**
|
|
484
548
|
* A second opinion on the draft, when --ai is given.
|
|
@@ -492,7 +556,16 @@ async function theOnlyNode(flags) {
|
|
|
492
556
|
* appeared with different ports and no explanation is worse than one with a
|
|
493
557
|
* mistake in it -- at least the mistake is yours to find.
|
|
494
558
|
*/
|
|
495
|
-
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) {
|
|
496
569
|
const { repoMap } = await import('../repomap.js');
|
|
497
570
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
498
571
|
// The second pass is a different, smaller question.
|
|
@@ -507,6 +580,7 @@ async function reviewed(draft, flags, services) {
|
|
|
507
580
|
body: {
|
|
508
581
|
draft: base,
|
|
509
582
|
repoMap: map,
|
|
583
|
+
...(plan ? { plan } : {}),
|
|
510
584
|
...(answers ? { answers } : {}),
|
|
511
585
|
...(parts ? { parts } : {}),
|
|
512
586
|
},
|
|
@@ -686,14 +760,41 @@ export const initCommand = {
|
|
|
686
760
|
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
687
761
|
const found = await discover();
|
|
688
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;
|
|
689
768
|
const drafted = manifestFromDiscovery(found, {
|
|
690
769
|
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
691
|
-
|
|
692
|
-
|
|
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 } : {}),
|
|
693
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
|
+
}
|
|
694
795
|
const questions = drafted.questions;
|
|
695
796
|
const manifest = flags.ai
|
|
696
|
-
? 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))
|
|
697
798
|
: drafted.manifest;
|
|
698
799
|
await writeFile(path, manifest);
|
|
699
800
|
console.log(`${c.green('created')} ${path}`);
|
|
@@ -832,7 +933,7 @@ export const importCommand = {
|
|
|
832
933
|
// a manifest that must name a node, and on a one-node fleet there is
|
|
833
934
|
// nothing to choose. Without this, import wrote a placeholder and the
|
|
834
935
|
// very next command failed on it.
|
|
835
|
-
node: (
|
|
936
|
+
node: (await pickNodeForData(flags, 'the database'))?.node,
|
|
836
937
|
});
|
|
837
938
|
}
|
|
838
939
|
catch (err) {
|
package/dist/commands/up.js
CHANGED
|
@@ -99,6 +99,7 @@ export const upCommand = {
|
|
|
99
99
|
gitSha,
|
|
100
100
|
buildContext: buildContexts.get(service.name),
|
|
101
101
|
wait: !flags['no-wait'],
|
|
102
|
+
...(typeof flags.node === 'string' ? { node: flags.node } : {}),
|
|
102
103
|
rootDir: typeof flags.file === 'string' ? dirname(flags.file) : rootDir,
|
|
103
104
|
});
|
|
104
105
|
deployed.push({ service, url });
|
|
@@ -137,7 +138,7 @@ async function deployOne(service, opts) {
|
|
|
137
138
|
});
|
|
138
139
|
try {
|
|
139
140
|
const result = (await request('POST', `/services/${service.id}/deploy`, {
|
|
140
|
-
body: { gitSha: opts.gitSha, contextId },
|
|
141
|
+
body: { gitSha: opts.gitSha, contextId, ...(opts.node ? { node: opts.node } : {}) },
|
|
141
142
|
})).body;
|
|
142
143
|
// Deliberately not walker.finish().
|
|
143
144
|
//
|
|
@@ -189,8 +190,12 @@ async function deployOne(service, opts) {
|
|
|
189
190
|
const line = await request('GET', `/services/${service.id}/progress`)
|
|
190
191
|
.then((r) => r.body)
|
|
191
192
|
.catch(() => null);
|
|
192
|
-
if (line && ['queued', 'building', 'pushing'].includes(line.status)) {
|
|
193
|
-
|
|
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];
|
|
194
199
|
if (line.step && line.ofSteps)
|
|
195
200
|
parts.push(`${line.step}/${line.ofSteps}`);
|
|
196
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'],
|