@yadurajfleetos/cli 0.6.0 → 0.7.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.
@@ -10,7 +10,7 @@ import { downCommand } from './down.js';
10
10
  import { unpairCommand, agentCommand } from './unpair.js';
11
11
  import { secretsCommand } from './secrets.js';
12
12
  import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
13
- import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
13
+ import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
14
14
  export const commands = {
15
15
  up: upCommand,
16
16
  open: openCommand,
@@ -22,6 +22,7 @@ export const commands = {
22
22
  doctor: doctorCommand,
23
23
  init: initCommand,
24
24
  import: importCommand,
25
+ explain: explainCommand,
25
26
  validate: validateCommand,
26
27
  apply: applyCommand,
27
28
  status: statusCommand,
@@ -418,6 +418,15 @@ export const initCommand = {
418
418
  if (found.layout)
419
419
  console.log(c.dim(` ${found.layout}`));
420
420
  for (const s of found.services) {
421
+ // A manifest saying `build: ./web` against a directory with no
422
+ // Dockerfile is a deploy that fails at the first step. detect() has
423
+ // already worked out what the file should contain; writing it is the
424
+ // difference between a manifest and something that runs.
425
+ if (s.detection.dockerfile && !s.detection.hasDockerfile) {
426
+ const target = join(process.cwd(), s.dir, 'Dockerfile');
427
+ await writeFile(target, s.detection.dockerfile);
428
+ console.log(`${c.green('created')} ${s.dir}/Dockerfile ${c.dim(`(${s.detection.label}, port ${s.detection.port})`)}`);
429
+ }
421
430
  console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
422
431
  }
423
432
  for (const db of found.databases) {
@@ -561,3 +570,78 @@ export const importCommand = {
561
570
  }
562
571
  },
563
572
  };
573
+ /**
574
+ * Ask why a deployment failed.
575
+ *
576
+ * The wall of Docker output is still there underneath — this adds a reading of
577
+ * it, it does not replace the evidence. Printed at the moment of failure is the
578
+ * point; `fleet explain` exists for when you have come back to it later.
579
+ */
580
+ export const explainCommand = {
581
+ async run(args, flags) {
582
+ const id = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
583
+ let deploymentId = typeof flags.deploy === 'string' ? flags.deploy : '';
584
+ if (!deploymentId) {
585
+ const name = args[0];
586
+ if (!name)
587
+ throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
588
+ const service = await findService(id, name);
589
+ const { body } = await request('GET', `/services/${service.id}/deployments`);
590
+ // The most recent failure, which is what someone asking "why did that
591
+ // fail" means — not the most recent deployment, which may since have
592
+ // succeeded.
593
+ const failed = body.deployments.find((d) => d.status === 'failed');
594
+ if (!failed) {
595
+ throw new CliError(`"${name}" has no failed deployment to explain.`, EXIT.usage);
596
+ }
597
+ deploymentId = failed.id;
598
+ }
599
+ const out = await task('reading the failure', () => request('POST', `/fleets/${id}/deployments/${deploymentId}/explain`));
600
+ printExplanation(out.body);
601
+ },
602
+ };
603
+ /** Shared by `fleet explain` and by a deploy that just failed. */
604
+ export function printExplanation(r) {
605
+ if (r.status === 'ok') {
606
+ console.log();
607
+ for (const line of wrapText(r.summary ?? '', 76))
608
+ console.log(` ${line}`);
609
+ if (r.steps?.length) {
610
+ console.log();
611
+ r.steps.forEach((step, i) => console.log(` ${c.dim(`${i + 1}.`)} ${step}`));
612
+ }
613
+ const seen = (r.hits ?? 1) > 1 ? `seen ${r.hits}× before` : 'first time this failure has been seen';
614
+ console.log(`\n ${c.dim(`${r.cached ? 'cached' : 'explained'} · ${seen}`)}`);
615
+ if (r.usage)
616
+ console.log(` ${c.dim(`${r.usage.used}/${r.usage.limit} explanations used today`)}`);
617
+ return;
618
+ }
619
+ if (r.status === 'rate_limited') {
620
+ const hours = Math.ceil((r.resetsInSec ?? 0) / 3600);
621
+ console.log(`\n ${c.yellow('daily limit reached')} ${c.dim(`— ${r.limit} explanations a day, resets in ${hours}h.`)}`);
622
+ console.log(c.dim(' Answers already generated are still free to read.'));
623
+ return;
624
+ }
625
+ // disabled / not_worth_it / failed all carry a reason worth printing as-is.
626
+ if (r.reason)
627
+ console.log(`\n ${c.dim(r.reason)}`);
628
+ }
629
+ /** Wrap to a width, on spaces, so a paragraph reads in a terminal. */
630
+ function wrapText(text, width) {
631
+ const out = [];
632
+ for (const paragraph of text.split('\n')) {
633
+ let line = '';
634
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
635
+ if (line && line.length + word.length + 1 > width) {
636
+ out.push(line);
637
+ line = word;
638
+ }
639
+ else {
640
+ line = line ? `${line} ${word}` : word;
641
+ }
642
+ }
643
+ if (line)
644
+ out.push(line);
645
+ }
646
+ return out;
647
+ }
@@ -63,6 +63,7 @@ export const upCommand = {
63
63
  // deploying one service of it and leaving the rest was never what anybody
64
64
  // wanted — it just meant typing the command again in the right order.
65
65
  const targets = args[0] ? [args[0]] : deployOrder(planned);
66
+ const isDatabase = new Set(planned.filter((p) => p.database).map((p) => p.name));
66
67
  if (!targets.length) {
67
68
  throw new CliError('The manifest declares no services to deploy.', EXIT.usage);
68
69
  }
@@ -74,12 +75,27 @@ export const upCommand = {
74
75
  }
75
76
  return service;
76
77
  });
