@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.
@@ -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`, { body: { gitSha, contextId } })).body;
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 node to pin a database to, when there is only one it could be.
462
+ * The fleet's nodes, fetched once per process.
453
463
  *
454
- * A database has to name the node holding its data that is the one decision
455
- * Fleet will not make for you, because moving a database moves its disk. But
456
- * on a fleet with a single node there is no decision to make, and writing
457
- * CHANGE_ME there meant `init` produced a manifest whose next command fails:
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
- * error The manifest names nodes that are not in this fleet
460
- * services.db.node: no node named "CHANGE_ME" in this fleet
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
- * Best effort, and quiet about it. `init` otherwise needs no control plane at
463
- * all it reads a directory so a missing session, an unreachable server or
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.
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 theOnlyNode(flags) {
503
+ export async function pickNodeForData(flags, what) {
504
+ let nodes;
468
505
  try {
469
- const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
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
- node: (typeof flags.node === 'string' ? flags.node : undefined) ??
692
- (found.databases.length ? await theOnlyNode(flags) : undefined),
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: (typeof flags.node === 'string' ? flags.node : undefined) ?? (await theOnlyNode(flags)),
936
+ node: (await pickNodeForData(flags, 'the database'))?.node,
836
937
  });
837
938
  }
838
939
  catch (err) {
@@ -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
- const parts = [line.status];
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'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",