@yadurajfleetos/cli 0.5.0 → 0.7.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 +109 -0
- package/dist/commands/up.js +27 -4
- package/dist/compose.js +26 -4
- package/dist/dbnames.js +34 -0
- package/dist/detect.js +6 -6
- package/dist/discover.js +387 -0
- package/dist/index.js +2 -1
- package/dist/plan.js +16 -4
- 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, importCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
13
|
+
import { applyCommand, deployCommand, deploymentsCommand, initCommand, importCommand, explainCommand, logsCommand, removeServiceCommand, restartCommand, rollbackCommand, rescheduleCommand, servicesCommand, validateCommand, whereCommand, } from './services.js';
|
|
14
14
|
export const commands = {
|
|
15
15
|
up: upCommand,
|
|
16
16
|
open: openCommand,
|
|
@@ -22,6 +22,7 @@ export const commands = {
|
|
|
22
22
|
doctor: doctorCommand,
|
|
23
23
|
init: initCommand,
|
|
24
24
|
import: importCommand,
|
|
25
|
+
explain: explainCommand,
|
|
25
26
|
validate: validateCommand,
|
|
26
27
|
apply: applyCommand,
|
|
27
28
|
status: statusCommand,
|
|
@@ -403,6 +403,40 @@ 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
|
+
// A manifest saying `build: ./web` against a directory with no
|
|
422
|
+
// Dockerfile is a deploy that fails at the first step. detect() has
|
|
423
|
+
// already worked out what the file should contain; writing it is the
|
|
424
|
+
// difference between a manifest and something that runs.
|
|
425
|
+
if (s.detection.dockerfile && !s.detection.hasDockerfile) {
|
|
426
|
+
const target = join(process.cwd(), s.dir, 'Dockerfile');
|
|
427
|
+
await writeFile(target, s.detection.dockerfile);
|
|
428
|
+
console.log(`${c.green('created')} ${s.dir}/Dockerfile ${c.dim(`(${s.detection.label}, port ${s.detection.port})`)}`);
|
|
429
|
+
}
|
|
430
|
+
console.log(c.dim(` · ${s.name} ${s.dir} ${s.detection.label}`));
|
|
431
|
+
}
|
|
432
|
+
for (const db of found.databases) {
|
|
433
|
+
console.log(c.dim(` · ${db.name} (${db.engine}) — ${db.because}`));
|
|
434
|
+
}
|
|
435
|
+
for (const q of questions)
|
|
436
|
+
console.log(` ${c.yellow('?')} ${q}`);
|
|
437
|
+
console.log(c.dim(`\n check it with: fleet apply --dry-run`));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
406
440
|
const d = await detect();
|
|
407
441
|
// Write a Dockerfile if we generated one and none exists.
|
|
408
442
|
if (d.dockerfile) {
|
|
@@ -536,3 +570,78 @@ export const importCommand = {
|
|
|
536
570
|
}
|
|
537
571
|
},
|
|
538
572
|
};
|
|
573
|
+
/**
|
|
574
|
+
* Ask why a deployment failed.
|
|
575
|
+
*
|
|
576
|
+
* The wall of Docker output is still there underneath — this adds a reading of
|
|
577
|
+
* it, it does not replace the evidence. Printed at the moment of failure is the
|
|
578
|
+
* point; `fleet explain` exists for when you have come back to it later.
|
|
579
|
+
*/
|
|
580
|
+
export const explainCommand = {
|
|
581
|
+
async run(args, flags) {
|
|
582
|
+
const id = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
583
|
+
let deploymentId = typeof flags.deploy === 'string' ? flags.deploy : '';
|
|
584
|
+
if (!deploymentId) {
|
|
585
|
+
const name = args[0];
|
|
586
|
+
if (!name)
|
|
587
|
+
throw new CliError('name a service, or pass --deploy <id>', EXIT.usage);
|
|
588
|
+
const service = await findService(id, name);
|
|
589
|
+
const { body } = await request('GET', `/services/${service.id}/deployments`);
|
|
590
|
+
// The most recent failure, which is what someone asking "why did that
|
|
591
|
+
// fail" means — not the most recent deployment, which may since have
|
|
592
|
+
// succeeded.
|
|
593
|
+
const failed = body.deployments.find((d) => d.status === 'failed');
|
|
594
|
+
if (!failed) {
|
|
595
|
+
throw new CliError(`"${name}" has no failed deployment to explain.`, EXIT.usage);
|
|
596
|
+
}
|
|
597
|
+
deploymentId = failed.id;
|
|
598
|
+
}
|
|
599
|
+
const out = await task('reading the failure', () => request('POST', `/fleets/${id}/deployments/${deploymentId}/explain`));
|
|
600
|
+
printExplanation(out.body);
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
/** Shared by `fleet explain` and by a deploy that just failed. */
|
|
604
|
+
export function printExplanation(r) {
|
|
605
|
+
if (r.status === 'ok') {
|
|
606
|
+
console.log();
|
|
607
|
+
for (const line of wrapText(r.summary ?? '', 76))
|
|
608
|
+
console.log(` ${line}`);
|
|
609
|
+
if (r.steps?.length) {
|
|
610
|
+
console.log();
|
|
611
|
+
r.steps.forEach((step, i) => console.log(` ${c.dim(`${i + 1}.`)} ${step}`));
|
|
612
|
+
}
|
|
613
|
+
const seen = (r.hits ?? 1) > 1 ? `seen ${r.hits}× before` : 'first time this failure has been seen';
|
|
614
|
+
console.log(`\n ${c.dim(`${r.cached ? 'cached' : 'explained'} · ${seen}`)}`);
|
|
615
|
+
if (r.usage)
|
|
616
|
+
console.log(` ${c.dim(`${r.usage.used}/${r.usage.limit} explanations used today`)}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (r.status === 'rate_limited') {
|
|
620
|
+
const hours = Math.ceil((r.resetsInSec ?? 0) / 3600);
|
|
621
|
+
console.log(`\n ${c.yellow('daily limit reached')} ${c.dim(`— ${r.limit} explanations a day, resets in ${hours}h.`)}`);
|
|
622
|
+
console.log(c.dim(' Answers already generated are still free to read.'));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// disabled / not_worth_it / failed all carry a reason worth printing as-is.
|
|
626
|
+
if (r.reason)
|
|
627
|
+
console.log(`\n ${c.dim(r.reason)}`);
|
|
628
|
+
}
|
|
629
|
+
/** Wrap to a width, on spaces, so a paragraph reads in a terminal. */
|
|
630
|
+
function wrapText(text, width) {
|
|
631
|
+
const out = [];
|
|
632
|
+
for (const paragraph of text.split('\n')) {
|
|
633
|
+
let line = '';
|
|
634
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
635
|
+
if (line && line.length + word.length + 1 > width) {
|
|
636
|
+
out.push(line);
|
|
637
|
+
line = word;
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
line = line ? `${line} ${word}` : word;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (line)
|
|
644
|
+
out.push(line);
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
package/dist/commands/up.js
CHANGED
|
@@ -63,6 +63,7 @@ export const upCommand = {
|
|
|
63
63
|
// deploying one service of it and leaving the rest was never what anybody
|
|
64
64
|
// wanted — it just meant typing the command again in the right order.
|
|
65
65
|
const targets = args[0] ? [args[0]] : deployOrder(planned);
|
|
66
|
+
const isDatabase = new Set(planned.filter((p) => p.database).map((p) => p.name));
|
|
66
67
|
if (!targets.length) {
|
|
67
68
|
throw new CliError('The manifest declares no services to deploy.', EXIT.usage);
|
|
68
69
|
}
|
|
@@ -74,12 +75,27 @@ export const upCommand = {
|
|
|
74
75
|
}
|
|
75
76
|
return service;
|
|
76
77
|
});
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// A database that is already serving is left alone.
|
|
79
|
+
//
|
|
80
|
+
// Redeploying one replaces a running container for no reason, and every
|
|
81
|
+
// service that talks to it loses its connections while it restarts. It is
|
|
82
|
+
// in the plan so that a database which is *not* running comes back — which
|
|
83
|
+
// is the case that used to need `fleet up db` by name — not so that every
|
|
84
|
+
// deploy of the stack restarts the database underneath it. Naming it
|
|
85
|
+
// explicitly still redeploys it.
|
|
86
|
+
const skipped = args[0]
|
|
87
|
+
? []
|
|
88
|
+
: resolved.filter((s) => isDatabase.has(s.name) && s.current?.status === 'running');
|
|
89
|
+
const toDeploy = resolved.filter((s) => !skipped.includes(s));
|
|
90
|
+
for (const s of skipped) {
|
|
91
|
+
console.log(` ${c.dim('already running')} ${c.bold(s.name)}`);
|
|
92
|
+
}
|
|
93
|
+
if (toDeploy.length > 1) {
|
|
94
|
+
console.log(`\n ${c.dim('deploying')} ${toDeploy.map((s) => c.bold(s.name)).join(c.dim(' → '))}\n`);
|
|
79
95
|
}
|
|
80
96
|
const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
|
|
81
97
|
const deployed = [];
|
|
82
|
-
for (const service of
|
|
98
|
+
for (const service of toDeploy) {
|
|
83
99
|
const url = await deployOne(service, {
|
|
84
100
|
fleetId,
|
|
85
101
|
gitSha,
|
|
@@ -148,11 +164,18 @@ async function deployOne(service, opts) {
|
|
|
148
164
|
if (opts.wait) {
|
|
149
165
|
await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
|
|
150
166
|
s.hints([
|
|
167
|
+
'the image is built on the control plane, for every architecture in the fleet',
|
|
168
|
+
'building for a different architecture than the control plane is emulated, and slow',
|
|
151
169
|
'the agent picks up desired state on its next poll',
|
|
152
170
|
"a cold image pull takes as long as the node's uplink does",
|
|
153
171
|
'a service with a health check goes running once it passes, not before',
|
|
154
172
|
]);
|
|
155
|
-
|
|
173
|
+
// Long, because this now covers the build as well as the rollout.
|
|
174
|
+
// The control plane answers as soon as a node is chosen and keeps
|
|
175
|
+
// building afterwards, so this is the window in which a multi-arch
|
|
176
|
+
// build has to finish - and an arm64 build emulated on an amd64 host
|
|
177
|
+
// is measured in tens of minutes, not minutes.
|
|
178
|
+
const deadline = Date.now() + 45 * 60_000;
|
|
156
179
|
while (Date.now() < deadline) {
|
|
157
180
|
const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
|
|
158
181
|
const current = body.services.find((s) => s.id === service.id)?.current;
|
package/dist/compose.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
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',
|
|
5
6
|
postgis: 'postgres',
|
|
7
|
+
// Postgres by another name. pgvector in particular is what anything doing
|
|
8
|
+
// embeddings runs, and leaving it unrecognised turned a managed database
|
|
9
|
+
// back into a container the reader has to look after themselves.
|
|
10
|
+
pgvector: 'postgres',
|
|
11
|
+
'ankane/pgvector': 'postgres',
|
|
12
|
+
'pgvector/pgvector': 'postgres',
|
|
13
|
+
'supabase/postgres': 'postgres',
|
|
14
|
+
'bitnami/postgresql': 'postgres',
|
|
6
15
|
'timescale/timescaledb': 'postgres',
|
|
7
16
|
mysql: 'mysql',
|
|
8
17
|
mariadb: 'mariadb',
|
|
@@ -184,13 +193,22 @@ export function composeToFleet(source, opts = {}) {
|
|
|
184
193
|
const databases = [];
|
|
185
194
|
/** Compose names that became databases, so `depends_on` can point at them. */
|
|
186
195
|
const asDatabase = new Set();
|
|
196
|
+
/** Compose name -> the name the database is declared under, which differs
|
|
197
|
+
whenever the compose name would collide with its engine's own env vars. */
|
|
198
|
+
const dbNames = new Map();
|
|
199
|
+
const takenDbNames = new Set();
|
|
187
200
|
// Databases first, so a service's `uses` can reference one by name.
|
|
188
201
|
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
189
202
|
const svc = (asRecord(raw) ?? {});
|
|
190
203
|
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
191
204
|
const engine = image ? engineFor(image) : null;
|
|
192
|
-
if (engine)
|
|
193
|
-
|
|
205
|
+
if (!engine)
|
|
206
|
+
continue;
|
|
207
|
+
asDatabase.add(name);
|
|
208
|
+
// Named here, in the pre-pass, not where the database is rendered: a
|
|
209
|
+
// service declared before it in the file still has to point at the name it
|
|
210
|
+
// ends up with, and the main loop would not know it yet.
|
|
211
|
+
dbNames.set(name, safeDatabaseName(name, engine, takenDbNames));
|
|
194
212
|
}
|
|
195
213
|
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
196
214
|
const svc = (asRecord(raw) ?? {});
|
|
@@ -199,7 +217,11 @@ export function composeToFleet(source, opts = {}) {
|
|
|
199
217
|
if (asDatabase.has(name)) {
|
|
200
218
|
const engine = engineFor(image);
|
|
201
219
|
const major = versionOf(image);
|
|
202
|
-
const
|
|
220
|
+
const declared = dbNames.get(name);
|
|
221
|
+
if (declared !== name) {
|
|
222
|
+
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.`);
|
|
223
|
+
}
|
|
224
|
+
const lines = [` ${declared}:`];
|
|
203
225
|
lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
|
|
204
226
|
if (dbNode)
|
|
205
227
|
lines.push(` node: ${scalar(dbNode)}`);
|
|
@@ -295,7 +317,7 @@ export function composeToFleet(source, opts = {}) {
|
|
|
295
317
|
const deps = Array.isArray(svc.depends_on)
|
|
296
318
|
? svc.depends_on.filter((d) => typeof d === 'string')
|
|
297
319
|
: Object.keys(asRecord(svc.depends_on) ?? {});
|
|
298
|
-
const dbDeps = deps.filter((d) => asDatabase.has(d));
|
|
320
|
+
const dbDeps = deps.filter((d) => asDatabase.has(d)).map((d) => dbNames.get(d) ?? d);
|
|
299
321
|
const svcDeps = deps.filter((d) => !asDatabase.has(d));
|
|
300
322
|
if (dbDeps.length)
|
|
301
323
|
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/detect.js
CHANGED
|
@@ -135,7 +135,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
135
135
|
framework: 'unknown',
|
|
136
136
|
label: 'existing Dockerfile',
|
|
137
137
|
port,
|
|
138
|
-
healthPath:
|
|
138
|
+
healthPath: null,
|
|
139
139
|
dockerfile: null,
|
|
140
140
|
hasDockerfile: true,
|
|
141
141
|
};
|
|
@@ -175,7 +175,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
175
175
|
framework: 'node',
|
|
176
176
|
label: 'Node.js API',
|
|
177
177
|
port: 3000,
|
|
178
|
-
healthPath:
|
|
178
|
+
healthPath: null,
|
|
179
179
|
dockerfile: NODE_DOCKERFILE,
|
|
180
180
|
hasDockerfile: false,
|
|
181
181
|
};
|
|
@@ -209,7 +209,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
209
209
|
framework: 'python',
|
|
210
210
|
label: isFastapi ? 'Python (FastAPI)' : isDjango ? 'Python (Django)' : isFlask ? 'Python (Flask)' : 'Python',
|
|
211
211
|
port: 8000,
|
|
212
|
-
healthPath:
|
|
212
|
+
healthPath: null,
|
|
213
213
|
dockerfile: pythonDF,
|
|
214
214
|
hasDockerfile: false,
|
|
215
215
|
};
|
|
@@ -223,7 +223,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
223
223
|
framework: 'go',
|
|
224
224
|
label: 'Go',
|
|
225
225
|
port: 8080,
|
|
226
|
-
healthPath:
|
|
226
|
+
healthPath: null,
|
|
227
227
|
dockerfile: GO_DOCKERFILE(moduleName),
|
|
228
228
|
hasDockerfile: false,
|
|
229
229
|
};
|
|
@@ -234,7 +234,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
234
234
|
framework: 'rust',
|
|
235
235
|
label: 'Rust',
|
|
236
236
|
port: 8080,
|
|
237
|
-
healthPath:
|
|
237
|
+
healthPath: null,
|
|
238
238
|
dockerfile: RUST_DOCKERFILE,
|
|
239
239
|
hasDockerfile: false,
|
|
240
240
|
};
|
|
@@ -255,7 +255,7 @@ export async function detect(cwd = process.cwd()) {
|
|
|
255
255
|
framework: 'unknown',
|
|
256
256
|
label: 'unknown project',
|
|
257
257
|
port: 3000,
|
|
258
|
-
healthPath:
|
|
258
|
+
healthPath: null,
|
|
259
259
|
dockerfile: null,
|
|
260
260
|
hasDockerfile: false,
|
|
261
261
|
};
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
/**
|
|
184
|
+
* Candidate directories for a repository that declares no workspaces.
|
|
185
|
+
*
|
|
186
|
+
* Two shapes, because both are common and neither is declared anywhere. The
|
|
187
|
+
* first is a parent holding many packages - apps/, services/, packages/. The
|
|
188
|
+
* second is simply a few directories at the top level: backend/ beside
|
|
189
|
+
* landing_page/, or api/ beside web/. Looking only for the first meant a
|
|
190
|
+
* perfectly ordinary two-app repository was read as one unrecognised project.
|
|
191
|
+
*
|
|
192
|
+
* Only immediate children are considered. Walking deeper finds vendored
|
|
193
|
+
* copies, fixtures and examples, and proposes deploying them.
|
|
194
|
+
*/
|
|
195
|
+
async function conventionalDirs(root) {
|
|
196
|
+
const dirs = [];
|
|
197
|
+
for (const parent of ['apps', 'services', 'packages']) {
|
|
198
|
+
try {
|
|
199
|
+
for (const entry of await readdir(join(root, parent), { withFileTypes: true })) {
|
|
200
|
+
if (entry.isDirectory() && !IGNORED.has(entry.name))
|
|
201
|
+
dirs.push(join(parent, entry.name));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* not this layout */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (dirs.length)
|
|
209
|
+
return dirs;
|
|
210
|
+
try {
|
|
211
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
212
|
+
if (!entry.isDirectory() || IGNORED.has(entry.name) || entry.name.startsWith('.'))
|
|
213
|
+
continue;
|
|
214
|
+
dirs.push(entry.name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* unreadable root is the caller's problem */
|
|
219
|
+
}
|
|
220
|
+
return dirs;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether a directory is something to deploy.
|
|
224
|
+
*
|
|
225
|
+
* A monorepo is mostly libraries, and a manifest that tries to deploy a shared
|
|
226
|
+
* types package is noise the reader has to delete. Something to run says so:
|
|
227
|
+
* it has a Dockerfile, a start script, or a recognised framework.
|
|
228
|
+
*/
|
|
229
|
+
async function isDeployable(dir, d) {
|
|
230
|
+
if (d.hasDockerfile)
|
|
231
|
+
return true;
|
|
232
|
+
if (d.framework !== 'unknown') {
|
|
233
|
+
const pkg = await readJson(join(dir, 'package.json'));
|
|
234
|
+
if (pkg) {
|
|
235
|
+
const scripts = (pkg.scripts ?? {});
|
|
236
|
+
// A package with no way to start is a library, whatever it depends on.
|
|
237
|
+
if (!scripts.start && !scripts.dev && !scripts.serve && d.framework !== 'vite')
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const serviceName = (dir, root) => {
|
|
245
|
+
const raw = dir === '.' ? basename(root) : basename(dir);
|
|
246
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
|
|
247
|
+
};
|
|
248
|
+
export async function discover(root = process.cwd()) {
|
|
249
|
+
const notes = [];
|
|
250
|
+
const ws = await workspaceGlobs(root);
|
|
251
|
+
let candidates = [];
|
|
252
|
+
let layout = null;
|
|
253
|
+
if (ws) {
|
|
254
|
+
layout = ws.layout;
|
|
255
|
+
candidates = await expand(root, ws.globs);
|
|
256
|
+
notes.push(`${ws.layout} declares ${candidates.length} package${candidates.length === 1 ? '' : 's'}`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const conventional = await conventionalDirs(root);
|
|
260
|
+
if (conventional.length) {
|
|
261
|
+
layout = 'directories that look like services';
|
|
262
|
+
candidates = conventional;
|
|
263
|
+
notes.push(`no workspace file, but ${conventional.length} directories look like services`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Always consider the root itself: a repository can be a monorepo and still
|
|
267
|
+
// deploy something from its top level.
|
|
268
|
+
if (!candidates.includes('.'))
|
|
269
|
+
candidates.unshift('.');
|
|
270
|
+
const services = [];
|
|
271
|
+
const allDeps = new Set();
|
|
272
|
+
for (const dir of candidates) {
|
|
273
|
+
const abs = dir === '.' ? root : join(root, dir);
|
|
274
|
+
const d = await detect(abs);
|
|
275
|
+
const deps = await dependenciesOf(abs);
|
|
276
|
+
deps.forEach((x) => allDeps.add(x));
|
|
277
|
+
// Which engines THIS package depends on. Taking the union across the
|
|
278
|
+
// repository made a Next.js frontend declare it uses the database, which
|
|
279
|
+
// is a claim the manifest should not be making on its behalf.
|
|
280
|
+
const ownEngines = DB_HINTS.filter(({ deps: hints }) => hints.some((h) => mentions(deps, h))).map((x) => x.engine);
|
|
281
|
+
if (!(await isDeployable(abs, d)))
|
|
282
|
+
continue;
|
|
283
|
+
// A root that only exists to hold workspaces is not a service; without
|
|
284
|
+
// this a monorepo gains a phantom service named after the repository.
|
|
285
|
+
if (dir === '.' && ws && !d.hasDockerfile)
|
|
286
|
+
continue;
|
|
287
|
+
const { env, secrets } = await envFrom(abs);
|
|
288
|
+
const gpu = GPU_HINTS.find((h) => mentions(deps, h)) ?? null;
|
|
289
|
+
services.push({
|
|
290
|
+
name: serviceName(dir, root),
|
|
291
|
+
dir: dir === '.' ? '.' : `./${relative(root, abs)}`,
|
|
292
|
+
detection: d,
|
|
293
|
+
env,
|
|
294
|
+
secrets,
|
|
295
|
+
gpu: gpu ? `depends on ${gpu}` : null,
|
|
296
|
+
engines: ownEngines,
|
|
297
|
+
// A model needs room; everything else gets a modest default the reader
|
|
298
|
+
// can lower once they know what it actually uses.
|
|
299
|
+
ramMb: gpu ? 4096 : 512,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const deps = [...allDeps];
|
|
303
|
+
const databases = [];
|
|
304
|
+
const takenDbNames = new Set();
|
|
305
|
+
for (const { engine, deps: hints } of DB_HINTS) {
|
|
306
|
+
const hit = hints.find((h) => mentions(deps, h));
|
|
307
|
+
// Named `db` and `cache` rather than `postgres` and `redis`: a database
|
|
308
|
+
// named after its engine collides with that engine's own environment
|
|
309
|
+
// variables, and it is not what anyone would write by hand either.
|
|
310
|
+
if (hit) {
|
|
311
|
+
databases.push({
|
|
312
|
+
name: safeDatabaseName(engine, engine, takenDbNames),
|
|
313
|
+
engine,
|
|
314
|
+
because: `something depends on ${hit}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!services.length) {
|
|
319
|
+
notes.push('nothing deployable found — no Dockerfile, and no framework this recognises');
|
|
320
|
+
}
|
|
321
|
+
for (const db of databases)
|
|
322
|
+
notes.push(`${db.engine}: ${db.because}`);
|
|
323
|
+
return { root, layout, services, databases, notes };
|
|
324
|
+
}
|
|
325
|
+
/** Render a discovery as a manifest. */
|
|
326
|
+
export function manifestFromDiscovery(d, opts = {}) {
|
|
327
|
+
const questions = [];
|
|
328
|
+
const fleet = opts.fleet ?? 'homelab';
|
|
329
|
+
const lines = [`fleet: ${fleet}`, '', 'services:'];
|
|
330
|
+
d.services.forEach((s, i) => {
|
|
331
|
+
if (i)
|
|
332
|
+
lines.push('');
|
|
333
|
+
lines.push(` ${s.name}:`);
|
|
334
|
+
lines.push(` build: ${s.dir}`);
|
|
335
|
+
lines.push(' placement: flexible');
|
|
336
|
+
// Always written, never omitted as "the default". Leaving it out does not
|
|
337
|
+
// mean 80: an unset container port becomes 8080 on the node, so an nginx
|
|
338
|
+
// image serving 80 got its traffic forwarded to a closed port and answered
|
|
339
|
+
// 502 while every status in the system said running.
|
|
340
|
+
lines.push(` container_port: ${s.detection.port}`);
|
|
341
|
+
lines.push(` resources: { ram: ${s.ramMb}Mi, cpu: 0.5 }`);
|
|
342
|
+
// Only where the framework genuinely answers at the path. A guessed one
|
|
343
|
+
// that is wrong does not fall back to "no check" — it fails for ever and
|
|
344
|
+
// the deploy never leaves "deploying", while the service runs correctly.
|
|
345
|
+
if (s.detection.healthPath) {
|
|
346
|
+
lines.push(` health: { path: ${s.detection.healthPath} }`);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
lines.push(' # No health check: container state decides whether this');
|
|
350
|
+
lines.push(' # is up. Add one once you know a path that returns 2xx —');
|
|
351
|
+
lines.push(' # health: { path: /healthz }');
|
|
352
|
+
lines.push(' # Note the probe runs from the node, not inside the');
|
|
353
|
+
lines.push(' # container, so the image needs nothing installed for it.');
|
|
354
|
+
}
|
|
355
|
+
if (s.gpu) {
|
|
356
|
+
lines.push(' gpu: true');
|
|
357
|
+
questions.push(`${s.name}: ${s.gpu}, so it asks for a GPU — remove "gpu: true" if it runs on CPU.`);
|
|
358
|
+
}
|
|
359
|
+
if (s.env.length) {
|
|
360
|
+
lines.push(' env:');
|
|
361
|
+
for (const key of s.env)
|
|
362
|
+
lines.push(` ${key}: "" # from .env.example`);
|
|
363
|
+
}
|
|
364
|
+
if (s.secrets.length)
|
|
365
|
+
lines.push(` secrets: [${s.secrets.join(', ')}]`);
|
|
366
|
+
const mine = d.databases.filter((db) => s.engines.includes(db.engine));
|
|
367
|
+
if (mine.length)
|
|
368
|
+
lines.push(` uses: [${mine.map((db) => db.name).join(', ')}]`);
|
|
369
|
+
});
|
|
370
|
+
if (d.databases.length) {
|
|
371
|
+
lines.push('', 'databases:');
|
|
372
|
+
d.databases.forEach((db, i) => {
|
|
373
|
+
if (i)
|
|
374
|
+
lines.push('');
|
|
375
|
+
lines.push(` ${db.name}:`);
|
|
376
|
+
lines.push(` engine: ${db.engine}`);
|
|
377
|
+
if (opts.node)
|
|
378
|
+
lines.push(` node: ${opts.node}`);
|
|
379
|
+
else {
|
|
380
|
+
lines.push(' node: CHANGE_ME');
|
|
381
|
+
questions.push(`${db.name}: a database must name the node that holds its data — replace CHANGE_ME, or re-run with --node.`);
|
|
382
|
+
}
|
|
383
|
+
lines.push(' backup: daily');
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return { manifest: lines.join('\n') + '\n', questions };
|
|
387
|
+
}
|
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'],
|
|
@@ -25,6 +25,7 @@ const GROUPS = [
|
|
|
25
25
|
['doctor', 'Check the control plane, nodes, services, ingress, and GitHub'],
|
|
26
26
|
['apply [file]', 'Apply a fleet.yaml to the fleet'],
|
|
27
27
|
['deploy <service>', 'Build, schedule, and roll out'],
|
|
28
|
+
['explain <service>', 'Read a failed deploy and say what to do about it'],
|
|
28
29
|
],
|
|
29
30
|
],
|
|
30
31
|
[
|
package/dist/plan.js
CHANGED
|
@@ -33,10 +33,7 @@ export function projectNameFor(dir) {
|
|
|
33
33
|
*/
|
|
34
34
|
export function planFromManifest(source) {
|
|
35
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]) => {
|
|
36
|
+
const services = Object.entries(doc?.services ?? {}).map(([name, raw]) => {
|
|
40
37
|
const body = (raw ?? {});
|
|
41
38
|
return {
|
|
42
39
|
name,
|
|
@@ -44,6 +41,21 @@ export function planFromManifest(source) {
|
|
|
44
41
|
affinity: Array.isArray(body.affinity) ? body.affinity.filter((a) => typeof a === 'string') : [],
|
|
45
42
|
};
|
|
46
43
|
});
|
|
44
|
+
// Databases are deployed too.
|
|
45
|
+
//
|
|
46
|
+
// They used to be left out entirely, on the reasoning that applying the
|
|
47
|
+
// manifest creates them. It does — once. After that a database whose
|
|
48
|
+
// deployment has failed is never revived, because nothing puts it back in
|
|
49
|
+
// the plan: `fleet up` walked the whole stack, skipped it, and reported
|
|
50
|
+
// success while the database it depended on stayed dead and every service
|
|
51
|
+
// using it crash-looped. Recovering needed `fleet up db` by name, which is
|
|
52
|
+
// knowledge the command should not require.
|
|
53
|
+
const databases = Object.keys(doc?.databases ?? {}).map((name) => ({
|
|
54
|
+
name,
|
|
55
|
+
affinity: [],
|
|
56
|
+
database: true,
|
|
57
|
+
}));
|
|
58
|
+
return [...databases, ...services];
|
|
47
59
|
}
|
|
48
60
|
/**
|
|
49
61
|
* Which secrets the manifest says it needs, and which services want each.
|