@yadurajfleetos/cli 0.10.2 → 0.11.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 +36 -2
- package/dist/commands/services.js +20 -5
- package/dist/compose.js +12 -5
- package/dist/dburl.js +1 -30
- 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
|
@@ -29,6 +29,38 @@ const ORIGIN_UNREACHABLE = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525,
|
|
|
29
29
|
* What does indicate a broken path is the edge answering on the origin's
|
|
30
30
|
* behalf, or nothing answering at all.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Whether this fleet can tell anybody something went wrong.
|
|
34
|
+
*
|
|
35
|
+
* A fleet with no alert rules fails silently, and the only way to find out is
|
|
36
|
+
* an outage. This one had none while its services went down four times in an
|
|
37
|
+
* afternoon: the empty state existed, and lived inside `fleet alerts`, a
|
|
38
|
+
* subcommand you only run once you already suspect the answer.
|
|
39
|
+
*
|
|
40
|
+
* Separated from the command so both outcomes can be tested without a control
|
|
41
|
+
* plane — the one that matters is the warning, and it is the one a live check
|
|
42
|
+
* against a working fleet never exercises.
|
|
43
|
+
*/
|
|
44
|
+
export function alertCheck(rules) {
|
|
45
|
+
const live = rules.filter((r) => r.enabled);
|
|
46
|
+
if (live.length) {
|
|
47
|
+
return {
|
|
48
|
+
state: 'ok',
|
|
49
|
+
label: 'alerts',
|
|
50
|
+
detail: `${live.length} rule(s): ${[...new Set(live.map((r) => r.channelType))].join(', ')}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
state: 'warn',
|
|
55
|
+
label: 'alerts',
|
|
56
|
+
// Disabled and absent are different mistakes: one was set up and turned
|
|
57
|
+
// off, the other never existed, and the person reading needs to know which.
|
|
58
|
+
detail: rules.length
|
|
59
|
+
? 'Every alert rule is disabled — failures will pass unreported.'
|
|
60
|
+
: 'No alert rules. A node going down or a deploy failing will tell nobody.',
|
|
61
|
+
remedy: 'Add one with `fleet alerts add --channel email --to you@example.com`, then prove it with `fleet alerts test`.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
32
64
|
async function reach(url) {
|
|
33
65
|
try {
|
|
34
66
|
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
|
|
@@ -60,13 +92,14 @@ export const doctorCommand = {
|
|
|
60
92
|
const profile = await loadProfile();
|
|
61
93
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
62
94
|
const result = await task('checking Fleet health', async () => {
|
|
63
|
-
const [identity, fleet, nodes, services, github, health] = await Promise.all([
|
|
95
|
+
const [identity, fleet, nodes, services, github, health, alerts] = await Promise.all([
|
|
64
96
|
request('GET', '/auth/me'),
|
|
65
97
|
request('GET', `/fleets/${fleetId}`),
|
|
66
98
|
request('GET', `/fleets/${fleetId}/nodes`),
|
|
67
99
|
request('GET', `/fleets/${fleetId}/services`),
|
|
68
100
|
request('GET', `/fleets/${fleetId}/github/status`),
|
|
69
101
|
request('GET', '/healthz'),
|
|
102
|
+
request('GET', `/fleets/${fleetId}/alert-rules`),
|
|
70
103
|
]);
|
|
71
104
|
const deploymentHistory = await Promise.all(services.body.services.map(async (service) => ({
|
|
72
105
|
service,
|
|
@@ -76,7 +109,7 @@ export const doctorCommand = {
|
|
|
76
109
|
.map((service) => ({ name: service.name, hostname: service.domain ?? service.hostname }))
|
|
77
110
|
.filter((service) => Boolean(service.hostname));
|
|
78
111
|
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 };
|
|
112
|
+
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
113
|
});
|
|
81
114
|
const checks = [
|
|
82
115
|
{ state: 'ok', label: 'control plane', detail: profile.api },
|
|
@@ -171,6 +204,7 @@ export const doctorCommand = {
|
|
|
171
204
|
detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
|
|
172
205
|
remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
|
|
173
206
|
});
|
|
207
|
+
checks.push(alertCheck(result.alerts));
|
|
174
208
|
checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
|
|
175
209
|
if (flags.json)
|
|
176
210
|
return console.log(JSON.stringify({ fleetId, checks }, null, 2));
|
|
@@ -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'}`);
|
|
@@ -596,7 +609,9 @@ export const initCommand = {
|
|
|
596
609
|
(found.databases.length ? await theOnlyNode(flags) : undefined),
|
|
597
610
|
});
|
|
598
611
|
const questions = drafted.questions;
|
|
599
|
-
const manifest = flags.ai
|
|
612
|
+
const manifest = flags.ai
|
|
613
|
+
? await reviewed(drafted.manifest, flags, found.services.map((s) => ({ name: s.name, dir: s.dir })))
|
|
614
|
+
: drafted.manifest;
|
|
600
615
|
await writeFile(path, manifest);
|
|
601
616
|
console.log(`${c.green('created')} ${path}`);
|
|
602
617
|
if (found.layout)
|
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
|