@yadurajfleetos/cli 0.15.0 → 0.17.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 +1 -1
- package/dist/commands/doctor.js +33 -2
- package/dist/commands/fix.js +95 -19
- package/dist/commands/services.js +31 -3
- package/dist/commands/tune.js +77 -21
- package/dist/commands/up.js +21 -0
- package/dist/detect.js +50 -15
- package/dist/manifest-edit.js +66 -0
- package/dist/source-edit.js +87 -0
- package/dist/source.js +146 -0
- package/dist/tune.js +25 -0
- package/package.json +1 -1
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
|
]);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -3,6 +3,37 @@ 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
|
+
* How full a node's disk is, from the right two numbers.
|
|
8
|
+
*
|
|
9
|
+
* It reported 615% used, which is arithmetic that cannot be right and quietly
|
|
10
|
+
* undermines every other line of the report. The denominator was `node.diskMb`
|
|
11
|
+
* — and the control plane says, in a comment directly above the field it sends
|
|
12
|
+
* instead:
|
|
13
|
+
*
|
|
14
|
+
* Capacity. node.diskMb is FREE space and is what the scheduler places
|
|
15
|
+
* against, so it is not the denominator for a "used of total" reading.
|
|
16
|
+
*
|
|
17
|
+
* Somebody wrote that warning and this divided by the wrong one anyway. Used
|
|
18
|
+
* over free exceeds 100% the moment a disk is more than half full, which is
|
|
19
|
+
* why the number looked wild rather than merely wrong.
|
|
20
|
+
*
|
|
21
|
+
* An agent too old to report a capacity gets no percentage at all. A missing
|
|
22
|
+
* figure is a gap somebody can fix; an invented one is a number people act on.
|
|
23
|
+
*/
|
|
24
|
+
export function diskUse(usedMb, totalMb) {
|
|
25
|
+
if (!totalMb || usedMb === undefined) {
|
|
26
|
+
return { state: 'ok', detail: 'capacity not reported by this agent' };
|
|
27
|
+
}
|
|
28
|
+
const percent = Math.round((usedMb / totalMb) * 100);
|
|
29
|
+
return {
|
|
30
|
+
state: percent >= 90 ? 'fail' : percent >= 80 ? 'warn' : 'ok',
|
|
31
|
+
detail: `${percent}% used · ${Math.round(usedMb / 1024)}GB of ${Math.round(totalMb / 1024)}GB`,
|
|
32
|
+
remedy: percent >= 80
|
|
33
|
+
? 'Free space from Docker images/volumes before the node becomes unschedulable.'
|
|
34
|
+
: undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
6
37
|
/**
|
|
7
38
|
* Services that answer on a health path but do not declare one.
|
|
8
39
|
*
|
|
@@ -191,7 +222,7 @@ export const doctorCommand = {
|
|
|
191
222
|
});
|
|
192
223
|
for (const node of result.nodes) {
|
|
193
224
|
const runtime = node.telemetry?.runtime;
|
|
194
|
-
const
|
|
225
|
+
const disk = diskUse(node.telemetry?.diskUsedMb, node.telemetry?.diskTotalMb);
|
|
195
226
|
// Redis intentionally retains the last heartbeat briefly, but a node
|
|
196
227
|
// that has stopped reporting must not have old host facts rendered as
|
|
197
228
|
// current failures. The heartbeat check above is the only actionable
|
|
@@ -207,7 +238,7 @@ export const doctorCommand = {
|
|
|
207
238
|
}
|
|
208
239
|
checks.push({ state: runtime?.dockerAvailable ? 'ok' : 'fail', label: `Docker ${node.name}`, detail: runtime?.dockerAvailable ? `available${runtime.dockerVersion ? ` · ${runtime.dockerVersion}` : ''}` : runtime?.dockerError ?? 'No Docker runtime reported', remedy: runtime?.dockerAvailable ? undefined : 'Start Docker, then inspect the local fleet-agent log.' });
|
|
209
240
|
checks.push({ state: runtime?.registryStatus === 'ok' ? 'ok' : runtime?.registryStatus === 'failed' ? 'fail' : 'warn', label: `registry ${node.name}`, detail: runtime?.registryStatus === 'ok' ? 'latest real image pull succeeded' : runtime?.registryError ?? 'not tested by a real image pull yet', remedy: runtime?.registryStatus === 'ok' ? undefined : 'Use a LAN-reachable REGISTRY_URL, then restart a service to run an authenticated pull.' });
|
|
210
|
-
checks.push({
|
|
241
|
+
checks.push({ ...disk, label: `disk ${node.name}` });
|
|
211
242
|
if (runtime?.lastReconcileError)
|
|
212
243
|
checks.push({ state: 'fail', label: `reconcile ${node.name}`, detail: runtime.lastReconcileError, remedy: 'Run `fleet logs <service> --follow` and inspect the deployment history.' });
|
|
213
244
|
}
|
package/dist/commands/fix.js
CHANGED
|
@@ -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: {
|
|
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
|
|
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
|
|
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 {
|
|
@@ -331,6 +332,24 @@ export const rescheduleCommand = {
|
|
|
331
332
|
console.log(`${c.green('moved')} ${service.name} → ${c.bold(body.movedTo.name)}`);
|
|
332
333
|
},
|
|
333
334
|
};
|
|
335
|
+
/**
|
|
336
|
+
* A building row, with what the builder is actually doing.
|
|
337
|
+
*
|
|
338
|
+
* Every field here has been travelling from buildx through Redis to the API for
|
|
339
|
+
* a while and stopping there, so this shows what already exists rather than
|
|
340
|
+
* measuring anything new. Emulation is called out because it is the usual
|
|
341
|
+
* answer to "why is this taking twenty minutes".
|
|
342
|
+
*/
|
|
343
|
+
function buildStatus(status, progress) {
|
|
344
|
+
const parts = [statusColour(status)];
|
|
345
|
+
if (progress.step && progress.ofSteps)
|
|
346
|
+
parts.push(c.dim(`${progress.step}/${progress.ofSteps}`));
|
|
347
|
+
if (progress.platform)
|
|
348
|
+
parts.push(c.dim(progress.platform));
|
|
349
|
+
if (progress.emulated)
|
|
350
|
+
parts.push(c.yellow('emulated'));
|
|
351
|
+
return parts.join(' ');
|
|
352
|
+
}
|
|
334
353
|
export const deploymentsCommand = {
|
|
335
354
|
async run(args, flags) {
|
|
336
355
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
@@ -345,8 +364,10 @@ export const deploymentsCommand = {
|
|
|
345
364
|
relativeTime(d.startedAt),
|
|
346
365
|
d.gitSha?.slice(0, 7) ?? c.dim('—'),
|
|
347
366
|
d.nodeName ?? c.dim('—'),
|
|
348
|
-
|
|
349
|
-
|
|
367
|
+
// "building" alone reads as stuck. The step counter is what tells a
|
|
368
|
+
// reader the difference between a slow build and a hung one.
|
|
369
|
+
d.progress ? buildStatus(d.status, d.progress) : statusColour(d.status),
|
|
370
|
+
d.failureReason ?? (d.progress?.detail ? c.dim(d.progress.detail) : ''),
|
|
350
371
|
])));
|
|
351
372
|
},
|
|
352
373
|
};
|
|
@@ -590,12 +611,19 @@ async function answerQuestions(questions, flags) {
|
|
|
590
611
|
export const diagnoseCommand = {
|
|
591
612
|
async run(args, flags) {
|
|
592
613
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
614
|
+
const source = await localSource(process.cwd());
|
|
593
615
|
const question = args.join(' ').trim();
|
|
594
616
|
if (!question) {
|
|
595
617
|
throw new CliError('usage: fleet diagnose "<what is wrong>"\n' +
|
|
596
618
|
' eg: fleet diagnose "why is backend returning 502?"', EXIT.usage);
|
|
597
619
|
}
|
|
598
|
-
const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, {
|
|
620
|
+
const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, {
|
|
621
|
+
// Source when there is a manifest here to read it from, and nothing
|
|
622
|
+
// when there is not. `diagnose` runs from anywhere on purpose, so
|
|
623
|
+
// this is the one lookup that is sometimes unavailable — the tool
|
|
624
|
+
// says so rather than pretending it looked.
|
|
625
|
+
body: { question, ...(Object.keys(source).length ? { source } : {}) },
|
|
626
|
+
}),
|
|
599
627
|
// What it looked at, so the wait is legible rather than a spinner.
|
|
600
628
|
{ done: (r) => ('calls' in r.body ? `looked at ${r.body.calls.length} thing(s)` : 'done') });
|
|
601
629
|
if (flags.json)
|
package/dist/commands/tune.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
8
|
+
* The manifest, checked against what the services actually did.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* a
|
|
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 ·
|
|
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
|
-
|
|
47
|
-
console.log(
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
},
|
package/dist/commands/up.js
CHANGED
|
@@ -188,6 +188,27 @@ async function deployOne(service, opts) {
|
|
|
188
188
|
// is measured in tens of minutes, not minutes.
|
|
189
189
|
const deadline = Date.now() + 45 * 60_000;
|
|
190
190
|
while (Date.now() < deadline) {
|
|
191
|
+
// What the builder is doing, rather than a hint about what it might
|
|
192
|
+
// be doing. Every field here has been reaching /progress since the
|
|
193
|
+
// phase writer was added and nothing asked for it, so this loop sat
|
|
194
|
+
// cycling generic advice past a reader who could have been told the
|
|
195
|
+
// step number. Failures are swallowed: this is a label, and losing it
|
|
196
|
+
// must not end a deploy that is going fine.
|
|
197
|
+
const line = await request('GET', `/services/${service.id}/progress`)
|
|
198
|
+
.then((r) => r.body)
|
|
199
|
+
.catch(() => null);
|
|
200
|
+
if (line && ['queued', 'building', 'pushing'].includes(line.status)) {
|
|
201
|
+
const parts = [line.status];
|
|
202
|
+
if (line.step && line.ofSteps)
|
|
203
|
+
parts.push(`${line.step}/${line.ofSteps}`);
|
|
204
|
+
if (line.platform)
|
|
205
|
+
parts.push(line.platform);
|
|
206
|
+
if (line.emulated)
|
|
207
|
+
parts.push('emulated');
|
|
208
|
+
s.update(`${c.bold(service.name)} · ${parts.join(' · ')}`);
|
|
209
|
+
if (line.detail)
|
|
210
|
+
s.hints([line.detail]);
|
|
211
|
+
}
|
|
191
212
|
const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
|
|
192
213
|
const current = body.services.find((s) => s.id === service.id)?.current;
|
|
193
214
|
if (current?.status === 'running')
|
package/dist/detect.js
CHANGED
|
@@ -45,11 +45,12 @@ const hasDep = (pkg, name) => {
|
|
|
45
45
|
return Boolean(deps?.[name] || devDeps?.[name]);
|
|
46
46
|
};
|
|
47
47
|
// ── Dockerfile templates ────────────────────────────────────────────────
|
|
48
|
-
const NEXTJS_DOCKERFILE = `#
|
|
48
|
+
const NEXTJS_DOCKERFILE = `# syntax=docker/dockerfile:1
|
|
49
|
+
# --- Build ---
|
|
49
50
|
FROM node:22-alpine AS builder
|
|
50
51
|
WORKDIR /app
|
|
51
52
|
COPY package*.json ./
|
|
52
|
-
RUN npm ci
|
|
53
|
+
RUN --mount=type=cache,target=/root/.npm npm ci
|
|
53
54
|
COPY . .
|
|
54
55
|
RUN npm run build
|
|
55
56
|
|
|
@@ -63,11 +64,12 @@ COPY --from=builder /app/public ./public
|
|
|
63
64
|
EXPOSE 3000
|
|
64
65
|
CMD ["node", "server.js"]
|
|
65
66
|
`;
|
|
66
|
-
const VITE_DOCKERFILE = `#
|
|
67
|
+
const VITE_DOCKERFILE = `# syntax=docker/dockerfile:1
|
|
68
|
+
# --- Build ---
|
|
67
69
|
FROM node:22-alpine AS builder
|
|
68
70
|
WORKDIR /app
|
|
69
71
|
COPY package*.json ./
|
|
70
|
-
RUN npm ci
|
|
72
|
+
RUN --mount=type=cache,target=/root/.npm npm ci
|
|
71
73
|
COPY . .
|
|
72
74
|
RUN npm run build
|
|
73
75
|
|
|
@@ -77,32 +79,57 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
|
|
77
79
|
EXPOSE 80
|
|
78
80
|
CMD ["nginx", "-g", "daemon off;"]
|
|
79
81
|
`;
|
|
80
|
-
const NODE_DOCKERFILE =
|
|
82
|
+
const NODE_DOCKERFILE = `# syntax=docker/dockerfile:1
|
|
83
|
+
FROM node:22-alpine
|
|
81
84
|
WORKDIR /app
|
|
82
85
|
COPY package*.json ./
|
|
83
|
-
RUN npm ci --omit=dev
|
|
86
|
+
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
|
84
87
|
COPY . .
|
|
85
88
|
EXPOSE 3000
|
|
86
89
|
CMD ["node", "src/index.js"]
|
|
87
90
|
`;
|
|
88
|
-
|
|
91
|
+
/**
|
|
92
|
+
* `--mount=type=cache` rather than `--no-cache-dir`.
|
|
93
|
+
*
|
|
94
|
+
* The two look interchangeable and are opposites. `--no-cache-dir` tells pip to
|
|
95
|
+
* keep no wheel cache *inside the layer*, which keeps the image small and makes
|
|
96
|
+
* every rebuild download and recompile from nothing. A BuildKit cache mount
|
|
97
|
+
* lives outside the image entirely — it is not a layer, so it adds nothing to
|
|
98
|
+
* the final size — and survives between builds, so a rebuild that changes only
|
|
99
|
+
* application source reuses every wheel it already has.
|
|
100
|
+
*
|
|
101
|
+
* That is the difference between a two-minute rebuild and a twenty-minute one
|
|
102
|
+
* for anything with `cryptography` or `psycopg2` in it, and more again when the
|
|
103
|
+
* build is emulated.
|
|
104
|
+
*
|
|
105
|
+
* The syntax line is required: `RUN --mount` is a Dockerfile frontend feature,
|
|
106
|
+
* and without it an older builder fails on the flag rather than ignoring it.
|
|
107
|
+
*/
|
|
108
|
+
const PYTHON_DOCKERFILE = (entry, usesPoetry) => `# syntax=docker/dockerfile:1
|
|
109
|
+
FROM python:3.12-slim
|
|
89
110
|
WORKDIR /app
|
|
90
111
|
${usesPoetry
|
|
91
112
|
? `COPY pyproject.toml poetry.lock* ./
|
|
92
|
-
RUN
|
|
113
|
+
RUN --mount=type=cache,target=/root/.cache/pip \\
|
|
114
|
+
--mount=type=cache,target=/root/.cache/pypoetry \\
|
|
115
|
+
pip install poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-dev`
|
|
93
116
|
: `COPY requirements*.txt ./
|
|
94
|
-
RUN
|
|
117
|
+
RUN --mount=type=cache,target=/root/.cache/pip \\
|
|
118
|
+
pip install -r requirements.txt`}
|
|
95
119
|
COPY . .
|
|
96
120
|
EXPOSE 8000
|
|
97
121
|
CMD ["python", "-m", "${entry}"]
|
|
98
122
|
`;
|
|
99
|
-
const GO_DOCKERFILE = (module) => `#
|
|
123
|
+
const GO_DOCKERFILE = (module) => `# syntax=docker/dockerfile:1
|
|
124
|
+
# --- Build ---
|
|
100
125
|
FROM golang:1.24-alpine AS builder
|
|
101
126
|
WORKDIR /app
|
|
102
127
|
COPY go.mod go.sum* ./
|
|
103
|
-
RUN go mod download
|
|
128
|
+
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
|
104
129
|
COPY . .
|
|
105
|
-
RUN
|
|
130
|
+
RUN --mount=type=cache,target=/go/pkg/mod \\
|
|
131
|
+
--mount=type=cache,target=/root/.cache/go-build \\
|
|
132
|
+
CGO_ENABLED=0 go build -o /server .
|
|
106
133
|
|
|
107
134
|
# --- Run ---
|
|
108
135
|
FROM alpine:3.21
|
|
@@ -110,13 +137,21 @@ COPY --from=builder /server /server
|
|
|
110
137
|
EXPOSE 8080
|
|
111
138
|
CMD ["/server"]
|
|
112
139
|
`;
|
|
113
|
-
const RUST_DOCKERFILE = `#
|
|
140
|
+
const RUST_DOCKERFILE = `# syntax=docker/dockerfile:1
|
|
141
|
+
# --- Build ---
|
|
114
142
|
FROM rust:1.87-slim AS builder
|
|
115
143
|
WORKDIR /app
|
|
116
144
|
COPY Cargo.toml Cargo.lock* ./
|
|
117
|
-
RUN
|
|
145
|
+
RUN --mount=type=cache,target=/usr/local/cargo/registry \\
|
|
146
|
+
mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
|
|
118
147
|
COPY . .
|
|
119
|
-
|
|
148
|
+
# Only the registry is cached, deliberately. A cache mount on /app/target would
|
|
149
|
+
# speed the compile up and put the binary somewhere the runtime stage cannot
|
|
150
|
+
# copy from: a mount is not part of the image, so "COPY --from=builder
|
|
151
|
+
# /app/target/release/*" would find an empty directory and the image would
|
|
152
|
+
# build cleanly and contain nothing.
|
|
153
|
+
RUN --mount=type=cache,target=/usr/local/cargo/registry \\
|
|
154
|
+
cargo build --release
|
|
120
155
|
|
|
121
156
|
# --- Run ---
|
|
122
157
|
FROM debian:bookworm-slim
|
|
@@ -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
|
+
}
|