@yadurajfleetos/cli 0.1.9 → 0.3.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,135 @@
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
+ * 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
+ }
43
+ /**
44
+ * Read .dockerignore into tar exclusion patterns.
45
+ *
46
+ * The two formats are close but not identical: .dockerignore has negations
47
+ * (`!keep-this`) and anchors paths at the context root. Negations are dropped
48
+ * rather than half-implemented — including a file that should have been
49
+ * excluded is a slow upload, whereas excluding one that should have been kept
50
+ * is a broken build, and silently doing the second would be worse.
51
+ */
52
+ export async function ignorePatterns(dir) {
53
+ let text = '';
54
+ try {
55
+ text = await readFile(join(dir, '.dockerignore'), 'utf8');
56
+ }
57
+ catch {
58
+ return [...ALWAYS_EXCLUDE];
59
+ }
60
+ const patterns = text
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
64
+ .map((line) => line.replace(/^\/+/, '').replace(/\/+$/, ''))
65
+ .filter(Boolean)
66
+ .filter((line) => !mustNotExclude(line));
67
+ return [...new Set([...ALWAYS_EXCLUDE, ...patterns])];
68
+ }
69
+ /**
70
+ * Pack `dir` into a gzipped tar in memory.
71
+ *
72
+ * Buffered rather than streamed because the whole thing is POSTed as one body,
73
+ * and the control plane rejects anything over its limit anyway — a stream would
74
+ * only defer discovering that until after the upload.
75
+ */
76
+ export async function packContext(dir) {
77
+ const excludes = await ignorePatterns(dir);
78
+ const args = [
79
+ '-czf',
80
+ '-',
81
+ '-C',
82
+ dir,
83
+ // Ownership and timestamps vary per machine and would make two packs of the
84
+ // same tree differ for no reason.
85
+ '--no-xattrs',
86
+ ...excludes.flatMap((p) => ['--exclude', p]),
87
+ '.',
88
+ ];
89
+ return new Promise((resolve, reject) => {
90
+ const child = spawn('tar', args, { stdio: ['ignore', 'pipe', 'pipe'] });
91
+ const chunks = [];
92
+ let stderr = '';
93
+ let settled = false;
94
+ const fail = (message) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ reject(new CliError(message, EXIT.failure));
99
+ };
100
+ child.stdout.on('data', (c) => chunks.push(c));
101
+ child.stderr.on('data', (c) => (stderr += c.toString()));
102
+ child.on('error', (err) => fail(`Could not run tar to package the build context: ${err.message}`));
103
+ child.on('close', (code) => {
104
+ if (settled)
105
+ return;
106
+ settled = true;
107
+ if (code !== 0) {
108
+ const detail = stderr.trim().split('\n').slice(-2).join(' ');
109
+ return reject(new CliError(`Could not package the build context${detail ? `: ${detail}` : ''}`, EXIT.failure));
110
+ }
111
+ resolve(Buffer.concat(chunks));
112
+ });
113
+ });
114
+ }
115
+ /** For the "uploading 4.2MB" line, so a slow upload says why it is slow. */
116
+ export function humanBytes(bytes) {
117
+ if (bytes < 1024)
118
+ return `${bytes}B`;
119
+ if (bytes < 1024 * 1024)
120
+ return `${(bytes / 1024).toFixed(0)}kB`;
121
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
122
+ }
123
+ /**
124
+ * Pack the directory and hand it to the control plane.
125
+ *
126
+ * Returns the id the deploy quotes back, or null when there is nothing to
127
+ * send — a service deploying a prebuilt `image:` has no context, and uploading
128
+ * one would be pure waste.
129
+ */
130
+ export async function uploadContext(serviceId, dir) {
131
+ const { request } = await import('./api.js');
132
+ const archive = await packContext(dir);
133
+ const { body } = await request('POST', `/services/${serviceId}/build-context`, { raw: { data: archive, contentType: 'application/gzip' } });
134
+ return body;
135
+ }
@@ -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>]', EXIT.usage);
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,6 +6,8 @@ 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, projectNameFor } from '../plan.js';
10
+ import { uploadContext, humanBytes } from '../archive.js';
9
11
  const manifestPath = (given) => given ?? 'fleet.yaml';
