@yadurajfleetos/cli 0.15.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 +1 -1
- package/dist/commands/fix.js +95 -19
- package/dist/commands/services.js +9 -1
- package/dist/commands/tune.js +77 -21
- 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/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 {
|
|
@@ -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`, {
|
|
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)
|
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
|
},
|
|
@@ -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
|
+
}
|