@yadurajfleetos/cli 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +12 -2
- package/dist/commands/doctor.js +87 -2
- package/dist/commands/index.js +4 -1
- package/dist/commands/services.js +66 -5
- package/dist/commands/tune.js +61 -0
- package/dist/compose.js +12 -5
- package/dist/dburl.js +1 -30
- package/dist/index.js +2 -0
- package/dist/tune.js +61 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -24,17 +24,27 @@ export async function request(method, path, opts = {}) {
|
|
|
24
24
|
if (opts.auth !== false && !profile.accessToken) {
|
|
25
25
|
throw new CliError('Not signed in. Run `fleet auth login` first.', EXIT.usage);
|
|
26
26
|
}
|
|
27
|
+
// A POST with nothing to send still needs a content type.
|
|
28
|
+
//
|
|
29
|
+
// Fastify answers 415 to a POST that arrives without one, so every bodyless
|
|
30
|
+
// POST from this CLI failed before reaching its route — including `fleet
|
|
31
|
+
// alerts test`, the command the CLI itself recommends for checking alerts
|
|
32
|
+
// work before an incident. Sending an empty object costs two bytes and makes
|
|
33
|
+
// the request well-formed, rather than leaving each such route to remember
|
|
34
|
+
// to accept a shape nothing sends.
|
|
35
|
+
const writes = method === 'POST' || method === 'PUT' || method === 'PATCH';
|
|
36
|
+
const payload = opts.raw ? opts.raw.data : writes || opts.body ? JSON.stringify(opts.body ?? {}) : undefined;
|
|
27
37
|
const send = async (token) => fetch(profile.api.replace(/\/+$/, '') + path, {
|
|
28
38
|
method,
|
|
29
39
|
headers: {
|
|
30
40
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
31
41
|
...(opts.raw
|
|
32
42
|
? { 'content-type': opts.raw.contentType }
|
|
33
|
-
:
|
|
43
|
+
: payload !== undefined
|
|
34
44
|
? { 'content-type': 'application/json' }
|
|
35
45
|
: {}),
|
|
36
46
|
},
|
|
37
|
-
body:
|
|
47
|
+
body: payload,
|
|
38
48
|
signal: AbortSignal.timeout(20 * 60_000),
|
|
39
49
|
});
|
|
40
50
|
let res;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -3,6 +3,56 @@ import { loadProfile } from '../config.js';
|
|
|
3
3
|
import { c, relativeTime } from '../render.js';
|
|
4
4
|
import { glyph, rule, task } from '../ui.js';
|
|
5
5
|
const icon = (state) => state === 'ok' ? glyph.ok : state === 'warn' ? glyph.warn : glyph.fail;
|
|
6
|
+
/**
|
|
7
|
+
* Services that answer on a health path but do not declare one.
|
|
8
|
+
*
|
|
9
|
+
* `fleet init` writes this into every manifest it generates:
|
|
10
|
+
*
|
|
11
|
+
* # No health check: container state decides whether this is up.
|
|
12
|
+
* # Add one once you know a path that returns 2xx —
|
|
13
|
+
*
|
|
14
|
+
* That is a research task handed to the reader, about a program the node is
|
|
15
|
+
* already running. The node does the research now -- it asks the container
|
|
16
|
+
* which of a handful of paths answers -- and this is where the answer is
|
|
17
|
+
* reported, because the sweep settles seconds after a deploy returns and there
|
|
18
|
+
* is nothing useful to say while it is still running.
|
|
19
|
+
*
|
|
20
|
+
* The negative result is deliberately not a warning. A service where nothing
|
|
21
|
+
* answered is a service whose manifest is already correct, and telling somebody
|
|
22
|
+
* their correct configuration is a problem is how a health report gets ignored.
|
|
23
|
+
*
|
|
24
|
+
* Separated from the command so both outcomes can be tested without a control
|
|
25
|
+
* plane -- and the one that matters is the suggestion, which a fleet whose
|
|
26
|
+
* services all declare health checks never produces.
|
|
27
|
+
*/
|
|
28
|
+
export function healthPathCheck(services) {
|
|
29
|
+
const swept = services.filter((s) => s.discoveredHealth);
|
|
30
|
+
const answering = swept
|
|
31
|
+
.map((s) => ({
|
|
32
|
+
name: s.name,
|
|
33
|
+
// The first 2xx-3xx, which is the order the node tried them in: a
|
|
34
|
+
// dedicated endpoint before "/", because a check that renders the whole
|
|
35
|
+
// application every ten seconds is the worse of two working answers.
|
|
36
|
+
path: s.discoveredHealth.find((c) => c.status >= 200 && c.status < 400)?.path,
|
|
37
|
+
}))
|
|
38
|
+
.filter((s) => Boolean(s.path));
|
|
39
|
+
if (!answering.length) {
|
|
40
|
+
return {
|
|
41
|
+
state: 'ok',
|
|
42
|
+
label: 'health paths',
|
|
43
|
+
detail: swept.length
|
|
44
|
+
? `${swept.length} service(s) without a health check answered nothing — container state is the only evidence, as declared.`
|
|
45
|
+
: 'Every service declares a health check, or none has been swept yet.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const named = answering.map((s) => `${s.name} → ${s.path}`).join(', ');
|
|
49
|
+
return {
|
|
50
|
+
state: 'warn',
|
|
51
|
+
label: 'health paths',
|
|
52
|
+
detail: `${named}. These answer 2xx but declare no health check, so a deploy is confirmed on container state alone.`,
|
|
53
|
+
remedy: `Add \`health: { path: ${answering[0].path} }\` to ${answering[0].name} in fleet.yaml. Without one, a container that starts and then fails every request still counts as a successful deploy.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
6
56
|
/**
|
|
7
57
|
* A build failure carries the whole buildx transcript. One summary line
|
|
8
58
|
* belongs in a health report; `fleet deployments` is where the rest lives.
|
|
@@ -29,6 +79,38 @@ const ORIGIN_UNREACHABLE = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525,
|
|
|
29
79
|
* What does indicate a broken path is the edge answering on the origin's
|
|
30
80
|
* behalf, or nothing answering at all.
|
|
31
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* Whether this fleet can tell anybody something went wrong.
|
|
84
|
+
*
|
|
85
|
+
* A fleet with no alert rules fails silently, and the only way to find out is
|
|
86
|
+
* an outage. This one had none while its services went down four times in an
|
|
87
|
+
* afternoon: the empty state existed, and lived inside `fleet alerts`, a
|
|
88
|
+
* subcommand you only run once you already suspect the answer.
|
|
89
|
+
*
|
|
90
|
+
* Separated from the command so both outcomes can be tested without a control
|
|
91
|
+
* plane — the one that matters is the warning, and it is the one a live check
|
|
92
|
+
* against a working fleet never exercises.
|
|
93
|
+
*/
|
|
94
|
+
export function alertCheck(rules) {
|
|
95
|
+
const live = rules.filter((r) => r.enabled);
|
|
96
|
+
if (live.length) {
|
|
97
|
+
return {
|
|
98
|
+
state: 'ok',
|
|
99
|
+
label: 'alerts',
|
|
100
|
+
detail: `${live.length} rule(s): ${[...new Set(live.map((r) => r.channelType))].join(', ')}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
state: 'warn',
|
|
105
|
+
label: 'alerts',
|
|
106
|
+
// Disabled and absent are different mistakes: one was set up and turned
|
|
107
|
+
// off, the other never existed, and the person reading needs to know which.
|
|
108
|
+
detail: rules.length
|
|
109
|
+
? 'Every alert rule is disabled — failures will pass unreported.'
|
|
110
|
+
: 'No alert rules. A node going down or a deploy failing will tell nobody.',
|
|
111
|
+
remedy: 'Add one with `fleet alerts add --channel email --to you@example.com`, then prove it with `fleet alerts test`.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
32
114
|
async function reach(url) {
|
|
33
115
|
try {
|
|
34
116
|
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
|
|
@@ -60,13 +142,14 @@ export const doctorCommand = {
|
|
|
60
142
|
const profile = await loadProfile();
|
|
61
143
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
62
144
|
const result = await task('checking Fleet health', async () => {
|
|
63
|
-
const [identity, fleet, nodes, services, github, health] = await Promise.all([
|
|
145
|
+
const [identity, fleet, nodes, services, github, health, alerts] = await Promise.all([
|
|
64
146
|
request('GET', '/auth/me'),
|
|
65
147
|
request('GET', `/fleets/${fleetId}`),
|
|
66
148
|
request('GET', `/fleets/${fleetId}/nodes`),
|
|
67
149
|
request('GET', `/fleets/${fleetId}/services`),
|
|
68
150
|
request('GET', `/fleets/${fleetId}/github/status`),
|
|
69
151
|
request('GET', '/healthz'),
|
|
152
|
+
request('GET', `/fleets/${fleetId}/alert-rules`),
|
|
70
153
|
]);
|
|
71
154
|
const deploymentHistory = await Promise.all(services.body.services.map(async (service) => ({
|
|
72
155
|
service,
|
|
@@ -76,7 +159,7 @@ export const doctorCommand = {
|
|
|
76
159
|
.map((service) => ({ name: service.name, hostname: service.domain ?? service.hostname }))
|
|
77
160
|
.filter((service) => Boolean(service.hostname));
|
|
78
161
|
const ingress = await Promise.all(urls.map(async (service) => ({ ...service, ...(await reach(`https://${service.hostname}`)) })));
|
|
79
|
-
return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, deploymentHistory, ingress };
|
|
162
|
+
return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, alerts: alerts.body.rules, deploymentHistory, ingress };
|
|
80
163
|
});
|
|
81
164
|
const checks = [
|
|
82
165
|
{ state: 'ok', label: 'control plane', detail: profile.api },
|
|
@@ -171,6 +254,8 @@ export const doctorCommand = {
|
|
|
171
254
|
detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
|
|
172
255
|
remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
|
|
173
256
|
});
|
|
257
|
+
checks.push(healthPathCheck(result.services));
|
|
258
|
+
checks.push(alertCheck(result.alerts));
|
|
174
259
|
checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
|
|
175
260
|
if (flags.json)
|
|
176
261
|
return console.log(JSON.stringify({ fleetId, checks }, null, 2));
|
package/dist/commands/index.js
CHANGED
|
@@ -5,12 +5,13 @@ import { alertsCommand } from './alerts.js';
|
|
|
5
5
|
import { configCommand, useCommand } from './config.js';
|
|
6
6
|
import { doctorCommand } from './doctor.js';
|
|
7
7
|
import { upCommand } from './up.js';
|
|
8
|
+
import { tuneCommand } from './tune.js';
|
|
8
9
|
import { openCommand } from './open.js';
|
|
9
10
|
import { downCommand } from './down.js';
|
|
10
11
|
import { unpairCommand, agentCommand } from './unpair.js';
|
|
11
12
|
import { secretsCommand } from './secrets.js';
|
|
12
13
|
import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
|
|
13
|
-
import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
14
|
+
import { applyCommand, deployCommand, deploymentsCommand, initCommand, diagnoseCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
14
15
|
export const commands = {
|
|
15
16
|
up: upCommand,
|
|
16
17
|
open: openCommand,
|
|
@@ -20,7 +21,9 @@ export const commands = {
|
|
|
20
21
|
config: configCommand,
|
|
21
22
|
use: useCommand,
|
|
22
23
|
doctor: doctorCommand,
|
|
24
|
+
tune: tuneCommand,
|
|
23
25
|
init: initCommand,
|
|
26
|
+
diagnose: diagnoseCommand,
|
|
24
27
|
import: importCommand,
|
|
25
28
|
explain: explainCommand,
|
|
26
29
|
validate: validateCommand,
|
|
@@ -462,7 +462,7 @@ async function theOnlyNode(flags) {
|
|
|
462
462
|
* appeared with different ports and no explanation is worse than one with a
|
|
463
463
|
* mistake in it -- at least the mistake is yours to find.
|
|
464
464
|
*/
|
|
465
|
-
async function reviewed(draft, flags) {
|
|
465
|
+
async function reviewed(draft, flags, services) {
|
|
466
466
|
const { repoMap } = await import('../repomap.js');
|
|
467
467
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
468
468
|
// The second pass is a different, smaller question.
|
|
@@ -473,14 +473,27 @@ async function reviewed(draft, flags) {
|
|
|
473
473
|
// just given was discarded. Applying an answer needs the manifest it applies
|
|
474
474
|
// to and the answer, not the evidence that produced it: the model has
|
|
475
475
|
// already read the repository and written its conclusions down.
|
|
476
|
-
const review = (base, map, answers) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
|
|
477
|
-
body: {
|
|
476
|
+
const review = (base, map, answers, parts) => task(answers ? 'applying your answers' : 'reading the repository for a second opinion', async () => request('POST', `/fleets/${fleetId}/manifest/assist`, {
|
|
477
|
+
body: {
|
|
478
|
+
draft: base,
|
|
479
|
+
repoMap: map,
|
|
480
|
+
...(answers ? { answers } : {}),
|
|
481
|
+
...(parts ? { parts } : {}),
|
|
482
|
+
},
|
|
478
483
|
}), { done: () => (answers ? 'done' : 'reviewed') });
|
|
479
484
|
let map;
|
|
480
485
|
let out;
|
|
481
486
|
try {
|
|
482
487
|
map = await repoMap();
|
|
483
|
-
|
|
488
|
+
// Evidence per service, so each is reviewed at full depth rather than
|
|
489
|
+
// every service being trimmed to fit one request. The whole-repository
|
|
490
|
+
// map still goes along: a service is judged partly by what surrounds it,
|
|
491
|
+
// and the tree is how the model knows what else exists.
|
|
492
|
+
const parts = await Promise.all(services.map(async (svc) => ({
|
|
493
|
+
service: svc.name,
|
|
494
|
+
map: await repoMap(join(process.cwd(), svc.dir)),
|
|
495
|
+
})));
|
|
496
|
+
out = (await review(draft, map, undefined, parts)).body;
|
|
484
497
|
}
|
|
485
498
|
catch (err) {
|
|
486
499
|
console.log(`${glyph.warn} ${c.yellow('review skipped')} ${err instanceof Error ? err.message : 'the control plane could not be reached'}`);
|
|
@@ -567,6 +580,52 @@ async function answerQuestions(questions, flags) {
|
|
|
567
580
|
}
|
|
568
581
|
return Object.keys(answers).length ? answers : null;
|
|
569
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Ask the control plane why something is wrong.
|
|
585
|
+
*
|
|
586
|
+
* Distinct from `explain`, which reads a failure log you already have. This
|
|
587
|
+
* goes and finds the evidence: the deployment history, what the node says it
|
|
588
|
+
* is running, the container's output, whether the public address answers.
|
|
589
|
+
*/
|
|
590
|
+
export const diagnoseCommand = {
|
|
591
|
+
async run(args, flags) {
|
|
592
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
593
|
+
const question = args.join(' ').trim();
|
|
594
|
+
if (!question) {
|
|
595
|
+
throw new CliError('usage: fleet diagnose "<what is wrong>"\n' +
|
|
596
|
+
' eg: fleet diagnose "why is backend returning 502?"', EXIT.usage);
|
|
597
|
+
}
|
|
598
|
+
const { body } = await task('looking', async () => request('POST', `/fleets/${fleetId}/diagnose`, { body: { question } }),
|
|
599
|
+
// What it looked at, so the wait is legible rather than a spinner.
|
|
600
|
+
{ done: (r) => ('calls' in r.body ? `looked at ${r.body.calls.length} thing(s)` : 'done') });
|
|
601
|
+
if (flags.json)
|
|
602
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
603
|
+
if (body.status === 'disabled') {
|
|
604
|
+
return console.log(`${glyph.warn} ${c.yellow('unavailable')} ${body.reason}`);
|
|
605
|
+
}
|
|
606
|
+
// What it looked at, always — the reader can repeat any of it by hand, and
|
|
607
|
+
// a diagnosis you cannot retrace is a diagnosis you have to take on faith.
|
|
608
|
+
for (const call of body.calls) {
|
|
609
|
+
const detail = Object.values(call.args)[0];
|
|
610
|
+
console.log(c.dim(` · ${call.tool}${detail ? ` ${String(detail)}` : ''}`));
|
|
611
|
+
}
|
|
612
|
+
if (body.status === 'inconclusive') {
|
|
613
|
+
console.log(`\n${glyph.warn} ${c.yellow('inconclusive')} ${body.reason}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
console.log(`\n${body.summary}\n`);
|
|
617
|
+
for (const f of body.findings) {
|
|
618
|
+
console.log(` ${c.bold(f.claim)}`);
|
|
619
|
+
console.log(c.dim(` ${f.evidence}`));
|
|
620
|
+
}
|
|
621
|
+
if (body.next.length) {
|
|
622
|
+
console.log(`\n ${c.dim('next')}`);
|
|
623
|
+
for (const n of body.next)
|
|
624
|
+
console.log(` ${glyph.info ?? '·'} ${n}`);
|
|
625
|
+
}
|
|
626
|
+
console.log(c.dim(`\n ${body.model}`));
|
|
627
|
+
},
|
|
628
|
+
};
|
|
570
629
|
export const initCommand = {
|
|
571
630
|
async run(args, flags) {
|
|
572
631
|
const { detect, manifestTemplate } = await import('../detect.js');
|
|
@@ -596,7 +655,9 @@ export const initCommand = {
|
|
|
596
655
|
(found.databases.length ? await theOnlyNode(flags) : undefined),
|
|
597
656
|
});
|
|
598
657
|
const questions = drafted.questions;
|
|
599
|
-
const manifest = flags.ai
|
|
658
|
+
const manifest = flags.ai
|
|
659
|
+
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
|
|
660
|
+
: drafted.manifest;
|
|
600
661
|
await writeFile(path, manifest);
|
|
601
662
|
console.log(`${c.green('created')} ${path}`);
|
|
602
663
|
if (found.layout)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { request, requireFleet } from '../api.js';
|
|
2
|
+
import { c } from '../render.js';
|
|
3
|
+
import { glyph, rule } from '../ui.js';
|
|
4
|
+
import { asQuantity, tuneRam, MIN_OBSERVATION_HOURS } from '../tune.js';
|
|
5
|
+
/**
|
|
6
|
+
* Reservations, checked against what the services actually used.
|
|
7
|
+
*
|
|
8
|
+
* It proposes and never applies. Every number here is the system inferring
|
|
9
|
+
* something about a machine, and the lesson of every inference this project has
|
|
10
|
+
* shipped is that one leaving its evidence needs a person between it and the
|
|
11
|
+
* manifest — a review once invented a node from a compose service name, and
|
|
12
|
+
* once replaced a `build:` with `image: nginx:alpine` and served the welcome
|
|
13
|
+
* page over somebody's site. Both were caught by a guardrail. A person reading
|
|
14
|
+
* a diff is the cheapest guardrail there is.
|
|
15
|
+
*/
|
|
16
|
+
export const tuneCommand = {
|
|
17
|
+
async run(_args, flags) {
|
|
18
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
19
|
+
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
20
|
+
const advice = body.services.map((s) => tuneRam(s));
|
|
21
|
+
if (flags.json)
|
|
22
|
+
return console.log(JSON.stringify({ fleetId, advice }, null, 2));
|
|
23
|
+
console.log(`\n${rule('tune · reservations against measured use')}`);
|
|
24
|
+
const advised = advice.filter((a) => a.verdict === 'advise');
|
|
25
|
+
const tight = advice.filter((a) => a.verdict === 'tight');
|
|
26
|
+
const waiting = advice.filter((a) => a.verdict === 'too-soon' || a.verdict === 'no-data');
|
|
27
|
+
for (const a of advice) {
|
|
28
|
+
if (a.verdict === 'advise') {
|
|
29
|
+
console.log(`${glyph.warn} ${c.bold(a.name.padEnd(18))} reserves ${asQuantity(a.from)}, peaks at ${a.peak}MB` +
|
|
30
|
+
` → ${c.bold(asQuantity(a.to))}`);
|
|
31
|
+
}
|
|
32
|
+
else if (a.verdict === 'tight') {
|
|
33
|
+
console.log(`${glyph.warn} ${c.bold(a.name.padEnd(18))} peaks at ${a.peak}MB of ${asQuantity(a.requestRamMb)}` +
|
|
34
|
+
` — close to its limit, which is also where the kernel kills it`);
|
|
35
|
+
}
|
|
36
|
+
else if (a.verdict === 'fits') {
|
|
37
|
+
console.log(`${glyph.ok} ${c.bold(a.name.padEnd(18))} peaks at ${a.peak}MB — about right`);
|
|
38
|
+
}
|
|
39
|
+
else if (a.verdict === 'too-soon') {
|
|
40
|
+
console.log(`${glyph.info} ${c.dim(a.name.padEnd(18))} ${c.dim(`watched for ${a.hours}h; needs ${MIN_OBSERVATION_HOURS}h`)}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(`${glyph.info} ${c.dim(a.name.padEnd(18))} ${c.dim('not measured yet')}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (advised.length) {
|
|
47
|
+
console.log(`\n ${c.dim('edit fleet.yaml, then')} fleet up`);
|
|
48
|
+
for (const a of advised) {
|
|
49
|
+
if (a.verdict !== 'advise')
|
|
50
|
+
continue;
|
|
51
|
+
console.log(` ${c.dim(`${a.name}:`)} resources: { ram: ${asQuantity(a.to)} }`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!advised.length && !tight.length) {
|
|
55
|
+
console.log(`\n ${c.dim(waiting.length === advice.length
|
|
56
|
+
? 'Nothing has been watched long enough to advise on yet.'
|
|
57
|
+
: 'Every measured reservation is about right.')}`);
|
|
58
|
+
}
|
|
59
|
+
console.log();
|
|
60
|
+
},
|
|
61
|
+
};
|
package/dist/compose.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parse as parseYaml } from 'yaml';
|
|
2
2
|
import { safeDatabaseName } from './dbnames.js';
|
|
3
|
-
import {
|
|
3
|
+
import { pointsAt } from './dburl.js';
|
|
4
4
|
/** Images Fleet manages as databases rather than as plain containers. */
|
|
5
5
|
const ENGINE_IMAGES = {
|
|
6
6
|
postgres: 'postgres',
|
|
@@ -310,10 +310,17 @@ export function composeToFleet(source, opts = {}) {
|
|
|
310
310
|
if (target) {
|
|
311
311
|
const [composeName, fleetName] = target;
|
|
312
312
|
const engine = dbEngines.get(composeName);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
313
|
+
if (engine) {
|
|
314
|
+
// A reference, not a computed URL.
|
|
315
|
+
//
|
|
316
|
+
// This used to build the connection string here, which meant the CLI
|
|
317
|
+
// carrying a copy of the control plane's engine table — ports,
|
|
318
|
+
// schemes, default users — kept honest by a test that caught it
|
|
319
|
+
// getting postgres's user wrong. The control plane resolves this at
|
|
320
|
+
// apply time from the table it owns, so the copy is gone and the
|
|
321
|
+
// manifest says what it means rather than a value that has to match.
|
|
322
|
+
plain.push([k, `\${db:${fleetName}.url}`]);
|
|
323
|
+
notes.push(`${name}: ${k} now points at the managed ${engine} — it named the compose service "${composeName}", which Fleet runs as "${fleetName}" and fills in when the manifest is applied.`);
|
|
317
324
|
continue;
|
|
318
325
|
}
|
|
319
326
|
}
|
package/dist/dburl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Recognising a connection string that names a managed database.
|
|
3
3
|
*
|
|
4
4
|
* `uses: [db]` gives a service DATABASE_URL and friends. An imported compose
|
|
5
5
|
* file usually has its own variable for the same thing — MONGODB_URI,
|
|
@@ -15,35 +15,6 @@
|
|
|
15
15
|
* in the control plane that reads this file and fails when the two disagree,
|
|
16
16
|
* which is the only thing that makes duplicating it acceptable.
|
|
17
17
|
*/
|
|
18
|
-
/** Mirrors ENGINES in control-plane/src/manifest/databases.ts. */
|
|
19
|
-
export const ENGINE_WIRE = {
|
|
20
|
-
postgres: { scheme: 'postgres', port: 5432, defaultUser: 'postgres', usesPassword: true, usesDatabase: true },
|
|
21
|
-
mysql: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
|
|
22
|
-
mariadb: { scheme: 'mysql', port: 3306, defaultUser: 'app', usesPassword: true, usesDatabase: true },
|
|
23
|
-
redis: { scheme: 'redis', port: 6379, defaultUser: '', usesPassword: false, usesDatabase: false },
|
|
24
|
-
mongo: { scheme: 'mongodb', port: 27017, defaultUser: 'app', usesPassword: true, usesDatabase: true },
|
|
25
|
-
};
|
|
26
|
-
/** Mirrors passwordRefFor: the secret name a database's password lives under. */
|
|
27
|
-
export const passwordRefFor = (name) => `${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD`;
|
|
28
|
-
/**
|
|
29
|
-
* The URL a service will see, written the way the manifest can carry it.
|
|
30
|
-
*
|
|
31
|
-
* The password is a `${secret:NAME}` reference rather than a value: it is
|
|
32
|
-
* generated by the control plane and never known here, and a manifest is a
|
|
33
|
-
* file people commit.
|
|
34
|
-
*/
|
|
35
|
-
export function injectedUrl(dbName, engine) {
|
|
36
|
-
const spec = ENGINE_WIRE[engine];
|
|
37
|
-
if (!spec)
|
|
38
|
-
return null;
|
|
39
|
-
const auth = spec.usesPassword
|
|
40
|
-
? `${encodeURIComponent(spec.defaultUser)}:\${secret:${passwordRefFor(dbName)}}@`
|
|
41
|
-
: '';
|
|
42
|
-
// `database` defaults to the declaration's own name, and the host is that
|
|
43
|
-
// same name: containers resolve each other by service name on the node.
|
|
44
|
-
const path = spec.usesDatabase ? `/${dbName}` : '';
|
|
45
|
-
return `${spec.scheme}://${auth}${dbName}:${spec.port}${path}`;
|
|
46
|
-
}
|
|
47
18
|
/** Does this value look like a connection URL aimed at `host`? */
|
|
48
19
|
export function pointsAt(value, host) {
|
|
49
20
|
// Scheme-relative on purpose: an app may hold a mongodb+srv:// or a
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const GROUPS = [
|
|
|
27
27
|
['apply [file]', 'Apply a fleet.yaml to the fleet'],
|
|
28
28
|
['deploy <service>', 'Build, schedule, and roll out'],
|
|
29
29
|
['explain <service>', 'Read a failed deploy and say what to do about it'],
|
|
30
|
+
['diagnose "<question>"', 'Investigate why something is wrong, and cite what it looked at'],
|
|
30
31
|
],
|
|
31
32
|
],
|
|
32
33
|
[
|
|
@@ -37,6 +38,7 @@ const GROUPS = [
|
|
|
37
38
|
['services', 'List services and where they are running'],
|
|
38
39
|
['nodes', 'List nodes'],
|
|
39
40
|
['where <service>', 'Explain where a service would be placed, and why'],
|
|
41
|
+
['tune', 'Compare each reservation with the memory the service actually used'],
|
|
40
42
|
['deployments <service>', 'Deployment history'],
|
|
41
43
|
['logs <service> --follow', 'Follow the latest agent-reported container tail'],
|
|
42
44
|
['events', 'Unified event timeline'],
|
package/dist/tune.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a service should reserve, from what it has been seen using.
|
|
3
|
+
*
|
|
4
|
+
* `fleet init` writes `resources: { ram: 512Mi }` because 512Mi is a round
|
|
5
|
+
* number and nothing in a repository says otherwise. Measured on the fleet this
|
|
6
|
+
* was written for: a steady 60MB and 20MB against exactly that reservation. The
|
|
7
|
+
* scheduler plans capacity around the number in the manifest for the life of
|
|
8
|
+
* the service, so the gap is invisible on one node and is the difference
|
|
9
|
+
* between a service fitting and `no_eligible_node` on a fleet where it matters.
|
|
10
|
+
*
|
|
11
|
+
* The judgement lives here rather than in the command so both outcomes can be
|
|
12
|
+
* tested without a control plane — and the outcome that matters is the refusal,
|
|
13
|
+
* which a fleet with weeks of history never produces.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* How long a service must have been watched before its peak means anything.
|
|
17
|
+
*
|
|
18
|
+
* A service observed for four minutes has not been observed; it has been
|
|
19
|
+
* glanced at. Nothing that runs a nightly job, or serves a morning, has shown
|
|
20
|
+
* its peak yet, and advising a reservation from that is how a tuned fleet
|
|
21
|
+
* starts OOM-killing at 3am.
|
|
22
|
+
*/
|
|
23
|
+
export const MIN_OBSERVATION_HOURS = 24;
|
|
24
|
+
/**
|
|
25
|
+
* Headroom over the observed peak.
|
|
26
|
+
*
|
|
27
|
+
* Double, which is generous, and deliberately so. The cost of too much headroom
|
|
28
|
+
* is capacity the scheduler reserves and does not use; the cost of too little
|
|
29
|
+
* is the kernel killing a container in production. Those are not symmetric, and
|
|
30
|
+
* a tool that trims to the bone the first time it is run does not get run twice.
|
|
31
|
+
*/
|
|
32
|
+
const HEADROOM = 2;
|
|
33
|
+
/** Reservations are read by people. 118 is a measurement; 128 is a decision. */
|
|
34
|
+
function round(mb) {
|
|
35
|
+
const steps = [64, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096];
|
|
36
|
+
return steps.find((s) => s >= mb) ?? Math.ceil(mb / 1024) * 1024;
|
|
37
|
+
}
|
|
38
|
+
export function tuneRam(svc, now = Date.now()) {
|
|
39
|
+
if (svc.observedRamPeakMb === null || !svc.observedRamSince) {
|
|
40
|
+
return { verdict: 'no-data', name: svc.name };
|
|
41
|
+
}
|
|
42
|
+
const hours = (now - new Date(svc.observedRamSince).getTime()) / 3_600_000;
|
|
43
|
+
if (hours < MIN_OBSERVATION_HOURS) {
|
|
44
|
+
return { verdict: 'too-soon', name: svc.name, hours: Math.max(0, Math.round(hours * 10) / 10) };
|
|
45
|
+
}
|
|
46
|
+
const peak = svc.observedRamPeakMb;
|
|
47
|
+
// Near its limit. Not a saving, and worth saying out loud: the reservation is
|
|
48
|
+
// also the container's hard limit, so a service peaking at four fifths of it
|
|
49
|
+
// is one traffic spike from being killed.
|
|
50
|
+
if (peak >= svc.requestRamMb * 0.8) {
|
|
51
|
+
return { verdict: 'tight', name: svc.name, peak, requestRamMb: svc.requestRamMb };
|
|
52
|
+
}
|
|
53
|
+
const want = round(peak * HEADROOM);
|
|
54
|
+
if (want >= svc.requestRamMb)
|
|
55
|
+
return { verdict: 'fits', name: svc.name, peak };
|
|
56
|
+
return { verdict: 'advise', name: svc.name, from: svc.requestRamMb, to: want, peak };
|
|
57
|
+
}
|
|
58
|
+
/** Megabytes as a manifest writes them. */
|
|
59
|
+
export function asQuantity(mb) {
|
|
60
|
+
return mb % 1024 === 0 ? `${mb / 1024}Gi` : `${mb}Mi`;
|
|
61
|
+
}
|