@yadurajfleetos/cli 0.1.9 → 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 +6 -2
- package/dist/archive.js +115 -0
- package/dist/commands/services.js +29 -1
- package/dist/commands/up.js +104 -57
- package/dist/index.js +1 -1
- package/dist/plan.js +69 -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,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
|
+
}
|
|
@@ -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 } 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 {
|
|
@@ -154,6 +156,22 @@ async function waitUntilRunning(fleetId, name, timeoutMs = 180_000) {
|
|
|
154
156
|
throw new CliError(`"${name}" was scheduled but has not reported running. \`fleet deployments ${name}\` has the detail.`, EXIT.healthCheckFailed);
|
|
155
157
|
}, { done: () => `${c.bold(name)} is running` });
|
|
156
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
|
+
}
|
|
157
175
|
export const deployCommand = {
|
|
158
176
|
async run(args, flags) {
|
|
159
177
|
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
@@ -182,13 +200,23 @@ export const deployCommand = {
|
|
|
182
200
|
console.log(c.dim('Deployment cancelled. Re-run with --yes to skip confirmation.'));
|
|
183
201
|
return;
|
|
184
202
|
}
|
|
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
|
+
}
|
|
185
213
|
const body = await withLadder(DEPLOY_STEPS, async (ladder) => {
|
|
186
214
|
const walker = phaseWalker(ladder);
|
|
187
215
|
const progress = follow(service.id, (p) => walker.apply(p), {
|
|
188
216
|
onUnavailable: () => ladder.note(c.dim('live progress unavailable; continuing with the deploy request')),
|
|
189
217
|
});
|
|
190
218
|
try {
|
|
191
|
-
const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body;
|
|
219
|
+
const result = (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha, contextId } })).body;
|
|
192
220
|
walker.finish(`scheduled onto ${result.placedOn.name}`);
|
|
193
221
|
return result;
|
|
194
222
|
}
|
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 } 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) {
|
|
@@ -52,70 +54,115 @@ export const upCommand = {
|
|
|
52
54
|
for (const w of applyResult.warnings) {
|
|
53
55
|
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
54
56
|
}
|
|
55
|
-
// ── Step 3:
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
59
66
|
}
|
|
60
|
-
// Look it up
|
|
61
67
|
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;
|
|
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);
|
|
77
72
|
}
|
|
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}`,
|
|
73
|
+
return service;
|
|
85
74
|
});
|
|
86
|
-
|
|
87
|
-
console.log(
|
|
75
|
+
if (resolved.length > 1) {
|
|
76
|
+
console.log(`\n ${c.dim('deploying')} ${resolved.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
|
|
88
77
|
}
|
|
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` });
|
|
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 });
|
|
110
88
|
}
|
|
111
|
-
// ── Step 6: print the
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
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}`;
|
|
115
95
|
console.log(`\n${glyph.ok} ${c.green('live')} ${c.bold(c.cyan(fullUrl))}`);
|
|
116
96
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
103
|
},
|
|
121
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]', '
|
|
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'],
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
* Read the manifest the way the control plane will.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately forgiving: the server validates, and this only needs enough
|
|
14
|
+
* structure to decide what to send and when. A manifest that is wrong will be
|
|
15
|
+
* rejected by apply with a proper message before any of this matters.
|
|
16
|
+
*/
|
|
17
|
+
export function planFromManifest(source) {
|
|
18
|
+
const doc = parseYaml(source);
|
|
19
|
+
const services = doc?.services;
|
|
20
|
+
if (!services || typeof services !== 'object')
|
|
21
|
+
return [];
|
|
22
|
+
return Object.entries(services).map(([name, raw]) => {
|
|
23
|
+
const body = (raw ?? {});
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
build: typeof body.build === 'string' ? body.build : undefined,
|
|
27
|
+
affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Order services so a dependency is deployed before whatever depends on it.
|
|
33
|
+
*
|
|
34
|
+
* `affinity` is the signal. It means "co-locate", not "depends on", but in
|
|
35
|
+
* practice the two coincide: a service is pinned next to the database it talks
|
|
36
|
+
* to, and it cannot become healthy until that database answers. Using it for
|
|
37
|
+
* ordering is a heuristic, and the cost of it being wrong is one slow retry
|
|
38
|
+
* rather than a failure — the agent's restart policy brings up a container that
|
|
39
|
+
* started too early anyway.
|
|
40
|
+
*
|
|
41
|
+
* Ties keep manifest order, so the file stays the explanation for what happens.
|
|
42
|
+
* A cycle is not an error here: the services involved are emitted in manifest
|
|
43
|
+
* order rather than dropped, because refusing to deploy is a worse answer than
|
|
44
|
+
* deploying in an imperfect order.
|
|
45
|
+
*/
|
|
46
|
+
export function deployOrder(services) {
|
|
47
|
+
const byName = new Map(services.map((s) => [s.name, s]));
|
|
48
|
+
const ordered = [];
|
|
49
|
+
const state = new Map();
|
|
50
|
+
const visit = (name) => {
|
|
51
|
+
if (state.get(name) === 'done')
|
|
52
|
+
return;
|
|
53
|
+
// Already on the stack: a cycle. Stop rather than recurse forever; the
|
|
54
|
+
// caller still gets every service, just not in a perfect order.
|
|
55
|
+
if (state.get(name) === 'visiting')
|
|
56
|
+
return;
|
|
57
|
+
const service = byName.get(name);
|
|
58
|
+
if (!service)
|
|
59
|
+
return; // affinity on something not in this manifest
|
|
60
|
+
state.set(name, 'visiting');
|
|
61
|
+
for (const dependency of service.affinity)
|
|
62
|
+
visit(dependency);
|
|
63
|
+
state.set(name, 'done');
|
|
64
|
+
ordered.push(name);
|
|
65
|
+
};
|
|
66
|
+
for (const service of services)
|
|
67
|
+
visit(service.name);
|
|
68
|
+
return ordered;
|
|
69
|
+
}
|