@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 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
- : opts.body
43
+ : payload !== undefined
34
44
  ? { 'content-type': 'application/json' }
35
45
  : {}),
36
46
  },
37
- body: opts.raw ? opts.raw.data : opts.body ? JSON.stringify(opts.body) : undefined,
47
+ body: payload,
38
48
  signal: AbortSignal.timeout(20 * 60_000),
39
49
  });
40
50
  let res;
@@ -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: { draft: base, repoMap: map, ...(answers ? { answers } : {}) },
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
- out = (await review(draft, map)).body;
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 ? await reviewed(drafted.manifest, flags) : drafted.manifest;
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 { injectedUrl, pointsAt } from './dburl.js';
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
- const url = engine ? injectedUrl(fleetName, engine) : null;
314
- if (url) {
315
- plain.push([k, url]);
316
- notes.push(`${name}: ${k} now points at the managed ${engine} — it named the compose service "${composeName}", which Fleet runs as "${fleetName}" with a password it generates.`);
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
- * The connection URL Fleet will inject for a managed database.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",