@yadurajfleetos/cli 0.11.0 → 0.13.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.
@@ -3,6 +3,56 @@ import { loadProfile } from '../config.js';
3
3
  import { c, relativeTime } from '../render.js';
4
4
  import { glyph, rule, task } from '../ui.js';
5
5
  const icon = (state) => state === 'ok' ? glyph.ok : state === 'warn' ? glyph.warn : glyph.fail;
6
+ /**
7
+ * Services that answer on a health path but do not declare one.
8
+ *
9
+ * `fleet init` writes this into every manifest it generates:
10
+ *
11
+ * # No health check: container state decides whether this is up.
12
+ * # Add one once you know a path that returns 2xx —
13
+ *
14
+ * That is a research task handed to the reader, about a program the node is
15
+ * already running. The node does the research now -- it asks the container
16
+ * which of a handful of paths answers -- and this is where the answer is
17
+ * reported, because the sweep settles seconds after a deploy returns and there
18
+ * is nothing useful to say while it is still running.
19
+ *
20
+ * The negative result is deliberately not a warning. A service where nothing
21
+ * answered is a service whose manifest is already correct, and telling somebody
22
+ * their correct configuration is a problem is how a health report gets ignored.
23
+ *
24
+ * Separated from the command so both outcomes can be tested without a control
25
+ * plane -- and the one that matters is the suggestion, which a fleet whose
26
+ * services all declare health checks never produces.
27
+ */
28
+ export function healthPathCheck(services) {
29
+ const swept = services.filter((s) => s.discoveredHealth);
30
+ const answering = swept
31
+ .map((s) => ({
32
+ name: s.name,
33
+ // The first 2xx-3xx, which is the order the node tried them in: a
34
+ // dedicated endpoint before "/", because a check that renders the whole
35
+ // application every ten seconds is the worse of two working answers.
36
+ path: s.discoveredHealth.find((c) => c.status >= 200 && c.status < 400)?.path,
37
+ }))
38
+ .filter((s) => Boolean(s.path));
39
+ if (!answering.length) {
40
+ return {
41
+ state: 'ok',
42
+ label: 'health paths',
43
+ detail: swept.length
44
+ ? `${swept.length} service(s) without a health check answered nothing — container state is the only evidence, as declared.`
45
+ : 'Every service declares a health check, or none has been swept yet.',
46
+ };
47
+ }
48
+ const named = answering.map((s) => `${s.name} → ${s.path}`).join(', ');
49
+ return {
50
+ state: 'warn',
51
+ label: 'health paths',
52
+ detail: `${named}. These answer 2xx but declare no health check, so a deploy is confirmed on container state alone.`,
53
+ remedy: `Add \`health: { path: ${answering[0].path} }\` to ${answering[0].name} in fleet.yaml. Without one, a container that starts and then fails every request still counts as a successful deploy.`,
54
+ };
55
+ }
6
56
  /**
7
57
  * A build failure carries the whole buildx transcript. One summary line
8
58
  * belongs in a health report; `fleet deployments` is where the rest lives.
@@ -204,6 +254,7 @@ export const doctorCommand = {
204
254
  detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
205
255
  remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
206
256
  });
257
+ checks.push(healthPathCheck(result.services));
207
258
  checks.push(alertCheck(result.alerts));
208
259
  checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
209
260
  if (flags.json)
@@ -5,12 +5,13 @@ import { alertsCommand } from './alerts.js';
5
5
  import { configCommand, useCommand } from './config.js';
6
6
  import { doctorCommand } from './doctor.js';
7
7
  import { upCommand } from './up.js';
8
+ import { tuneCommand } from './tune.js';
8
9
  import { openCommand } from './open.js';
9
10
  import { downCommand } from './down.js';
10
11
  import { unpairCommand, agentCommand } from './unpair.js';
11
12
  import { secretsCommand } from './secrets.js';
12
13
  import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
13
- import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
14
+ import { applyCommand, deployCommand, deploymentsCommand, initCommand, diagnoseCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
14
15
  export const commands = {
15
16
  up: upCommand,
16
17
  open: openCommand,
@@ -20,7 +21,9 @@ export const commands = {
20
21
  config: configCommand,
21
22
  use: useCommand,
22
23
  doctor: doctorCommand,
24
+ tune: tuneCommand,
23
25
  init: initCommand,
26
+ diagnose: diagnoseCommand,
24
27
  import: importCommand,
25
28
  explain: explainCommand,
26
29
  validate: validateCommand,
@@ -580,6 +580,52 @@ async function answerQuestions(questions, flags) {
580
580
  }
581
581
  return Object.keys(answers).length ? answers : null;
582
582
  }
583
+ /**
584
+ * Ask the control plane why something is wrong.
585
+ *
586
+ * Distinct from `explain`, which reads a failure log you already have. This
587
+ * goes and finds the evidence: the deployment history, what the node says it
588
+ * is running, the container's output, whether the public address answers.
589
+ */
590
+ export const diagnoseCommand = {
591
+ async run(args, flags) {
592
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
593
+ const question = args.join(' ').trim();
594
+ if (!question) {
595
+ throw new CliError('usage: fleet diagnose "<what is wrong>"\n' +
596
+ ' eg: fleet diagnose "why is backend returning 502?"', EXIT.usage);
597
+ }
598
+ const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, { body: { question } }),
599
+ // What it looked at, so the wait is legible rather than a spinner.
600
+ { done: (r) => ('calls' in r.body ? `looked at ${r.body.calls.length} thing(s)` : 'done') });
601
+ if (flags.json)
602
+ return console.log(JSON.stringify(body, null, 2));
603
+ if (body.status === 'disabled') {
604
+ return console.log(`${glyph.warn} ${c.yellow('unavailable')} ${body.reason}`);
605
+ }
606
+ // What it looked at, always — the reader can repeat any of it by hand, and
607
+ // a diagnosis you cannot retrace is a diagnosis you have to take on faith.
608
+ for (const call of body.calls) {
609
+ const detail = Object.values(call.args)[0];
610
+ console.log(c.dim(` · ${call.tool}${detail ? ` ${String(detail)}` : ''}`));
611
+ }
612
+ if (body.status === 'inconclusive') {
613
+ console.log(`\n${glyph.warn} ${c.yellow('inconclusive')} ${body.reason}`);
614
+ return;
615
+ }
616
+ console.log(`\n${body.summary}\n`);
617
+ for (const f of body.findings) {
618
+ console.log(` ${c.bold(f.claim)}`);
619
+ console.log(c.dim(` ${f.evidence}`));
620
+ }
621
+ if (body.next.length) {
622
+ console.log(`\n ${c.dim('next')}`);
623
+ for (const n of body.next)
624
+ console.log(` ${glyph.info ?? '·'} ${n}`);
625
+ }
626
+ console.log(c.dim(`\n ${body.model}`));
627
+ },
628
+ };
583
629
  export const initCommand = {
584
630
  async run(args, flags) {
585
631
  const { detect, manifestTemplate } = await import('../detect.js');
@@ -0,0 +1,61 @@
1
+ import { request, requireFleet } from '../api.js';
2
+ import { c } from '../render.js';
3
+ import { glyph, rule } from '../ui.js';
4
+ import { asQuantity, tuneRam, MIN_OBSERVATION_HOURS } from '../tune.js';
5
+ /**
6
+ * Reservations, checked against what the services actually used.
7
+ *
8
+ * It proposes and never applies. Every number here is the system inferring
9
+ * something about a machine, and the lesson of every inference this project has
10
+ * shipped is that one leaving its evidence needs a person between it and the
11
+ * manifest — a review once invented a node from a compose service name, and
12
+ * once replaced a `build:` with `image: nginx:alpine` and served the welcome
13
+ * page over somebody's site. Both were caught by a guardrail. A person reading
14
+ * a diff is the cheapest guardrail there is.
15
+ */
16
+ export const tuneCommand = {
17
+ async run(_args, flags) {
18
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
19
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
20
+ const advice = body.services.map((s) => tuneRam(s));
21
+ if (flags.json)
22
+ return console.log(JSON.stringify({ fleetId, advice }, null, 2));
23
+ console.log(`\n${rule('tune · reservations against measured use')}`);
24
+ const advised = advice.filter((a) => a.verdict === 'advise');
25
+ const tight = advice.filter((a) => a.verdict === 'tight');
26
+ const waiting = advice.filter((a) => a.verdict === 'too-soon' || a.verdict === 'no-data');
27
+ for (const a of advice) {
28
+ if (a.verdict === 'advise') {
29
+ console.log(`${glyph.warn} ${c.bold(a.name.padEnd(18))} reserves ${asQuantity(a.from)}, peaks at ${a.peak}MB` +
30
+ ` → ${c.bold(asQuantity(a.to))}`);
31
+ }
32
+ else if (a.verdict === 'tight') {
33
+ console.log(`${glyph.warn} ${c.bold(a.name.padEnd(18))} peaks at ${a.peak}MB of ${asQuantity(a.requestRamMb)}` +
34
+ ` — close to its limit, which is also where the kernel kills it`);
35
+ }
36
+ else if (a.verdict === 'fits') {
37
+ console.log(`${glyph.ok} ${c.bold(a.name.padEnd(18))} peaks at ${a.peak}MB — about right`);
38
+ }
39
+ else if (a.verdict === 'too-soon') {
40
+ console.log(`${glyph.info} ${c.dim(a.name.padEnd(18))} ${c.dim(`watched for ${a.hours}h; needs ${MIN_OBSERVATION_HOURS}h`)}`);
41
+ }
42
+ else {
43
+ console.log(`${glyph.info} ${c.dim(a.name.padEnd(18))} ${c.dim('not measured yet')}`);
44
+ }
45
+ }
46
+ if (advised.length) {
47
+ console.log(`\n ${c.dim('edit fleet.yaml, then')} fleet up`);
48
+ for (const a of advised) {
49
+ if (a.verdict !== 'advise')
50
+ continue;
51
+ console.log(` ${c.dim(`${a.name}:`)} resources: { ram: ${asQuantity(a.to)} }`);
52
+ }
53
+ }
54
+ if (!advised.length && !tight.length) {
55
+ console.log(`\n ${c.dim(waiting.length === advice.length
56
+ ? 'Nothing has been watched long enough to advise on yet.'
57
+ : 'Every measured reservation is about right.')}`);
58
+ }
59
+ console.log();
60
+ },
61
+ };
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ const GROUPS = [
27
27
  ['apply [file]', 'Apply a fleet.yaml to the fleet'],
28
28
  ['deploy <service>', 'Build, schedule, and roll out'],
29
29
  ['explain <service>', 'Read a failed deploy and say what to do about it'],
30
+ ['diagnose "<question>"', 'Investigate why something is wrong, and cite what it looked at'],
30
31
  ],
31
32
  ],
32
33
  [
@@ -37,6 +38,7 @@ const GROUPS = [
37
38
  ['services', 'List services and where they are running'],
38
39
  ['nodes', 'List nodes'],
39
40
  ['where <service>', 'Explain where a service would be placed, and why'],
41
+ ['tune', 'Compare each reservation with the memory the service actually used'],
40
42
  ['deployments <service>', 'Deployment history'],
41
43
  ['logs <service> --follow', 'Follow the latest agent-reported container tail'],
42
44
  ['events', 'Unified event timeline'],
package/dist/tune.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What a service should reserve, from what it has been seen using.
3
+ *
4
+ * `fleet init` writes `resources: { ram: 512Mi }` because 512Mi is a round
5
+ * number and nothing in a repository says otherwise. Measured on the fleet this
6
+ * was written for: a steady 60MB and 20MB against exactly that reservation. The
7
+ * scheduler plans capacity around the number in the manifest for the life of
8
+ * the service, so the gap is invisible on one node and is the difference
9
+ * between a service fitting and `no_eligible_node` on a fleet where it matters.
10
+ *
11
+ * The judgement lives here rather than in the command so both outcomes can be
12
+ * tested without a control plane — and the outcome that matters is the refusal,
13
+ * which a fleet with weeks of history never produces.
14
+ */
15
+ /**
16
+ * How long a service must have been watched before its peak means anything.
17
+ *
18
+ * A service observed for four minutes has not been observed; it has been
19
+ * glanced at. Nothing that runs a nightly job, or serves a morning, has shown
20
+ * its peak yet, and advising a reservation from that is how a tuned fleet
21
+ * starts OOM-killing at 3am.
22
+ */
23
+ export const MIN_OBSERVATION_HOURS = 24;
24
+ /**
25
+ * Headroom over the observed peak.
26
+ *
27
+ * Double, which is generous, and deliberately so. The cost of too much headroom
28
+ * is capacity the scheduler reserves and does not use; the cost of too little
29
+ * is the kernel killing a container in production. Those are not symmetric, and
30
+ * a tool that trims to the bone the first time it is run does not get run twice.
31
+ */
32
+ const HEADROOM = 2;
33
+ /** Reservations are read by people. 118 is a measurement; 128 is a decision. */
34
+ function round(mb) {
35
+ const steps = [64, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096];
36
+ return steps.find((s) => s >= mb) ?? Math.ceil(mb / 1024) * 1024;
37
+ }
38
+ export function tuneRam(svc, now = Date.now()) {
39
+ if (svc.observedRamPeakMb === null || !svc.observedRamSince) {
40
+ return { verdict: 'no-data', name: svc.name };
41
+ }
42
+ const hours = (now - new Date(svc.observedRamSince).getTime()) / 3_600_000;
43
+ if (hours < MIN_OBSERVATION_HOURS) {
44
+ return { verdict: 'too-soon', name: svc.name, hours: Math.max(0, Math.round(hours * 10) / 10) };
45
+ }
46
+ const peak = svc.observedRamPeakMb;
47
+ // Near its limit. Not a saving, and worth saying out loud: the reservation is
48
+ // also the container's hard limit, so a service peaking at four fifths of it
49
+ // is one traffic spike from being killed.
50
+ if (peak >= svc.requestRamMb * 0.8) {
51
+ return { verdict: 'tight', name: svc.name, peak, requestRamMb: svc.requestRamMb };
52
+ }
53
+ const want = round(peak * HEADROOM);
54
+ if (want >= svc.requestRamMb)
55
+ return { verdict: 'fits', name: svc.name, peak };
56
+ return { verdict: 'advise', name: svc.name, from: svc.requestRamMb, to: want, peak };
57
+ }
58
+ /** Megabytes as a manifest writes them. */
59
+ export function asQuantity(mb) {
60
+ return mb % 1024 === 0 ? `${mb / 1024}Gi` : `${mb}Mi`;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",