@yadurajfleetos/cli 0.4.1 → 0.6.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/commands/index.js +2 -1
- package/dist/commands/services.js +88 -0
- package/dist/compose.js +338 -0
- package/dist/dbnames.js +34 -0
- package/dist/discover.js +349 -0
- package/dist/index.js +2 -1
- package/package.json +1 -1
package/dist/commands/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { downCommand } from './down.js';
|
|
|
10
10
|
import { unpairCommand, agentCommand } from './unpair.js';
|
|
11
11
|
import { secretsCommand } from './secrets.js';
|
|
12
12
|
import { backupCommand, backupsCommand, restoreCommand } from './backups.js';
|
|
13
|
-
import { applyCommand, deployCommand, deploymentsCommand, initCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
13
|
+
import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
14
14
|
export const commands = {
|
|
15
15
|
up: upCommand,
|
|
16
16
|
open: openCommand,
|
|
@@ -21,6 +21,7 @@ export const commands = {
|
|
|
21
21
|
use: useCommand,
|
|
22
22
|
doctor: doctorCommand,
|
|
23
23
|
init: initCommand,
|
|
24
|
+
import: importCommand,
|
|
24
25
|
validate: validateCommand,
|
|
25
26
|
apply: applyCommand,
|
|
26
27
|
status: statusCommand,
|
|
@@ -403,6 +403,31 @@ export const initCommand = {
|
|
|
403
403
|
const name = (typeof flags.name === 'string' ? flags.name : '') ||
|
|
404
404
|
process.cwd().split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
|
|
405
405
|
'app';
|
|
406
|
+
// Read the whole repository first. A monorepo, an apps/ directory, or a
|
|
407
|
+
// service beside a database is the ordinary case, and describing only the
|
|
408
|
+
// current directory left every one of those to be written out by hand.
|
|
409
|
+
const { discover, manifestFromDiscovery } = await import('../discover.js');
|
|
410
|
+
const found = await discover();
|
|
411
|
+
if (found.services.length > 1 || found.databases.length) {
|
|
412
|
+
const { manifest, questions } = manifestFromDiscovery(found, {
|
|
413
|
+
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
414
|
+
node: typeof flags.node === 'string' ? flags.node : undefined,
|
|
415
|
+
});
|
|
416
|
+
await writeFile(path, manifest);
|
|
417
|
+
console.log(`${c.green('created')} ${path}`);
|
|
418
|
+
if (found.layout)
|
|
419
|
+
console.log(c.dim(` ${found.layout}`));
|
|
420
|
+
for (const s of found.services) {
|
|
421
|
+
console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
|
|
422
|
+
}
|
|
423
|
+
for (const db of found.databases) {
|
|
424
|
+
console.log(c.dim(` · ${db.name} (${db.engine}) — ${db.because}`));
|
|
425
|
+
}
|
|
426
|
+
for (const q of questions)
|
|
427
|
+
console.log(` ${c.yellow('?')} ${q}`);
|
|
428
|
+
console.log(c.dim(`\n check it with: fleet apply --dry-run`));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
406
431
|
const d = await detect();
|
|
407
432
|
// Write a Dockerfile if we generated one and none exists.
|
|
408
433
|
if (d.dockerfile) {
|
|
@@ -473,3 +498,66 @@ export const removeServiceCommand = {
|
|
|
473
498
|
console.log(c.dim(` ${body.note}`));
|
|
474
499
|
},
|
|
475
500
|
};
|
|
501
|
+
/**
|
|
502
|
+
* docker-compose.yml → fleet.yaml.
|
|
503
|
+
*
|
|
504
|
+
* The most common way to arrive at Fleet with several services, two languages
|
|
505
|
+
* and a database is to already have a compose file describing exactly that.
|
|
506
|
+
* Reading it is a transform rather than a guess, so this needs no network, no
|
|
507
|
+
* account, and no model — it works before you have signed in.
|
|
508
|
+
*
|
|
509
|
+
* It prints what it decided and what it could not answer. A converter that
|
|
510
|
+
* silently drops a bind mount or invents a node is worse than one that refuses,
|
|
511
|
+
* because the reader only finds out at deploy time.
|
|
512
|
+
*/
|
|
513
|
+
export const importCommand = {
|
|
514
|
+
async run(args, flags) {
|
|
515
|
+
const { composeToFleet } = await import('../compose.js');
|
|
516
|
+
const source = args[0] ?? 'docker-compose.yml';
|
|
517
|
+
let text;
|
|
518
|
+
try {
|
|
519
|
+
text = await readFile(source, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
throw new CliError(`could not read ${source}\n pass a path: fleet import path/to/docker-compose.yml`, EXIT.usage);
|
|
523
|
+
}
|
|
524
|
+
const out = manifestPath(typeof flags.out === 'string' ? flags.out : undefined);
|
|
525
|
+
const force = flags.force === true;
|
|
526
|
+
if (!force) {
|
|
527
|
+
try {
|
|
528
|
+
await access(out);
|
|
529
|
+
throw new CliError(`${out} already exists — pass --force to overwrite it.`, EXIT.usage);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
if (err instanceof CliError)
|
|
533
|
+
throw err;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
let result;
|
|
537
|
+
try {
|
|
538
|
+
result = composeToFleet(text, {
|
|
539
|
+
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
540
|
+
node: typeof flags.node === 'string' ? flags.node : undefined,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
throw new CliError(err.message, EXIT.usage);
|
|
545
|
+
}
|
|
546
|
+
// --dry-run prints and writes nothing, so the output can be piped or read
|
|
547
|
+
// before it lands next to the file it was derived from.
|
|
548
|
+
if (flags['dry-run'] === true) {
|
|
549
|
+
process.stdout.write(result.manifest);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
await writeFile(out, result.manifest);
|
|
553
|
+
console.log(`${c.green('created')} ${out} ${c.dim(`from ${source}`)}`);
|
|
554
|
+
}
|
|
555
|
+
for (const note of result.notes)
|
|
556
|
+
console.log(` ${c.dim('·')} ${c.dim(note)}`);
|
|
557
|
+
for (const q of result.questions)
|
|
558
|
+
console.log(` ${c.yellow('?')} ${q}`);
|
|
559
|
+
if (!result.questions.length) {
|
|
560
|
+
console.log(c.dim(`\n check it with: fleet apply --dry-run`));
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
};
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
|
+
import { safeDatabaseName } from './dbnames.js';
|
|
3
|
+
/** Images Fleet manages as databases rather than as plain containers. */
|
|
4
|
+
const ENGINE_IMAGES = {
|
|
5
|
+
postgres: 'postgres',
|
|
6
|
+
postgis: 'postgres',
|
|
7
|
+
'timescale/timescaledb': 'postgres',
|
|
8
|
+
mysql: 'mysql',
|
|
9
|
+
mariadb: 'mariadb',
|
|
10
|
+
redis: 'redis',
|
|
11
|
+
valkey: 'redis',
|
|
12
|
+
mongo: 'mongo',
|
|
13
|
+
mongodb: 'mongo',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Names that mean "this value is a credential".
|
|
17
|
+
*
|
|
18
|
+
* Compose has no notion of a secret, so a password sits in the file as plain
|
|
19
|
+
* text. Copying it into `env` would move a credential into a manifest people
|
|
20
|
+
* commit; these keys become `secrets`, which Fleet injects at deploy time.
|
|
21
|
+
*/
|
|
22
|
+
const SECRET_HINT = /(PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|ACCESS_KEY|PRIVATE|CREDENTIAL|_DSN|_URI|_URL_AUTH|SALT|CERT)/i;
|
|
23
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
const asRecord = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
25
|
+
/** compose accepts both `KEY=value` lists and `{KEY: value}` maps. */
|
|
26
|
+
function readEnv(raw) {
|
|
27
|
+
const out = {};
|
|
28
|
+
if (Array.isArray(raw)) {
|
|
29
|
+
for (const entry of raw) {
|
|
30
|
+
if (typeof entry !== 'string')
|
|
31
|
+
continue;
|
|
32
|
+
const eq = entry.indexOf('=');
|
|
33
|
+
if (eq < 0)
|
|
34
|
+
out[entry] = '';
|
|
35
|
+
else
|
|
36
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const map = asRecord(raw);
|
|
41
|
+
if (map)
|
|
42
|
+
for (const [k, v] of Object.entries(map))
|
|
43
|
+
out[k] = v == null ? '' : String(v);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* "127.0.0.1:8080:80/tcp" → { host: 8080, container: 80 }.
|
|
49
|
+
*
|
|
50
|
+
* Counted from the end because the optional bind address is on the front, so
|
|
51
|
+
* the last two numbers are always the pair that matters.
|
|
52
|
+
*/
|
|
53
|
+
function readPorts(raw) {
|
|
54
|
+
if (!Array.isArray(raw))
|
|
55
|
+
return null;
|
|
56
|
+
for (const entry of raw) {
|
|
57
|
+
if (typeof entry === 'number')
|
|
58
|
+
return { host: entry, container: entry };
|
|
59
|
+
if (typeof entry !== 'string') {
|
|
60
|
+
const obj = asRecord(entry);
|
|
61
|
+
if (obj && typeof obj.target === 'number') {
|
|
62
|
+
return { host: Number(obj.published ?? obj.target), container: obj.target };
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const parts = entry.split('/')[0].split(':').filter(Boolean);
|
|
67
|
+
const nums = parts.map(Number).filter((n) => Number.isFinite(n));
|
|
68
|
+
if (!nums.length)
|
|
69
|
+
continue;
|
|
70
|
+
if (nums.length === 1)
|
|
71
|
+
return { host: nums[0], container: nums[0] };
|
|
72
|
+
return { host: nums[nums.length - 2], container: nums[nums.length - 1] };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The first named volume, plus every mount that was left behind.
|
|
78
|
+
*
|
|
79
|
+
* Both halves are returned because dropping something quietly is the whole
|
|
80
|
+
* problem: a bind mount that disappears is a directory the service expected
|
|
81
|
+
* and will not find, and reporting it only when nothing else matched meant the
|
|
82
|
+
* common case - one real volume beside one bind mount - said nothing at all.
|
|
83
|
+
*/
|
|
84
|
+
function readVolumes(raw) {
|
|
85
|
+
const skipped = [];
|
|
86
|
+
let volume = null;
|
|
87
|
+
if (!Array.isArray(raw))
|
|
88
|
+
return { volume, skipped };
|
|
89
|
+
for (const entry of raw) {
|
|
90
|
+
if (typeof entry !== 'string') {
|
|
91
|
+
const obj = asRecord(entry);
|
|
92
|
+
if (obj?.type === 'volume' && typeof obj.source === 'string' && typeof obj.target === 'string') {
|
|
93
|
+
if (!volume)
|
|
94
|
+
volume = { name: obj.source, path: obj.target };
|
|
95
|
+
else
|
|
96
|
+
skipped.push(`${obj.source}:${obj.target}`);
|
|
97
|
+
}
|
|
98
|
+
else if (obj?.type === 'bind') {
|
|
99
|
+
skipped.push(String(obj.source ?? 'bind mount'));
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const [source, target] = entry.split(':');
|
|
104
|
+
if (!source || !target)
|
|
105
|
+
continue;
|
|
106
|
+
// A path is a bind mount of the developer's own machine, and means nothing
|
|
107
|
+
// on a node that has never seen that directory.
|
|
108
|
+
if (source.startsWith('.') || source.startsWith('/') || source.startsWith('~')) {
|
|
109
|
+
skipped.push(entry);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!volume)
|
|
113
|
+
volume = { name: source, path: target };
|
|
114
|
+
else
|
|
115
|
+
skipped.push(entry);
|
|
116
|
+
}
|
|
117
|
+
return { volume, skipped };
|
|
118
|
+
}
|
|
119
|
+
/** compose memory limits are "512m", "2g", or a byte count. */
|
|
120
|
+
function readMemory(raw) {
|
|
121
|
+
if (raw == null)
|
|
122
|
+
return null;
|
|
123
|
+
const s = String(raw).trim().toLowerCase();
|
|
124
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmg])?b?$/);
|
|
125
|
+
if (!m)
|
|
126
|
+
return null;
|
|
127
|
+
const n = Number(m[1]);
|
|
128
|
+
switch (m[2]) {
|
|
129
|
+
case 'g': return `${Math.round(n * 1024)}Mi`;
|
|
130
|
+
case 'm': return `${Math.round(n)}Mi`;
|
|
131
|
+
case 'k': return `${Math.max(1, Math.round(n / 1024))}Mi`;
|
|
132
|
+
default: return `${Math.max(1, Math.round(n / (1024 * 1024)))}Mi`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A health path out of a compose healthcheck, when one is discoverable. */
|
|
136
|
+
function readHealthPath(raw) {
|
|
137
|
+
const hc = asRecord(raw);
|
|
138
|
+
if (!hc)
|
|
139
|
+
return null;
|
|
140
|
+
const test = hc.test;
|
|
141
|
+
const line = Array.isArray(test) ? test.join(' ') : typeof test === 'string' ? test : '';
|
|
142
|
+
const url = line.match(/https?:\/\/[^\s"']+/);
|
|
143
|
+
if (url) {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(url[0]).pathname || '/';
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const engineFor = (image) => {
|
|
154
|
+
const name = image.split('@')[0].split(':')[0].toLowerCase();
|
|
155
|
+
const bare = name.replace(/^(docker\.io|library)\//, '');
|
|
156
|
+
return ENGINE_IMAGES[bare] ?? ENGINE_IMAGES[bare.split('/').pop() ?? ''] ?? null;
|
|
157
|
+
};
|
|
158
|
+
const versionOf = (image) => {
|
|
159
|
+
const tag = image.split('@')[0].split(':')[1];
|
|
160
|
+
if (!tag || tag === 'latest')
|
|
161
|
+
return null;
|
|
162
|
+
const major = tag.match(/^(\d+)/);
|
|
163
|
+
return major ? major[1] : null;
|
|
164
|
+
};
|
|
165
|
+
/** YAML-safe scalar. Quoted unless it is unambiguously a bare word. */
|
|
166
|
+
const scalar = (v) => /^[A-Za-z0-9._/-]+$/.test(v) && v !== '' ? v : JSON.stringify(v);
|
|
167
|
+
export function composeToFleet(source, opts = {}) {
|
|
168
|
+
const notes = [];
|
|
169
|
+
const questions = [];
|
|
170
|
+
let doc;
|
|
171
|
+
try {
|
|
172
|
+
doc = parseYaml(source);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
throw new Error(`that file is not valid YAML: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
const root = asRecord(doc);
|
|
178
|
+
const servicesRaw = asRecord(root?.services);
|
|
179
|
+
if (!servicesRaw || !Object.keys(servicesRaw).length) {
|
|
180
|
+
throw new Error('no services found — is this a docker-compose file?');
|
|
181
|
+
}
|
|
182
|
+
const fleetName = opts.fleet ?? 'homelab';
|
|
183
|
+
const dbNode = opts.node ?? null;
|
|
184
|
+
const services = [];
|
|
185
|
+
const databases = [];
|
|
186
|
+
/** Compose names that became databases, so `depends_on` can point at them. */
|
|
187
|
+
const asDatabase = new Set();
|
|
188
|
+
/** Compose name -> the name the database is declared under, which differs
|
|
189
|
+
whenever the compose name would collide with its engine's own env vars. */
|
|
190
|
+
const dbNames = new Map();
|
|
191
|
+
const takenDbNames = new Set();
|
|
192
|
+
// Databases first, so a service's `uses` can reference one by name.
|
|
193
|
+
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
194
|
+
const svc = (asRecord(raw) ?? {});
|
|
195
|
+
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
196
|
+
const engine = image ? engineFor(image) : null;
|
|
197
|
+
if (!engine)
|
|
198
|
+
continue;
|
|
199
|
+
asDatabase.add(name);
|
|
200
|
+
// Named here, in the pre-pass, not where the database is rendered: a
|
|
201
|
+
// service declared before it in the file still has to point at the name it
|
|
202
|
+
// ends up with, and the main loop would not know it yet.
|
|
203
|
+
dbNames.set(name, safeDatabaseName(name, engine, takenDbNames));
|
|
204
|
+
}
|
|
205
|
+
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
206
|
+
const svc = (asRecord(raw) ?? {});
|
|
207
|
+
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
208
|
+
const env = readEnv(svc.environment);
|
|
209
|
+
if (asDatabase.has(name)) {
|
|
210
|
+
const engine = engineFor(image);
|
|
211
|
+
const major = versionOf(image);
|
|
212
|
+
const declared = dbNames.get(name);
|
|
213
|
+
if (declared !== name) {
|
|
214
|
+
notes.push(`${name} is declared as "${declared}": a database named after its own engine derives a password secret that collides with the engine's own environment variable, and the manifest is rejected.`);
|
|
215
|
+
}
|
|
216
|
+
const lines = [` ${declared}:`];
|
|
217
|
+
lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
|
|
218
|
+
if (dbNode)
|
|
219
|
+
lines.push(` node: ${scalar(dbNode)}`);
|
|
220
|
+
else {
|
|
221
|
+
lines.push(` node: CHANGE_ME`);
|
|
222
|
+
questions.push(`${name}: a database must name the node that holds its data. Replace CHANGE_ME with a node name, or re-run with --node.`);
|
|
223
|
+
}
|
|
224
|
+
// Compose states these as env; Fleet takes them as fields and manages
|
|
225
|
+
// the credential itself, so they are lifted rather than copied.
|
|
226
|
+
const dbName = env.POSTGRES_DB ?? env.MYSQL_DATABASE ?? env.MARIADB_DATABASE ?? env.MONGO_INITDB_DATABASE;
|
|
227
|
+
const dbUser = env.POSTGRES_USER ?? env.MYSQL_USER ?? env.MARIADB_USER ?? env.MONGO_INITDB_ROOT_USERNAME;
|
|
228
|
+
if (dbName)
|
|
229
|
+
lines.push(` database: ${scalar(dbName)}`);
|
|
230
|
+
if (dbUser)
|
|
231
|
+
lines.push(` user: ${scalar(dbUser)}`);
|
|
232
|
+
lines.push(` backup: daily`);
|
|
233
|
+
databases.push(lines.join('\n'));
|
|
234
|
+
notes.push(`${name} became a managed ${engine} database — Fleet owns its volume, credentials and backups, so the password from your compose file is not carried over.`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const lines = [` ${name}:`];
|
|
238
|
+
// build or image, never both: the schema rejects it, and they mean
|
|
239
|
+
// different things.
|
|
240
|
+
if (svc.build != null && !image) {
|
|
241
|
+
const b = asRecord(svc.build);
|
|
242
|
+
const context = typeof svc.build === 'string' ? svc.build : typeof b?.context === 'string' ? b.context : '.';
|
|
243
|
+
lines.push(` build: ${scalar(context)}`);
|
|
244
|
+
}
|
|
245
|
+
else if (image) {
|
|
246
|
+
lines.push(` image: ${scalar(image)}`);
|
|
247
|
+
if (svc.build != null) {
|
|
248
|
+
notes.push(`${name}: compose set both build and image; kept image, because that is what compose would have run.`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
lines.push(` build: .`);
|
|
253
|
+
questions.push(`${name}: compose named neither an image nor a build context — check the build path.`);
|
|
254
|
+
}
|
|
255
|
+
lines.push(' placement: flexible');
|
|
256
|
+
const ports = readPorts(svc.ports) ?? readPorts(svc.expose);
|
|
257
|
+
if (ports) {
|
|
258
|
+
lines.push(` port: ${ports.host}`);
|
|
259
|
+
if (ports.container !== ports.host)
|
|
260
|
+
lines.push(` container_port: ${ports.container}`);
|
|
261
|
+
}
|
|
262
|
+
const mem = readMemory(asRecord(asRecord(asRecord(svc.deploy)?.resources)?.limits)?.memory ?? svc.mem_limit);
|
|
263
|
+
const cpus = asRecord(asRecord(asRecord(svc.deploy)?.resources)?.limits)?.cpus ?? svc.cpus;
|
|
264
|
+
const ram = mem ?? '512Mi';
|
|
265
|
+
const cpu = cpus != null && Number.isFinite(Number(cpus)) ? Number(cpus) : 0.5;
|
|
266
|
+
lines.push(` resources: { ram: ${ram}, cpu: ${cpu} }`);
|
|
267
|
+
if (!mem)
|
|
268
|
+
notes.push(`${name}: compose set no memory limit, so 512Mi was assumed — the scheduler needs a number to place against.`);
|
|
269
|
+
const health = readHealthPath(svc.healthcheck);
|
|
270
|
+
lines.push(` health: { path: ${health ?? '/'} }`);
|
|
271
|
+
if (svc.healthcheck && !health) {
|
|
272
|
+
notes.push(`${name}: its healthcheck is a command Fleet cannot reuse, so health falls back to "/" — set a real path if that is wrong.`);
|
|
273
|
+
}
|
|
274
|
+
const { volume: vol, skipped } = readVolumes(svc.volumes);
|
|
275
|
+
if (vol)
|
|
276
|
+
lines.push(` volume: { name: ${scalar(vol.name)}, path: ${scalar(vol.path)} }`);
|
|
277
|
+
if (skipped.length) {
|
|
278
|
+
notes.push(`${name}: dropped ${skipped.join(', ')} — a service carries one named volume, and a bind mount of your own machine means nothing on a node.`);
|
|
279
|
+
}
|
|
280
|
+
// env vs secrets. A value compose leaves to interpolation is not in the
|
|
281
|
+
// file at all, so it cannot be copied - it becomes a secret to supply.
|
|
282
|
+
const plain = [];
|
|
283
|
+
const secrets = [];
|
|
284
|
+
for (const [k, v] of Object.entries(env)) {
|
|
285
|
+
if (!ENV_NAME.test(k)) {
|
|
286
|
+
notes.push(`${name}: dropped env key "${k}" — not a usable variable name.`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const unresolved = v === '' || /^\$\{?[A-Za-z_]/.test(v);
|
|
290
|
+
if (SECRET_HINT.test(k) || unresolved)
|
|
291
|
+
secrets.push(k);
|
|
292
|
+
else
|
|
293
|
+
plain.push([k, v]);
|
|
294
|
+
}
|
|
295
|
+
if (plain.length) {
|
|
296
|
+
lines.push(' env:');
|
|
297
|
+
for (const [k, v] of plain)
|
|
298
|
+
lines.push(` ${k}: ${scalar(v)}`);
|
|
299
|
+
}
|
|
300
|
+
if (secrets.length) {
|
|
301
|
+
lines.push(` secrets: [${secrets.join(', ')}]`);
|
|
302
|
+
notes.push(`${name}: ${secrets.join(', ')} moved to secrets — set them with \`fleet secret set\`, so no credential lives in this file.`);
|
|
303
|
+
}
|
|
304
|
+
// `uses` names databases, not services — the parser rejects anything else
|
|
305
|
+
// with "is not a database in this manifest". compose's depends_on covers
|
|
306
|
+
// both, so it has to be split: the database half becomes `uses`, and the
|
|
307
|
+
// service half is dropped, because Fleet works out deploy order itself
|
|
308
|
+
// rather than taking it from the file.
|
|
309
|
+
const deps = Array.isArray(svc.depends_on)
|
|
310
|
+
? svc.depends_on.filter((d) => typeof d === 'string')
|
|
311
|
+
: Object.keys(asRecord(svc.depends_on) ?? {});
|
|
312
|
+
const dbDeps = deps.filter((d) => asDatabase.has(d)).map((d) => dbNames.get(d) ?? d);
|
|
313
|
+
const svcDeps = deps.filter((d) => !asDatabase.has(d));
|
|
314
|
+
if (dbDeps.length)
|
|
315
|
+
lines.push(` uses: [${dbDeps.join(', ')}]`);
|
|
316
|
+
if (svcDeps.length) {
|
|
317
|
+
notes.push(`${name}: depends_on ${svcDeps.join(', ')} was dropped — "uses" declares databases, and Fleet decides deploy order from the manifest rather than being told.`);
|
|
318
|
+
}
|
|
319
|
+
const replicas = asRecord(svc.deploy)?.replicas;
|
|
320
|
+
if (typeof replicas === 'number' && replicas > 1)
|
|
321
|
+
lines.push(` replicas: ${replicas}`);
|
|
322
|
+
services.push(lines.join('\n'));
|
|
323
|
+
}
|
|
324
|
+
// A manifest requires at least one service - "a manifest with no services
|
|
325
|
+
// has nothing to deploy" is the product's rule, not an accident. A compose
|
|
326
|
+
// file that is only databases would otherwise emit an empty `services:` key,
|
|
327
|
+
// which YAML reads as null and the parser rejects with a message about
|
|
328
|
+
// records that explains nothing about what the reader actually did.
|
|
329
|
+
if (!services.length) {
|
|
330
|
+
const only = [...asDatabase].join(', ');
|
|
331
|
+
throw new Error(`every service in that file is a database (${only}), and a manifest needs something to deploy. ` +
|
|
332
|
+
`Fleet manages those engines for you — declare them alongside the application that uses them.`);
|
|
333
|
+
}
|
|
334
|
+
const out = [`fleet: ${scalar(fleetName)}`, '', 'services:', services.join('\n\n')];
|
|
335
|
+
if (databases.length)
|
|
336
|
+
out.push('', 'databases:', databases.join('\n\n'));
|
|
337
|
+
return { manifest: out.join('\n') + '\n', notes, questions };
|
|
338
|
+
}
|
package/dist/dbnames.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to call a managed database, when the obvious name breaks.
|
|
3
|
+
*
|
|
4
|
+
* The manifest derives a database's password secret from its name:
|
|
5
|
+
* `passwordRefFor("main")` is MAIN_PASSWORD. That is a good rule, and it has
|
|
6
|
+
* one collision. A database called `postgres` derives POSTGRES_PASSWORD, which
|
|
7
|
+
* is also the environment variable the Postgres image itself expects — so the
|
|
8
|
+
* generated service ends up with the same key in both `env` and `secrets`, and
|
|
9
|
+
* the parser rejects the whole manifest.
|
|
10
|
+
*
|
|
11
|
+
* It matters because `postgres:` is one of the most common service names in a
|
|
12
|
+
* docker-compose file, so the most ordinary input produced something the
|
|
13
|
+
* product refused. Renaming to `db` is also simply what a person writing this
|
|
14
|
+
* by hand would have done: `uses: [db, cache]` reads better than
|
|
15
|
+
* `uses: [postgres, redis]`.
|
|
16
|
+
*/
|
|
17
|
+
/** What a person would call each engine, rather than what the image is called. */
|
|
18
|
+
const FRIENDLY = {
|
|
19
|
+
postgres: 'db',
|
|
20
|
+
mysql: 'db',
|
|
21
|
+
mariadb: 'db',
|
|
22
|
+
mongo: 'db',
|
|
23
|
+
redis: 'cache',
|
|
24
|
+
};
|
|
25
|
+
export function safeDatabaseName(preferred, engine, taken) {
|
|
26
|
+
const base = preferred.toLowerCase() === engine.toLowerCase() ? (FRIENDLY[engine] ?? 'db') : preferred;
|
|
27
|
+
// Two Postgres databases in one file both want to be `db`.
|
|
28
|
+
let name = base;
|
|
29
|
+
let n = 2;
|
|
30
|
+
while (taken.has(name))
|
|
31
|
+
name = `${base}${n++}`;
|
|
32
|
+
taken.add(name);
|
|
33
|
+
return name;
|
|
34
|
+
}
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { readdir, readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { detect } from './detect.js';
|
|
4
|
+
import { safeDatabaseName } from './dbnames.js';
|
|
5
|
+
const exists = async (p) => {
|
|
6
|
+
try {
|
|
7
|
+
await access(p);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const readText = async (p) => {
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(p, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const readJson = async (p) => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(await readFile(p, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** Directories that are never a service, whatever else they contain. */
|
|
31
|
+
const IGNORED = new Set([
|
|
32
|
+
'node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.next',
|
|
33
|
+
'coverage', '__pycache__', '.venv', 'venv', 'tmp', '.turbo', '.cache',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Dependencies that mean "this service talks to a database".
|
|
37
|
+
*
|
|
38
|
+
* A driver in the dependency list is evidence, not a guess: nobody installs
|
|
39
|
+
* `pg` for a service that does not speak to Postgres.
|
|
40
|
+
*/
|
|
41
|
+
const DB_HINTS = [
|
|
42
|
+
{
|
|
43
|
+
engine: 'postgres',
|
|
44
|
+
deps: [
|
|
45
|
+
'pg', 'postgres', 'pg-promise', 'postgres.js', '@prisma/client', 'prisma',
|
|
46
|
+
'typeorm', 'sequelize', 'drizzle-orm', 'knex', 'psycopg2', 'psycopg2-binary',
|
|
47
|
+
'psycopg', 'asyncpg', 'sqlalchemy', 'django', 'lib/pq', 'jackc/pgx', 'sqlx',
|
|
48
|
+
'tokio-postgres', 'diesel',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{ engine: 'redis', deps: ['redis', 'ioredis', 'redis-py', 'go-redis', 'bull', 'bullmq', 'celery'] },
|
|
52
|
+
{ engine: 'mysql', deps: ['mysql', 'mysql2', 'mysqlclient', 'pymysql', 'go-sql-driver'] },
|
|
53
|
+
{ engine: 'mongo', deps: ['mongodb', 'mongoose', 'pymongo', 'motor', 'mongo-driver'] },
|
|
54
|
+
];
|
|
55
|
+
/** Dependencies that mean "this wants a GPU and a lot more memory". */
|
|
56
|
+
const GPU_HINTS = [
|
|
57
|
+
'torch', 'pytorch', 'tensorflow', 'transformers', 'vllm', 'accelerate',
|
|
58
|
+
'onnxruntime-gpu', 'jax', 'diffusers', 'sentence-transformers', 'llama-cpp-python',
|
|
59
|
+
];
|
|
60
|
+
const SECRET_HINT = /(PASSWORD|SECRET|TOKEN|APIKEY|API_KEY|ACCESS_KEY|PRIVATE|CREDENTIAL|_DSN|SALT|CERT|_URL$)/i;
|
|
61
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
62
|
+
/** Every dependency name a directory declares, across ecosystems. */
|
|
63
|
+
async function dependenciesOf(dir) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const pkg = await readJson(join(dir, 'package.json'));
|
|
66
|
+
if (pkg) {
|
|
67
|
+
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
68
|
+
const deps = pkg[field];
|
|
69
|
+
if (deps && typeof deps === 'object')
|
|
70
|
+
out.push(...Object.keys(deps));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Python: one name per line, before any version specifier.
|
|
74
|
+
for (const file of ['requirements.txt', 'requirements-prod.txt', 'pyproject.toml', 'Pipfile']) {
|
|
75
|
+
const text = await readText(join(dir, file));
|
|
76
|
+
for (const line of text.split('\n')) {
|
|
77
|
+
const name = line.trim().split(/[=<>!~\[;#"']/)[0]?.trim();
|
|
78
|
+
if (name && /^[A-Za-z][A-Za-z0-9._-]*$/.test(name))
|
|
79
|
+
out.push(name.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Go and Rust name their dependencies as paths and table keys respectively;
|
|
83
|
+
// a substring match on the whole file is enough to spot a driver.
|
|
84
|
+
for (const file of ['go.mod', 'go.sum', 'Cargo.toml']) {
|
|
85
|
+
const text = await readText(join(dir, file));
|
|
86
|
+
if (text)
|
|
87
|
+
out.push(...text.split('\n').map((l) => l.trim().toLowerCase()));
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
const mentions = (deps, needle) => deps.some((d) => d === needle || d.includes(needle));
|
|
92
|
+
/** Variable names a service declares it needs. */
|
|
93
|
+
async function envFrom(dir) {
|
|
94
|
+
const env = [];
|
|
95
|
+
const secrets = [];
|
|
96
|
+
for (const file of ['.env.example', '.env.sample', '.env.template']) {
|
|
97
|
+
const text = await readText(join(dir, file));
|
|
98
|
+
for (const line of text.split('\n')) {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
101
|
+
continue;
|
|
102
|
+
const key = trimmed.split('=')[0]?.trim();
|
|
103
|
+
if (!key || !ENV_NAME.test(key))
|
|
104
|
+
continue;
|
|
105
|
+
// A credential and a setting are different things: one belongs in the
|
|
106
|
+
// manifest, the other must never appear in a file anyone commits.
|
|
107
|
+
if (SECRET_HINT.test(key))
|
|
108
|
+
secrets.push(key);
|
|
109
|
+
else
|
|
110
|
+
env.push(key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { env: [...new Set(env)], secrets: [...new Set(secrets)] };
|
|
114
|
+
}
|
|
115
|
+
/** Workspace globs, from whichever convention this repository uses. */
|
|
116
|
+
async function workspaceGlobs(root) {
|
|
117
|
+
const pkg = await readJson(join(root, 'package.json'));
|
|
118
|
+
const ws = pkg?.workspaces;
|
|
119
|
+
if (Array.isArray(ws))
|
|
120
|
+
return { layout: 'npm workspaces', globs: ws.filter((w) => typeof w === 'string') };
|
|
121
|
+
if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
|
|
122
|
+
return { layout: 'yarn workspaces', globs: ws.packages };
|
|
123
|
+
}
|
|
124
|
+
const pnpm = await readText(join(root, 'pnpm-workspace.yaml'));
|
|
125
|
+
if (pnpm) {
|
|
126
|
+
const globs = pnpm
|
|
127
|
+
.split('\n')
|
|
128
|
+
.map((l) => l.trim())
|
|
129
|
+
.filter((l) => l.startsWith('- '))
|
|
130
|
+
.map((l) => l.slice(2).replace(/['"]/g, '').trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
if (globs.length)
|
|
133
|
+
return { layout: 'pnpm workspace', globs };
|
|
134
|
+
}
|
|
135
|
+
const lerna = await readJson(join(root, 'lerna.json'));
|
|
136
|
+
if (Array.isArray(lerna?.packages))
|
|
137
|
+
return { layout: 'lerna', globs: lerna.packages };
|
|
138
|
+
const goWork = await readText(join(root, 'go.work'));
|
|
139
|
+
if (goWork) {
|
|
140
|
+
const globs = [...goWork.matchAll(/^\s*\.?\/?([\w./-]+)\s*$/gm)]
|
|
141
|
+
.map((m) => m[1])
|
|
142
|
+
.filter((p) => p !== 'go' && !p.includes('use') && p !== '.');
|
|
143
|
+
if (globs.length)
|
|
144
|
+
return { layout: 'go workspace', globs };
|
|
145
|
+
}
|
|
146
|
+
const cargo = await readText(join(root, 'Cargo.toml'));
|
|
147
|
+
const members = cargo.match(/members\s*=\s*\[([^\]]*)\]/);
|
|
148
|
+
if (members) {
|
|
149
|
+
const globs = [...members[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
|
150
|
+
if (globs.length)
|
|
151
|
+
return { layout: 'cargo workspace', globs };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Expand the one glob shape workspaces actually use: a directory then `/*`.
|
|
157
|
+
*
|
|
158
|
+
* Deliberately not a glob library. `apps/*` and `packages/*` are what these
|
|
159
|
+
* files contain in practice, and a dependency to handle the rest is not worth
|
|
160
|
+
* carrying in a CLI whose whole install is one package.
|
|
161
|
+
*/
|
|
162
|
+
async function expand(root, globs) {
|
|
163
|
+
const dirs = new Set();
|
|
164
|
+
for (const glob of globs) {
|
|
165
|
+
if (!glob.includes('*')) {
|
|
166
|
+
if (await exists(join(root, glob)))
|
|
167
|
+
dirs.add(glob);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const base = glob.slice(0, glob.indexOf('*')).replace(/\/$/, '');
|
|
171
|
+
try {
|
|
172
|
+
for (const entry of await readdir(join(root, base), { withFileTypes: true })) {
|
|
173
|
+
if (entry.isDirectory() && !IGNORED.has(entry.name))
|
|
174
|
+
dirs.add(join(base, entry.name));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* a workspace glob pointing at nothing is the repository's problem */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...dirs];
|
|
182
|
+
}
|
|
183
|
+
/** Conventional layouts, for repositories that declare no workspaces. */
|
|
184
|
+
async function conventionalDirs(root) {
|
|
185
|
+
const dirs = [];
|
|
186
|
+
for (const parent of ['apps', 'services', 'packages']) {
|
|
187
|
+
try {
|
|
188
|
+
for (const entry of await readdir(join(root, parent), { withFileTypes: true })) {
|
|
189
|
+
if (entry.isDirectory() && !IGNORED.has(entry.name))
|
|
190
|
+
dirs.push(join(parent, entry.name));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* not this layout */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return dirs;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether a directory is something to deploy.
|
|
201
|
+
*
|
|
202
|
+
* A monorepo is mostly libraries, and a manifest that tries to deploy a shared
|
|
203
|
+
* types package is noise the reader has to delete. Something to run says so:
|
|
204
|
+
* it has a Dockerfile, a start script, or a recognised framework.
|
|
205
|
+
*/
|
|
206
|
+
async function isDeployable(dir, d) {
|
|
207
|
+
if (d.hasDockerfile)
|
|
208
|
+
return true;
|
|
209
|
+
if (d.framework !== 'unknown') {
|
|
210
|
+
const pkg = await readJson(join(dir, 'package.json'));
|
|
211
|
+
if (pkg) {
|
|
212
|
+
const scripts = (pkg.scripts ?? {});
|
|
213
|
+
// A package with no way to start is a library, whatever it depends on.
|
|
214
|
+
if (!scripts.start && !scripts.dev && !scripts.serve && d.framework !== 'vite')
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const serviceName = (dir, root) => {
|
|
222
|
+
const raw = dir === '.' ? basename(root) : basename(dir);
|
|
223
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
|
|
224
|
+
};
|
|
225
|
+
export async function discover(root = process.cwd()) {
|
|
226
|
+
const notes = [];
|
|
227
|
+
const ws = await workspaceGlobs(root);
|
|
228
|
+
let candidates = [];
|
|
229
|
+
let layout = null;
|
|
230
|
+
if (ws) {
|
|
231
|
+
layout = ws.layout;
|
|
232
|
+
candidates = await expand(root, ws.globs);
|
|
233
|
+
notes.push(`${ws.layout} declares ${candidates.length} package${candidates.length === 1 ? '' : 's'}`);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const conventional = await conventionalDirs(root);
|
|
237
|
+
if (conventional.length) {
|
|
238
|
+
layout = 'apps/ and services/ directories';
|
|
239
|
+
candidates = conventional;
|
|
240
|
+
notes.push(`no workspace file, but ${conventional.length} directories look like services`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Always consider the root itself: a repository can be a monorepo and still
|
|
244
|
+
// deploy something from its top level.
|
|
245
|
+
if (!candidates.includes('.'))
|
|
246
|
+
candidates.unshift('.');
|
|
247
|
+
const services = [];
|
|
248
|
+
const allDeps = new Set();
|
|
249
|
+
for (const dir of candidates) {
|
|
250
|
+
const abs = dir === '.' ? root : join(root, dir);
|
|
251
|
+
const d = await detect(abs);
|
|
252
|
+
const deps = await dependenciesOf(abs);
|
|
253
|
+
deps.forEach((x) => allDeps.add(x));
|
|
254
|
+
// Which engines THIS package depends on. Taking the union across the
|
|
255
|
+
// repository made a Next.js frontend declare it uses the database, which
|
|
256
|
+
// is a claim the manifest should not be making on its behalf.
|
|
257
|
+
const ownEngines = DB_HINTS.filter(({ deps: hints }) => hints.some((h) => mentions(deps, h))).map((x) => x.engine);
|
|
258
|
+
if (!(await isDeployable(abs, d)))
|
|
259
|
+
continue;
|
|
260
|
+
// A root that only exists to hold workspaces is not a service; without
|
|
261
|
+
// this a monorepo gains a phantom service named after the repository.
|
|
262
|
+
if (dir === '.' && ws && !d.hasDockerfile)
|
|
263
|
+
continue;
|
|
264
|
+
const { env, secrets } = await envFrom(abs);
|
|
265
|
+
const gpu = GPU_HINTS.find((h) => mentions(deps, h)) ?? null;
|
|
266
|
+
services.push({
|
|
267
|
+
name: serviceName(dir, root),
|
|
268
|
+
dir: dir === '.' ? '.' : `./${relative(root, abs)}`,
|
|
269
|
+
detection: d,
|
|
270
|
+
env,
|
|
271
|
+
secrets,
|
|
272
|
+
gpu: gpu ? `depends on ${gpu}` : null,
|
|
273
|
+
engines: ownEngines,
|
|
274
|
+
// A model needs room; everything else gets a modest default the reader
|
|
275
|
+
// can lower once they know what it actually uses.
|
|
276
|
+
ramMb: gpu ? 4096 : 512,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const deps = [...allDeps];
|
|
280
|
+
const databases = [];
|
|
281
|
+
const takenDbNames = new Set();
|
|
282
|
+
for (const { engine, deps: hints } of DB_HINTS) {
|
|
283
|
+
const hit = hints.find((h) => mentions(deps, h));
|
|
284
|
+
// Named `db` and `cache` rather than `postgres` and `redis`: a database
|
|
285
|
+
// named after its engine collides with that engine's own environment
|
|
286
|
+
// variables, and it is not what anyone would write by hand either.
|
|
287
|
+
if (hit) {
|
|
288
|
+
databases.push({
|
|
289
|
+
name: safeDatabaseName(engine, engine, takenDbNames),
|
|
290
|
+
engine,
|
|
291
|
+
because: `something depends on ${hit}`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!services.length) {
|
|
296
|
+
notes.push('nothing deployable found — no Dockerfile, and no framework this recognises');
|
|
297
|
+
}
|
|
298
|
+
for (const db of databases)
|
|
299
|
+
notes.push(`${db.engine}: ${db.because}`);
|
|
300
|
+
return { root, layout, services, databases, notes };
|
|
301
|
+
}
|
|
302
|
+
/** Render a discovery as a manifest. */
|
|
303
|
+
export function manifestFromDiscovery(d, opts = {}) {
|
|
304
|
+
const questions = [];
|
|
305
|
+
const fleet = opts.fleet ?? 'homelab';
|
|
306
|
+
const lines = [`fleet: ${fleet}`, '', 'services:'];
|
|
307
|
+
d.services.forEach((s, i) => {
|
|
308
|
+
if (i)
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push(` ${s.name}:`);
|
|
311
|
+
lines.push(` build: ${s.dir}`);
|
|
312
|
+
lines.push(' placement: flexible');
|
|
313
|
+
if (s.detection.port !== 80)
|
|
314
|
+
lines.push(` container_port: ${s.detection.port}`);
|
|
315
|
+
lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
|
|
316
|
+
lines.push(` health: { path: ${s.detection.healthPath} }`);
|
|
317
|
+
if (s.gpu) {
|
|
318
|
+
lines.push(' gpu: true');
|
|
319
|
+
questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
|
|
320
|
+
}
|
|
321
|
+
if (s.env.length) {
|
|
322
|
+
lines.push(' env:');
|
|
323
|
+
for (const key of s.env)
|
|
324
|
+
lines.push(` ${key}: "" # from .env.example`);
|
|
325
|
+
}
|
|
326
|
+
if (s.secrets.length)
|
|
327
|
+
lines.push(` secrets: [${s.secrets.join(', ')}]`);
|
|
328
|
+
const mine = d.databases.filter((db) => s.engines.includes(db.engine));
|
|
329
|
+
if (mine.length)
|
|
330
|
+
lines.push(` uses: [${mine.map((db) => db.name).join(', ')}]`);
|
|
331
|
+
});
|
|
332
|
+
if (d.databases.length) {
|
|
333
|
+
lines.push('', 'databases:');
|
|
334
|
+
d.databases.forEach((db, i) => {
|
|
335
|
+
if (i)
|
|
336
|
+
lines.push('');
|
|
337
|
+
lines.push(` ${db.name}:`);
|
|
338
|
+
lines.push(` engine: ${db.engine}`);
|
|
339
|
+
if (opts.node)
|
|
340
|
+
lines.push(` node: ${opts.node}`);
|
|
341
|
+
else {
|
|
342
|
+
lines.push(' node: CHANGE_ME');
|
|
343
|
+
questions.push(`${db.name}: a database must name the node that holds its data — replace CHANGE_ME, or re-run with --node.`);
|
|
344
|
+
}
|
|
345
|
+
lines.push(' backup: daily');
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return { manifest: lines.join('\n') + '\n', questions };
|
|
349
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,8 @@ const GROUPS = [
|
|
|
16
16
|
'getting started',
|
|
17
17
|
[
|
|
18
18
|
['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
|
|
19
|
-
['init', '
|
|
19
|
+
['init', 'Read the repository — monorepo, databases, secrets — and write a fleet.yaml'],
|
|
20
|
+
['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
|
|
20
21
|
['config show', 'Show the saved control plane and selected fleet'],
|
|
21
22
|
['use <fleet>', 'Select the default fleet for later commands'],
|
|
22
23
|
['auth login', 'Sign in and save a secure local session'],
|