@yadurajfleetos/cli 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -16,7 +16,7 @@ npx @yadurajfleetos/cli --help
16
16
 
17
17
  ### Build and Install from Source
18
18
  ```bash
19
- git clone https://github.com/YadurajManu/BhaiMerra.git fleet-os
19
+ git clone https://github.com/YadurajManu/fleet-os.git fleet-os
20
20
  cd fleet-os/cli
21
21
  npm install
22
22
  npm run build
package/dist/api.js CHANGED
@@ -89,13 +89,32 @@ export async function requireFleet(explicit) {
89
89
  if (explicit)
90
90
  return explicit;
91
91
  const profile = await loadProfile();
92
- if (profile.fleetId)
93
- return profile.fleetId;
92
+ // A cached id is a hint, not a fact. It survives the fleet being deleted, the
93
+ // control plane being rebuilt, and signing in as somebody else — and the
94
+ // failure was a bare "Fleet not found" on every command, which names neither
95
+ // the cache nor the way out. Verify it, and fall through when it is stale.
96
+ if (profile.fleetId) {
97
+ try {
98
+ await request('GET', `/fleets/${profile.fleetId}`);
99
+ return profile.fleetId;
100
+ }
101
+ catch (err) {
102
+ if (!(err instanceof CliError) || err.exitCode !== EXIT.failure)
103
+ throw err;
104
+ await saveProfile({ ...profile, fleetId: undefined, fleetName: undefined });
105
+ }
106
+ }
94
107
  const { body } = await request('GET', '/fleets');
95
- if (body.fleets.length === 1)
108
+ if (body.fleets.length === 1) {
109
+ // Remember it, so the next command does not pay for this lookup again.
110
+ await saveProfile({ ...profile, fleetId: body.fleets[0].id, fleetName: body.fleets[0].name });
96
111
  return body.fleets[0].id;
97
- if (!body.fleets.length)
98
- throw new CliError('You have no fleets yet.', EXIT.usage);
112
+ }
113
+ if (!body.fleets.length) {
114
+ throw new CliError('This account owns no fleets on ' + (profile.api ?? 'this control plane') + '.\n' +
115
+ ' If you expected one, you may be signed in as the wrong account — check with `fleet auth whoami`,\n' +
116
+ ' or create a fleet in the dashboard and run `fleet use <name>`.', EXIT.usage);
117
+ }
99
118
  throw new CliError(`You are in several fleets. Pass --fleet <id> or run \`fleet use <name>\`:\n` +
100
119
  body.fleets.map((f) => ` ${f.name} ${f.id}`).join('\n'), EXIT.usage);
101
120
  }
@@ -232,8 +232,58 @@ export const authCommand = {
232
232
  ]));
233
233
  return;
234
234
  }
235
+ /**
236
+ * Ask for a reset link. The control plane answers 204 for every address,
237
+ * known or not, so this command cannot be used to discover whether an
238
+ * account exists either - and says so, rather than implying the mail is
239
+ * definitely on its way.
240
+ */
241
+ case 'forgot': {
242
+ if (!profile.api) {
243
+ profile.api = validApi(await requiredPrompt('control plane URL', { hint: 'e.g. https://fleetapi.example.com' }));
244
+ }
245
+ const email = typeof flags.email === 'string' ? flags.email : await requiredPrompt('email');
246
+ await task('requesting a reset link', async () => {
247
+ // auth:false - you cannot be signed in when you have forgotten the password.
248
+ await request('POST', '/auth/forgot', { profile, body: { email }, auth: false });
249
+ });
250
+ console.log(`\n${glyph.ok} If an account exists for ${c.bold(email)}, a reset link is on its way.`);
251
+ console.log(c.dim(' The link works once and expires in 30 minutes.'));
252
+ console.log(c.dim(`\n fleet auth reset --token <token> finish it here`));
253
+ console.log(c.dim(' or open the link in a browser instead'));
254
+ return;
255
+ }
256
+ /**
257
+ * Finish a reset without a browser. The token comes from the email; the
258
+ * new password is prompted for rather than passed as a flag, because a
259
+ * flag lands in shell history.
260
+ */
261
+ case 'reset': {
262
+ if (!profile.api) {
263
+ profile.api = validApi(await requiredPrompt('control plane URL'));
264
+ }
265
+ const token = typeof flags.token === 'string'
266
+ ? flags.token
267
+ : await requiredPrompt('reset token', {
268
+ hint: 'the token from the reset email, or the token= part of the link',
269
+ });
270
+ const password = await requiredPrompt('new password', { silent: true });
271
+ if (password.length < 12) {
272
+ throw new CliError('Password must be at least 12 characters.', EXIT.usage);
273
+ }
274
+ const again = await requiredPrompt('confirm password', { silent: true });
275
+ if (password !== again)
276
+ throw new CliError('Those did not match.', EXIT.usage);
277
+ await task('setting your new password', async () => {
278
+ await request('POST', '/auth/reset', { profile, body: { token, password }, auth: false });
279
+ });
280
+ console.log(`\n${glyph.ok} ${c.green('password changed')}`);
281
+ console.log(c.dim(' Every other session was signed out.'));
282
+ console.log(c.dim(`\n fleet auth login sign in again`));
283
+ return;
284
+ }
235
285
  default:
