@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 +1 -1
- package/dist/api.js +24 -5
- package/dist/commands/auth.js +51 -1
- package/dist/commands/backups.js +122 -0
- package/dist/commands/doctor.js +53 -5
- package/dist/commands/index.js +4 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
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/
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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.' });
|
package/dist/commands/index.js
CHANGED
|
@@ -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
|
];
|