77
- if (resolved.length > 1) {
78
- console.log(`\n ${c.dim('deploying')} ${resolved.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
78
+ // A database that is already serving is left alone.
79
+ //
80
+ // Redeploying one replaces a running container for no reason, and every
81
+ // service that talks to it loses its connections while it restarts. It is
82
+ // in the plan so that a database which is *not* running comes back — which
83
+ // is the case that used to need `fleet up db` by name — not so that every
84
+ // deploy of the stack restarts the database underneath it. Naming it
85
+ // explicitly still redeploys it.
86
+ const skipped = args[0]
87
+ ? []
88
+ : resolved.filter((s) => isDatabase.has(s.name) && s.current?.status === 'running');
89
+ const toDeploy = resolved.filter((s) => !skipped.includes(s));
90
+ for (const s of skipped) {
91
+ console.log(` ${c.dim('already running')} ${c.bold(s.name)}`);
92
+ }
93
+ if (toDeploy.length > 1) {
94
+ console.log(`\n ${c.dim('deploying')} ${toDeploy.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
79
95
  }
80
96
  const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
81
97
  const deployed = [];
82
- for (const service of resolved) {
98
+ for (const service of toDeploy) {
83
99
  const url = await deployOne(service, {
84
100
  fleetId,
85
101
  gitSha,
@@ -148,11 +164,18 @@ async function deployOne(service, opts) {
148
164
  if (opts.wait) {
149
165
  await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
150
166
  s.hints([
167
+ 'the image is built on the control plane, for every architecture in the fleet',
168
+ 'building for a different architecture than the control plane is emulated, and slow',
151
169
  'the agent picks up desired state on its next poll',
152
170
  "a cold image pull takes as long as the node's uplink does",
153
171
  'a service with a health check goes running once it passes, not before',
154
172
  ]);
155
- const deadline = Date.now() + 180_000;
173
+ // Long, because this now covers the build as well as the rollout.
174
+ // The control plane answers as soon as a node is chosen and keeps
175
+ // building afterwards, so this is the window in which a multi-arch
176
+ // build has to finish - and an arm64 build emulated on an amd64 host
177
+ // is measured in tens of minutes, not minutes.
178
+ const deadline = Date.now() + 45 * 60_000;
156
179
  while (Date.now() < deadline) {
157
180
  const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
158
181
  const current = body.services.find((s) => s.id === service.id)?.current;
package/dist/compose.js CHANGED
@@ -4,6 +4,14 @@ import { safeDatabaseName } from './dbnames.js';
4
4
  const ENGINE_IMAGES = {
5
5
  postgres: 'postgres',
6
6
  postgis: 'postgres',
7
+ // Postgres by another name. pgvector in particular is what anything doing
8
+ // embeddings runs, and leaving it unrecognised turned a managed database
9
+ // back into a container the reader has to look after themselves.
10
+ pgvector: 'postgres',
11
+ 'ankane/pgvector': 'postgres',
12
+ 'pgvector/pgvector': 'postgres',
13
+ 'supabase/postgres': 'postgres',
14
+ 'bitnami/postgresql': 'postgres',
7
15
  'timescale/timescaledb': 'postgres',
8
16
  mysql: 'mysql',
9
17
  mariadb: 'mariadb',
package/dist/detect.js CHANGED
@@ -135,7 +135,7 @@ export async function detect(cwd = process.cwd()) {
135
135
  framework: 'unknown',
136
136
  label: 'existing Dockerfile',
137
137
  port,
138
- healthPath: '/',
138
+ healthPath: null,
139
139
  dockerfile: null,
140
140
  hasDockerfile: true,
141
141
  };
@@ -175,7 +175,7 @@ export async function detect(cwd = process.cwd()) {
175
175
  framework: 'node',
176
176
  label: 'Node.js API',
177
177
  port: 3000,
178
- healthPath: '/health',
178
+ healthPath: null,
179
179
  dockerfile: NODE_DOCKERFILE,
180
180
  hasDockerfile: false,
181
181
  };
@@ -209,7 +209,7 @@ export async function detect(cwd = process.cwd()) {
209
209
  framework: 'python',
210
210
  label: isFastapi ? 'Python (FastAPI)' : isDjango ? 'Python (Django)' : isFlask ? 'Python (Flask)' : 'Python',
211
211
  port: 8000,
212
- healthPath: '/health',
212
+ healthPath: null,
213
213
  dockerfile: pythonDF,
214
214
  hasDockerfile: false,
215
215
  };
@@ -223,7 +223,7 @@ export async function detect(cwd = process.cwd()) {
223
223
  framework: 'go',
224
224
  label: 'Go',
225
225
  port: 8080,
226
- healthPath: '/healthz',
226
+ healthPath: null,
227
227
  dockerfile: GO_DOCKERFILE(moduleName),
228
228
  hasDockerfile: false,
229
229
  };
@@ -234,7 +234,7 @@ export async function detect(cwd = process.cwd()) {
234
234
  framework: 'rust',
235
235
  label: 'Rust',
236
236
  port: 8080,
237
- healthPath: '/healthz',
237
+ healthPath: null,
238
238
  dockerfile: RUST_DOCKERFILE,
239
239
  hasDockerfile: false,
240
240
  };
@@ -255,7 +255,7 @@ export async function detect(cwd = process.cwd()) {
255
255
  framework: 'unknown',
256
256
  label: 'unknown project',
257
257
  port: 3000,
258
- healthPath: '/',
258
+ healthPath: null,
259
259
  dockerfile: null,
260
260
  hasDockerfile: false,
261
261
  };
package/dist/discover.js CHANGED
@@ -180,7 +180,18 @@ async function expand(root, globs) {
180
180
  }
181
181
  return [...dirs];
182
182
  }
183
- /** Conventional layouts, for repositories that declare no workspaces. */
183
+ /**
184
+ * Candidate directories for a repository that declares no workspaces.
185
+ *
186
+ * Two shapes, because both are common and neither is declared anywhere. The
187
+ * first is a parent holding many packages - apps/, services/, packages/. The
188
+ * second is simply a few directories at the top level: backend/ beside
189
+ * landing_page/, or api/ beside web/. Looking only for the first meant a
190
+ * perfectly ordinary two-app repository was read as one unrecognised project.
191
+ *
192
+ * Only immediate children are considered. Walking deeper finds vendored
193
+ * copies, fixtures and examples, and proposes deploying them.
194
+ */
184
195
  async function conventionalDirs(root) {
185
196
  const dirs = [];
186
197
  for (const parent of ['apps', 'services', 'packages']) {
@@ -194,6 +205,18 @@ async function conventionalDirs(root) {
194
205
  /* not this layout */
195
206
  }
196
207
  }
208
+ if (dirs.length)
209
+ return dirs;
210
+ try {
211
+ for (const entry of await readdir(root, { withFileTypes: true })) {
212
+ if (!entry.isDirectory() || IGNORED.has(entry.name) || entry.name.startsWith('.'))
213
+ continue;
214
+ dirs.push(entry.name);
215
+ }
216
+ }
217
+ catch {
218
+ /* unreadable root is the caller's problem */
219
+ }
197
220
  return dirs;
198
221
  }
199
222
  /**
@@ -235,7 +258,7 @@ export async function discover(root = process.cwd()) {
235
258
  else {
236
259
  const conventional = await conventionalDirs(root);
237
260
  if (conventional.length) {
238
- layout = 'apps/ and services/ directories';
261
+ layout = 'directories that look like services';
239
262
  candidates = conventional;
240
263
  notes.push(`no workspace file, but ${conventional.length} directories look like services`);
241
264
  }
@@ -310,10 +333,25 @@ export function manifestFromDiscovery(d, opts = {}) {
310
333
  lines.push(` ${s.name}:`);
311
334
  lines.push(` build: ${s.dir}`);
312
335
  lines.push(' placement: flexible');
313
- if (s.detection.port !== 80)
314
- lines.push(` container_port: ${s.detection.port}`);
336
+ // Always written, never omitted as "the default". Leaving it out does not
337
+ // mean 80: an unset container port becomes 8080 on the node, so an nginx
338
+ // image serving 80 got its traffic forwarded to a closed port and answered
339
+ // 502 while every status in the system said running.
340
+ lines.push(` container_port: ${s.detection.port}`);
315
341
  lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
316
- lines.push(` health: { path: ${s.detection.healthPath} }`);
342
+ // Only where the framework genuinely answers at the path. A guessed one
343
+ // that is wrong does not fall back to "no check" — it fails for ever and
344
+ // the deploy never leaves "deploying", while the service runs correctly.
345
+ if (s.detection.healthPath) {
346
+ lines.push(` health: { path: ${s.detection.healthPath} }`);
347
+ }
348
+ else {
349
+ lines.push(' # No health check: container state decides whether this');
350
+ lines.push(' # is up. Add one once you know a path that returns 2xx —');
351
+ lines.push(' # health: { path: /healthz }');
352
+ lines.push(' # Note the probe runs from the node, not inside the');
353
+ lines.push(' # container, so the image needs nothing installed for it.');
354
+ }
317
355
  if (s.gpu) {
318
356
  lines.push(' gpu: true');
319
357
  questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ const GROUPS = [
25
25
  ['doctor', 'Check the control plane, nodes, services, ingress, and GitHub'],
26
26
  ['apply [file]', 'Apply a fleet.yaml to the fleet'],
27
27
  ['deploy <service>', 'Build, schedule, and roll out'],
28
+ ['explain <service>', 'Read a failed deploy and say what to do about it'],
28
29
  ],
29
30
  ],
30
31
  [
package/dist/plan.js CHANGED
@@ -33,10 +33,7 @@ export function projectNameFor(dir) {
33
33
  */
34
34
  export function planFromManifest(source) {
35
35
  const doc = parseYaml(source);
36
- const services = doc?.services;
37
- if (!services || typeof services !== 'object')
38
- return [];
39
- return Object.entries(services).map(([name, raw]) => {
36
+ const services = Object.entries(doc?.services ?? {}).map(([name, raw]) => {
40
37
  const body = (raw ?? {});
41
38
  return {
42
39
  name,
@@ -44,6 +41,21 @@ export function planFromManifest(source) {
44
41
  affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
45
42
  };
46
43
  });
44
+ // Databases are deployed too.
45
+ //
46
+ // They used to be left out entirely, on the reasoning that applying the
47
+ // manifest creates them. It does — once. After that a database whose
48
+ // deployment has failed is never revived, because nothing puts it back in
49
+ // the plan: `fleet up` walked the whole stack, skipped it, and reported
50
+ // success while the database it depended on stayed dead and every service
51
+ // using it crash-looped. Recovering needed `fleet up db` by name, which is
52
+ // knowledge the command should not require.
53
+ const databases = Object.keys(doc?.databases ?? {}).map((name) => ({
54
+ name,
55
+ affinity: [],
56
+ database: true,
57
+ }));
58
+ return [...databases, ...services];
47
59
  }
48
60
  /**
49
61
  * Which secrets the manifest says it needs, and which services want each.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",