@yadurajfleetos/cli 0.5.0 → 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/services.js +25 -0
- package/dist/compose.js +18 -4
- package/dist/dbnames.js +34 -0
- package/dist/discover.js +349 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -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) {
|
package/dist/compose.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parse as parseYaml } from 'yaml';
|
|
2
|
+
import { safeDatabaseName } from './dbnames.js';
|
|
2
3
|
/** Images Fleet manages as databases rather than as plain containers. */
|
|
3
4
|
const ENGINE_IMAGES = {
|
|
4
5
|
postgres: 'postgres',
|
|
@@ -184,13 +185,22 @@ export function composeToFleet(source, opts = {}) {
|
|
|
184
185
|
const databases = [];
|
|
185
186
|
/** Compose names that became databases, so `depends_on` can point at them. */
|
|
186
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();
|
|
187
192
|
// Databases first, so a service's `uses` can reference one by name.
|
|
188
193
|
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
189
194
|
const svc = (asRecord(raw) ?? {});
|
|
190
195
|
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
191
196
|
const engine = image ? engineFor(image) : null;
|
|
192
|
-
if (engine)
|
|
193
|
-
|
|
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));
|
|
194
204
|
}
|
|
195
205
|
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
196
206
|
const svc = (asRecord(raw) ?? {});
|
|
@@ -199,7 +209,11 @@ export function composeToFleet(source, opts = {}) {
|
|
|
199
209
|
if (asDatabase.has(name)) {
|
|
200
210
|
const engine = engineFor(image);
|
|
201
211
|
const major = versionOf(image);
|
|
202
|
-
const
|
|
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}:`];
|
|
203
217
|
lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
|
|
204
218
|
if (dbNode)
|
|
205
219
|
lines.push(` node: ${scalar(dbNode)}`);
|
|
@@ -295,7 +309,7 @@ export function composeToFleet(source, opts = {}) {
|
|
|
295
309
|
const deps = Array.isArray(svc.depends_on)
|
|
296
310
|
? svc.depends_on.filter((d) => typeof d === 'string')
|
|
297
311
|
: Object.keys(asRecord(svc.depends_on) ?? {});
|
|
298
|
-
const dbDeps = deps.filter((d) => asDatabase.has(d));
|
|
312
|
+
const dbDeps = deps.filter((d) => asDatabase.has(d)).map((d) => dbNames.get(d) ?? d);
|
|
299
313
|
const svcDeps = deps.filter((d) => !asDatabase.has(d));
|
|
300
314
|
if (dbDeps.length)
|
|
301
315
|
lines.push(` uses: [${dbDeps.join(', ')}]`);
|
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,7 @@ 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
20
|
['import [file]', 'Convert a docker-compose.yml into a fleet.yaml'],
|
|
21
21
|
['config show', 'Show the saved control plane and selected fleet'],
|
|
22
22
|
['use <fleet>', 'Select the default fleet for later commands'],
|