@yadurajfleetos/cli 0.1.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/README.md +86 -0
- package/dist/api.js +97 -0
- package/dist/args.js +27 -0
- package/dist/commands/alerts.js +53 -0
- package/dist/commands/auth.js +101 -0
- package/dist/commands/config.js +51 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/down.js +50 -0
- package/dist/commands/index.js +34 -0
- package/dist/commands/nodes.js +76 -0
- package/dist/commands/open.js +65 -0
- package/dist/commands/services.js +365 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/up.js +110 -0
- package/dist/config.js +35 -0
- package/dist/detect.js +277 -0
- package/dist/index.js +136 -0
- package/dist/mark.js +102 -0
- package/dist/render.js +159 -0
- package/dist/ui.js +210 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Fleet OS CLI (`fleet`)
|
|
2
|
+
|
|
3
|
+
The command-line interface for **Fleet OS** — git-push deploys onto hardware you already own.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Via npm (Global)
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @yadurajfleetos/cli
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Run Directly via npx
|
|
13
|
+
```bash
|
|
14
|
+
npx @yadurajfleetos/cli --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Build and Install from Source
|
|
18
|
+
```bash
|
|
19
|
+
git clone https://github.com/YadurajManu/BhaiMerra.git fleet-os
|
|
20
|
+
cd fleet-os/cli
|
|
21
|
+
npm install
|
|
22
|
+
npm run build
|
|
23
|
+
npm link
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### 1. Sign In
|
|
31
|
+
```bash
|
|
32
|
+
# Hosted control plane (default: https://fleetapi.plastikworld.xyz)
|
|
33
|
+
fleet auth login
|
|
34
|
+
|
|
35
|
+
# Self-hosted control plane
|
|
36
|
+
fleet auth login --api https://fleetapi.example.com
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Pair a Machine
|
|
40
|
+
```bash
|
|
41
|
+
fleet nodes pair
|
|
42
|
+
```
|
|
43
|
+
Run the generated `curl -fsSL ... | sh` command on the machine you want to add to your fleet.
|
|
44
|
+
|
|
45
|
+
### 3. Check Fleet Status & Health
|
|
46
|
+
```bash
|
|
47
|
+
fleet status
|
|
48
|
+
fleet doctor
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 4. Deploy a Service
|
|
52
|
+
```bash
|
|
53
|
+
# Validate manifest
|
|
54
|
+
fleet validate
|
|
55
|
+
|
|
56
|
+
# Apply services to fleet
|
|
57
|
+
fleet apply
|
|
58
|
+
|
|
59
|
+
# Plan and deploy a service
|
|
60
|
+
fleet deploy web
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Command Reference
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| :--- | :--- |
|
|
69
|
+
| `fleet auth login` | Sign in and save secure local session |
|
|
70
|
+
| `fleet config show` | Show active control plane and selected fleet |
|
|
71
|
+
| `fleet use <fleet>` | Choose default fleet |
|
|
72
|
+
| `fleet nodes pair` | Generate single-use pairing token for a new node |
|
|
73
|
+
| `fleet nodes` | List nodes, status, and resource usage |
|
|
74
|
+
| `fleet doctor` | Diagnostic health check across cluster |
|
|
75
|
+
| `fleet apply [file]` | Apply `fleet.yaml` manifest |
|
|
76
|
+
| `fleet deploy <svc>` | Plan, build, schedule, and roll out a service |
|
|
77
|
+
| `fleet logs <svc> -f` | Follow live container logs |
|
|
78
|
+
| `fleet restart <svc>` | Restart a service |
|
|
79
|
+
| `fleet rollback <svc>` | Roll back to previous deployment |
|
|
80
|
+
| `fleet where <svc>` | Explain scheduler placement and candidate scores |
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT License.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { loadProfile, saveProfile } from './config.js';
|
|
2
|
+
export class CliError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
detail;
|
|
5
|
+
constructor(message, exitCode = 1, detail) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
this.detail = detail;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Exit codes are a contract; scripts branch on them (docs/cli reference). */
|
|
12
|
+
export const EXIT = {
|
|
13
|
+
ok: 0,
|
|
14
|
+
failure: 1,
|
|
15
|
+
usage: 2,
|
|
16
|
+
noEligibleNode: 3,
|
|
17
|
+
healthCheckFailed: 4,
|
|
18
|
+
};
|
|
19
|
+
export async function request(method, path, opts = {}) {
|
|
20
|
+
const profile = opts.profile ?? (await loadProfile());
|
|
21
|
+
if (!profile.api) {
|
|
22
|
+
throw new CliError('No control plane URL is configured. Run `fleet auth login --api https://your-api-host` or set FLEET_API.', EXIT.usage);
|
|
23
|
+
}
|
|
24
|
+
if (opts.auth !== false && !profile.accessToken) {
|
|
25
|
+
throw new CliError('Not signed in. Run `fleet auth login` first.', EXIT.usage);
|
|
26
|
+
}
|
|
27
|
+
const send = async (token) => fetch(profile.api.replace(/\/+$/, '') + path, {
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
31
|
+
...(opts.body ? { 'content-type': 'application/json' } : {}),
|
|
32
|
+
},
|
|
33
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
34
|
+
signal: AbortSignal.timeout(20 * 60_000),
|
|
35
|
+
});
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await send(profile.accessToken);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new CliError(`Could not reach ${profile.api}. Is the control plane running?\n ${String(err)}`, EXIT.failure);
|
|
42
|
+
}
|
|
43
|
+
// Access tokens are short-lived; refresh once and retry rather than making
|
|
44
|
+
// the user log in again mid-command.
|
|
45
|
+
if (res.status === 401) {
|
|
46
|
+
if (profile.refreshToken) {
|
|
47
|
+
try {
|
|
48
|
+
const refreshed = await fetch(profile.api.replace(/\/+$/, '') + '/auth/refresh', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ refreshToken: profile.refreshToken }),
|
|
52
|
+
signal: AbortSignal.timeout(15_000),
|
|
53
|
+
});
|
|
54
|
+
if (refreshed.ok) {
|
|
55
|
+
const tokens = (await refreshed.json());
|
|
56
|
+
await saveProfile({ ...profile, ...tokens });
|
|
57
|
+
res = await send(tokens.accessToken);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Fall through to a deliberate, actionable session-expired message.
|
|
62
|
+
// A network error while refreshing must not turn into a misleading
|
|
63
|
+
// "invalid token" response from the original request.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 401) {
|
|
67
|
+
throw new CliError('Your Fleet session has expired. Run `fleet auth login` to sign in again.', EXIT.usage);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const text = await res.text();
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = JSON.parse(text);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
body = text;
|
|
77
|
+
}
|
|
78
|
+
if (res.status >= 400) {
|
|
79
|
+
const err = body.error;
|
|
80
|
+
throw new CliError(err?.message ?? `Request failed (${res.status})`, err?.code === 'no_eligible_node' ? EXIT.noEligibleNode : EXIT.failure, err?.detail);
|
|
81
|
+
}
|
|
82
|
+
return { status: res.status, body: body };
|
|
83
|
+
}
|
|
84
|
+
export async function requireFleet(explicit) {
|
|
85
|
+
if (explicit)
|
|
86
|
+
return explicit;
|
|
87
|
+
const profile = await loadProfile();
|
|
88
|
+
if (profile.fleetId)
|
|
89
|
+
return profile.fleetId;
|
|
90
|
+
const { body } = await request('GET', '/fleets');
|
|
91
|
+
if (body.fleets.length === 1)
|
|
92
|
+
return body.fleets[0].id;
|
|
93
|
+
if (!body.fleets.length)
|
|
94
|
+
throw new CliError('You have no fleets yet.', EXIT.usage);
|
|
95
|
+
throw new CliError(`You are in several fleets. Pass --fleet <id> or run \`fleet use <name>\`:\n` +
|
|
96
|
+
body.fleets.map((f) => ` ${f.name} ${f.id}`).join('\n'), EXIT.usage);
|
|
97
|
+
}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal argument parser: no dependency, and the flag set is small and
|
|
3
|
+
* stable. Lives apart from the entrypoint so it can be tested without the
|
|
4
|
+
* entrypoint running.
|
|
5
|
+
*/
|
|
6
|
+
export function parseArgs(argv) {
|
|
7
|
+
const positional = [];
|
|
8
|
+
const flags = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (!arg.startsWith('-')) {
|
|
12
|
+
positional.push(arg);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const name = arg.replace(/^--?/, '');
|
|
16
|
+
const next = argv[i + 1];
|
|
17
|
+
// A flag followed by a non-flag takes it as a value; otherwise boolean.
|
|
18
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
19
|
+
flags[name] = next;
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
flags[name] = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { positional, flags };
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
2
|
+
import { c, table } from '../render.js';
|
|
3
|
+
export const alertsCommand = {
|
|
4
|
+
async run(args, flags) {
|
|
5
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
6
|
+
const [sub] = args;
|
|
7
|
+
if (!sub || sub === 'ls' || sub === 'list') {
|
|
8
|
+
const { body } = await request('GET', `/fleets/${fleetId}/alert-rules`);
|
|
9
|
+
if (flags.json)
|
|
10
|
+
return console.log(JSON.stringify(body.rules, null, 2));
|
|
11
|
+
if (!body.rules.length) {
|
|
12
|
+
console.log('No alert rules. Failover will happen silently — add one with `fleet alerts add`.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(table(['channel', 'target', 'events', 'enabled'], body.rules.map((r) => [
|
|
16
|
+
r.channelType,
|
|
17
|
+
r.target,
|
|
18
|
+
r.eventTypes.length ? r.eventTypes.join(', ') : c.dim('everything'),
|
|
19
|
+
r.enabled ? c.green('yes') : c.dim('no'),
|
|
20
|
+
])));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (sub === 'add') {
|
|
24
|
+
const channelType = typeof flags.channel === 'string' ? flags.channel : 'webhook';
|
|
25
|
+
const url = typeof flags.url === 'string' ? flags.url : undefined;
|
|
26
|
+
const to = typeof flags.to === 'string' ? flags.to : undefined;
|
|
27
|
+
const secret = typeof flags.secret === 'string' ? flags.secret : undefined;
|
|
28
|
+
const eventTypes = typeof flags.events === 'string' ? flags.events.split(',').map((s) => s.trim()) : [];
|
|
29
|
+
if (!url && !to) {
|
|
30
|
+
throw new CliError('usage: fleet alerts add --channel webhook|discord|slack --url <url> [--secret <s>]\n' +
|
|
31
|
+
' fleet alerts add --channel email --to you@example.com', EXIT.usage);
|
|
32
|
+
}
|
|
33
|
+
await request('POST', `/fleets/${fleetId}/alert-rules`, {
|
|
34
|
+
body: { channelType, url, to, secret, eventTypes },
|
|
35
|
+
});
|
|
36
|
+
console.log(`${c.green('added')} ${channelType} alert rule`);
|
|
37
|
+
console.log(c.dim(' verify it with `fleet alerts test` before you need it'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'test') {
|
|
41
|
+
const { body } = await request('POST', `/fleets/${fleetId}/alert-rules/test`);
|
|
42
|
+
if (!body.results.length)
|
|
43
|
+
return console.log('no alert rules to test');
|
|
44
|
+
for (const r of body.results) {
|
|
45
|
+
console.log(r.ok ? `${c.green('ok')} ${r.channel}` : `${c.red('fail')} ${r.channel} ${r.error}`);
|
|
46
|
+
}
|
|
47
|
+
if (body.delivered !== body.results.length)
|
|
48
|
+
process.exit(EXIT.failure);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
throw new CliError('usage: fleet alerts [ls|add|test]', EXIT.usage);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { request, CliError, EXIT } from '../api.js';
|
|
3
|
+
import { loadProfile, saveProfile, configLocation } from '../config.js';
|
|
4
|
+
import { c, keyValues } from '../render.js';
|
|
5
|
+
import { banner } from '../mark.js';
|
|
6
|
+
import { glyph, rule, task } from '../ui.js';
|
|
7
|
+
async function prompt(question, silent = false) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
if (!silent) {
|
|
10
|
+
const answer = await rl.question(question);
|
|
11
|
+
rl.close();
|
|
12
|
+
return answer.trim();
|
|
13
|
+
}
|
|
14
|
+
// Passwords must not land in the terminal scrollback or a screen recording.
|
|
15
|
+
const stdout = process.stdout;
|
|
16
|
+
process.stdout.write(question);
|
|
17
|
+
const rlAny = rl;
|
|
18
|
+
rlAny._writeToOutput = () => { };
|
|
19
|
+
const answer = await rl.question('');
|
|
20
|
+
rl.close();
|
|
21
|
+
process.stdout.write('\n');
|
|
22
|
+
void stdout;
|
|
23
|
+
return answer.trim();
|
|
24
|
+
}
|
|
25
|
+
async function requiredPrompt(label, opts = {}) {
|
|
26
|
+
if (opts.hint)
|
|
27
|
+
console.log(c.dim(` ${opts.hint}`));
|
|
28
|
+
const value = await prompt(` ${c.dim(label.padEnd(18))}`, opts.silent);
|
|
29
|
+
if (!value)
|
|
30
|
+
throw new CliError(`${label.trim()} is required.`, EXIT.usage);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function validApi(value) {
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(value);
|
|
36
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
37
|
+
throw new Error('scheme');
|
|
38
|
+
return url.toString().replace(/\/+$/, '');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new CliError('Control plane URL must begin with http:// or https://', EXIT.usage);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const authCommand = {
|
|
45
|
+
async run(args, flags) {
|
|
46
|
+
const [sub] = args;
|
|
47
|
+
const profile = await loadProfile();
|
|
48
|
+
if (typeof flags.api === 'string')
|
|
49
|
+
profile.api = flags.api;
|
|
50
|
+
switch (sub) {
|
|
51
|
+
case 'login': {
|
|
52
|
+
const interactive = !flags.email && !flags.password;
|
|
53
|
+
if (interactive) {
|
|
54
|
+
console.log(banner('secure control-plane sign in'));
|
|
55
|
+
console.log(`\n${rule('sign in')}`);
|
|
56
|
+
}
|
|
57
|
+
if (!profile.api) {
|
|
58
|
+
profile.api = validApi(await requiredPrompt('control plane URL', {
|
|
59
|
+
hint: 'Example: https://fleetapi.yourdomain.com',
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
if (interactive) {
|
|
63
|
+
console.log(`${c.dim(' control plane ')}${c.cyan(profile.api)}`);
|
|
64
|
+
console.log();
|
|
65
|
+
}
|
|
66
|
+
const email = (typeof flags.email === 'string' ? flags.email : '') ||
|
|
67
|
+
(await requiredPrompt('email'));
|
|
68
|
+
const password = (typeof flags.password === 'string' ? flags.password : '') ||
|
|
69
|
+
(await requiredPrompt('password', { silent: true, hint: 'Password is hidden while you type.' }));
|
|
70
|
+
const body = await task('verifying credentials', async () => (await request('POST', '/auth/login', { body: { email, password }, auth: false, profile })).body, { hints: ['the control plane never stores your password in this CLI'] });
|
|
71
|
+
await saveProfile({
|
|
72
|
+
...profile,
|
|
73
|
+
accessToken: body.accessToken,
|
|
74
|
+
refreshToken: body.refreshToken,
|
|
75
|
+
});
|
|
76
|
+
console.log(`\n${glyph.ok} ${c.signal('signed in')} ${body.user.email}`);
|
|
77
|
+
console.log(c.dim(` profile saved to ${configLocation()}`));
|
|
78
|
+
console.log(c.dim(' next: fleet status'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case 'logout': {
|
|
82
|
+
await saveProfile({ api: profile.api });
|
|
83
|
+
console.log('signed out');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case 'whoami': {
|
|
87
|
+
const { body } = await request('GET', '/auth/me', { profile });
|
|
88
|
+
if (flags.json)
|
|
89
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
90
|
+
console.log(keyValues([
|
|
91
|
+
['email', body.user.email],
|
|
92
|
+
['control plane', profile.api],
|
|
93
|
+
...body.orgs.map((o) => [o.orgName, `${o.role} · ${o.plan}`]),
|
|
94
|
+
]));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw new CliError('usage: fleet auth login|logout|whoami', EXIT.usage);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { CliError, EXIT, request } from '../api.js';
|
|
2
|
+
import { loadProfile, saveProfile } from '../config.js';
|
|
3
|
+
import { c, keyValues } from '../render.js';
|
|
4
|
+
import { glyph, rule, task } from '../ui.js';
|
|
5
|
+
/** Keep credentials out of normal command output and screen recordings. */
|
|
6
|
+
function profileRows(profile) {
|
|
7
|
+
return [
|
|
8
|
+
['control plane', profile.api || c.yellow('not configured')],
|
|
9
|
+
['fleet', profile.fleetName ?? profile.fleetId ?? c.yellow('not selected')],
|
|
10
|
+
['signed in', profile.accessToken ? c.green('yes') : c.yellow('no')],
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
export const configCommand = {
|
|
14
|
+
async run(args, flags) {
|
|
15
|
+
const [sub = 'show'] = args;
|
|
16
|
+
if (sub !== 'show')
|
|
17
|
+
throw new CliError('usage: fleet config show', EXIT.usage);
|
|
18
|
+
const profile = await loadProfile();
|
|
19
|
+
if (flags.json) {
|
|
20
|
+
return console.log(JSON.stringify({
|
|
21
|
+
api: profile.api || null,
|
|
22
|
+
fleetId: profile.fleetId ?? null,
|
|
23
|
+
fleetName: profile.fleetName ?? null,
|
|
24
|
+
signedIn: Boolean(profile.accessToken),
|
|
25
|
+
}, null, 2));
|
|
26
|
+
}
|
|
27
|
+
console.log(keyValues(profileRows(profile)));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export const useCommand = {
|
|
31
|
+
async run(args, flags) {
|
|
32
|
+
const [target] = args;
|
|
33
|
+
if (!target)
|
|
34
|
+
throw new CliError('usage: fleet use <fleet-name-or-id>', EXIT.usage);
|
|
35
|
+
const profile = await loadProfile();
|
|
36
|
+
const fleets = await task('finding fleets you can access', async () => (await request('GET', '/fleets')).body.fleets);
|
|
37
|
+
const matches = fleets.filter((fleet) => fleet.id === target || fleet.id.startsWith(target) || fleet.name === target);
|
|
38
|
+
if (!matches.length) {
|
|
39
|
+
throw new CliError(`No fleet called "${target}". Available: ${fleets.map((fleet) => fleet.name).join(', ') || 'none'}`, EXIT.usage);
|
|
40
|
+
}
|
|
41
|
+
if (matches.length > 1) {
|
|
42
|
+
throw new CliError(`"${target}" is ambiguous. Use one of:\n${matches.map((f) => ` ${f.name} ${f.id}`).join('\n')}`, EXIT.usage);
|
|
43
|
+
}
|
|
44
|
+
const fleet = matches[0];
|
|
45
|
+
await saveProfile({ ...profile, fleetId: fleet.id, fleetName: fleet.name });
|
|
46
|
+
if (flags.json)
|
|
47
|
+
return console.log(JSON.stringify({ fleet }, null, 2));
|
|
48
|
+
console.log(`${glyph.ok} using ${c.signal(fleet.name)} ${c.dim(`${fleet.role} · ${fleet.id}`)}`);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export { profileRows };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { CliError, EXIT, request, requireFleet } from '../api.js';
|
|
2
|
+
import { loadProfile } from '../config.js';
|
|
3
|
+
import { c, relativeTime } from '../render.js';
|
|
4
|
+
import { glyph, rule, task } from '../ui.js';
|
|
5
|
+
const icon = (state) => state === 'ok' ? glyph.ok : state === 'warn' ? glyph.warn : glyph.fail;
|
|
6
|
+
async function reach(url) {
|
|
7
|
+
try {
|
|
8
|
+
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}` };
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A candid, read-only diagnosis. A check is never marked healthy merely
|
|
19
|
+
* because Fleet lacks enough telemetry to prove it — that is a warning with
|
|
20
|
+
* the next concrete product capability stated plainly.
|
|
21
|
+
*/
|
|
22
|
+
export const doctorCommand = {
|
|
23
|
+
async run(_args, flags) {
|
|
24
|
+
const profile = await loadProfile();
|
|
25
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
26
|
+
const result = await task('checking Fleet health', async () => {
|
|
27
|
+
const [identity, fleet, nodes, services, github, health] = await Promise.all([
|
|
28
|
+
request('GET', '/auth/me'),
|
|
29
|
+
request('GET', `/fleets/${fleetId}`),
|
|
30
|
+
request('GET', `/fleets/${fleetId}/nodes`),
|
|
31
|
+
request('GET', `/fleets/${fleetId}/services`),
|
|
32
|
+
request('GET', `/fleets/${fleetId}/github/status`),
|
|
33
|
+
request('GET', '/healthz'),
|
|
34
|
+
]);
|
|
35
|
+
const deploymentHistory = await Promise.all(services.body.services.map(async (service) => ({
|
|
36
|
+
service,
|
|
37
|
+
deployments: (await request('GET', `/services/${service.id}/deployments`)).body.deployments,
|
|
38
|
+
})));
|
|
39
|
+
const urls = services.body.services
|
|
40
|
+
.map((service) => ({ name: service.name, hostname: service.domain ?? service.hostname }))
|
|
41
|
+
.filter((service) => Boolean(service.hostname));
|
|
42
|
+
const ingress = await Promise.all(urls.map(async (service) => ({ ...service, ...(await reach(`https://${service.hostname}`)) })));
|
|
43
|
+
return { identity: identity.body, fleet: fleet.body, nodes: nodes.body.nodes, services: services.body.services, github: github.body, health: health.body, deploymentHistory, ingress };
|
|
44
|
+
});
|
|
45
|
+
const checks = [
|
|
46
|
+
{ state: 'ok', label: 'control plane', detail: profile.api },
|
|
47
|
+
{ state: 'ok', label: 'signed in', detail: result.identity.user.email },
|
|
48
|
+
{ state: 'ok', label: 'fleet access', detail: `${result.fleet.fleet.name} · ${result.fleet.role}` },
|
|
49
|
+
];
|
|
50
|
+
if (!result.nodes.length) {
|
|
51
|
+
checks.push({ state: 'fail', label: 'nodes', detail: 'No nodes are paired.', remedy: 'Run `fleet nodes pair`, then run the printed command on a machine you own.' });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const offline = result.nodes.filter((node) => node.status === 'offline' || !node.live);
|
|
55
|
+
const cordoned = result.nodes.filter((node) => node.status === 'cordoned');
|
|
56
|
+
const versions = new Set(result.nodes.map((node) => node.agentVersion).filter(Boolean));
|
|
57
|
+
checks.push({
|
|
58
|
+
state: offline.length ? 'fail' : cordoned.length ? 'warn' : 'ok',
|
|
59
|
+
label: 'nodes',
|
|
60
|
+
detail: offline.length
|
|
61
|
+
? `${offline.map((node) => `${node.name} (${relativeTime(node.lastHeartbeatAt)})`).join(', ')} not reporting`
|
|
62
|
+
: cordoned.length
|
|
63
|
+
? `${result.nodes.length} paired; ${cordoned.map((node) => node.name).join(', ')} cordoned`
|
|
64
|
+
: `${result.nodes.length} paired and reporting`,
|
|
65
|
+
remedy: offline.length ? 'Check the agent service and its outbound connection, then run `fleet doctor` again.' : undefined,
|
|
66
|
+
});
|
|
67
|
+
checks.push({
|
|
68
|
+
state: versions.size > 1 ? 'warn' : 'ok',
|
|
69
|
+
label: 'agent versions',
|
|
70
|
+
detail: versions.size ? [...versions].join(', ') : 'agent version not reported',
|
|
71
|
+
remedy: versions.size > 1 ? 'Update nodes so all agents run the same compatible release.' : undefined,
|
|
72
|
+
});
|
|
73
|
+
for (const node of result.nodes) {
|
|
74
|
+
const runtime = node.telemetry?.runtime;
|
|
75
|
+
const diskPercent = node.diskMb ? Math.round(((node.telemetry?.diskUsedMb ?? 0) / node.diskMb) * 100) : 0;
|
|
76
|
+
// Redis intentionally retains the last heartbeat briefly, but a node
|
|
77
|
+
// that has stopped reporting must not have old host facts rendered as
|
|
78
|
+
// current failures. The heartbeat check above is the only actionable
|
|
79
|
+
// check until the agent resumes.
|
|
80
|
+
if (!node.live || !node.telemetry) {
|
|
81
|
+
checks.push({
|
|
82
|
+
state: 'warn',
|
|
83
|
+
label: `runtime ${node.name}`,
|
|
84
|
+
detail: 'Unavailable because this node is not reporting a current heartbeat.',
|
|
85
|
+
remedy: 'Restart the agent, then run `fleet doctor` again for live Docker, registry, and disk checks.',
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
checks.push({ state: runtime?.dockerAvailable ? 'ok' : 'fail', label: `Docker ${node.name}`, detail: runtime?.dockerAvailable ? `available${runtime.dockerVersion ? ` · ${runtime.dockerVersion}` : ''}` : runtime?.dockerError ?? 'No Docker runtime reported', remedy: runtime?.dockerAvailable ? undefined : 'Start Docker, then inspect the local fleet-agent log.' });
|
|
90
|
+
checks.push({ state: runtime?.registryStatus === 'ok' ? 'ok' : runtime?.registryStatus === 'failed' ? 'fail' : 'warn', label: `registry ${node.name}`, detail: runtime?.registryStatus === 'ok' ? 'latest real image pull succeeded' : runtime?.registryError ?? 'not tested by a real image pull yet', remedy: runtime?.registryStatus === 'ok' ? undefined : 'Use a LAN-reachable REGISTRY_URL, then restart a service to run an authenticated pull.' });
|
|
91
|
+
checks.push({ state: diskPercent >= 90 ? 'fail' : diskPercent >= 80 ? 'warn' : 'ok', label: `disk ${node.name}`, detail: `${diskPercent}% used`, remedy: diskPercent >= 80 ? 'Free space from Docker images/volumes before the node becomes unschedulable.' : undefined });
|
|
92
|
+
if (runtime?.lastReconcileError)
|
|
93
|
+
checks.push({ state: 'fail', label: `reconcile ${node.name}`, detail: runtime.lastReconcileError, remedy: 'Run `fleet logs <service> --follow` and inspect the deployment history.' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
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 })));
|
|
97
|
+
checks.push(failed.length
|
|
98
|
+
? {
|
|
99
|
+
state: 'fail',
|
|
100
|
+
label: 'deployments',
|
|
101
|
+
detail: failed.map(({ service, deployment }) => `${service}: ${deployment.failureReason ?? deployment.status}`).join('; '),
|
|
102
|
+
remedy: 'Run `fleet deployments <service>` for history and `fleet logs <service> --follow` for the current container tail.',
|
|
103
|
+
}
|
|
104
|
+
: { state: 'ok', label: 'deployments', detail: result.services.length ? 'No recorded deployment failures.' : 'No services declared yet.' });
|
|
105
|
+
if (!result.ingress.length) {
|
|
106
|
+
checks.push({ state: 'warn', label: 'ingress', detail: 'No public service hostname is configured yet.' });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
for (const service of result.ingress) {
|
|
110
|
+
checks.push({
|
|
111
|
+
state: service.ok ? 'ok' : 'fail',
|
|
112
|
+
label: `HTTPS ${service.name}`,
|
|
113
|
+
detail: service.detail,
|
|
114
|
+
remedy: service.ok ? undefined : 'Check the node is online, its advertised address is reachable, and the ingress domain resolves to this control plane.',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
checks.push(result.github.configured && !result.github.error
|
|
119
|
+
? { state: 'ok', label: 'GitHub App', detail: `${result.github.installations?.length ?? 0} installation(s) available` }
|
|
120
|
+
: {
|
|
121
|
+
state: 'warn',
|
|
122
|
+
label: 'GitHub App',
|
|
123
|
+
detail: result.github.error ?? 'Not configured; public repositories can still deploy.',
|
|
124
|
+
remedy: 'Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, restart the control plane, then connect repositories in Dashboard → Settings.',
|
|
125
|
+
});
|
|
126
|
+
checks.push({ state: 'ok', label: 'control-plane version', detail: result.health.version ?? 'version not reported' });
|
|
127
|
+
if (flags.json)
|
|
128
|
+
return console.log(JSON.stringify({ fleetId, checks }, null, 2));
|
|
129
|
+
console.log(`\n${rule(`doctor · ${result.fleet.fleet.name}`)}`);
|
|
130
|
+
for (const check of checks) {
|
|
131
|
+
console.log(`${icon(check.state)} ${c.bold(check.label.padEnd(18))} ${check.detail}`);
|
|
132
|
+
if (check.remedy)
|
|
133
|
+
console.log(` ${c.dim(check.remedy)}`);
|
|
134
|
+
}
|
|
135
|
+
const failing = checks.filter((check) => check.state === 'fail').length;
|
|
136
|
+
const warnings = checks.filter((check) => check.state === 'warn').length;
|
|
137
|
+
console.log(`\n${failing ? c.red(`${failing} failed`) : c.green('no blocking failures')}${warnings ? c.dim(` · ${warnings} needs attention`) : ''}`);
|
|
138
|
+
if (failing)
|
|
139
|
+
process.exitCode = EXIT.failure;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet down <service> — cleanly stops and tears down a running service deployment.
|
|
3
|
+
*
|
|
4
|
+
* Marks active deployments as stopped and instructs the assigned node agent
|
|
5
|
+
* to unassign and remove the container on its next reconciliation cycle.
|
|
6
|
+
*/
|
|
7
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
8
|
+
import { c } from '../render.js';
|
|
9
|
+
import { glyph } from '../ui.js';
|
|
10
|
+
async function confirmTeardown(name) {
|
|
11
|
+
if (!process.stdin.isTTY)
|
|
12
|
+
return true;
|
|
13
|
+
const { createInterface } = await import('node:readline/promises');
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
try {
|
|
16
|
+
const ans = await rl.question(` Stop and tear down ${c.bold(name)}? [y/N] `);
|
|
17
|
+
return ans.trim().toLowerCase() === 'y';
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rl.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const downCommand = {
|
|
24
|
+
async run(args, flags) {
|
|
25
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
26
|
+
const [name] = args;
|
|
27
|
+
if (!name)
|
|
28
|
+
throw new CliError('usage: fleet down <service> [--yes]', EXIT.usage);
|
|
29
|
+
const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
|
|
30
|
+
const service = listBody.services.find((s) => s.name === name || s.id === name);
|
|
31
|
+
if (!service) {
|
|
32
|
+
throw new CliError(`No service called "${name}". Known: ${listBody.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
|
|
33
|
+
}
|
|
34
|
+
if (!flags.yes && !flags.y && !(await confirmTeardown(service.name))) {
|
|
35
|
+
console.log(c.dim('Teardown cancelled.'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { body } = await request('POST', `/services/${service.id}/stop`, { body: {} });
|
|
39
|
+
if (flags.json)
|
|
40
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
41
|
+
if (body.stopped === 0) {
|
|
42
|
+
console.log(`${glyph.info} ${body.message ?? `"${service.name}" is not currently running.`}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log(`${glyph.ok} ${c.yellow('stopped')} ${c.bold(service.name)}`);
|
|
46
|
+
if (body.note) {
|
|
47
|
+
console.log(c.dim(` ${body.note}`));
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { authCommand } from './auth.js';
|
|
2
|
+
import { nodesCommand } from './nodes.js';
|
|
3
|
+
import { statusCommand, eventsCommand } from './status.js';
|
|
4
|
+
import { alertsCommand } from './alerts.js';
|
|
5
|
+
import { configCommand, useCommand } from './config.js';
|
|
6
|
+
import { doctorCommand } from './doctor.js';
|
|
7
|
+
import { upCommand } from './up.js';
|
|
8
|
+
import { openCommand } from './open.js';
|
|
9
|
+
import { downCommand } from './down.js';
|
|
10
|
+
import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
11
|
+
export const commands = {
|
|
12
|
+
up: upCommand,
|
|
13
|
+
open: openCommand,
|
|
14
|
+
down: downCommand,
|
|
15
|
+
auth: authCommand,
|
|
16
|
+
config: configCommand,
|
|
17
|
+
use: useCommand,
|
|
18
|
+
doctor: doctorCommand,
|
|
19
|
+
init: initCommand,
|
|
20
|
+
validate: validateCommand,
|
|
21
|
+
apply: applyCommand,
|
|
22
|
+
status: statusCommand,
|
|
23
|
+
nodes: nodesCommand,
|
|
24
|
+
services: servicesCommand,
|
|
25
|
+
deploy: deployCommand,
|
|
26
|
+
where: whereCommand,
|
|
27
|
+
reschedule: rescheduleCommand,
|
|
28
|
+
deployments: deploymentsCommand,
|
|
29
|
+
logs: logsCommand,
|
|
30
|
+
restart: restartCommand,
|
|
31
|
+
rollback: rollbackCommand,
|
|
32
|
+
events: eventsCommand,
|
|
33
|
+
alerts: alertsCommand,
|
|
34
|
+
};
|