@yadurajfleetos/cli 0.1.8 → 0.2.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/dist/api.js CHANGED
@@ -28,9 +28,13 @@ export async function request(method, path, opts = {}) {
28
28
  method,
29
29
  headers: {
30
30
  ...(token ? { authorization: `Bearer ${token}` } : {}),
31
- ...(opts.body ? { 'content-type': 'application/json' } : {}),
31
+ ...(opts.raw
32
+ ? { 'content-type': opts.raw.contentType }
33
+ : opts.body
34
+ ? { 'content-type': 'application/json' }
35
+ : {}),
32
36
  },
33
- body: opts.body ? JSON.stringify(opts.body) : undefined,
37
+ body: opts.raw ? opts.raw.data : opts.body ? JSON.stringify(opts.body) : undefined,
34
38
  signal: AbortSignal.timeout(20 * 60_000),
35
39
  });
36
40
  let res;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Packing a build context.
3
+ *
4
+ * `build:` used to mean "only via a git push", because a checkout on the
5
+ * control plane was the one context a build could run against. This sends the
6
+ * directory instead, so a deploy from a laptop builds the same way a pushed
7
+ * commit does — and Fleet, which can see the fleet's architectures, builds for
8
+ * all of them rather than leaving you to notice that the image you made on an
9
+ * Apple laptop will not start on an amd64 node.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { CliError, EXIT } from './api.js';
15
+ /**
16
+ * Excluded even when no .dockerignore says so.
17
+ *
18
+ * These are never part of an image and are the difference between an upload of
19
+ * a few hundred kilobytes and one of several hundred megabytes. A Dockerfile
20
+ * that genuinely needs .git is rare enough to be worth an explicit exception
21
+ * later rather than a slow upload for everybody now.
22
+ */
23
+ const ALWAYS_EXCLUDE = ['.git', 'node_modules', '.DS_Store'];
24
+ /**
25
+ * Read .dockerignore into tar exclusion patterns.
26
+ *
27
+ * The two formats are close but not identical: .dockerignore has negations
28
+ * (`!keep-this`) and anchors paths at the context root. Negations are dropped
29
+ * rather than half-implemented — including a file that should have been
30
+ * excluded is a slow upload, whereas excluding one that should have been kept
31
+ * is a broken build, and silently doing the second would be worse.
32
+ */
33
+ export async function ignorePatterns(dir) {
34
+ let text = '';
35
+ try {
36
+ text = await readFile(join(dir, '.dockerignore'), 'utf8');
37
+ }
38
+ catch {
39
+ return [...ALWAYS_EXCLUDE];
40
+ }
41
+ const patterns = text
42
+ .split('\n')
43
+ .map((line) => line.trim())
44
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
45
+ .map((line) => line.replace(/^\/+/, '').replace(/\/+$/, ''))
46
+ .filter(Boolean);
47
+ return [...new Set([...ALWAYS_EXCLUDE, ...patterns])];
48
+ }
49
+ /**
50
+ * Pack `dir` into a gzipped tar in memory.
51
+ *
52
+ * Buffered rather than streamed because the whole thing is POSTed as one body,
53
+ * and the control plane rejects anything over its limit anyway — a stream would
54
+ * only defer discovering that until after the upload.
55
+ */
56
+ export async function packContext(dir) {
57
+ const excludes = await ignorePatterns(dir);
58
+ const args = [
59
+ '-czf',
60
+ '-',
61
+ '-C',
62
+ dir,
63
+ // Ownership and timestamps vary per machine and would make two packs of the
64
+ // same tree differ for no reason.
65
+ '--no-xattrs',
66
+ ...excludes.flatMap((p) => ['--exclude', p]),
67
+ '.',
68
+ ];
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn('tar', args, { stdio: ['ignore', 'pipe', 'pipe'] });
71
+ const chunks = [];
72
+ let stderr = '';
73
+ let settled = false;
74
+ const fail = (message) => {
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ reject(new CliError(message, EXIT.failure));
79
+ };
80
+ child.stdout.on('data', (c) => chunks.push(c));
81
+ child.stderr.on('data', (c) => (stderr += c.toString()));
82
+ child.on('error', (err) => fail(`Could not run tar to package the build context: ${err.message}`));
83
+ child.on('close', (code) => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ if (code !== 0) {
88
+ const detail = stderr.trim().split('\n').slice(-2).join(' ');
89
+ return reject(new CliError(`Could not package the build context${detail ? `: ${detail}` : ''}`, EXIT.failure));
90
+ }
91
+ resolve(Buffer.concat(chunks));
92
+ });
93
+ });
94
+ }
95
+ /** For the "uploading 4.2MB" line, so a slow upload says why it is slow. */
96
+ export function humanBytes(bytes) {
97
+ if (bytes < 1024)
98
+ return `${bytes}B`;
99
+ if (bytes < 1024 * 1024)
100
+ return `${(bytes / 1024).toFixed(0)}kB`;
101
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
102
+ }
103
+ /**
104
+ * Pack the directory and hand it to the control plane.
105
+ *
106
+ * Returns the id the deploy quotes back, or null when there is nothing to
107
+ * send — a service deploying a prebuilt `image:` has no context, and uploading
108
+ * one would be pure waste.
109
+ */
110
+ export async function uploadContext(serviceId, dir) {
111
+ const { request } = await import('./api.js');
112
+ const archive = await packContext(dir);
113
+ const { body } = await request('POST', `/services/${serviceId}/build-context`, { raw: { data: archive, contentType: 'application/gzip' } });
114
+ return body;
115
+ }
@@ -8,6 +8,7 @@ import { upCommand } from './up.js';
8
8
  import { openCommand } from './open.js';