236
- throw new CliError('usage: fleet auth login|logout|whoami', EXIT.usage);
286
+ throw new CliError('usage: fleet auth login|logout|whoami|forgot|reset', EXIT.usage);
237
287
  }
238
288
  },
239
289
  };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * fleet backup — take a copy of a service's volume, and list what exists.
3
+ *
4
+ * A volume is the one thing Fleet cannot reproduce. Everything else here is
5
+ * derived: an image rebuilds from a commit, a container recreates from a
6
+ * manifest. The bytes in a database's data directory live on exactly one disk,
7
+ * and until now there was no way to get a copy of them off it.
8
+ */
9
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
10
+ import { c, table, relativeTime } from '../render.js';
11
+ import { glyph, task } from '../ui.js';
12
+ export function humanBytes(n) {
13
+ if (n < 1024)
14
+ return `${n} B`;
15
+ const units = ['KB', 'MB', 'GB', 'TB'];
16
+ let value = n / 1024;
17
+ let i = 0;
18
+ while (value >= 1024 && i < units.length - 1) {
19
+ value /= 1024;
20
+ i++;
21
+ }
22
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[i]}`;
23
+ }
24
+ async function resolveService(fleetId, name) {
25
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
26
+ const match = body.services.find((s) => s.name === name || s.id === name);
27
+ if (!match) {
28
+ throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
29
+ }
30
+ return match;
31
+ }
32
+ const STATUS_TONE = {
33
+ complete: c.green,
34
+ running: c.yellow,
35
+ pending: c.dim,
36
+ failed: c.red,
37
+ };
38
+ export const backupCommand = {
39
+ async run(args, flags) {
40
+ const [name] = args;
41
+ if (!name)
42
+ throw new CliError('usage: fleet backup <service>', EXIT.usage);
43
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
44
+ const service = await resolveService(fleetId, name);
45
+ const created = await task(`asking ${c.bold(service.name)}'s node for a copy of its volume`, async () => (await request('POST', `/fleets/${fleetId}/services/${service.id}/backups`, { body: {} })).body.backup, { done: (b) => `queued backup of ${b.volumeRef}` });
46
+ console.log(`\n${glyph.ok} ${c.green('queued')} ${c.bold(created.id.slice(0, 8))}`);
47
+ console.log(c.dim(' The node holding the volume performs it on its next poll — a large volume takes a while.'));
48
+ console.log(c.dim(`\n fleet backups ${service.name} watch it finish`));
49
+ },
50
+ };
51
+ export const backupsCommand = {
52
+ async run(args, flags) {
53
+ const [name] = args;
54
+ if (!name)
55
+ throw new CliError('usage: fleet backups <service>', EXIT.usage);
56
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
57
+ const service = await resolveService(fleetId, name);
58
+ const { body } = await request('GET', `/fleets/${fleetId}/services/${service.id}/backups`);
59
+ if (flags.json)
60
+ return console.log(JSON.stringify(body.backups, null, 2));
61
+ if (!body.backups.length) {
62
+ if (!service.persistentVolume) {
63
+ console.log(`"${service.name}" has no volume, so there is nothing to back up.`);
64
+ console.log(c.dim(' Its image and manifest already describe everything it holds.'));
65
+ return;
66
+ }
67
+ console.log(`No backups of ${c.bold(service.name)} yet.`);
68
+ console.log(c.dim(` take one with \`fleet backup ${service.name}\``));
69
+ return;
70
+ }
71
+ console.log(table(['when', 'status', 'size', 'source', 'id'], body.backups.map((b) => [
72
+ relativeTime(b.createdAt),
73
+ STATUS_TONE[b.status](b.status),
74
+ b.sizeBytes ? humanBytes(b.sizeBytes) : c.dim('—'),
75
+ b.scheduled ? c.dim('scheduled') : c.dim('manual'),
76
+ c.dim(b.id.slice(0, 8)),
77
+ ])));
78
+ // Failures are the half people come here for, and a table cell is too
79
+ // narrow to say anything useful about one.
80
+ const failed = body.backups.filter((b) => b.status === 'failed' && b.failureReason);
81
+ for (const b of failed.slice(0, 3)) {
82
+ console.log(`\n${glyph.warn} ${c.yellow(b.id.slice(0, 8))} ${b.failureReason.split('\n')[0].slice(0, 160)}`);
83
+ }
84
+ const complete = body.backups.filter((b) => b.status === 'complete');
85
+ if (complete.length) {
86
+ const total = complete.reduce((sum, b) => sum + (b.sizeBytes ?? 0), 0);
87
+ console.log(c.dim(`\n ${complete.length} complete, ${humanBytes(total)} stored.`));
88
+ }
89
+ },
90
+ };
91
+ export const restoreCommand = {
92
+ async run(args, flags) {
93
+ const [name, which] = args;
94
+ if (!name)
95
+ throw new CliError('usage: fleet restore <service> [backup-id]', EXIT.usage);
96
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
97
+ const service = await resolveService(fleetId, name);
98
+ const { body } = await request('GET', `/fleets/${fleetId}/services/${service.id}/backups`);
99
+ const complete = body.backups.filter((b) => b.status === 'complete');
100
+ if (!complete.length) {
101
+ throw new CliError(`No completed backup of "${service.name}" to restore. \`fleet backups ${service.name}\` shows what exists.`, EXIT.usage);
102
+ }
103
+ // Named by prefix, or the most recent — which is what "restore it" almost
104
+ // always means, and typing a uuid from a table is nobody's idea of a good
105
+ // recovery experience.
106
+ const target = which
107
+ ? complete.find((b) => b.id === which || b.id.startsWith(which))
108
+ : complete[0];
109
+ if (!target) {
110
+ throw new CliError(`No completed backup of "${service.name}" starting with "${which}".`, EXIT.usage);
111
+ }
112
+ console.log(`\n Restoring ${c.bold(service.name)} from ${c.bold(target.id.slice(0, 8))}` +
113
+ ` ${c.dim(`(${relativeTime(target.createdAt)}, ${target.sizeBytes ? humanBytes(target.sizeBytes) : 'unknown size'})`)}`);
114
+ console.log(c.dim(' The archive is written into the volume, over whatever is there now.\n' +
115
+ ' The service must be stopped: writing a data directory underneath a\n' +
116
+ ' running process corrupts it, so this is refused while it serves.\n'));
117
+ const started = await task('queueing the restore', async () => (await request('POST', `/fleets/${fleetId}/backups/${target.id}/restore`, { body: {} })).body.restore, { done: () => 'queued' });
118
+ console.log(`\n${glyph.ok} ${c.green('queued')} ${c.bold(started.id.slice(0, 8))}`);
119
+ console.log(c.dim(' The node writes it into the volume on its next poll.'));
120
+ console.log(c.dim(`\n fleet deploy ${service.name} bring it back up once the restore lands`));
121
+ },
122
+ };
@@ -3,12 +3,48 @@ 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
+ * A build failure carries the whole buildx transcript. One summary line
8
+ * belongs in a health report; `fleet deployments` is where the rest lives.
9
+ */
10
+ function firstLine(text) {
11
+ const line = text.split('\n').find((l) => l.trim()) ?? text;
12
+ return line.length > 160 ? `${line.slice(0, 157)}…` : line;
13
+ }
14
+ /**
15
+ * Statuses an edge returns when it could not reach the origin at all.
16
+ * Cloudflare's 52x range and 530 mean the tunnel is down; 502/503/504 mean the
17
+ * same thing from any reverse proxy.
18
+ */
19
+ const ORIGIN_UNREACHABLE = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
20
+ /**
21
+ * Is the service reachable through the ingress?
22
+ *
23
+ * That question is not "did it return 200". An API with no route at `/` answers
24
+ * 404, and a 404 from the *application* is proof the whole chain works —
25
+ * ingress, tunnel, agent, container. Reporting that as a failure sends people
26
+ * to check DNS and node health when nothing is wrong, which is exactly what it
27
+ * did for an API whose /health returned 200 the whole time.
28
+ *
29
+ * What does indicate a broken path is the edge answering on the origin's
30
+ * behalf, or nothing answering at all.
31
+ */
6
32
  async function reach(url) {
7
33
  try {
8
34
  const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(8_000) });
