@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 +6 -2
- package/dist/archive.js +135 -0
- package/dist/commands/secrets.js +110 -1
- package/dist/commands/services.js +57 -12
- package/dist/commands/up.js +109 -60
- package/dist/dotenv.js +103 -0
- package/dist/index.js +2 -1
- package/dist/plan.js +112 -0
- package/package.json +1 -1
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.
|
|
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;
|
package/dist/archive.js
ADDED
|
@@ -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
|
+
}
|
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,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`, {
|
|
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
|
-
:
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
s.
|
|
78
|
-
|
|
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
|
}
|
package/dist/commands/up.js
CHANGED
|
@@ -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`, {
|
|
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
|
-
:
|
|
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:
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
console.log(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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]', '
|
|
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
|
+
}
|