9
9
  import { downCommand } from './down.js';
10
10
  import { unpairCommand, agentCommand } from './unpair.js';
11
+ import { secretsCommand } from './secrets.js';
11
12
  import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
12
13
  export const commands = {
13
14
  up: upCommand,
@@ -33,6 +34,7 @@ export const commands = {
33
34
  rollback: rollbackCommand,
34
35
  events: eventsCommand,
35
36
  alerts: alertsCommand,
37
+ secrets: secretsCommand,
36
38
  unpair: unpairCommand,
37
39
  agent: agentCommand,
38
40
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * fleet secrets — the fleet credential store.
3
+ *
4
+ * The one rule this command exists to enforce: a secret value never appears in
5
+ * `argv`. Arguments land in shell history, in `ps` output for every user on the
6
+ * box, and in CI logs — so `fleet secrets set KEY hunter2` is deliberately not
7
+ * a supported spelling. The value comes from a pipe or from a prompt with the
8
+ * echo off, and nothing here ever prints one back.
9
+ */
10
+ import { request, requireFleet, CliError, EXIT } from '../api.js';
11
+ import { c, table, relativeTime } from '../render.js';
12
+ import { glyph } from '../ui.js';
13
+ import { askSecret, canPrompt } from '../prompt.js';
14
+ const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
15
+ /**
16
+ * Read the value from a pipe when there is one, otherwise ask for it.
17
+ *
18
+ * The piped form is what a script or a password manager uses:
19
+ * pass show db/url | fleet secrets set DATABASE_URL
20
+ *
21
+ * Trailing newlines are stripped because every here-string and every `echo`
22
+ * adds one, and a credential with an invisible newline on the end fails
23
+ * authentication somewhere far away from here.
24
+ */
25
+ async function readValue(key) {
26
+ if (!process.stdin.isTTY) {
27
+ const chunks = [];
28
+ for await (const chunk of process.stdin)
29
+ chunks.push(Buffer.from(chunk));
30
+ const piped = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
31
+ if (piped)
32
+ return piped;
33
+ }
34
+ if (!canPrompt()) {
35
+ throw new CliError(`No value for ${key}. Pipe it in, or run this where there is a terminal to type into:\n` +
36
+ ` echo -n "value" | fleet secrets set ${key}`, EXIT.usage);
37
+ }
38
+ return askSecret(`${key}`, { hint: 'the value is not echoed and is not stored in shell history' });
39
+ }
40
+ async function resolveServiceId(fleetId, name) {
41
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
42
+ const match = body.services.find((s) => s.name === name || s.id === name);
43
+ if (!match) {
44
+ throw new CliError(`No service called "${name}". Known: ${body.services.map((s) => s.name).join(', ') || 'none'}`, EXIT.usage);
45
+ }
46
+ return match;
47
+ }
48
+ export const secretsCommand = {
49
+ async run(args, flags) {
50
+ const [sub, key] = args;
51
+ const service = typeof flags.service === 'string' ? flags.service : undefined;
52
+ /* ── list ──────────────────────────────────────────────────── */
53
+ if (!sub || sub === 'ls' || sub === 'list') {
54
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
55
+ const { body } = await request('GET', `/fleets/${fleetId}/secrets`);
56
+ if (flags.json)
57
+ return console.log(JSON.stringify(body.secrets, null, 2));
58
+ if (!body.secrets.length) {
59
+ console.log('No secrets in this fleet.');
60
+ console.log(c.dim(' set one with `fleet secrets set DATABASE_URL`'));
61
+ return;
62
+ }
63
+ console.log(table(['key', 'scope', 'updated'], body.secrets.map((s) => [
64
+ s.key,
65
+ s.scope === 'service' ? `${c.dim('service:')}${s.service ?? '?'}` : c.dim('fleet'),
66
+ relativeTime(s.updatedAt),
67
+ ])));
68
+ console.log(c.dim(`\n ${body.secrets.length} stored. Values cannot be read back — only replaced.`));
69
+ return;
70
+ }
71
+ /* ── set ───────────────────────────────────────────────────── */
72
+ if (sub === 'set') {
73
+ if (!key)
74
+ throw new CliError('usage: fleet secrets set <KEY> [--service <name>]', EXIT.usage);
75
+ if (!KEY_PATTERN.test(key)) {
76
+ throw new CliError(`"${key}" is not a usable environment variable name.\n` +
77
+ ` Use upper snake case: A-Z, 0-9 and _, not starting with a digit.`, EXIT.usage);
78
+ }
79
+ // A third positional is almost always someone typing the value inline.
80
+ // Refuse it rather than accepting a credential into shell history.
81
+ if (args[2]) {
82
+ throw new CliError('Do not pass the value as an argument — it would be written to your shell history.\n' +
83
+ ` Pipe it: echo -n "value" | fleet secrets set ${key}\n` +
84
+ ` Or type it: fleet secrets set ${key}`, EXIT.usage);
85
+ }
86
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
87
+ const target = service ? await resolveServiceId(fleetId, service) : null;
88
+ const value = await readValue(key);
89
+ const path = target
90
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
91
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
92
+ const { body } = await request('PUT', path, { body: { value } });
93
+ const where = target ? ` for ${c.bold(target.name)}` : '';
94
+ console.log(`${glyph.ok} ${c.green(body.created ? 'stored' : 'replaced')} ${c.bold(key)}${where}`);
95
+ console.log(c.dim(' takes effect on the next deploy of any service that references it'));
96
+ return;
97
+ }
98
+ /* ── rm ────────────────────────────────────────────────────── */
99
+ if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
100
+ if (!key)
101
+ throw new CliError('usage: fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
102
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
103
+ const target = service ? await resolveServiceId(fleetId, service) : null;
104
+ const path = target
105
+ ? `/services/${target.id}/secrets/${encodeURIComponent(key)}`
106
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(key)}`;
107
+ await request('DELETE', path);
108
+ const where = target ? ` override for ${c.bold(target.name)}` : '';
109
+ console.log(`${glyph.ok} ${c.yellow('removed')} ${c.bold(key)}${where}`);
110
+ console.log(c.dim(' services already running keep the value they started with until redeployed'));
111
+ return;
112
+ }
113
+ throw new CliError('usage: fleet secrets [ls]\n' +
114
+ ' fleet secrets set <KEY> [--service <name>]\n' +
115
+ ' fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
116
+ },
117
+ };
@@ -2,7 +2,12 @@ import { readFile, writeFile, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { request, requireFleet, CliError, EXIT } from '../api.js';
4
4
  import { c, table, statusColour, keyValues, relativeTime, mb } from '../render.js';
5
- import { task, splash, glyph } from '../ui.js';
5
+ import { task, glyph } from '../ui.js';
6
+ import { withLadder } from '../ladder.js';
7
+ import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
+ import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
9
+ import { planFromManifest } from '../plan.js';
10
+ import { uploadContext, humanBytes } from '../archive.js';
6
11
  const manifestPath = (given) => given ?? 'fleet.yaml';
7
12
  async function readManifest(path) {
8
13
  try {
@@ -151,6 +156,22 @@ async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
151
156
  throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
152
157
  }, { done: () => `${c.bold(name)} is running` });
153
158
  }
159
+ /**
160
+ * The build context a service declares, if any, read from the local manifest.
161
+ *
162
+ * Absent when there is no fleet.yaml here — deploying from outside the
163
+ * repository is legitimate for a prebuilt `image:` service, and should not
164
+ * become an error about a file the operator never needed.
165
+ */
166
+ async function buildContextFor(serviceName) {
167
+ try {
168
+ const source = await readFile('fleet.yaml', 'utf8');
169
+ return planFromManifest(source).find((s) => s.name === serviceName)?.build;
170
+ }
171
+ catch {
172
+ return undefined;
173
+ }
174
+ }
154
175
  export const deployCommand = {
155
176
  async run(args, flags) {
156
177
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
@@ -179,16 +200,33 @@ export const deployCommand = {
179
200
  console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
180
201
  return;
181
202
  }
182
- 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, {
183
- // What the request involves, not a stage it has reached the call is a
184
- // single synchronous POST and the CLI cannot see inside it.
185
- hints: [
186
- 'scoring every online node on headroom, reliability and load',
187
- 'building for every architecture an eligible node runs',
188
- 'the first multi-arch build is the slow one; layers cache after it',
189
- 'pushing the image to the fleet registry',
190
- ],
191
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
203
+ // A service that builds from source needs its directory sent, or the
204
+ // control plane has nothing to build and says the context does not exist.
205
+ // Read from the manifest here rather than from the service row, because
206
+ // the build path is relative to the file the operator is standing in.
207
+ let contextId;
208
+ const buildContext = await buildContextFor(service.name);
209
+ if (buildContext) {
210
+ const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, join(process.cwd(), buildContext)), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
211
+ contextId = uploaded.contextId;
212
+ }
213
+ const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
214
+ const walker = phaseWalker(ladder);
215
+ const progress = follow(service.id, (p) => walker.apply(p), {
216
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
217
+ });
218
+ try {
219
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha, contextId } })).body;
220
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
221
+ return result;
222
+ }
223
+ finally {
224
+ await progress.stop();
225
+ }
226
+ }, {
227
+ mark: true,
228
+ title: `deploying ${service.name}${gitSha ? ` at ${gitSha.slice(0, 7)}` : ''}`,
229
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
192
230
  });
193
231
  if (flags.json)
194
232
  return console.log(JSON.stringify(body, null, 2));
@@ -11,7 +11,11 @@ import { readFile, writeFile, access } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { request, requireFleet, CliError, EXIT } from '../api.js';
13
13
  import { c } from '../render.js';
14
- import { task, splash, glyph } from '../ui.js';
14
+ import { task, glyph } from '../ui.js';
15
+ import { withLadder } from '../ladder.js';
16
+ import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
17
+ import { planFromManifest, deployOrder } from '../plan.js';
18
+ import { uploadContext, humanBytes } from '../archive.js';
15
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
20
  export const upCommand = {
17
21
  async run(args, flags) {
@@ -50,61 +54,115 @@ export const upCommand = {
50
54
  for (const w of applyResult.warnings) {
51
55
  console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
52
56
  }
53
- // ── Step 3: pick the target service ───────────────────────────────
54
- const serviceName = args[0] || applyResult.created[0] || applyResult.updated[0];
55
- if (!serviceName) {
56
- throw new CliError('Could not determine which service to deploy. Pass the name: fleet up <service>', EXIT.usage);
57
+ // ── Step 3: decide what to deploy, and in what order ──────────────
58
+ const planned = planFromManifest(manifest);
59
+ const buildContexts = new Map(planned.map((p) => [p.name, p.build]));
60
+ // No argument means the whole stack. A manifest describes a system, and
61
+ // deploying one service of it and leaving the rest was never what anybody
62
+ // wanted — it just meant typing the command again in the right order.
63
+ const targets = args[0] ? [args[0]] : deployOrder(planned);
64
+ if (!targets.length) {
65
+ throw new CliError('The manifest declares no services to deploy.', EXIT.usage);
57
66
  }
58
- // Look it up
59
67
  const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
60
- const service = listBody.services.find((s) => s.name === serviceName || s.id === serviceName);
61
- if (!service) {
62
- throw new CliError(`Service "${serviceName}" not found after apply. Known: ${listBody.services.map((s) => s.name).join(', ')}`, EXIT.usage);
63
- }
64
- // ── Step 4: deploy ────────────────────────────────────────────────
65
- const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
66
- const deployResult = await splash(`deploying ${c.bold(service.name)}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
67
- hints: [
68
- 'scoring every online node on headroom, reliability and load',
69
- 'building for every architecture an eligible node runs',
70
- 'the first multi-arch build is the slow one; layers cache after it',
71
- 'pushing the image to the fleet registry',
72
- ],
73
- done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
68
+ const resolved = targets.map((name) => {
69
+ const service = listBody.services.find((s) => s.name === name || s.id === name);
70
+ if (!service) {
71
+ throw new CliError(`Service "${name}" not found after apply. Known: ${listBody.services.map((s) => s.name).join(', ')}`, EXIT.usage);
72
+ }
73
+ return service;
74
74
  });
75
- for (const w of deployResult.warnings ?? []) {
76
- console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
75
+ if (resolved.length > 1) {
76
+ console.log(`\n ${c.dim('deploying')} ${resolved.map((s) => c.bold(s.name)).join(c.dim(''))}\n`);
77
77
  }
78
- // ── Step 5: wait for healthy ──────────────────────────────────────
79
- if (!flags['no-wait']) {
80
- await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
81
- s.hints([
82
- 'the agent picks up desired state on its next poll',
83
- "a cold image pull takes as long as the node's uplink does",
84
- 'this clears once the agent reports the container running',
85
- ]);
86
- const deadline = Date.now() + 180_000;
87
- while (Date.now() < deadline) {
88
- const { body } = await request('GET', `/fleets/${fleetId}/services`);
89
- const current = body.services.find((s) => s.id === service.id)?.current;
90
- if (current?.status === 'running')
91
- return;
92
- if (current?.status === 'failed') {
93
- throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
94
- }
95
- await sleep(2000);
96
- }
97
- throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
98
- }, { done: () => `${c.bold(service.name)} is running` });
78
+ const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
79
+ const deployed = [];
80
+ for (const service of resolved) {
81
+ const url = await deployOne(service, {
82
+ fleetId,
83
+ gitSha,
84
+ buildContext: buildContexts.get(service.name),
85
+ wait: !flags['no-wait'],
86
+ });
87
+ deployed.push({ service, url });
99
88
  }
100
- // ── Step 6: print the URL ─────────────────────────────────────────
101
- const url = deployResult.url ?? service.domain ?? service.hostname;
102
- if (url) {
103
- const fullUrl = url.startsWith('http') ? url : `https://${url}`;
89
+ // ── Step 6: print the URLs ────────────────────────────────────────
90
+ for (const { service, url } of deployed) {
91
+ const target = url ?? service.domain ?? service.hostname;
92
+ if (!target)
93
+ continue;
94
+ const fullUrl = target.startsWith('http') ? target : `https://${target}`;
104
95
  console.log(`\n${glyph.ok} ${c.green('live')} ${c.bold(c.cyan(fullUrl))}`);
105
96
  }
106
- console.log(c.dim(`\n fleet open ${service.name} open in browser`));
107
- console.log(c.dim(` fleet logs ${service.name} follow logs`));
108
- console.log(c.dim(` fleet down ${service.name} tear down`));
97
+ const last = deployed[deployed.length - 1]?.service;
98
+ if (last) {
99
+ console.log(c.dim(`\n fleet open ${last.name} open in browser`));
100
+ console.log(c.dim(` fleet logs ${last.name} follow logs`));
101
+ console.log(c.dim(` fleet down ${last.name} tear down`));
102
+ }
109
103
  },
110
104
  };
105
+ /**
106
+ * Deploy one service: upload its build context if it has one, run the deploy,
107
+ * and wait for it to report running.
108
+ *
109
+ * Returns the URL the control plane handed back, or null for a service that
110
+ * has none — an internal one, which is reached by name from its neighbours
111
+ * rather than from outside.
112
+ */
113
+ async function deployOne(service, opts) {
114
+ // A service that builds from source sends its directory first. The control
115
+ // plane then builds it for every architecture the fleet has, which is the
116
+ // part that is easy to get wrong by hand and silent when you do.
117
+ let contextId;
118
+ if (opts.buildContext) {
119
+ const dir = join(process.cwd(), opts.buildContext);
120
+ const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, dir), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
121
+ contextId = uploaded.contextId;
122
+ }
123
+ const deployResult = await withLadder(DEPLOY_STEPS, async (ladder) => {
124
+ const walker = phaseWalker(ladder);
125
+ const progress = follow(service.id, (p) => walker.apply(p), {
126
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
127
+ });
128
+ try {
129
+ const result = (await request('POST', `/services/${service.id}/deploy`, {
130
+ body: { gitSha: opts.gitSha, contextId },
131
+ })).body;
132
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
133
+ return result;
134
+ }
135
+ finally {
136
+ await progress.stop();
137
+ }
138
+ }, {
139
+ mark: true,
140
+ title: `deploying ${service.name}`,
141
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
142
+ });
143
+ for (const w of deployResult.warnings ?? []) {
144
+ console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
145
+ }
146
+ if (opts.wait) {
147
+ await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
148
+ s.hints([
149
+ 'the agent picks up desired state on its next poll',
150
+ "a cold image pull takes as long as the node's uplink does",
151
+ 'a service with a health check goes running once it passes, not before',
152
+ ]);
153
+ const deadline = Date.now() + 180_000;
154
+ while (Date.now() < deadline) {
155
+ const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
156
+ const current = body.services.find((s) => s.id === service.id)?.current;
157
+ if (current?.status === 'running')
158
+ return;
159
+ if (current?.status === 'failed') {
160
+ throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
161
+ }
162
+ await sleep(2000);
163
+ }
164
+ throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
165
+ }, { done: () => `${c.bold(service.name)} is running` });
166
+ }
167
+ return deployResult.url;
168
+ }
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ const GROUPS = [
15
15
  [
16
16
  'getting started',
17
17
  [
18
- ['up [service]', 'Detect, scaffold, apply, and deploy in one command'],
18
+ ['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
19
19
  ['init', 'Scaffold a fleet.yaml and Dockerfile from this repository'],
20
20
  ['config show', 'Show the saved control plane and selected fleet'],
21
21
  ['use <fleet>', 'Select the default fleet for later commands'],
@@ -48,6 +48,9 @@ const GROUPS = [
48
48
  ['reschedule <service>', 'Force a service to move'],
49
49
  ['restart <service>', 'Replace the current release on its node'],
50
50
  ['rollback <service> [release]', 'Restore the previous or selected release'],
51
+ ['secrets', 'List the fleet secret store'],
52
+ ['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
53
+ ['secrets rm <KEY>', 'Remove a stored credential'],
51
54
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
52
55
  ['nodes uncordon <name>', 'Allow scheduling again'],
53
56
  ['nodes rm <name>', 'Revoke and remove a node'],