9
- return response.status >= 200 && response.status < 400
10
- ? { ok: true, detail: `HTTPS answered ${response.status}` }
11
- : { ok: false, detail: `HTTPS answered ${response.status}` };
35
+ const status = response.status;
36
+ if (ORIGIN_UNREACHABLE.has(status)) {
37
+ return { ok: false, detail: `HTTPS answered ${status} — the edge could not reach the container` };
38
+ }
39
+ if (status >= 200 && status < 400) {
40
+ return { ok: true, detail: `HTTPS answered ${status}` };
41
+ }
42
+ if (status < 500) {
43
+ // The application answered. It has an opinion about the request, which
44
+ // means everything in front of it is working.
45
+ return { ok: true, detail: `HTTPS answered ${status} — reachable; the app has no route there` };
46
+ }
47
+ return { ok: false, detail: `HTTPS answered ${status} — reachable, but the app is erroring` };
12
48
  }
13
49
  catch (error) {
14
50
  return { ok: false, detail: error instanceof Error ? error.message : String(error) };
@@ -93,12 +129,24 @@ export const doctorCommand = {
93
129
  checks.push({ state: 'fail', label: `reconcile ${node.name}`, detail: runtime.lastReconcileError, remedy: 'Run `fleet logs <service> --follow` and inspect the deployment history.' });
94
130
  }
95
131
  }