10
12
  async function readManifest(path) {
11
13
  try {
@@ -38,10 +40,12 @@ export const applyCommand = {
38
40
  async run(args, flags) {
39
41
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
40
42
  const manifest = await readManifest(manifestPath(args[0]));
41
- const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
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, {
42
46
  done: (b) => b.created.length || b.updated.length
43
- ? `applied ${b.created.length + b.updated.length} service(s)`
44
- : 'no changes',
47
+ ? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
48
+ : `no changes in project ${b.project}`,
45
49
  });
46
50
  if (flags.json)
47
51
  return console.log(JSON.stringify(body, null, 2));
@@ -69,14 +73,29 @@ export const servicesCommand = {
69
73
  return console.log(JSON.stringify(body.services, null, 2));
70
74
  if (!body.services.length)
71
75
  return console.log('No services. Run `fleet apply` with a fleet.yaml.');
72
- console.log(table(['service', 'url', 'placement', 'node', 'sha', 'status'], body.services.map((s) => [
73
- s.name + (s.persistentVolume ? c.dim(' ') : ''),
74
- s.domain ?? s.hostname ?? c.dim('—'),
75
- s.placementPolicy,
76
- s.current?.nodeName ?? c.dim('—'),
77
- s.current?.gitSha?.slice(0, 7) ?? c.dim(''),
78
- s.current ? statusColour(s.current.status) : c.dim('not deployed'),
79
- ])));
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
+ }
80
99
  },
81
100
  };
82
101
  async function findService(fleetId, name) {
@@ -154,6 +173,22 @@ async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
154
173
  throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
155
174
  }, { done: () => `${c.bold(name)} is running` });
156
175
  }
