@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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
2
|
+
import { c, table, statusColour, relativeTime, mb } from '../render.js';
|
|
3
|
+
export const nodesCommand = {
|
|
4
|
+
async run(args, flags) {
|
|
5
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
6
|
+
const [sub, target] = args;
|
|
7
|
+
if (!sub || sub === 'ls' || sub === 'list') {
|
|
8
|
+
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
9
|
+
if (flags.json)
|
|
10
|
+
return console.log(JSON.stringify(body.nodes, null, 2));
|
|
11
|
+
if (!body.nodes.length) {
|
|
12
|
+
console.log('No nodes yet. Run `fleet nodes pair` to add one.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(table(['name', 'arch', 'tier', 'cpu', 'ram', 'services', 'seen', 'status'], body.nodes.map((n) => [
|
|
16
|
+
n.name,
|
|
17
|
+
n.arch,
|
|
18
|
+
n.reliabilityTier,
|
|
19
|
+
n.telemetry ? `${Math.round(n.telemetry.cpuPct)}%` : c.dim('—'),
|
|
20
|
+
n.telemetry ? `${mb(n.telemetry.ramUsedMb)}/${mb(n.ramMb)}` : mb(n.ramMb),
|
|
21
|
+
String(n.telemetry?.containers.length ?? 0),
|
|
22
|
+
relativeTime(n.lastHeartbeatAt),
|
|
23
|
+
statusColour(n.status),
|
|
24
|
+
])));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (sub === 'pair') {
|
|
28
|
+
const { body } = await request('POST', `/fleets/${fleetId}/nodes/pair-token`,
|
|
29
|
+
// Fastify requires a recognised media type for a POST. Supplying an
|
|
30
|
+
// explicit empty JSON object keeps this body-less operation portable
|
|
31
|
+
// through proxies and avoids Node fetch's implicit text/plain type.
|
|
32
|
+
{ body: {} });
|
|
33
|
+
if (flags.json)
|
|
34
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
35
|
+
console.log(`Run this on the machine you want to add:\n`);
|
|
36
|
+
console.log(` ${c.cyan(body.install_command)}\n`);
|
|
37
|
+
console.log(c.dim(`The token is single-use and expires ${relativeTime(body.expires_at)}.`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'cordon' || sub === 'uncordon') {
|
|
41
|
+
if (!target)
|
|
42
|
+
throw new CliError(`usage: fleet nodes ${sub} <name>`, EXIT.usage);
|
|
43
|
+
const node = await findNode(fleetId, target);
|
|
44
|
+
await request('POST', `/fleets/${fleetId}/nodes/${node.id}/cordon`, {
|
|
45
|
+
body: { cordoned: sub === 'cordon' },
|
|
46
|
+
});
|
|
47
|
+
console.log(sub === 'cordon'
|
|
48
|
+
? `${node.name} cordoned — running services stay put, nothing new is scheduled here`
|
|
49
|
+
: `${node.name} is schedulable again`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (sub === 'rm' || sub === 'remove') {
|
|
53
|
+
if (!target)
|
|
54
|
+
throw new CliError('usage: fleet nodes rm <name>', EXIT.usage);
|
|
55
|
+
const node = await findNode(fleetId, target);
|
|
56
|
+
if (!flags.force && !flags.f) {
|
|
57
|
+
throw new CliError(`This revokes ${node.name}'s credentials and removes it from the fleet.\n` +
|
|
58
|
+
` Anything pinned to it will have nowhere to run. Re-run with --force if that is intended.`, EXIT.usage);
|
|
59
|
+
}
|
|
60
|
+
await request('DELETE', `/fleets/${fleetId}/nodes/${node.id}`);
|
|
61
|
+
console.log(`${node.name} removed and its agent credentials revoked`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw new CliError('usage: fleet nodes [ls|pair|cordon|uncordon|rm]', EXIT.usage);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
/** Names are what people type; ids are what the API wants. */
|
|
68
|
+
async function findNode(fleetId, name) {
|
|
69
|
+
const { body } = await request('GET', `/fleets/${fleetId}/nodes`);
|
|
70
|
+
const match = body.nodes.find((n) => n.name === name || n.id === name || n.id.startsWith(name));
|
|
71
|
+
if (!match) {
|
|
72
|
+
throw new CliError(`No node called "${name}". Known nodes: ${body.nodes.map((n) => n.name).join(', ') || 'none'}`, EXIT.usage);
|
|
73
|
+
}
|
|
74
|
+
return match;
|
|
75
|
+
}
|
|
76
|
+
export { findNode };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet open [service] — opens the deployed service in your default browser.
|
|
3
|
+
*
|
|
4
|
+
* Discovers the public HTTPS URL for the service and launches it via the
|
|
5
|
+
* platform's native opener (open on macOS, xdg-open on Linux, start on Windows).
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
10
|
+
import { c } from '../render.js';
|
|
11
|
+
import { glyph } from '../ui.js';
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
async function openUrl(url) {
|
|
14
|
+
const platform = process.platform;
|
|
15
|
+
let cmd = '';
|
|
16
|
+
if (platform === 'darwin') {
|
|
17
|
+
cmd = `open "${url}"`;
|
|
18
|
+
}
|
|
19
|
+
else if (platform === 'win32') {
|
|
20
|
+
cmd = `start "" "${url}"`;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
cmd = `xdg-open "${url}"`;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await execAsync(cmd);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new CliError(`Could not open browser automatically: ${String(err)}`, EXIT.failure);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const openCommand = {
|
|
33
|
+
async run(args, flags) {
|
|
34
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
35
|
+
const [nameArg] = args;
|
|
36
|
+
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
37
|
+
if (!body.services.length) {
|
|
38
|
+
throw new CliError('No services in this fleet. Run `fleet up` to deploy one.', EXIT.usage);
|
|
39
|
+
}
|
|
40
|
+
let service;
|
|
41
|
+
if (nameArg) {
|
|
42
|
+
service = body.services.find((s) => s.name === nameArg || s.id === nameArg);
|
|
43
|
+
if (!service) {
|
|
44
|
+
throw new CliError(`No service called "${nameArg}". Known: ${body.services.map((s) => s.name).join(', ')}`, EXIT.usage);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// If only one service exists, pick it. Otherwise ask user to specify.
|
|
49
|
+
if (body.services.length === 1) {
|
|
50
|
+
service = body.services[0];
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
throw new CliError(`Multiple services available. Specify which to open:\n` +
|
|
54
|
+
body.services.map((s) => ` fleet open ${s.name}`).join('\n'), EXIT.usage);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const rawUrl = service.domain ?? service.hostname;
|
|
58
|
+
if (!rawUrl) {
|
|
59
|
+
throw new CliError(`Service "${service.name}" does not have an assigned URL yet.`, EXIT.usage);
|
|
60
|
+
}
|
|
61
|
+
const url = rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`;
|
|
62
|
+
console.log(`${glyph.ok} opening ${c.bold(service.name)} ${c.dim('→')} ${c.cyan(url)}`);
|
|
63
|
+
await openUrl(url);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
4
|
+
import { c, table, statusColour, keyValues, relativeTime, mb } from '../render.js';
|
|
5
|
+
import { task, splash, glyph } from '../ui.js';
|
|
6
|
+
const manifestPath = (given) => given ?? 'fleet.yaml';
|
|
7
|
+
async function readManifest(path) {
|
|
8
|
+
try {
|
|
9
|
+
return await readFile(path, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new CliError(`No ${path} here. Run \`fleet init\` to scaffold one.`, EXIT.usage);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const validateCommand = {
|
|
16
|
+
async run(args, flags) {
|
|
17
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
18
|
+
const manifest = await readManifest(manifestPath(args[0]));
|
|
19
|
+
const body = await task(`checking ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services/validate`, { body: { manifest } })).body);
|
|
20
|
+
if (flags.json)
|
|
21
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
22
|
+
if (!body.valid) {
|
|
23
|
+
console.error(c.red(`${body.issues.length} problem(s) in ${manifestPath(args[0])}:\n`));
|
|
24
|
+
for (const issue of body.issues)
|
|
25
|
+
console.error(` ${c.bold(issue.path)}\n ${issue.message}`);
|
|
26
|
+
process.exit(EXIT.usage);
|
|
27
|
+
}
|
|
28
|
+
console.log(c.green('valid') + ` ${body.services.length} service(s)`);
|
|
29
|
+
console.log(table(['service', 'placement', 'ram'], body.services.map((s) => [s.name, s.placement, mb(s.ramMb)])));
|
|
30
|
+
for (const w of body.warnings ?? [])
|
|
31
|
+
console.log(`\n${c.yellow('warning')} ${w}`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export const applyCommand = {
|
|
35
|
+
async run(args, flags) {
|
|
36
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
37
|
+
const manifest = await readManifest(manifestPath(args[0]));
|
|
38
|
+
const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
|
|
39
|
+
done: (b) => b.created.length || b.updated.length
|
|
40
|
+
? `applied ${b.created.length + b.updated.length} service(s)`
|
|
41
|
+
: 'no changes',
|
|
42
|
+
});
|
|
43
|
+
if (flags.json)
|
|
44
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
45
|
+
if (body.created.length)
|
|
46
|
+
console.log(`${glyph.ok} ${c.green('created')} ${body.created.join(', ')}`);
|
|
47
|
+
if (body.updated.length)
|
|
48
|
+
console.log(`${glyph.ok} ${c.cyan('updated')} ${body.updated.join(', ')}`);
|
|
49
|
+
for (const w of body.warnings)
|
|
50
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
51
|
+
if (body.created.length)
|
|
52
|
+
console.log(c.dim(`\nnext: fleet deploy ${body.created[0]}`));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export const servicesCommand = {
|
|
56
|
+
async run(_args, flags) {
|
|
57
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
58
|
+
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
59
|
+
if (flags.json)
|
|
60
|
+
return console.log(JSON.stringify(body.services, null, 2));
|
|
61
|
+
if (!body.services.length)
|
|
62
|
+
return console.log('No services. Run `fleet apply` with a fleet.yaml.');
|
|
63
|
+
console.log(table(['service', 'url', 'placement', 'node', 'sha', 'status'], body.services.map((s) => [
|
|
64
|
+
s.name + (s.persistentVolume ? c.dim(' ⛁') : ''),
|
|
65
|
+
s.domain ?? s.hostname ?? c.dim('—'),
|
|
66
|
+
s.placementPolicy,
|
|
67
|
+
s.current?.nodeName ?? c.dim('—'),
|
|
68
|
+
s.current?.gitSha?.slice(0, 7) ?? c.dim('—'),
|
|
69
|
+
s.current ? statusColour(s.current.status) : c.dim('not deployed'),
|
|
70
|
+
])));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
async function findService(fleetId, name) {
|
|
74
|
+
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
75
|
+
const match = body.services.find((s) => s.name === name || s.id === name);
|
|
76
|
+
if (!match) {
|
|
77
|
+
throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
|
|
78
|
+
}
|
|
79
|
+
return match;
|
|
80
|
+
}
|
|
81
|
+
async function deployPlan(fleetId, service) {
|
|
82
|
+
return (await request('GET', `/services/${service.id}/placement-preview`)).body.decision;
|
|
83
|
+
}
|
|
84
|
+
function printPlan(service, plan, gitSha) {
|
|
85
|
+
console.log(`\n${c.bold(`Plan for ${service.name}`)}`);
|
|
86
|
+
if (plan.outcome !== 'placed' || !plan.nodeName) {
|
|
87
|
+
console.log(`${c.red(' placement')} ${plan.summary ?? 'No eligible node'}`);
|
|
88
|
+
for (const rejected of plan.rejected)
|
|
89
|
+
console.log(` ${c.dim(rejected.nodeName.padEnd(12))} ${rejected.detail}`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const winner = plan.candidates[0];
|
|
93
|
+
const source = service.repoUrl
|
|
94
|
+
? `${service.repoUrl}${gitSha ? ` · ${gitSha.slice(0, 12)}` : ''}`
|
|
95
|
+
: gitSha ? gitSha.slice(0, 12) : 'service definition';
|
|
96
|
+
const target = plan.nodeName;
|
|
97
|
+
const reason = winner
|
|
98
|
+
? `highest eligible score (${winner.score.toFixed(3)}; headroom ${winner.breakdown.headroom.toFixed(2)}, load ${winner.breakdown.load.toFixed(2)})`
|
|
99
|
+
: 'eligible for this service';
|
|
100
|
+
const url = service.domain ?? service.hostname ?? 'assigned after scheduling';
|
|
101
|
+
console.log(` ${c.dim('source'.padEnd(12))} ${source}`);
|
|
102
|
+
console.log(` ${c.dim('target'.padEnd(12))} ${c.signal(target)}`);
|
|
103
|
+
console.log(` ${c.dim('reason'.padEnd(12))} ${reason}`);
|
|
104
|
+
console.log(` ${c.dim('URL'.padEnd(12))} ${url.startsWith('http') ? url : `https://${url}`}`);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
async function confirmDeploy() {
|
|
108
|
+
if (!process.stdin.isTTY)
|
|
109
|
+
return true;
|
|
110
|
+
const { createInterface } = await import('node:readline/promises');
|
|
111
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
112
|
+
try {
|
|
113
|
+
return (await rl.question(' Continue? [y/N] ')).trim().toLowerCase() === 'y';
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rl.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
/**
|
|
121
|
+
* The deploy request returns once the image exists and a node has been chosen.
|
|
122
|
+
* The container starting is the agent's job and happens afterwards, so the CLI
|
|
123
|
+
* follows it to conclusion rather than reporting "scheduled" and leaving the
|
|
124
|
+
* operator to guess.
|
|
125
|
+
*/
|
|
126
|
+
async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
|
|
127
|
+
await task(`waiting for ${c.bold(name)} to come up`, async (s) => {
|
|
128
|
+
s.hints([
|
|
129
|
+
'the agent picks up desired state on its next poll',
|
|
130
|
+
'a cold image pull takes as long as the node\'s uplink does',
|
|
131
|
+
'this clears once the agent reports the container running',
|
|
132
|
+
]);
|
|
133
|
+
const deadline = Date.now() + timeoutMs;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
const current = await findService(fleetId, name)
|
|
136
|
+
.then((svc) => svc.current)
|
|
137
|
+
.catch(() => null);
|
|
138
|
+
if (current?.status === 'running')
|
|
139
|
+
return;
|
|
140
|
+
if (current?.status === 'failed') {
|
|
141
|
+
throw new CliError(`"${name}" did not start. \`fleet deployments ${name}\` has the reason.`, EXIT.healthCheckFailed);
|
|
142
|
+
}
|
|
143
|
+
await sleep(2000);
|
|
144
|
+
}
|
|
145
|
+
throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
|
|
146
|
+
}, { done: () => `${c.bold(name)} is running` });
|
|
147
|
+
}
|
|
148
|
+
export const deployCommand = {
|
|
149
|
+
async run(args, flags) {
|
|
150
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
151
|
+
const [name] = args;
|
|
152
|
+
if (!name)
|
|
153
|
+
throw new CliError('usage: fleet deploy <service> [--sha <git-sha>] [--no-wait]', EXIT.usage);
|
|
154
|
+
const service = await findService(fleetId, name);
|
|
155
|
+
const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
|
|
156
|
+
const plan = await task('checking deployment plan', async () => deployPlan(fleetId, service));
|
|
157
|
+
const viable = plan.outcome === 'placed' && Boolean(plan.nodeName);
|
|
158
|
+
if (!flags.json)
|
|
159
|
+
printPlan(service, plan, gitSha);
|
|
160
|
+
if (flags.json && (flags.plan || flags['dry-run'])) {
|
|
161
|
+
console.log(JSON.stringify({ service: service.name, gitSha: gitSha ?? null, plan }, null, 2));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!viable) {
|
|
165
|
+
if (flags.json)
|
|
166
|
+
console.log(JSON.stringify({ service: service.name, gitSha: gitSha ?? null, plan }, null, 2));
|
|
167
|
+
process.exitCode = EXIT.noEligibleNode;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (flags.plan || flags['dry-run'])
|
|
171
|
+
return;
|
|
172
|
+
if (!flags.yes && !flags.y && !(await confirmDeploy())) {
|
|
173
|
+
console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const body = await splash(`deploying ${c.bold(service.name)}${gitSha ? c.dim(` at ${gitSha.slice(0, 7)}`) : ''}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
|
|
177
|
+
// What the request involves, not a stage it has reached — the call is a
|
|
178
|
+
// single synchronous POST and the CLI cannot see inside it.
|
|
179
|
+
hints: [
|
|
180
|
+
'scoring every online node on headroom, reliability and load',
|
|
181
|
+
'building for every architecture an eligible node runs',
|
|
182
|
+
'the first multi-arch build is the slow one; layers cache after it',
|
|
183
|
+
'pushing the image to the fleet registry',
|
|
184
|
+
],
|
|
185
|
+
done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
|
|
186
|
+
});
|
|
187
|
+
if (flags.json)
|
|
188
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
189
|
+
for (const w of body.warnings ?? [])
|
|
190
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
191
|
+
if (body.url)
|
|
192
|
+
console.log(`${glyph.info} ${c.cyan(body.url)}`);
|
|
193
|
+
if (!flags['no-wait'])
|
|
194
|
+
await waitUntilRunning(fleetId, service.name);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
export const whereCommand = {
|
|
198
|
+
async run(args, flags) {
|
|
199
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
200
|
+
const [name] = args;
|
|
201
|
+
if (!name)
|
|
202
|
+
throw new CliError('usage: fleet where <service>', EXIT.usage);
|
|
203
|
+
const service = await findService(fleetId, name);
|
|
204
|
+
const { body } = await request('GET', `/services/${service.id}/placement-preview`);
|
|
205
|
+
const d = body.decision;
|
|
206
|
+
if (flags.json)
|
|
207
|
+
return console.log(JSON.stringify(d, null, 2));
|
|
208
|
+
if (d.outcome !== 'placed') {
|
|
209
|
+
console.log(c.red('no eligible node'));
|
|
210
|
+
console.log(` ${d.summary}\n`);
|
|
211
|
+
console.log(table(['node', 'why not'], d.rejected.map((r) => [r.nodeName, `${c.dim(r.code)} ${r.detail}`])));
|
|
212
|
+
process.exit(EXIT.noEligibleNode);
|
|
213
|
+
}
|
|
214
|
+
console.log(`${c.green('would place on')} ${c.bold(d.nodeName)}\n`);
|
|
215
|
+
console.log(table(['node', 'score', 'headroom', 'reliability', 'load', 'free'], d.candidates.map((cand) => [
|
|
216
|
+
cand.nodeName,
|
|
217
|
+
cand.score.toFixed(4),
|
|
218
|
+
cand.breakdown.headroom.toFixed(3),
|
|
219
|
+
cand.breakdown.reliability.toFixed(2),
|
|
220
|
+
cand.breakdown.load.toFixed(2),
|
|
221
|
+
mb(cand.freeRamMb),
|
|
222
|
+
])));
|
|
223
|
+
if (d.rejected.length) {
|
|
224
|
+
console.log(`\n${c.dim('not eligible:')}`);
|
|
225
|
+
for (const r of d.rejected)
|
|
226
|
+
console.log(` ${r.nodeName} ${c.dim(r.code)} ${r.detail}`);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
export const rescheduleCommand = {
|
|
231
|
+
async run(args, flags) {
|
|
232
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
233
|
+
const [name] = args;
|
|
234
|
+
if (!name)
|
|
235
|
+
throw new CliError('usage: fleet reschedule <service>', EXIT.usage);
|
|
236
|
+
const service = await findService(fleetId, name);
|
|
237
|
+
const { body } = await request('POST', `/services/${service.id}/reschedule`);
|
|
238
|
+
console.log(`${c.green('moved')} ${service.name} → ${c.bold(body.movedTo.name)}`);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
export const deploymentsCommand = {
|
|
242
|
+
async run(args, flags) {
|
|
243
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
244
|
+
const [name] = args;
|
|
245
|
+
if (!name)
|
|
246
|
+
throw new CliError('usage: fleet deployments <service>', EXIT.usage);
|
|
247
|
+
const service = await findService(fleetId, name);
|
|
248
|
+
const { body } = await request('GET', `/services/${service.id}/deployments`);
|
|
249
|
+
if (flags.json)
|
|
250
|
+
return console.log(JSON.stringify(body.deployments, null, 2));
|
|
251
|
+
console.log(table(['when', 'sha', 'node', 'status', 'note'], body.deployments.map((d) => [
|
|
252
|
+
relativeTime(d.startedAt),
|
|
253
|
+
d.gitSha?.slice(0, 7) ?? c.dim('—'),
|
|
254
|
+
d.nodeName ?? c.dim('—'),
|
|
255
|
+
statusColour(d.status),
|
|
256
|
+
d.failureReason ?? '',
|
|
257
|
+
])));
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
export const restartCommand = {
|
|
261
|
+
async run(args, flags) {
|
|
262
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
263
|
+
const [name] = args;
|
|
264
|
+
if (!name)
|
|
265
|
+
throw new CliError('usage: fleet restart <service>', EXIT.usage);
|
|
266
|
+
const service = await findService(fleetId, name);
|
|
267
|
+
const { body } = await request('POST', `/services/${service.id}/restart`, { body: {} });
|
|
268
|
+
if (flags.json)
|
|
269
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
270
|
+
console.log(`${glyph.ok} ${c.green('restart scheduled')} ${service.name} ${c.dim(body.deployment.id.slice(0, 8))}`);
|
|
271
|
+
if (!flags['no-wait'])
|
|
272
|
+
await waitUntilRunning(fleetId, service.name);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
export const rollbackCommand = {
|
|
276
|
+
async run(args, flags) {
|
|
277
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
278
|
+
const [name, deploymentId] = args;
|
|
279
|
+
if (!name)
|
|
280
|
+
throw new CliError('usage: fleet rollback <service> [deployment-id]', EXIT.usage);
|
|
281
|
+
const service = await findService(fleetId, name);
|
|
282
|
+
if (!flags.yes && !flags.y && !(await confirmDeploy())) {
|
|
283
|
+
console.log(c.dim('Rollback cancelled.'));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const { body } = await request('POST', `/services/${service.id}/rollback`, { body: deploymentId ? { deploymentId } : {} });
|
|
287
|
+
if (flags.json)
|
|
288
|
+
return console.log(JSON.stringify(body, null, 2));
|
|
289
|
+
console.log(`${glyph.ok} ${c.green('rollback scheduled')} ${service.name} ← ${c.dim(body.rolledBackTo.slice(0, 8))}`);
|
|
290
|
+
if (!flags['no-wait'])
|
|
291
|
+
await waitUntilRunning(fleetId, service.name);
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
export const logsCommand = {
|
|
295
|
+
async run(args, flags) {
|
|
296
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
297
|
+
const [name] = args;
|
|
298
|
+
if (!name)
|
|
299
|
+
throw new CliError('usage: fleet logs <service> [--follow] [--since 1h]', EXIT.usage);
|
|
300
|
+
const service = await findService(fleetId, name);
|
|
301
|
+
if (flags.since)
|
|
302
|
+
console.error(c.dim('note: agent log tails are live snapshots; --since is limited to the current retained tail.'));
|
|
303
|
+
let previous = '';
|
|
304
|
+
const render = async () => {
|
|
305
|
+
const { body } = await request('GET', `/services/${service.id}/logs`);
|
|
306
|
+
const next = body.lines.join('\n');
|
|
307
|
+
if (!next) {
|
|
308
|
+
if (body.diagnostic)
|
|
309
|
+
console.log(c.yellow(`waiting: ${body.diagnostic}`));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const output = next.startsWith(previous) ? next.slice(previous.length) : next;
|
|
313
|
+
if (output)
|
|
314
|
+
process.stdout.write(output + (output.endsWith('\n') ? '' : '\n'));
|
|
315
|
+
previous = next;
|
|
316
|
+
};
|
|
317
|
+
await render();
|
|
318
|
+
if (!flags.follow && !flags.f)
|
|
319
|
+
return;
|
|
320
|
+
if (!process.stdout.isTTY)
|
|
321
|
+
throw new CliError('--follow needs an interactive terminal', EXIT.usage);
|
|
322
|
+
while (true) {
|
|
323
|
+
await sleep(2000);
|
|
324
|
+
await render();
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
export const initCommand = {
|
|
329
|
+
async run(args, flags) {
|
|
330
|
+
const { detect, manifestTemplate } = await import('../detect.js');
|
|
331
|
+
const path = manifestPath(args[0]);
|
|
332
|
+
try {
|
|
333
|
+
await access(path);
|
|
334
|
+
throw new CliError(`${path} already exists — not overwriting it.`, EXIT.usage);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (err instanceof CliError)
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
// Infer the service name from the directory, which is right often enough
|
|
341
|
+
// to be useful and obvious enough to correct when it is not.
|
|
342
|
+
const name = (typeof flags.name === 'string' ? flags.name : '') ||
|
|
343
|
+
process.cwd().split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
|
|
344
|
+
'app';
|
|
345
|
+
const d = await detect();
|
|
346
|
+
// Write a Dockerfile if we generated one and none exists.
|
|
347
|
+
if (d.dockerfile) {
|
|
348
|
+
await writeFile(join(process.cwd(), 'Dockerfile'), d.dockerfile);
|
|
349
|
+
console.log(`${c.green('created')} Dockerfile ${c.dim(`(${d.label}, port ${d.port})`)}`);
|
|
350
|
+
}
|
|
351
|
+
await writeFile(path, manifestTemplate(name, d));
|
|
352
|
+
console.log(`${c.green('created')} ${path}`);
|
|
353
|
+
if (d.framework === 'unknown' && !d.hasDockerfile) {
|
|
354
|
+
console.log(c.dim(' could not detect framework — using defaults. Edit fleet.yaml to tune.'));
|
|
355
|
+
}
|
|
356
|
+
else if (d.hasDockerfile) {
|
|
357
|
+
console.log(c.dim(` using existing Dockerfile (detected EXPOSE ${d.port})`));
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.log(c.dim(` detected ${c.bold(d.label)} → optimised Dockerfile + manifest`));
|
|
361
|
+
}
|
|
362
|
+
console.log(`\nNext:\n fleet validate\n fleet apply\n fleet deploy ${name}`);
|
|
363
|
+
console.log(c.dim(`\n …or just run: fleet up`));
|
|
364
|
+
},
|
|
365
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { request, requireFleet } from '../api.js';
|
|
2
|
+
import { c, table, statusColour, mb, relativeTime } from '../render.js';
|
|
3
|
+
/**
|
|
4
|
+
* One screen that answers "is my fleet fine?" — the command people will run
|
|
5
|
+
* most, so it leads with what is wrong rather than burying it in a table.
|
|
6
|
+
*/
|
|
7
|
+
export const statusCommand = {
|
|
8
|
+
async run(_args, flags) {
|
|
9
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
10
|
+
const [map, events] = await Promise.all([
|
|
11
|
+
request('GET', `/fleets/${fleetId}/placement-map`),
|
|
12
|
+
request('GET', `/fleets/${fleetId}/events?limit=5`),
|
|
13
|
+
]);
|
|
14
|
+
if (flags.json) {
|
|
15
|
+
return console.log(JSON.stringify({ nodes: map.body.nodes, events: events.body.events }, null, 2));
|
|
16
|
+
}
|
|
17
|
+
const nodes = map.body.nodes;
|
|
18
|
+
const offline = nodes.filter((n) => n.status === 'offline');
|
|
19
|
+
const pinnedDown = nodes.flatMap((n) => n.services.filter((s) => s.status === 'pinned_unavailable').map((s) => ({ node: n.name, service: s.name })));
|
|
20
|
+
if (!nodes.length) {
|
|
21
|
+
console.log('No nodes in this fleet yet.');
|
|
22
|
+
console.log(c.dim(' Run `fleet nodes pair` and install the agent on a machine you own.'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Anything needing a human goes first, in the colour that says so.
|
|
26
|
+
if (pinnedDown.length) {
|
|
27
|
+
for (const p of pinnedDown) {
|
|
28
|
+
console.log(`${c.red('CRITICAL')} ${c.bold(p.service)} is down and was not moved — pinned to ${p.node}`);
|
|
29
|
+
}
|
|
30
|
+
console.log();
|
|
31
|
+
}
|
|
32
|
+
else if (offline.length) {
|
|
33
|
+
console.log(`${c.yellow('degraded')} ${offline.map((n) => n.name).join(', ')} offline\n`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(`${c.green('healthy')} ${nodes.length} node(s), all reporting\n`);
|
|
37
|
+
}
|
|
38
|
+
console.log(table(['node', 'arch', 'tier', 'free ram', 'load', 'services', 'status'], nodes.map((n) => [
|
|
39
|
+
n.name,
|
|
40
|
+
n.arch,
|
|
41
|
+
n.reliabilityTier,
|
|
42
|
+
`${mb(n.freeRamMb)}/${mb(n.ramMb)}`,
|
|
43
|
+
n.loadFactor === null ? c.dim('—') : `${Math.round(n.loadFactor * 100)}%`,
|
|
44
|
+
n.services.length
|
|
45
|
+
? n.services
|
|
46
|
+
.map((s) => s.status === 'pinned_unavailable' ? c.red(s.name) : s.policy === 'pinned' ? c.yellow(s.name) : s.name)
|
|
47
|
+
.join(' ')
|
|
48
|
+
: c.dim('—'),
|
|
49
|
+
statusColour(n.status),
|
|
50
|
+
])));
|
|
51
|
+
if (map.body.unplaced.length) {
|
|
52
|
+
console.log(`\n${c.yellow('unplaced')} ${map.body.unplaced.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
if (events.body.events.length) {
|
|
55
|
+
console.log(`\n${c.dim('recent')}`);
|
|
56
|
+
for (const e of events.body.events) {
|
|
57
|
+
const arrow = e.from ? `${e.from} → ${e.to}` : `→ ${e.to}`;
|
|
58
|
+
console.log(` ${relativeTime(e.at).padEnd(9)} ${e.service.padEnd(12)} ${c.dim(e.reason)} ${arrow}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export const eventsCommand = {
|
|
64
|
+
async run(_args, flags) {
|
|
65
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
66
|
+
const limit = typeof flags.limit === 'string' ? flags.limit : '30';
|
|
67
|
+
const { body } = await request('GET', `/fleets/${fleetId}/events?limit=${limit}`);
|
|
68
|
+
if (flags.json)
|
|
69
|
+
return console.log(JSON.stringify(body.events, null, 2));
|
|
70
|
+
if (!body.events.length)
|
|
71
|
+
return console.log('no events yet');
|
|
72
|
+
console.log(table(['when', 'service', 'reason', 'from', 'to', 'score'], body.events.map((e) => [
|
|
73
|
+
relativeTime(e.at),
|
|
74
|
+
e.service,
|
|
75
|
+
e.reason === 'failover' ? c.yellow(e.reason) : e.reason,
|
|
76
|
+
e.from ?? c.dim('—'),
|
|
77
|
+
e.to ?? c.dim('—'),
|
|
78
|
+
typeof e.detail?.score === 'number' ? e.detail.score.toFixed(3) : c.dim('—'),
|
|
79
|
+
])));
|
|
80
|
+
},
|
|
81
|
+
};
|