96
- const failed = result.deploymentHistory.flatMap(({ service, deployments }) => deployments.filter((deployment) => deployment.status === 'failed' || deployment.failureReason).slice(0, 1).map((deployment) => ({ service: service.name, deployment })));
132
+ // Only the *current* deployment of each service can be a current problem.
133
+ // Scanning the whole history meant a service that failed once and then
134
+ // deployed successfully still reported the old failure, so a healthy fleet
135
+ // showed a wall of buildx output from a build that had since been redone.
136
+ const failed = result.deploymentHistory.flatMap(({ service, deployments }) => {
137
+ const latest = deployments[0];
138
+ if (!latest)
139
+ return [];
140
+ const isFailure = latest.status === 'failed' || Boolean(latest.failureReason);
141
+ return isFailure ? [{ service: service.name, deployment: latest }] : [];
142
+ });
97
143
  checks.push(failed.length
98
144
  ? {
99
145
  state: 'fail',
100
146
  label: 'deployments',
101
- detail: failed.map(({ service, deployment }) => `${service}: ${deployment.failureReason ?? deployment.status}`).join('; '),
147
+ detail: failed
148
+ .map(({ service, deployment }) => `${service}: ${firstLine(deployment.failureReason ?? deployment.status)}`)
149
+ .join('; '),
102
150
  remedy: 'Run `fleet deployments <service>` for history and `fleet logs <service> --follow` for the current container tail.',
103
151
  }
104
152
  : { state: 'ok', label: 'deployments', detail: result.services.length ? 'No recorded deployment failures.' : 'No services declared yet.' });
@@ -9,6 +9,7 @@ import { openCommand } from './open.js';
9
9
  import { downCommand } from './down.js';
10
10
  import { unpairCommand, agentCommand } from './unpair.js';
11
11
  import { secretsCommand } from './secrets.js';
12
+ import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
12
13
  import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
13
14
  export const commands = {
14
15
  up: upCommand,
@@ -35,6 +36,9 @@ export const commands = {
35
36
  events: eventsCommand,
36
37
  alerts: alertsCommand,
37
38
  secrets: secretsCommand,
39
+ backup: backupCommand,
40
+ backups: backupsCommand,
41
+ restore: restoreCommand,
38
42
  unpair: unpairCommand,
39
43
  agent: agentCommand,
40
44
  };
package/dist/index.js CHANGED
@@ -52,12 +52,16 @@ const GROUPS = [
52
52
  ['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
53
53
  ['secrets import [.env]', 'Store the secrets fleet.yaml declares, read from a .env file'],
54
54
  ['secrets rm <KEY>', 'Remove a stored credential'],
55
+ ['backup <service>', "Copy a service's volume off the node holding it"],
56
+ ['backups <service>', 'List backups, newest first'],
57
+ ['restore <service> [id]', 'Write a backup back into the volume; service must be stopped'],
55
58
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
56
59
  ['nodes uncordon <name>', 'Allow scheduling again'],
57
60
  ['nodes rm <name>', 'Revoke and remove a node'],
58
61
  ['unpair', 'Remove this machine from its fleet, run on the machine'],
59
62
  ['alerts', 'List, add, and test alert rules'],
60
63
  ['auth login|logout|whoami', 'Sign in to a control plane'],
64
+ ['auth forgot|reset', 'Recover an account you are locked out of'],
61
65
  ],
62
66
  ],
63
67
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",