@yadurajfleetos/cli 0.14.0 → 0.16.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 CHANGED
@@ -45,7 +45,7 @@ export const KNOWN_FLAGS = new Set([
45
45
  'fleet', 'api', 'json', 'yes', 'y', 'help', 'h', 'version', 'v', 'no-wait',
46
46
  'plan', 'dry-run', 'force',
47
47
  // per command
48
- 'ai', 'all', 'channel', 'deploy', 'email', 'events', 'f', 'follow', 'limit',
48
+ 'ai', 'all', 'apply', 'channel', 'deploy', 'email', 'events', 'f', 'follow', 'limit',
49
49
  'name', 'node', 'only', 'out', 'password', 'secret', 'service', 'sha',
50
50
  'since', 'terminal', 'to', 'token', 'url',
51
51
  ]);
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { parseDocument } from 'yaml';
3
1
  import { CliError, EXIT, request, requireFleet } from '../api.js';
2
+ import { editManifest, restoreManifest } from '../manifest-edit.js';
3
+ import { localSource } from '../source.js';
4
+ import { checkEdit, applyEdit, revertEdit } from '../source-edit.js';
4
5
  import { c } from '../render.js';
5
6
  import { glyph, rule } from '../ui.js';
6
7
  import { confirm } from '../prompt.js';
@@ -19,6 +20,69 @@ async function statusOf(fleetId, name) {
19
20
  const { body } = await request('GET', `/fleets/${fleetId}/services`);
20
21
  return body.services.find((s) => s.name === name)?.current?.status ?? 'not running';
21
22
  }