176
+ /**
177
+ * The build context a service declares, if any, read from the local manifest.
178
+ *
179
+ * Absent when there is no fleet.yaml here — deploying from outside the
180
+ * repository is legitimate for a prebuilt `image:` service, and should not
181
+ * become an error about a file the operator never needed.
182
+ */
183
+ async function buildContextFor(serviceName) {
184
+ try {
185
+ const source = await readFile('fleet.yaml', 'utf8');
186
+ return planFromManifest(source).find((s) => s.name === serviceName)?.build;
187
+ }
188
+ catch {
189
+ return undefined;
190
+ }
191
+ }
157
192
  export const deployCommand = {
158
193
  async run(args, flags) {
159
194
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
@@ -182,13 +217,23 @@ export const deployCommand = {
182
217
  console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
183
218
  return;
184
219
  }
220
+ // A service that builds from source needs its directory sent, or the
221
+ // control plane has nothing to build and says the context does not exist.
222
+ // Read from the manifest here rather than from the service row, because
223
+ // the build path is relative to the file the operator is standing in.
224
+ let contextId;
225
+ const buildContext = await buildContextFor(service.name);
226
+ if (buildContext) {
227
+ 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` });
228
+ contextId = uploaded.contextId;
229
+ }
185
230
  const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
186
231
  const walker = phaseWalker(ladder);
187
232
  const progress = follow(service.id, (p) => walker.apply(p), {
188
233
  onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
189
234
  });
190
235
  try {
191
- const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
236
+ const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha, contextId } })).body;
192
237
  walker.finish(`scheduled onto ${result.placedOn.name}`);
193
238
  return result;
194
239
  }
@@ -14,6 +14,8 @@ 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, projectNameFor } from '../plan.js';
18
+ import { uploadContext, humanBytes } from '../archive.js';
17
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
20
  export const upCommand = {
19
21
  async run(args, flags) {
@@ -44,78 +46,125 @@ export const upCommand = {
44
46
  }
45
47
  // ── Step 2: read and apply the manifest ───────────────────────────
46
48
  const manifest = await readFile(manifestPath, 'utf8');
47
- const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
49
+ const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
50
+ body: { manifest, project: projectNameFor(process.cwd()) },
51
+ })).body, {
48
52
  done: (b) => b.created.length || b.updated.length
49
- ? `applied ${b.created.length + b.updated.length} service(s)`
50
- : 'no changes',
53
+ ? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
54
+ : `no changes in project ${b.project}`,
51
55
  });
52
56
  for (const w of applyResult.warnings) {
53
57
  console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
54
58
  }
55
- // ── Step 3: pick the target service ───────────────────────────────
56
- const serviceName = args[0] || applyResult.created[0] || applyResult.updated[0];
57
- if (!serviceName) {
58
- throw new CliError('Could not determine which service to deploy. Pass the name: fleet up <service>', EXIT.usage);
59
+ // ── Step 3: decide what to deploy, and in what order ──────────────
60
+ const planned = planFromManifest(manifest);
61
+ const buildContexts = new Map(planned.map((p) => [p.name, p.build]));
62
+ // No argument means the whole stack. A manifest describes a system, and
63
+ // deploying one service of it and leaving the rest was never what anybody
64
+ // wanted — it just meant typing the command again in the right order.
65
+ const targets = args[0] ? [args[0]] : deployOrder(planned);
66
+ if (!targets.length) {
67
+ throw new CliError('The manifest declares no services to deploy.', EXIT.usage);
59
68
  }
60
- // Look it up
61
69
  const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
62
- const service = listBody.services.find((s) => s.name === serviceName || s.id === serviceName);
63
- if (!service) {
64
- throw new CliError(`Service "${serviceName}" not found after apply. Known: ${listBody.services.map((s) => s.name).join(', ')}`, EXIT.usage);
65
- }
66
- // ── Step 4: deploy ────────────────────────────────────────────────
67
- const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
68
- const deployResult = await withLadder(DEPLOY_STEPS, async (ladder) => {
69
- const walker = phaseWalker(ladder);
70
- const progress = follow(service.id, (p) => walker.apply(p), {
71
- onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
72
- });
73
- try {
74
- const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
75
- walker.finish(`scheduled onto ${result.placedOn.name}`);
76
- return result;
70
+ const resolved = targets.map((name) => {
71
+ const service = listBody.services.find((s) => s.name === name || s.id === name);
72
+ if (!service) {
73
+ throw new CliError(`Service "${name}" not found after apply. Known: ${listBody.services.map((s) => s.name).join(', ')}`, EXIT.usage);
77
74
  }
78
- finally {
79
- await progress.stop();
80
- }
81
- }, {
82
- mark: true,
83
- title: `deploying ${service.name}`,
84
- onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
75
+ return service;
85
76
  });
86
- for (const w of deployResult.warnings ?? []) {
87
- console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
77
+ if (resolved.length > 1) {
78
+ console.log(`\n ${c.dim('deploying')} ${resolved.map((s) => c.bold(s.name)).join(c.dim(''))}\n`);
88
79
  }
89
- // ── Step 5: wait for healthy ──────────────────────────────────────
90
- if (!flags['no-wait']) {
91
- await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
92
- s.hints([
93
- 'the agent picks up desired state on its next poll',
94
- "a cold image pull takes as long as the node's uplink does",
95
- 'this clears once the agent reports the container running',
96
- ]);
97
- const deadline = Date.now() + 180_000;
98
- while (Date.now() < deadline) {
99
- const { body } = await request('GET', `/fleets/${fleetId}/services`);
100
- const current = body.services.find((s) => s.id === service.id)?.current;
101
- if (current?.status === 'running')
102
- return;
103
- if (current?.status === 'failed') {
104
- throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
105
- }
106
- await sleep(2000);
107
- }
108
- throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
109
- }, { done: () => `${c.bold(service.name)} is running` });
80
+ const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
81
+ const deployed = [];
82
+ for (const service of resolved) {
83
+ const url = await deployOne(service, {
84
+ fleetId,
85
+ gitSha,
86
+ buildContext: buildContexts.get(service.name),
87
+ wait: !flags['no-wait'],
88
+ });
89
+ deployed.push({ service, url });
110
90
  }
111
- // ── Step 6: print the URL ─────────────────────────────────────────
112
- const url = deployResult.url ?? service.domain ?? service.hostname;
113
- if (url) {
114
- const fullUrl = url.startsWith('http') ? url : `https://${url}`;
91
+ // ── Step 6: print the URLs ────────────────────────────────────────
92
+ for (const { service, url } of deployed) {
93
+ const target = url ?? service.domain ?? service.hostname;
94
+ if (!target)
95
+ continue;
96
+ const fullUrl = target.startsWith('http') ? target : `https://${target}`;
115
97
  console.log(`\n${glyph.ok} ${c.green('live')} ${c.bold(c.cyan(fullUrl))}`);
116
98
  }
117
- console.log(c.dim(`\n fleet open ${service.name} open in browser`));
118
- console.log(c.dim(` fleet logs ${service.name} follow logs`));
119
- console.log(c.dim(` fleet down ${service.name} tear down`));
99
+ const last = deployed[deployed.length - 1]?.service;
100
+ if (last) {
101
+ console.log(c.dim(`\n fleet open ${last.name} open in browser`));
102
+ console.log(c.dim(` fleet logs ${last.name} follow logs`));
103
+ console.log(c.dim(` fleet down ${last.name} tear down`));
104
+ }
120
105
  },
121
106
  };
107
+ /**
108
+ * Deploy one service: upload its build context if it has one, run the deploy,
109
+ * and wait for it to report running.
110
+ *
111
+ * Returns the URL the control plane handed back, or null for a service that
112
+ * has none — an internal one, which is reached by name from its neighbours
113
+ * rather than from outside.
114
+ */
115
+ async function deployOne(service, opts) {
116
+ // A service that builds from source sends its directory first. The control
117
+ // plane then builds it for every architecture the fleet has, which is the
118
+ // part that is easy to get wrong by hand and silent when you do.
119
+ let contextId;
120
+ if (opts.buildContext) {
121
+ const dir = join(process.cwd(), opts.buildContext);
122
+ const uploaded = await task(`packaging ${c.bold(service.name)}`, async () => uploadContext(service.id, dir), { done: (r) => `uploaded ${humanBytes(r.bytes)} of build context` });
123
+ contextId = uploaded.contextId;
124
+ }
125
+ const deployResult = await withLadder(DEPLOY_STEPS, async (ladder) => {
126
+ const walker = phaseWalker(ladder);
127
+ const progress = follow(service.id, (p) => walker.apply(p), {
128
+ onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
129
+ });
130
+ try {
131
+ const result = (await request('POST', `/services/${service.id}/deploy`, {
132
+ body: { gitSha: opts.gitSha, contextId },
133
+ })).body;
134
+ walker.finish(`scheduled onto ${result.placedOn.name}`);
135
+ return result;
136
+ }
137
+ finally {
138
+ await progress.stop();
139
+ }
140
+ }, {
141
+ mark: true,
142
+ title: `deploying ${service.name}`,
143
+ onCancel: `deploy is still running on the control plane; inspect with fleet deployments ${service.name}`,
144
+ });
145
+ for (const w of deployResult.warnings ?? []) {
146
+ console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
147
+ }
148
+ if (opts.wait) {
149
+ await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
150
+ s.hints([
151
+ 'the agent picks up desired state on its next poll',
152
+ "a cold image pull takes as long as the node's uplink does",
153
+ 'a service with a health check goes running once it passes, not before',
154
+ ]);
155
+ const deadline = Date.now() + 180_000;
156
+ while (Date.now() < deadline) {
157
+ const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
158
+ const current = body.services.find((s) => s.id === service.id)?.current;
159
+ if (current?.status === 'running')
160
+ return;
161
+ if (current?.status === 'failed') {
162
+ throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
163
+ }
164
+ await sleep(2000);
165
+ }
166
+ throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
167
+ }, { done: () => `${c.bold(service.name)} is running` });
168
+ }
169
+ return deployResult.url;
170
+ }
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
@@ -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'],
@@ -50,6 +50,7 @@ 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'],
54
55
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
55
56
  ['nodes uncordon <name>', 'Allow scheduling again'],
