@yadurajfleetos/cli 0.10.2 → 0.12.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/api.js CHANGED
@@ -24,17 +24,27 @@ export async function request(method, path, opts = {}) {
24
24
  if (opts.auth !== false && !profile.accessToken) {
25
25
  throw new CliError('Not signed in. Run `fleet auth login` first.', EXIT.usage);
26
26
  }
27
+ // A POST with nothing to send still needs a content type.
28
+ //
29
+ // Fastify answers 415 to a POST that arrives without one, so every bodyless
30
+ // POST from this CLI failed before reaching its route — including `fleet
31
+ // alerts test`, the command the CLI itself recommends for checking alerts
32
+ // work before an incident. Sending an empty object costs two bytes and makes
33
+ // the request well-formed, rather than leaving each such route to remember
34
+ // to accept a shape nothing sends.
35
+ const writes = method === 'POST' || method === 'PUT' || method === 'PATCH';
36
+ const payload = opts.raw ? opts.raw.data : writes || opts.body ? JSON.stringify(opts.body ?? {}) : undefined;
27
37
  const send = async (token) => fetch(profile.api.replace(/\/+$/, '') + path, {
28
38
  method,
29
39
  headers: {
30
40
  ...(token ? { authorization: `Bearer ${token}` } : {}),
31
41
  ...(opts.raw
32
42
  ? { 'content-type': opts.raw.contentType }
33
- : opts.body
43
+ : payload !== undefined
34
44
  ? { 'content-type': 'application/json' }
35
45
  : {}),
36
46
  },
37
- body: opts.raw ? opts.raw.data : opts.body ? JSON.stringify(opts.body) : undefined,
47
+ body: payload,
38
48
  signal: AbortSignal.timeout(20 * 60_000),
39
49
  });
40
50
  let res;
@@ -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.
@@ -29,6 +79,38 @@ const ORIGIN_UNREACHABLE = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525,
29
79
  * What does indicate a broken path is the edge answering on the origin's
30
80
  * behalf, or nothing answering at all.
31
81
  */
82
+ /**
83
+ * Whether this fleet can tell anybody something went wrong.
84
+ *
85
+ * A fleet with no alert rules fails silently, and the only way to find out is
86
+ * an outage. This one had none while its services went down four times in an
87
+ * afternoon: the empty state existed, and lived inside `fleet alerts`, a
88
+ * subcommand you only run once you already suspect the answer.
89
+ *
90
+ * Separated from the command so both outcomes can be tested without a control
91
+ * plane — the one that matters is the warning, and it is the one a live check
92
+ * against a working fleet never exercises.
93
+ */
94
+ export function alertCheck(rules) {
95
+ const live = rules.filter((r) => r.enabled);
96
+ if (live.length) {
97
+ return {
98
+ state: 'ok',
99
+ label: 'alerts',
100
+ detail: `${live.length} rule(s): ${[...new Set(live.map((r) => r.channelType))].join(', ')}`,
101
+ };
102
+ }
103
+ return {
104
+ state: 'warn',
105
+ label: 'alerts',
106
+ // Disabled and absent are different mistakes: one was set up and turned
107
+ // off, the other never existed, and the person reading needs to know which.
108
+ detail: rules.length
109
+ ? 'Every alert rule is disabled — failures will pass unreported.'
110
+ : 'No alert rules. A node going down or a deploy failing will tell nobody.',
111
+ remedy: 'Add one with `fleet alerts add --channel email --to you@example.com`, then prove it with `fleet alerts test`.',
112
+ };
113
+ }
32
114
  async function reach(url) {
33
115
  try {
34
116
  const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
@@ -60,13 +142,14 @@ export const doctorCommand = {
60
142
  const profile = await loadProfile();
61
143
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
62
144
  const result = await task('checking Fleet health', async () => {
63
- const [identity, fleet, nodes, services, github, health] = await Promise.all([
145
+ const [identity, fleet, nodes, services, github, health, alerts] = await Promise.all([
64
146
  request('GET', '/auth/me'),
65
147
  request('GET', `/fleets/${fleetId}`),
66
148
  request('GET', `/fleets/${fleetId}/nodes`),
67
149
  request('GET', `/fleets/${fleetId}/services`),
68
150
  request('GET', `/fleets/${fleetId}/github/status`),
69
151
  request('GET', '/healthz'),
152
+ request('GET', `/fleets/${fleetId}/alert-rules`),
70
153
  ]);
71
154
  const deploymentHistory = await Promise.all(services.body.services.map(async (service) => ({
72
155
  service,
@@ -76,7 +159,7 @@ export const doctorCommand = {
76
159
  .map((service) => ({ name: service.name, hostname: service.domain ?? service.hostname }))
77
160
  .filter((service) => Boolean(service.hostname));
78
161
  const ingress = await Promise.all(urls.map(async (service) => ({ ...service, ...(await reach(`https://${service.hostname}`)) })));
79
- return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, deploymentHistory, ingress };
162
+ return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, alerts: alerts.body.rules, deploymentHistory, ingress };
80
163
  });
81
164
  const checks = [
82
165
  { state: 'ok', label: 'control plane', detail: profile.api },
@@ -171,6 +254,8 @@ export const doctorCommand = {
171
254
  detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
172
255
  remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
173
256
  });
257
+ checks.push(healthPathCheck(result.services));
258
+ checks.push(alertCheck(result.alerts));
174
259
  checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
175
260
  if (flags.json)
176
261
  return console.log(JSON.stringify({ fleetId, checks }, null, 2));
@@ -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,
@@ -462,7 +462,7 @@ async function theOnlyNode(flags) {
462
462
  * appeared with different ports and no explanation is worse than one with a
463
463
  * mistake in it -- at least the mistake is yours to find.
464
464
  */
465
- async function reviewed(draft, flags) {
465
+ async function reviewed(draft, flags, services) {
466
466
  const { repoMap } = await import('../repomap.js');
467
467
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
468
468
  // The second pass is a different, smaller question.
@@ -473,14 +473,27 @@ async function reviewed(draft, flags) {
473
473
  // just given was discarded. Applying an answer needs the manifest it applies
474
474
  // to and the answer, not the evidence that produced it: the model has
475
475
  // already read the repository and written its conclusions down.
476
- const review = (base, map, answers) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
477
- body: { draft: base, repoMap: map, ...(answers ? { answers } : {}) },
476
+ const review = (base, map, answers, parts) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
477
+ body: {
478
+ draft: base,
479
+ repoMap: map,
480
+ ...(answers ? { answers } : {}),
481
+ ...(parts ? { parts } : {}),
482
+ },
478
483
  }), { done: () => (answers ? 'done' : 'reviewed') });
479
484
  let map;
480
485
  let out;
481
486
  try {
482
487
  map = await repoMap();
483
- out = (await review(draft, map)).body;
488
+ // Evidence per service, so each is reviewed at full depth rather than
489
+ // every service being trimmed to fit one request. The whole-repository
490
+ // map still goes along: a service is judged partly by what surrounds it,
491
+ // and the tree is how the model knows what else exists.
492
+ const parts = await Promise.all(services.map(async (svc) => ({
493
+ service: svc.name,
494
+ map: await repoMap(join(process.cwd(), svc.dir)),
495
+ })));
496
+ out = (await review(draft, map, undefined, parts)).body;
484
497
  }
485
498
  catch (err) {
486
499
  console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
@@ -567,6 +580,52 @@ async function answerQuestions(questions, flags) {
567
580
  }
568
581
  return Object.keys(answers).length ? answers : null;
569
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
+ };
570
629
  export const initCommand = {
571
630
  async run(args, flags) {
572
631
  const { detect, manifestTemplate } = await import('../detect.js');
@@ -596,7 +655,9 @@ export const initCommand = {
596
655
  (found.databases.length ? await theOnlyNode(flags) : undefined),
597
656
  });
598
657
  const questions = drafted.questions;
599
- const manifest = flags.ai ? await reviewed(drafted.manifest, flags) : drafted.manifest;
658
+ const manifest = flags.ai
659
+ ? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
660
+ : drafted.manifest;
600
661
  await writeFile(path, manifest);
601
662
  console.log(`${c.green('created')} ${path}`);
602
663
  if (found.layout)
@@ -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/compose.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
2
  import { safeDatabaseName } from './dbnames.js';
3
- import { injectedUrl, pointsAt } from './dburl.js';
3
+ import { pointsAt } from './dburl.js';
4
4
  /** Images Fleet manages as databases rather than as plain containers. */
5
5
  const ENGINE_IMAGES = {
6
6
  postgres: 'postgres',
@@ -310,10 +310,17 @@ export function composeToFleet(source, opts = {}) {
310
310
  if (target) {
311
311
  const [composeName, fleetName] = target;
312
312
  const engine = dbEngines.get(composeName);
313
- const url = engine ? injectedUrl(fleetName, engine) : null;
314
- if (url) {
315
- plain.push([k, url]);
316
- notes.push(`${name}: ${k} now points at the managed ${engine} — it named the compose service "${composeName}", which Fleet runs as "${fleetName}" with a password it generates.`);
313
+ if (engine) {
314
+ // A reference, not a computed URL.
315
+ //
316
+ // This used to build the connection string here, which meant the CLI
317
+ // carrying a copy of the control plane's engine table — ports,
318
+ // schemes, default users — kept honest by a test that caught it
319
+ // getting postgres's user wrong. The control plane resolves this at
320
+ // apply time from the table it owns, so the copy is gone and the
321
+ // manifest says what it means rather than a value that has to match.
322
+ plain.push([k, `\${db:${fleetName}.url}`]);
323
+ notes.push(`${name}: ${k} now points at the managed ${engine} — it named the compose service "${composeName}", which Fleet runs as "${fleetName}" and fills in when the manifest is applied.`);
317
324
  continue;
318
325
  }
319
326
  }
package/dist/dburl.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The connection URL Fleet will inject for a managed database.
2
+ * Recognising a connection string that names a managed database.
3
3
  *
4
4
  * `uses: [db]` gives a service DATABASE_URL and friends. An imported compose
5
5
  * file usually has its own variable for the same thing — MONGODB_URI,
@@ -15,35 +15,6 @@
15
15
  * in the control plane that reads this file and fails when the two disagree,
16
16
  * which is the only thing that makes duplicating it acceptable.
17
17
  */
18
- /** Mirrors ENGINES in control-plane/src/manifest/databases.ts. */
19
- export const ENGINE_WIRE = {
20
- postgres: { scheme: 'postgres', port: 5432, defaultUser: 'postgres', usesPassword: true, usesDatabase: true },
21
- mysql: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
22
- mariadb: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
23
- redis: { scheme: 'redis', port: 6379, defaultUser: '', usesPassword: false, usesDatabase: false },
24
- mongo: { scheme: 'mongodb', port: 27017, defaultUser: 'app', usesPassword: true, usesDatabase: true },
25
- };
26
- /** Mirrors passwordRefFor: the secret name a database's password lives under. */
27
- export const passwordRefFor = (name) => `${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD`;
28
- /**
29
- * The URL a service will see, written the way the manifest can carry it.
30
- *
31
- * The password is a `${secret:NAME}` reference rather than a value: it is
32
- * generated by the control plane and never known here, and a manifest is a
33
- * file people commit.
34
- */
35
- export function injectedUrl(dbName, engine) {
36
- const spec = ENGINE_WIRE[engine];
37
- if (!spec)
38
- return null;
39
- const auth = spec.usesPassword
40
- ? `${encodeURIComponent(spec.defaultUser)}:\${secret:${passwordRefFor(dbName)}}@`
41
- : '';
42
- // `database` defaults to the declaration's own name, and the host is that
43
- // same name: containers resolve each other by service name on the node.
44
- const path = spec.usesDatabase ? `/${dbName}` : '';
45
- return `${spec.scheme}://${auth}${dbName}:${spec.port}${path}`;
46
- }
47
18
  /** Does this value look like a connection URL aimed at `host`? */
48
19
  export function pointsAt(value, host) {
49
20
  // Scheme-relative on purpose: an app may hold a mongodb+srv:// or a
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.10.2",
3
+ "version": "0.12.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",