23
+ /**
24
+ * Change one line of a service's source, deploy it, and put it back if that
25
+ * made things worse.
26
+ *
27
+ * The furthest this reaches into somebody's work, so it is the most guarded
28
+ * thing here. `checkEdit` refuses anything it could not undo — a file outside
29
+ * the service's build context, one git does not track, one already carrying
30
+ * uncommitted work, a line that is absent or appears twice — and the undo is
31
+ * `git checkout`, which is exact rather than a best effort.
32
+ */
33
+ async function applySourceEdit(fleetId, edit, flags) {
34
+ const root = process.cwd();
35
+ const check = await checkEdit(root, edit);
36
+ if (!check.ok) {
37
+ console.log(`\n${glyph.warn} ${c.bold('This one has to be done by hand')}`);
38
+ console.log(` ${edit.service} · ${edit.file}`);
39
+ console.log(` ${c.dim(edit.why)}`);
40
+ console.log(` ${c.dim(check.reason)}`);
41
+ return console.log();
42
+ }
43
+ console.log(`\n${glyph.info} ${c.bold('proposed')} ${edit.file}:${check.line} ${c.dim(edit.service)}`);
44
+ console.log(` ${c.dim('-')} ${edit.find.trim()}`);
45
+ console.log(` ${c.dim('+')} ${edit.replace.trim()}`);
46
+ console.log(` ${c.dim(edit.why)}\n`);
47
+ if (!flags.yes && !flags.y) {
48
+ const ok = await confirm(`Change this line and redeploy ${edit.service}?`);
49
+ if (!ok)
50
+ return console.log(` ${c.dim('left alone')}\n`);
51
+ }
52
+ const wasRunning = (await statusOf(fleetId, edit.service)) === 'running';
53
+ await applyEdit(check.path, edit);
54
+ console.log(`${glyph.ok} ${edit.file} updated`);
55
+ const { body: svc } = await request('GET', `/fleets/${fleetId}/services`);
56
+ const target = svc.services.find((s) => s.name === edit.service);
57
+ if (!target) {
58
+ await revertEdit(root, check.path);
59
+ console.log(`${glyph.warn} "${edit.service}" is not in this fleet — ${edit.file} put back\n`);
60
+ return;
61
+ }
62
+ console.log(`${glyph.pending} deploying…`);
63
+ try {
64
+ await request('POST', `/services/${target.id}/deploy`, { body: {} });
65
+ await awaitRunning(target, { timeoutMs: 240_000 });
66
+ }
67
+ catch (err) {
68
+ // Judged against where it started, like a manifest change: one already down
69
+ // and still down has not been made worse, and reverting there would take
70
+ // away a change that may well be right.
71
+ if (wasRunning) {
72
+ await revertEdit(root, check.path);
73
+ console.log(`${glyph.warn} ${edit.service} was running before and did not come back — ${edit.file} put back`);
74
+ console.log(` ${c.dim(`Deploy the previous source with: fleet up ${edit.service}`)}\n`);
75
+ return;
76
+ }
77
+ console.log(`${glyph.warn} still not running — ${err.message}`);
78
+ console.log(` ${c.dim('The edit was kept: it was already down, so this did not make it worse.')}`);
79
+ console.log(` ${c.dim(`undo it with: git checkout ${edit.file} && fleet up ${edit.service}`)}\n`);
80
+ return;
81
+ }
82
+ console.log(`${glyph.ok} ${edit.service} is running`);
83
+ console.log(`\n ${c.dim('review the change:')} git diff`);
84
+ console.log(` ${c.dim('undo it:')} git checkout ${edit.file} && fleet up ${edit.service}\n`);
85
+ }
22
86
  export const fixCommand = {
23
87
  async run(args, flags) {
24
88
  const service = args[0];
@@ -27,8 +91,16 @@ export const fixCommand = {
27
91
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
28
92
  console.log(`\n${rule(`fix · ${service}`)}`);
29
93
  console.log(`${glyph.pending} looking…`);
94
+ // Source travels with the question. `fix` already requires a project
95
+ // directory, so the files are right here — and the control plane, which
96
+ // deletes every build context the moment a build ends, never has to hold
97
+ // any of it.
98
+ const source = await localSource(process.cwd());
30
99
  const { body: found } = await request('POST', `/fleets/${fleetId}/diagnose`, {
31
- body: { question: `Why is the "${service}" service not working as it should?` },
100
+ body: {
101
+ question: `Why is the "${service}" service not working as it should?`,
102
+ ...(Object.keys(source).length ? { source } : {}),
103
+ },
32
104
  });
33
105
  if (found.status !== 'ok') {
34
106
  throw new CliError(found.status === 'disabled' ? found.reason : `inconclusive — ${found.reason}`, EXIT.failure);
@@ -38,6 +110,14 @@ export const fixCommand = {
38
110
  console.log(` ${c.bold(f.claim)}`);
39
111
  console.log(` ${c.dim(f.evidence)}`);
40
112
  }
113
+ // A source edit, when the manifest cannot carry the change.
114
+ //
115
+ // Offered only after the manifest options are exhausted: a manifest is
116
+ // Fleet's to alter and a repository is not, so the same fault fixed either
117
+ // way should be fixed in the manifest.
118
+ if (!found.fix && found.edit) {
119
+ return await applySourceEdit(fleetId, found.edit, flags);
120
+ }
41
121
  const fix = found.fix;
42
122
  if (!fix) {
43
123
  // Most investigations do not end in one exact manifest change, and this
@@ -67,26 +147,22 @@ export const fixCommand = {
67
147
  }
68
148
  // Read before writing, and keep the original in memory: this is what makes
69
149
  // the change reversible without a second file on disk.
150
+ //
151
+ // The same editor `fleet tune` uses. Two implementations of "write one
152
+ // field into fleet.yaml" would drift — one would learn to keep the comments
153
+ // and the other would not, and nobody would notice until a manifest came
154
+ // back stripped of the only explanation it carried.
70
155
  const path = 'fleet.yaml';
71
- const before = await readFile(path, 'utf8').catch(() => {
72
- throw new CliError(`No fleet.yaml here. Run this from the project directory.`, EXIT.usage);
73
- });
74
- const doc = parseDocument(before);
75
- if (!doc.hasIn(['services', fix.service])) {
76
- throw new CliError(`fleet.yaml has no service named "${fix.service}"`, EXIT.failure);
77
- }
78
- // Edited as a document, so the comments `fleet init` wrote survive. A
79
- // round trip through parse and re-serialise would take them out, and they
80
- // are the only explanation a generated manifest carries.
81
- if (fix.value === null)
82
- doc.deleteIn(['services', fix.service, fix.field]);
83
- else
84
- doc.setIn(['services', fix.service, fix.field], shape(fix.field, fix.value));
85
156
  const wasRunning = (await statusOf(fleetId, fix.service)) === 'running';
86
- await writeFile(path, String(doc));
157
+ const { applied, refused, before } = await editManifest(path, [
158
+ { service: fix.service, field: fix.field, value: shape(fix.field, fix.value), why: fix.why },
159
+ ]);
160
+ if (!applied.length) {
161
+ throw new CliError(refused[0]?.reason ?? `nothing could be applied to ${path}`, EXIT.failure);
162
+ }
87
163
  console.log(`${glyph.ok} fleet.yaml updated`);
88
164
  const restore = async (why) => {
89
- await writeFile(path, before);
165
+ await restoreManifest(path, before);
90
166
  console.log(`${glyph.warn} ${why} — fleet.yaml put back`);
91
167
  console.log(` ${c.dim(`Deploy the previous manifest with: fleet up ${fix.service}`)}\n`);
92
168
  };
@@ -8,6 +8,7 @@ import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
8
  import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
9
9
  import { planFromManifest, projectNameFor } from '../plan.js';
10
10
  import { uploadContext, humanBytes } from '../archive.js';
11
+ import { localSource } from '../source.js';
11
12
  const manifestPath = (given) => given ?? 'fleet.yaml';
12
13
  async function readManifest(path) {
13
14
  try {
@@ -590,12 +591,19 @@ async function answerQuestions(questions, flags) {
590
591
  export const diagnoseCommand = {
591
592
  async run(args, flags) {
592
593
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
594
+ const source = await localSource(process.cwd());
593
595
  const question = args.join(' ').trim();
594
596
  if (!question) {
595
597
  throw new CliError('usage: fleet diagnose "<what is wrong>"\n' +
596
598
  ' eg: fleet diagnose "why is backend returning 502?"', EXIT.usage);
597
599
  }
598
- const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, { body: { question } }),
600
+ const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, {
601
+ // Source when there is a manifest here to read it from, and nothing
602
+ // when there is not. `diagnose` runs from anywhere on purpose, so
603
+ // this is the one lookup that is sometimes unavailable — the tool
604
+ // says so rather than pretending it looked.
605
+ body: { question, ...(Object.keys(source).length ? { source } : {}) },
606
+ }),
599
607
  // What it looked at, so the wait is legible rather than a spinner.
600
608
  { done: (r) => ('calls' in r.body ? `looked at ${r.body.calls.length} thing(s)` : 'done') });
601
609
  if (flags.json)
@@ -1,26 +1,38 @@
1
1
  import { request, requireFleet } from '../api.js';
2
2
  import { c } from '../render.js';
3
3
  import { glyph, rule } from '../ui.js';
4
- import { asQuantity, tuneRam, MIN_OBSERVATION_HOURS } from '../tune.js';
4
+ import { confirm } from '../prompt.js';
5
+ import { editManifest } from '../manifest-edit.js';
6
+ import { asQuantity, tuneHealth, tuneRam, MIN_OBSERVATION_HOURS, } from '../tune.js';
5
7
  /**
6
- * Reservations, checked against what the services actually used.
8
+ * The manifest, checked against what the services actually did.
7
9
  *
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.
10
+ * Two measured facts, in one place because they are the same kind of thing: a
11
+ * number a repository could not have told anyone, that Fleet found out by
12
+ * running the program. How much memory it uses, and which path it answers on.
13
+ *
14
+ * Both used to end as advice a line telling the reader to go and edit
15
+ * `fleet.yaml` themselves, about something this already knew. `--apply` writes
16
+ * them, with a confirmation and through the same guarded editor `fleet fix`
17
+ * uses.
18
+ *
19
+ * It proposes and it asks. Every number here is the system inferring something
20
+ * about a machine, and the lesson of every inference this project has shipped
21
+ * is that one leaving its evidence needs a person between it and the manifest —
22
+ * a review once invented a node from a compose service name, and once replaced
23
+ * a `build:` with `image: nginx:alpine` and served the welcome page over
24
+ * somebody's site. Both were caught by a guardrail. A person reading a diff is
25
+ * the cheapest guardrail there is.
15
26
  */
16
27
  export const tuneCommand = {
17
28
  async run(_args, flags) {
18
29
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
19
30
  const { body } = await request('GET', `/fleets/${fleetId}/services`);
20
31
  const advice = body.services.map((s) => tuneRam(s));
32
+ const health = body.services.map((s) => tuneHealth(s)).filter((h) => h !== null);
21
33
  if (flags.json)
22
- return console.log(JSON.stringify({ fleetId, advice }, null, 2));
23
- console.log(`\n${rule('tune · reservations against measured use')}`);
34
+ return console.log(JSON.stringify({ fleetId, advice, health }, null, 2));
35
+ console.log(`\n${rule('tune · the manifest against what was measured')}`);
24
36
  const advised = advice.filter((a) => a.verdict === 'advise');
25
37
  const tight = advice.filter((a) => a.verdict === 'tight');
26
38
  const waiting = advice.filter((a) => a.verdict === 'too-soon' || a.verdict === 'no-data');
@@ -43,18 +55,62 @@ export const tuneCommand = {
43
55
  console.log(`${glyph.info} ${c.dim(a.name.padEnd(18))} ${c.dim('not measured yet')}`);
44
56
  }
45
57
  }
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
- }
58
+ for (const h of health) {
59
+ console.log(`${glyph.warn} ${c.bold(h.name.padEnd(18))} answers 2xx on ${c.bold(h.path)} but declares no health check`);
53
60
  }
54
- if (!advised.length && !tight.length) {
55
- console.log(`\n ${c.dim(waiting.length === advice.length
61
+ // One list, because to the manifest they are the same edit.
62
+ const edits = [
63
+ ...advised.flatMap((a) => a.verdict === 'advise'
64
+ ? [
65
+ {
66
+ service: a.name,
67
+ field: 'resources',
68
+ value: { ram: asQuantity(a.to), cpu: 0.5 },
69
+ why: `peaks at ${a.peak}MB against ${asQuantity(a.from)} reserved`,
70
+ },
71
+ ]
72
+ : []),
73
+ ...health.map((h) => ({
74
+ service: h.name,
75
+ field: 'health',
76
+ value: { path: h.path },
77
+ why: `measured answering 2xx on ${h.path}`,
78
+ })),
79
+ ];
80
+ if (!edits.length && !tight.length) {
81
+ console.log(`\n ${c.dim(waiting.length === advice.length && !health.length
56
82
  ? 'Nothing has been watched long enough to advise on yet.'
57
- : 'Every measured reservation is about right.')}`);
83
+ : 'Every measured setting is about right.')}`);
84
+ return console.log();
85
+ }
86
+ if (!edits.length)
87
+ return console.log();
88
+ if (!flags.apply) {
89
+ console.log(`\n ${c.dim('write these with')} fleet tune --apply`);
90
+ for (const e of edits) {
91
+ console.log(` ${c.dim(`${e.service}:`)} ${e.field}: ${JSON.stringify(e.value)}`);
92
+ }
93
+ return console.log();
94
+ }
95
+ console.log();
96
+ for (const e of edits) {
97
+ console.log(` ${e.service}.${e.field} → ${JSON.stringify(e.value)} ${c.dim(e.why)}`);
98
+ }
99
+ if (!flags.yes && !flags.y) {
100
+ const ok = await confirm(`\nWrite ${edits.length} change(s) to fleet.yaml?`);
101
+ if (!ok)
102
+ return console.log(` ${c.dim('left alone')}\n`);
103
+ }
104
+ const { applied, refused } = await editManifest('fleet.yaml', edits);
105
+ for (const r of refused) {
106
+ console.log(`${glyph.warn} ${r.edit.service}.${r.edit.field} — ${r.reason}`);
107
+ }
108
+ if (applied.length) {
109
+ console.log(`${glyph.ok} fleet.yaml updated — ${applied.length} change(s)`);
110
+ // Not deployed here on purpose. `tune` changes settings that only take
111
+ // effect on the next rollout, and quietly restarting somebody's fleet
112
+ // because they asked for a manifest edit is a surprise nobody wants.
113
+ console.log(`\n ${c.dim('review it, then')} fleet up`);
58
114
  }
59
115
  console.log();
60
116
  },
@@ -0,0 +1,66 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { parseDocument } from 'yaml';
3
+ import { CliError, EXIT } from './api.js';
4
+ /**
5
+ * Fields the CLI will write. Deliberately the same set the control plane's
6
+ * review is allowed to touch, and for the same reasons — see
7
+ * `control-plane/src/ai/edits.ts`, which is the authority. Duplicated rather
8
+ * than imported because the two do not share a package, and a copy that drifts
9
+ * open is caught by the server refusing the edit anyway.
10
+ */
11
+ const WRITABLE = new Set([
12
+ 'container_port',
13
+ 'health',
14
+ 'resources',
15
+ 'placement',
16
+ 'replicas',
17
+ 'env',
18
+ 'command',
19
+ ]);
20
+ /**
21
+ * Apply edits to a manifest file in place.
22
+ *
23
+ * Edited as a document rather than parsed and re-serialised, so the comments
24
+ * survive. A generated manifest's comments are the only thing explaining why it
25
+ * looks the way it does, and a round trip through `parse`/`stringify` silently
26
+ * removes every one of them.
27
+ *
28
+ * A service can live under `services:` or `databases:`; both are looked at,
29
+ * because to everything downstream a database is a service and a reader
30
+ * correcting one should not have to know which block the tool expects.
31
+ */
32
+ export async function editManifest(path, edits) {
33
+ const before = await readFile(path, 'utf8').catch(() => {
34
+ throw new CliError(`No ${path} here. Run this from the project directory.`, EXIT.usage);
35
+ });
36
+ const doc = parseDocument(before);
37
+ const applied = [];
38
+ const refused = [];
39
+ for (const edit of edits) {
40
+ if (!WRITABLE.has(edit.field)) {
41
+ refused.push({ edit, reason: `"${edit.field}" is not a field this may write` });
42
+ continue;
43
+ }
44
+ const block = doc.hasIn(['services', edit.service])
45
+ ? 'services'
46
+ : doc.hasIn(['databases', edit.service])
47
+ ? 'databases'
48
+ : null;
49
+ if (!block) {
50
+ refused.push({ edit, reason: `no service or database named "${edit.service}"` });
51
+ continue;
52
+ }
53
+ if (edit.value === null)
54
+ doc.deleteIn([block, edit.service, edit.field]);
55
+ else
56
+ doc.setIn([block, edit.service, edit.field], edit.value);
57
+ applied.push(edit);
58
+ }
59
+ if (applied.length)
60
+ await writeFile(path, String(doc));
61
+ return { applied, refused, before };
62
+ }
63
+ /** Put a manifest back, after a change that made things worse. */
64
+ export async function restoreManifest(path, before) {
65
+ await writeFile(path, before);
66
+ }
@@ -0,0 +1,87 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { resolve, relative, join } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
6
+ const run = promisify(execFile);
7
+ /** Where a service's build context is, according to the local manifest. */
8
+ export async function buildContextOf(root, service) {
9
+ try {
10
+ const doc = parseYaml(await readFile(join(root, 'fleet.yaml'), 'utf8'));
11
+ return doc?.services?.[service]?.build ?? null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ async function git(root, args) {
18
+ try {
19
+ const { stdout } = await run('git', args, { cwd: root });
20
+ return { ok: true, out: stdout };
21
+ }
22
+ catch (err) {
23
+ return { ok: false, out: err.stdout ?? '' };
24
+ }
25
+ }
26
+ /**
27
+ * Everything that must be true before a line of somebody's source is touched.
28
+ *
29
+ * Separated from applying it so the answer can be shown before anything
30
+ * happens, and so each refusal can be tested without a repository being
31
+ * modified to find out.
32
+ */
33
+ export async function checkEdit(root, edit) {
34
+ const context = await buildContextOf(root, edit.service);
35
+ if (!context) {
36
+ return { ok: false, reason: `fleet.yaml does not say where "${edit.service}" is built from` };
37
+ }
38
+ const base = resolve(root, context);
39
+ const path = resolve(base, edit.file);
40
+ // Inside the service's own build context, and nowhere else. A path is checked
41
+ // after resolving, so `../` cannot walk out of it.
42
+ const within = relative(base, path);
43
+ if (within.startsWith('..') || within.startsWith('/')) {
44
+ return { ok: false, reason: `${edit.file} is outside ${context}, which is all this service may change` };
45
+ }
46
+ let text;
47
+ try {
48
+ text = await readFile(path, 'utf8');
49
+ }
50
+ catch {
51
+ return { ok: false, reason: `${edit.file} does not exist in ${context}` };
52
+ }
53
+ const occurrences = text.split(edit.find).length - 1;
54
+ if (occurrences === 0) {
55
+ return { ok: false, reason: `that line is not in ${edit.file} — it may have been quoted from memory` };
56
+ }
57
+ if (occurrences > 1) {
58
+ return {
59
+ ok: false,
60
+ reason: `that line appears ${occurrences} times in ${edit.file}, so a replacement could hit the wrong one`,
61
+ };
62
+ }
63
+ // Undoable, which is the condition on all of this. A file git does not track,
64
+ // or one already carrying uncommitted work, cannot be restored by checking it
65
+ // out — and losing somebody's unsaved change to fix a hostname is not a trade
66
+ // worth offering.
67
+ const tracked = await git(root, ['ls-files', '--error-unmatch', path]);
68
+ if (!tracked.ok) {
69
+ return { ok: false, reason: `${edit.file} is not tracked by git, so this could not be undone` };
70
+ }
71
+ const dirty = await git(root, ['status', '--porcelain', '--', path]);
72
+ if (dirty.out.trim()) {
73
+ return { ok: false, reason: `${edit.file} has uncommitted changes — commit or stash them first, so this can be undone` };
74
+ }
75
+ const line = text.slice(0, text.indexOf(edit.find)).split('\n').length;
76
+ return { ok: true, path, line };
77
+ }
78
+ /** Apply a checked edit. Call `checkEdit` first; this trusts it. */
79
+ export async function applyEdit(path, edit) {
80
+ const text = await readFile(path, 'utf8');
81
+ await writeFile(path, text.replace(edit.find, edit.replace));
82
+ }
83
+ /** Put a file back exactly as git has it. */
84
+ export async function revertEdit(root, path) {
85
+ const out = await git(root, ['checkout', '--', path]);
86
+ return out.ok;
87
+ }
package/dist/source.js ADDED
@@ -0,0 +1,146 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ /**
5
+ * The few files a service is built from, for an investigation to read.
6
+ *
7
+ * The blind spot this closes. A service failed because `vote/app.py` hardcodes
8
+ * `Redis(host="redis")` while the manifest names that database `cache`. The
9
+ * diagnosis traced it correctly — unhealthy, then a Redis connection failure in
10
+ * the logs — and stopped at "check the cache service", because every lookup it
11
+ * has reads the database, a node's heartbeat, or a build-context listing, and
12
+ * the answer was in none of them.
13
+ *
14
+ * Sent by the CLI rather than read by the control plane, which is the whole
15
+ * design. The control plane deletes an uploaded build context the moment a
16
+ * build ends, on purpose: customer source is held only for as long as it takes
17
+ * to build it. A `source` lookup that read from the server would have to break
18
+ * that. This way the source exists in one request, for the length of one
19
+ * investigation, and is never written down.
20
+ *
21
+ * The cost is that it only works from a project directory. That is honest —
22
+ * `fleet fix` already requires one, and a diagnosis run from elsewhere simply
23
+ * reports that it has no source rather than pretending.
24
+ */
25
+ /** Never read: build output, dependencies, and anything that is not evidence. */
26
+ const SKIP = new Set([
27
+ 'node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.next', '.nuxt',
28
+ 'coverage', '__pycache__', '.venv', 'venv', '.turbo', '.cache', 'tmp',
29
+ ]);
30
+ /**
31
+ * What is worth reading, in the order it is worth reading.
32
+ *
33
+ * An entry point first, because that is where a program says what it connects
34
+ * to — the hostname, the port, the environment variable it reads. Then the
35
+ * dependency manifest, which says what it is. The Dockerfile last: the build
36
+ * context listing already covers most of what it would tell us.
37
+ */
38
+ const EVIDENCE = [
39
+ /^(main|app|server|index|program)\.(py|js|ts|mjs|go|rb|cs|java)$/i,
40
+ /^(package\.json|requirements\.txt|pyproject\.toml|go\.mod|Cargo\.toml|Gemfile|.*\.csproj)$/i,
41
+ /^\.env\.(example|sample|template)$/,
42
+ /^Dockerfile(\..+)?$/,
43
+ ];
44
+ /**
45
+ * How much of one service to send.
46
+ *
47
+ * Small on purpose. This lands in a loop whose whole conversation is resent
48
+ * every turn against a token budget that took real work to fit inside — the
49
+ * compaction, the deadline and the step budget all exist because that budget
50
+ * is tight. A generous source dump would undo all three.
51
+ */
52
+ const MAX_PER_FILE = 2_000;
53
+ const MAX_PER_SERVICE = 4_000;
54
+ /** The head of a file: a program declares its connections near the top. */
55
+ function head(text, limit) {
56
+ return text.length <= limit ? text.trimEnd() : `${text.slice(0, limit)}\n… (truncated)`;
57
+ }
58
+ async function filesIn(dir) {
59
+ const found = [];
60
+ const walk = async (at, depth) => {
61
+ if (depth > 2)
62
+ return;
63
+ let entries;
64
+ try {
65
+ entries = await readdir(at, { withFileTypes: true });
66
+ }
67
+ catch {
68
+ return;
69
+ }
70
+ for (const e of entries) {
71
+ if (e.name.startsWith('.') && !e.name.startsWith('.env.'))
72
+ continue;
73
+ if (SKIP.has(e.name))
74
+ continue;
75
+ const full = join(at, e.name);
76
+ if (e.isDirectory())
77
+ await walk(full, depth + 1);
78
+ else if (EVIDENCE.some((r) => r.test(e.name)))
79
+ found.push(full);
80
+ }
81
+ };
82
+ await walk(dir, 0);
83
+ // Evidence order, so a truncated bundle keeps the entry point rather than
84
+ // whichever file the filesystem happened to return first.
85
+ return found.sort((a, b) => {
86
+ const rank = (p) => EVIDENCE.findIndex((r) => r.test(p.split('/').pop() ?? '')) ?? EVIDENCE.length;
87
+ return rank(a) - rank(b);
88
+ });
89
+ }
90
+ /** One service's source evidence, or null when there is nothing to read. */
91
+ export async function sourceFor(root, buildContext) {
92
+ const dir = join(root, buildContext);
93
+ try {
94
+ if (!(await stat(dir)).isDirectory())
95
+ return null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const parts = [];
101
+ let budget = MAX_PER_SERVICE;
102
+ for (const file of await filesIn(dir)) {
103
+ if (budget <= 0)
104
+ break;
105
+ try {
106
+ const info = await stat(file);
107
+ if (info.size > 256 * 1024)
108
+ continue;
109
+ const text = head(await readFile(file, 'utf8'), Math.min(MAX_PER_FILE, budget));
110
+ const rel = file.slice(dir.length + 1);
111
+ const block = `--- ${rel}\n${text}`;
112
+ parts.push(block);
113
+ budget -= block.length;
114
+ }
115
+ catch {
116
+ // Unreadable is not fatal; it is simply not evidence.
117
+ }
118
+ }
119
+ return parts.length ? parts.join('\n\n') : null;
120
+ }
121
+ /**
122
+ * Source for every service the local manifest builds, keyed by service name.
123
+ *
124
+ * Every service, because the investigation chooses which one it wants and the
125
+ * CLI cannot know in advance. Only what it asks for is ever put in front of the
126
+ * model, so the cost of the others is bytes on one request rather than tokens
127
+ * on every turn.
128
+ */
129
+ export async function localSource(root, manifestPath = 'fleet.yaml') {
130
+ let doc;
131
+ try {
132
+ doc = parseYaml(await readFile(join(root, manifestPath), 'utf8')) ?? {};
133
+ }
134
+ catch {
135
+ return {};
136
+ }
137
+ const out = {};
138
+ for (const [name, svc] of Object.entries(doc.services ?? {})) {
139
+ if (!svc?.build)
140
+ continue;
141
+ const found = await sourceFor(root, svc.build);
142
+ if (found)
143
+ out[name] = found;
144
+ }
145
+ return out;
146
+ }
package/dist/tune.js CHANGED
@@ -59,3 +59,28 @@ export function tuneRam(svc, now = Date.now()) {
59
59
  export function asQuantity(mb) {
60
60
  return mb % 1024 === 0 ? `${mb / 1024}Gi` : `${mb}Mi`;
61
61
  }
62
+ /**
63
+ * The health check a service should declare, from what it was measured
64
+ * answering.
65
+ *
66
+ * The gap this closes: the node sweeps candidate paths after every deploy and
67
+ * records exactly which ones returned 2xx, and until now that reached one line
68
+ * of advice telling a person to go and type it into the manifest. Fleet knew
69
+ * the answer and asked for it back.
70
+ *
71
+ * Only for a service that declares no check. One that declares a path has an
72
+ * operator's decision behind it, and overwriting that with a measurement would
73
+ * be the tool deciding it knows better about a choice it cannot see the reason
74
+ * for.
75
+ */
76
+ export function tuneHealth(svc) {
77
+ if (!svc.healthDisabled)
78
+ return null;
79
+ if (!svc.discoveredHealth?.length)
80
+ return null;
81
+ // The first 2xx-3xx, in the order the node tried them: a dedicated endpoint
82
+ // before "/", because a check that renders the whole application every ten
83
+ // seconds is the worse of two working answers.
84
+ const path = svc.discoveredHealth.find((c) => c.status >= 200 && c.status < 400)?.path;
85
+ return path ? { name: svc.name, path } : null;
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",