@yadurajfleetos/cli 0.2.0 → 0.4.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 +1 -1
- package/dist/archive.js +21 -1
- 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/commands/secrets.js +110 -1
- package/dist/commands/services.js +29 -12
- package/dist/commands/up.js +6 -4
- package/dist/dotenv.js +103 -0
- package/dist/index.js +5 -0
- package/dist/plan.js +43 -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/archive.js
CHANGED
|
@@ -21,6 +21,25 @@ import { CliError, EXIT } from './api.js';
|
|
|
21
21
|
* later rather than a slow upload for everybody now.
|
|
22
22
|
*/
|
|
23
23
|
const ALWAYS_EXCLUDE = ['.git', 'node_modules', '.DS_Store'];
|
|
24
|
+
/**
|
|
25
|
+
* Patterns that must never be honoured, however the .dockerignore is written.
|
|
26
|
+
*
|
|
27
|
+
* `Dockerfile` in a .dockerignore is standard, recommended practice: a local
|
|
28
|
+
* `docker build` reads it from the host rather than from the context, so
|
|
29
|
+
* excluding it avoids shipping it twice. Here the context is built somewhere
|
|
30
|
+
* else, and the Dockerfile has to travel with it — honouring that line
|
|
31
|
+
* produces "failed to read dockerfile" on a context that is otherwise perfect.
|
|
32
|
+
*
|
|
33
|
+
* A bare `*` is the other one. It is the whitelist idiom, always paired with
|
|
34
|
+
* `!keep-this` lines, and since negations are not supported it would otherwise
|
|
35
|
+
* mean "exclude the entire project".
|
|
36
|
+
*/
|
|
37
|
+
function mustNotExclude(pattern) {
|
|
38
|
+
const p = pattern.replace(/^\.?\//, '').replace(/^\*\*\//, '');
|
|
39
|
+
if (p === '*' || p === '**' || p === '.')
|
|
40
|
+
return true;
|
|
41
|
+
return /^\*?dockerfile/i.test(p);
|
|
42
|
+
}
|
|
24
43
|
/**
|
|
25
44
|
* Read .dockerignore into tar exclusion patterns.
|
|
26
45
|
*
|
|
@@ -43,7 +62,8 @@ export async function ignorePatterns(dir) {
|
|
|
43
62
|
.map((line) => line.trim())
|
|
44
63
|
.filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
|
|
45
64
|
.map((line) => line.replace(/^\/+/, '').replace(/\/+$/, ''))
|
|
46
|
-
.filter(Boolean)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.filter((line) => !mustNotExclude(line));
|
|
47
67
|
return [...new Set([...ALWAYS_EXCLUDE, ...patterns])];
|
|
48
68
|
}
|
|
49
69
|
/**
|
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/commands/secrets.js
CHANGED
|
@@ -7,10 +7,22 @@
|
|
|
7
7
|
* a supported spelling. The value comes from a pipe or from a prompt with the
|
|
8
8
|
* echo off, and nothing here ever prints one back.
|
|
9
9
|
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
10
11
|
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
11
12
|
import { c, table, relativeTime } from '../render.js';
|
|
12
13
|
import { glyph } from '../ui.js';
|
|
13
14
|
import { askSecret, canPrompt } from '../prompt.js';
|
|
15
|
+
import { parseDotenv } from '../dotenv.js';
|
|
16
|
+
import { declaredSecrets } from '../plan.js';
|
|
17
|
+
/** The manifest in the working directory, if there is one to read. */
|
|
18
|
+
async function declaredSecretsNearby() {
|
|
19
|
+
try {
|
|
20
|
+
return declaredSecrets(await readFile('fleet.yaml', 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
14
26
|
const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
15
27
|
/**
|
|
16
28
|
* Read the value from a pipe when there is one, otherwise ask for it.
|
|
@@ -95,6 +107,99 @@ export const secretsCommand = {
|
|
|
95
107
|
console.log(c.dim(' takes effect on the next deploy of any service that references it'));
|
|
96
108
|
return;
|
|
97
109
|
}
|
|
110
|
+
/* ── import ────────────────────────────────────────────────── */
|
|
111
|
+
if (sub === 'import') {
|
|
112
|
+
// Reading and choosing happen before anything touches the network, so
|
|
113
|
+
// `--dry-run` works on a plane, and a typo in a filename is not reported
|
|
114
|
+
// only after a sign-in prompt.
|
|
115
|
+
const file = key ?? '.env';
|
|
116
|
+
let source;
|
|
117
|
+
try {
|
|
118
|
+
source = await readFile(file, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new CliError(`Cannot read "${file}".\n` +
|
|
122
|
+
` usage: fleet secrets import [file] (defaults to .env)`, EXIT.usage);
|
|
123
|
+
}
|
|
124
|
+
const parsed = parseDotenv(source);
|
|
125
|
+
for (const skip of parsed.skipped) {
|
|
126
|
+
console.log(`${glyph.warn} ${c.yellow('skipped')} line ${skip.line}: ${skip.reason}`);
|
|
127
|
+
}
|
|
128
|
+
for (const warning of parsed.warnings) {
|
|
129
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${warning}`);
|
|
130
|
+
}
|
|
131
|
+
if (!parsed.entries.length) {
|
|
132
|
+
throw new CliError(`No usable assignments in "${file}".`, EXIT.usage);
|
|
133
|
+
}
|
|
134
|
+
// Which of them to send. The default is what the manifest declares,
|
|
135
|
+
// because a .env is half configuration and the store is only for the
|
|
136
|
+
// other half.
|
|
137
|
+
const only = typeof flags.only === 'string' ? flags.only.split(',').map((k) => k.trim()) : null;
|
|
138
|
+
let chosen;
|
|
139
|
+
let basis;
|
|
140
|
+
if (only) {
|
|
141
|
+
const missing = only.filter((k) => !parsed.entries.some((e) => e.key === k));
|
|
142
|
+
if (missing.length) {
|
|
143
|
+
throw new CliError(`Not in ${file}: ${missing.join(', ')}`, EXIT.usage);
|
|
144
|
+
}
|
|
145
|
+
chosen = parsed.entries.filter((e) => only.includes(e.key));
|
|
146
|
+
basis = '--only';
|
|
147
|
+
}
|
|
148
|
+
else if (flags.all) {
|
|
149
|
+
chosen = parsed.entries;
|
|
150
|
+
basis = '--all';
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const declared = await declaredSecretsNearby();
|
|
154
|
+
if (!declared) {
|
|
155
|
+
throw new CliError(`No fleet.yaml here to say which keys are secrets.\n` +
|
|
156
|
+
` Pick them: fleet secrets import ${file} --only KEY,OTHER_KEY\n` +
|
|
157
|
+
` Or send it all: fleet secrets import ${file} --all`, EXIT.usage);
|
|
158
|
+
}
|
|
159
|
+
chosen = parsed.entries.filter((e) => declared.has(e.key));
|
|
160
|
+
basis = 'fleet.yaml';
|
|
161
|
+
// Named in the manifest but absent from the file: the deploy will be
|
|
162
|
+
// refused for a missing secret later, so say it now.
|
|
163
|
+
for (const [name, wanted] of declared) {
|
|
164
|
+
if (!parsed.entries.some((e) => e.key === name)) {
|
|
165
|
+
console.log(`${glyph.warn} ${c.yellow('missing')} ${c.bold(name)} is declared by ${wanted.join(', ')} but is not in ${file}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!chosen.length) {
|
|
170
|
+
throw new CliError(`Nothing in "${file}" matches ${basis === 'fleet.yaml' ? 'the secrets fleet.yaml declares' : basis}.\n` +
|
|
171
|
+
` Send everything with --all, or name keys with --only KEY,OTHER_KEY`, EXIT.usage);
|
|
172
|
+
}
|
|
173
|
+
if (flags['dry-run']) {
|
|
174
|
+
const scope = service ? ` for ${c.bold(service)}` : '';
|
|
175
|
+
console.log(`\n ${c.dim(`would store from ${file}${scope}, chosen by ${basis}`)}\n`);
|
|
176
|
+
for (const entry of chosen)
|
|
177
|
+
console.log(` ${c.bold(entry.key)} ${c.dim(`(line ${entry.line})`)}`);
|
|
178
|
+
console.log(c.dim(`\n ${chosen.length} key(s). No values are shown, here or ever.`));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
182
|
+
const target = service ? await resolveServiceId(fleetId, service) : null;
|
|
183
|
+
const where = target ? ` for ${c.bold(target.name)}` : '';
|
|
184
|
+
let stored = 0;
|
|
185
|
+
let replaced = 0;
|
|
186
|
+
for (const entry of chosen) {
|
|
187
|
+
const path = target
|
|
188
|
+
? `/services/${target.id}/secrets/${encodeURIComponent(entry.key)}`
|
|
189
|
+
: `/fleets/${fleetId}/secrets/${encodeURIComponent(entry.key)}`;
|
|
190
|
+
const { body } = await request('PUT', path, { body: { value: entry.value } });
|
|
191
|
+
if (body.created)
|
|
192
|
+
stored++;
|
|
193
|
+
else
|
|
194
|
+
replaced++;
|
|
195
|
+
console.log(`${glyph.ok} ${c.green(body.created ? 'stored' : 'replaced')} ${c.bold(entry.key)}${where}`);
|
|
196
|
+
}
|
|
197
|
+
const untouched = parsed.entries.length - chosen.length;
|
|
198
|
+
console.log(c.dim(`\n ${stored} stored, ${replaced} replaced` +
|
|
199
|
+
(untouched ? `; ${untouched} other key(s) in ${file} left alone` : '')));
|
|
200
|
+
console.log(c.dim(' takes effect on the next deploy of any service that references them'));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
98
203
|
/* ── rm ────────────────────────────────────────────────────── */
|
|
99
204
|
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
100
205
|
if (!key)
|
|
@@ -112,6 +217,10 @@ export const secretsCommand = {
|
|
|
112
217
|
}
|
|
113
218
|
throw new CliError('usage: fleet secrets [ls]\n' +
|
|
114
219
|
' fleet secrets set <KEY> [--service <name>]\n' +
|
|
115
|
-
' fleet secrets rm <KEY> [--service <name>]'
|
|
220
|
+
' fleet secrets rm <KEY> [--service <name>]\n' +
|
|
221
|
+
' fleet secrets import [file] [--all | --only A,B] [--service <name>] [--dry-run]\n' +
|
|
222
|
+
'\n' +
|
|
223
|
+
' import reads a .env (default: ./.env) and stores the keys fleet.yaml\n' +
|
|
224
|
+
' declares as secrets. --all sends every key in the file instead.', EXIT.usage);
|
|
116
225
|
},
|
|
117
226
|
};
|
|
@@ -6,7 +6,7 @@ import { task, glyph } from '../ui.js';
|
|
|
6
6
|
import { withLadder } from '../ladder.js';
|
|
7
7
|
import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
|
|
8
8
|
import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
|
|
9
|
-
import { planFromManifest } from '../plan.js';
|
|
9
|
+
import { planFromManifest, projectNameFor } from '../plan.js';
|
|
10
10
|
import { uploadContext, humanBytes } from '../archive.js';
|
|
11
11
|
const manifestPath = (given) => given ?? 'fleet.yaml';
|
|
12
12
|
async function readManifest(path) {
|
|
@@ -40,10 +40,12 @@ export const applyCommand = {
|
|
|
40
40
|
async run(args, flags) {
|
|
41
41
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
42
42
|
const manifest = await readManifest(manifestPath(args[0]));
|
|
43
|
-
const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
43
|
+
const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
44
|
+
body: { manifest, project: projectNameFor(process.cwd()) },
|
|
45
|
+
})).body, {
|
|
44
46
|
done: (b) => b.created.length || b.updated.length
|
|
45
|
-
? `applied ${b.created.length + b.updated.length} service(s)`
|
|
46
|
-
:
|
|
47
|
+
? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
|
|
48
|
+
: `no changes in project ${b.project}`,
|
|
47
49
|
});
|
|
48
50
|
if (flags.json)
|
|
49
51
|
return console.log(JSON.stringify(body, null, 2));
|
|
@@ -71,14 +73,29 @@ export const servicesCommand = {
|
|
|
71
73
|
return console.log(JSON.stringify(body.services, null, 2));
|
|
72
74
|
if (!body.services.length)
|
|
73
75
|
return console.log('No services. Run `fleet apply` with a fleet.yaml.');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
s.
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
// Grouped by project. A fleet.yaml describes a stack, and listing its
|
|
77
|
+
// services flat among somebody else's is how four related things came to
|
|
78
|
+
// look like four unrelated ones.
|
|
79
|
+
const byProject = new Map();
|
|
80
|
+
for (const s of body.services) {
|
|
81
|
+
const key = s.project || 'default';
|
|
82
|
+
const group = byProject.get(key) ?? [];
|
|
83
|
+
group.push(s);
|
|
84
|
+
byProject.set(key, group);
|
|
85
|
+
}
|
|
86
|
+
for (const [project, group] of [...byProject].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
87
|
+
const running = group.filter((s) => s.current?.status === 'running').length;
|
|
88
|
+
const ram = group.reduce((sum, s) => sum + s.requestRamMb, 0);
|
|
89
|
+
console.log(`\n${c.bold(project)} ${c.dim(`${running}/${group.length} running · ${mb(ram)}`)}`);
|
|
90
|
+
console.log(table(['service', 'url', 'placement', 'node', 'sha', 'status'], group.map((s) => [
|
|
91
|
+
s.name + (s.persistentVolume ? c.dim(' ⛁') : ''),
|
|
92
|
+
s.domain ?? s.hostname ?? c.dim('—'),
|
|
93
|
+
s.placementPolicy,
|
|
94
|
+
s.current?.nodeName ?? c.dim('—'),
|
|
95
|
+
s.current?.gitSha?.slice(0, 7) ?? c.dim('—'),
|
|
96
|
+
s.current ? statusColour(s.current.status) : c.dim('not deployed'),
|
|
97
|
+
])));
|
|
98
|
+
}
|
|
82
99
|
},
|
|
83
100
|
};
|
|
84
101
|
async function findService(fleetId, name) {
|
package/dist/commands/up.js
CHANGED
|
@@ -14,7 +14,7 @@ import { c } from '../render.js';
|
|
|
14
14
|
import { task, glyph } from '../ui.js';
|
|
15
15
|
import { withLadder } from '../ladder.js';
|
|
16
16
|
import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
|
|
17
|
-
import { planFromManifest, deployOrder } from '../plan.js';
|
|
17
|
+
import { planFromManifest, deployOrder, projectNameFor } from '../plan.js';
|
|
18
18
|
import { uploadContext, humanBytes } from '../archive.js';
|
|
19
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
20
|
export const upCommand = {
|
|
@@ -46,10 +46,12 @@ export const upCommand = {
|
|
|
46
46
|
}
|
|
47
47
|
// ── Step 2: read and apply the manifest ───────────────────────────
|
|
48
48
|
const manifest = await readFile(manifestPath, 'utf8');
|
|
49
|
-
const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
49
|
+
const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
|
|
50
|
+
body: { manifest, project: projectNameFor(process.cwd()) },
|
|
51
|
+
})).body, {
|
|
50
52
|
done: (b) => b.created.length || b.updated.length
|
|
51
|
-
? `applied ${b.created.length + b.updated.length} service(s)`
|
|
52
|
-
:
|
|
53
|
+
? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
|
|
54
|
+
: `no changes in project ${b.project}`,
|
|
53
55
|
});
|
|
54
56
|
for (const w of applyResult.warnings) {
|
|
55
57
|
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
package/dist/dotenv.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a .env file.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a general dotenv implementation. This one feeds a
|
|
5
|
+
* credential store, which changes the trade-offs: a value that is silently
|
|
6
|
+
* altered on the way in fails authentication somewhere far away from here,
|
|
7
|
+
* with nothing to point back at this file. So the rules are narrow, and
|
|
8
|
+
* anything ambiguous is reported rather than guessed at.
|
|
9
|
+
*/
|
|
10
|
+
/** The same shape the control plane accepts as an environment variable name. */
|
|
11
|
+
const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
12
|
+
export function parseDotenv(source) {
|
|
13
|
+
const entries = [];
|
|
14
|
+
const skipped = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const lines = source.split(/\r?\n/);
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const raw = lines[i];
|
|
20
|
+
const line = i + 1;
|
|
21
|
+
const trimmed = raw.trim();
|
|
22
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
23
|
+
continue;
|
|
24
|
+
// `export FOO=bar` is common in files meant to be sourced by a shell.
|
|
25
|
+
const withoutExport = trimmed.replace(/^export\s+/, '');
|
|
26
|
+
const eq = withoutExport.indexOf('=');
|
|
27
|
+
if (eq < 1) {
|
|
28
|
+
skipped.push({ line, text: trimmed, reason: 'not a KEY=VALUE assignment' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
32
|
+
if (!KEY_PATTERN.test(key)) {
|
|
33
|
+
skipped.push({ line, text: key, reason: 'not a usable environment variable name' });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const rest = withoutExport.slice(eq + 1);
|
|
37
|
+
let value;
|
|
38
|
+
const quote = rest.trimStart()[0];
|
|
39
|
+
if (quote === '"' || quote === "'") {
|
|
40
|
+
const body = rest.trimStart();
|
|
41
|
+
const end = findClosingQuote(body, quote);
|
|
42
|
+
if (end < 0) {
|
|
43
|
+
// A multi-line value, or a typo. Either way, do not guess where it ends.
|
|
44
|
+
skipped.push({ line, text: key, reason: `unterminated ${quote === '"' ? 'double' : 'single'} quote` });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const inner = body.slice(1, end);
|
|
48
|
+
// Single quotes are literal, as in a shell. Double quotes take the usual
|
|
49
|
+
// escapes so a value can contain a newline.
|
|
50
|
+
value = quote === "'" ? inner : unescape(inner);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
value = rest.trim();
|
|
54
|
+
// A '#' after whitespace is a comment in most dotenv readers and part of
|
|
55
|
+
// the password in some. Truncating a credential is the worse mistake, so
|
|
56
|
+
// this keeps the whole value and says so.
|
|
57
|
+
if (/\s#/.test(value)) {
|
|
58
|
+
warnings.push(`${key} (line ${line}) contains " #" and was stored whole, comment included. ` +
|
|
59
|
+
`Quote the value if part of it is a comment.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (seen.has(key)) {
|
|
63
|
+
// Later wins, as a shell would do, but a duplicate is worth saying aloud:
|
|
64
|
+
// two different values for one key is rarely intentional.
|
|
65
|
+
warnings.push(`${key} appears more than once; the value on line ${line} is the one used.`);
|
|
66
|
+
const previous = entries.findIndex((e) => e.key === key);
|
|
67
|
+
entries.splice(previous, 1);
|
|
68
|
+
}
|
|
69
|
+
seen.add(key);
|
|
70
|
+
entries.push({ key, value, line });
|
|
71
|
+
}
|
|
72
|
+
return { entries, skipped, warnings };
|
|
73
|
+
}
|
|
74
|
+
/** Index of the closing quote, skipping ones that are escaped. */
|
|
75
|
+
function findClosingQuote(body, quote) {
|
|
76
|
+
for (let i = 1; i < body.length; i++) {
|
|
77
|
+
if (body[i] === '\\' && quote === '"') {
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (body[i] === quote)
|
|
82
|
+
return i;
|
|
83
|
+
}
|
|
84
|
+
return -1;
|
|
85
|
+
}
|
|
86
|
+
function unescape(input) {
|
|
87
|
+
return input.replace(/\\(.)/g, (_, ch) => {
|
|
88
|
+
switch (ch) {
|
|
89
|
+
case 'n':
|
|
90
|
+
return '\n';
|
|
91
|
+
case 'r':
|
|
92
|
+
return '\r';
|
|
93
|
+
case 't':
|
|
94
|
+
return '\t';
|
|
95
|
+
case '\\':
|
|
96
|
+
return '\\';
|
|
97
|
+
case '"':
|
|
98
|
+
return '"';
|
|
99
|
+
default:
|
|
100
|
+
return `\\${ch}`;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -50,13 +50,18 @@ const GROUPS = [
|
|
|
50
50
|
['rollback <service> [release]', 'Restore the previous or selected release'],
|
|
51
51
|
['secrets', 'List the fleet secret store'],
|
|
52
52
|
['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
|
|
53
|
+
['secrets import [.env]', 'Store the secrets fleet.yaml declares, read from a .env file'],
|
|
53
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'],
|
|
54
58
|
['nodes cordon <name>', 'Stop scheduling new work onto a node'],
|
|
55
59
|
['nodes uncordon <name>', 'Allow scheduling again'],
|
|
56
60
|
['nodes rm <name>', 'Revoke and remove a node'],
|
|
57
61
|
['unpair', 'Remove this machine from its fleet, run on the machine'],
|
|
58
62
|
['alerts', 'List, add, and test alert rules'],
|
|
59
63
|
['auth login|logout|whoami', 'Sign in to a control plane'],
|
|
64
|
+
['auth forgot|reset', 'Recover an account you are locked out of'],
|
|
60
65
|
],
|
|
61
66
|
],
|
|
62
67
|
];
|
package/dist/plan.js
CHANGED
|
@@ -7,6 +7,23 @@
|
|
|
7
7
|
* whose database is not up yet fails its health check like any other outage.
|
|
8
8
|
*/
|
|
9
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
/**
|
|
11
|
+
* What to call this manifest's services collectively when it does not say.
|
|
12
|
+
*
|
|
13
|
+
* The directory name, which is what Compose does and what a person would
|
|
14
|
+
* answer if asked "which project is this". Normalised to the same shape a
|
|
15
|
+
* service name has to be, so the server never rejects a name it derived.
|
|
16
|
+
*/
|
|
17
|
+
export function projectNameFor(dir) {
|
|
18
|
+
const base = dir.split('/').filter(Boolean).pop() ?? 'default';
|
|
19
|
+
const slug = base
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '')
|
|
23
|
+
.slice(0, 48)
|
|
24
|
+
.replace(/-+$/, '');
|
|
25
|
+
return slug || 'default';
|
|
26
|
+
}
|
|
10
27
|
/**
|
|
11
28
|
* Read the manifest the way the control plane will.
|
|
12
29
|
*
|
|
@@ -28,6 +45,32 @@ export function planFromManifest(source) {
|
|
|
28
45
|
};
|
|
29
46
|
});
|
|
30
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Which secrets the manifest says it needs, and which services want each.
|
|
50
|
+
*
|
|
51
|
+
* A .env holds a mix — half configuration, half credentials — and only the
|
|
52
|
+
* credentials belong in the secret store. The manifest already draws that line
|
|
53
|
+
* by declaring `secrets:`, so importing can honour it rather than asking
|
|
54
|
+
* somebody to re-draw it at the command line.
|
|
55
|
+
*/
|
|
56
|
+
export function declaredSecrets(source) {
|
|
57
|
+
const doc = parseYaml(source);
|
|
58
|
+
const services = doc?.services;
|
|
59
|
+
const declared = new Map();
|
|
60
|
+
if (!services || typeof services !== 'object')
|
|
61
|
+
return declared;
|
|
62
|
+
for (const [name, raw] of Object.entries(services)) {
|
|
63
|
+
const body = (raw ?? {});
|
|
64
|
+
if (!Array.isArray(body.secrets))
|
|
65
|
+
continue;
|
|
66
|
+
for (const key of body.secrets) {
|
|
67
|
+
if (typeof key !== 'string')
|
|
68
|
+
continue;
|
|
69
|
+
declared.set(key, [...(declared.get(key) ?? []), name]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return declared;
|
|
73
|
+
}
|
|
31
74
|
/**
|
|
32
75
|
* Order services so a dependency is deployed before whatever depends on it.
|
|
33
76
|
*
|