package/dist/plan.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What to deploy, and in what order.
3
+ *
4
+ * `fleet up` used to deploy exactly one service, which meant a stack was four
5
+ * invocations typed in the right order — and getting the order wrong looked
6
+ * like a broken application rather than a sequencing mistake, because an API
7
+ * whose database is not up yet fails its health check like any other outage.
8
+ */
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
+ }
27
+ /**
28
+ * Read the manifest the way the control plane will.
29
+ *
30
+ * Deliberately forgiving: the server validates, and this only needs enough
31
+ * structure to decide what to send and when. A manifest that is wrong will be
32
+ * rejected by apply with a proper message before any of this matters.
33
+ */
34
+ export function planFromManifest(source) {
35
+ const doc = parseYaml(source);
36
+ const services = doc?.services;
37
+ if (!services || typeof services !== 'object')
38
+ return [];
39
+ return Object.entries(services).map(([name, raw]) => {
40
+ const body = (raw ?? {});
41
+ return {
42
+ name,
43
+ build: typeof body.build === 'string' ? body.build : undefined,
44
+ affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
45
+ };
46
+ });
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
+ }
74
+ /**
75
+ * Order services so a dependency is deployed before whatever depends on it.
76
+ *
77
+ * `affinity` is the signal. It means "co-locate", not "depends on", but in
78
+ * practice the two coincide: a service is pinned next to the database it talks
79
+ * to, and it cannot become healthy until that database answers. Using it for
80
+ * ordering is a heuristic, and the cost of it being wrong is one slow retry
81
+ * rather than a failure — the agent's restart policy brings up a container that
82
+ * started too early anyway.
83
+ *
84
+ * Ties keep manifest order, so the file stays the explanation for what happens.
85
+ * A cycle is not an error here: the services involved are emitted in manifest
86
+ * order rather than dropped, because refusing to deploy is a worse answer than
87
+ * deploying in an imperfect order.
88
+ */
89
+ export function deployOrder(services) {
90
+ const byName = new Map(services.map((s) => [s.name, s]));
91
+ const ordered = [];
92
+ const state = new Map();
93
+ const visit = (name) => {
94
+ if (state.get(name) === 'done')
95
+ return;
96
+ // Already on the stack: a cycle. Stop rather than recurse forever; the
97
+ // caller still gets every service, just not in a perfect order.
98
+ if (state.get(name) === 'visiting')
99
+ return;
100
+ const service = byName.get(name);
101
+ if (!service)
102
+ return; // affinity on something not in this manifest
103
+ state.set(name, 'visiting');
104
+ for (const dependency of service.affinity)
105
+ visit(dependency);
106
+ state.set(name, 'done');
107
+ ordered.push(name);
108
+ };
109
+ for (const service of services)
110
+ visit(service.name);
111
+ return ordered;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",