@yadurajfleetos/cli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +24 -5
- package/dist/commands/index.js +2 -1
- package/dist/commands/services.js +63 -0
- package/dist/compose.js +324 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -89,13 +89,32 @@ export async function requireFleet(explicit) {
|
|
|
89
89
|
if (explicit)
|
|
90
90
|
return explicit;
|
|
91
91
|
const profile = await loadProfile();
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// A cached id is a hint, not a fact. It survives the fleet being deleted, the
|
|
93
|
+
// control plane being rebuilt, and signing in as somebody else — and the
|
|
94
|
+
// failure was a bare "Fleet not found" on every command, which names neither
|
|
95
|
+
// the cache nor the way out. Verify it, and fall through when it is stale.
|
|
96
|
+
if (profile.fleetId) {
|
|
97
|
+
try {
|
|
98
|
+
await request('GET', `/fleets/${profile.fleetId}`);
|
|
99
|
+
return profile.fleetId;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (!(err instanceof CliError) || err.exitCode !== EXIT.failure)
|
|
103
|
+
throw err;
|
|
104
|
+
await saveProfile({ ...profile, fleetId: undefined, fleetName: undefined });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
94
107
|
const { body } = await request('GET', '/fleets');
|
|
95
|
-
if (body.fleets.length === 1)
|
|
108
|
+
if (body.fleets.length === 1) {
|
|
109
|
+
// Remember it, so the next command does not pay for this lookup again.
|
|
110
|
+
await saveProfile({ ...profile, fleetId: body.fleets[0].id, fleetName: body.fleets[0].name });
|
|
96
111
|
return body.fleets[0].id;
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
}
|
|
113
|
+
if (!body.fleets.length) {
|
|
114
|
+
throw new CliError('This account owns no fleets on ' + (profile.api ?? 'this control plane') + '.\n' +
|
|
115
|
+
' If you expected one, you may be signed in as the wrong account — check with `fleet auth whoami`,\n' +
|
|
116
|
+
' or create a fleet in the dashboard and run `fleet use <name>`.', EXIT.usage);
|
|
117
|
+
}
|
|
99
118
|
throw new CliError(`You are in several fleets. Pass --fleet <id> or run \`fleet use <name>\`:\n` +
|
|
100
119
|
body.fleets.map((f) => ` ${f.name} ${f.id}`).join('\n'), EXIT.usage);
|
|
101
120
|
}
|
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,
|
|
@@ -473,3 +473,66 @@ export const removeServiceCommand = {
|
|
|
473
473
|
console.log(c.dim(` ${body.note}`));
|
|
474
474
|
},
|
|
475
475
|
};
|
|
476
|
+
/**
|
|
477
|
+
* docker-compose.yml → fleet.yaml.
|
|
478
|
+
*
|
|
479
|
+
* The most common way to arrive at Fleet with several services, two languages
|
|
480
|
+
* and a database is to already have a compose file describing exactly that.
|
|
481
|
+
* Reading it is a transform rather than a guess, so this needs no network, no
|
|
482
|
+
* account, and no model — it works before you have signed in.
|
|
483
|
+
*
|
|
484
|
+
* It prints what it decided and what it could not answer. A converter that
|
|
485
|
+
* silently drops a bind mount or invents a node is worse than one that refuses,
|
|
486
|
+
* because the reader only finds out at deploy time.
|
|
487
|
+
*/
|
|
488
|
+
export const importCommand = {
|
|
489
|
+
async run(args, flags) {
|
|
490
|
+
const { composeToFleet } = await import('../compose.js');
|
|
491
|
+
const source = args[0] ?? 'docker-compose.yml';
|
|
492
|
+
let text;
|
|
493
|
+
try {
|
|
494
|
+
text = await readFile(source, 'utf8');
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
throw new CliError(`could not read ${source}\n pass a path: fleet import path/to/docker-compose.yml`, EXIT.usage);
|
|
498
|
+
}
|
|
499
|
+
const out = manifestPath(typeof flags.out === 'string' ? flags.out : undefined);
|
|
500
|
+
const force = flags.force === true;
|
|
501
|
+
if (!force) {
|
|
502
|
+
try {
|
|
503
|
+
await access(out);
|
|
504
|
+
throw new CliError(`${out} already exists — pass --force to overwrite it.`, EXIT.usage);
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
if (err instanceof CliError)
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
let result;
|
|
512
|
+
try {
|
|
513
|
+
result = composeToFleet(text, {
|
|
514
|
+
fleet: typeof flags.fleet === 'string' ? flags.fleet : undefined,
|
|
515
|
+
node: typeof flags.node === 'string' ? flags.node : undefined,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
throw new CliError(err.message, EXIT.usage);
|
|
520
|
+
}
|
|
521
|
+
// --dry-run prints and writes nothing, so the output can be piped or read
|
|
522
|
+
// before it lands next to the file it was derived from.
|
|
523
|
+
if (flags['dry-run'] === true) {
|
|
524
|
+
process.stdout.write(result.manifest);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
await writeFile(out, result.manifest);
|
|
528
|
+
console.log(`${c.green('created')} ${out} ${c.dim(`from ${source}`)}`);
|
|
529
|
+
}
|
|
530
|
+
for (const note of result.notes)
|
|
531
|
+
console.log(` ${c.dim('·')} ${c.dim(note)}`);
|
|
532
|
+
for (const q of result.questions)
|
|
533
|
+
console.log(` ${c.yellow('?')} ${q}`);
|
|
534
|
+
if (!result.questions.length) {
|
|
535
|
+
console.log(c.dim(`\n check it with: fleet apply --dry-run`));
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
};
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
|
+
/** Images Fleet manages as databases rather than as plain containers. */
|
|
3
|
+
const ENGINE_IMAGES = {
|
|
4
|
+
postgres: 'postgres',
|
|
5
|
+
postgis: 'postgres',
|
|
6
|
+
'timescale/timescaledb': 'postgres',
|
|
7
|
+
mysql: 'mysql',
|
|
8
|
+
mariadb: 'mariadb',
|
|
9
|
+
redis: 'redis',
|
|
10
|
+
valkey: 'redis',
|
|
11
|
+
mongo: 'mongo',
|
|
12
|
+
mongodb: 'mongo',
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Names that mean "this value is a credential".
|
|
16
|
+
*
|
|
17
|
+
* Compose has no notion of a secret, so a password sits in the file as plain
|
|
18
|
+
* text. Copying it into `env` would move a credential into a manifest people
|
|
19
|
+
* commit; these keys become `secrets`, which Fleet injects at deploy time.
|
|
20
|
+
*/
|
|
21
|
+
const SECRET_HINT = /(PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|ACCESS_KEY|PRIVATE|CREDENTIAL|_DSN|_URI|_URL_AUTH|SALT|CERT)/i;
|
|
22
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
23
|
+
const asRecord = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
24
|
+
/** compose accepts both `KEY=value` lists and `{KEY: value}` maps. */
|
|
25
|
+
function readEnv(raw) {
|
|
26
|
+
const out = {};
|
|
27
|
+
if (Array.isArray(raw)) {
|
|
28
|
+
for (const entry of raw) {
|
|
29
|
+
if (typeof entry !== 'string')
|
|
30
|
+
continue;
|
|
31
|
+
const eq = entry.indexOf('=');
|
|
32
|
+
if (eq < 0)
|
|
33
|
+
out[entry] = '';
|
|
34
|
+
else
|
|
35
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const map = asRecord(raw);
|
|
40
|
+
if (map)
|
|
41
|
+
for (const [k, v] of Object.entries(map))
|
|
42
|
+
out[k] = v == null ? '' : String(v);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* "127.0.0.1:8080:80/tcp" → { host: 8080, container: 80 }.
|
|
48
|
+
*
|
|
49
|
+
* Counted from the end because the optional bind address is on the front, so
|
|
50
|
+
* the last two numbers are always the pair that matters.
|
|
51
|
+
*/
|
|
52
|
+
function readPorts(raw) {
|
|
53
|
+
if (!Array.isArray(raw))
|
|
54
|
+
return null;
|
|
55
|
+
for (const entry of raw) {
|
|
56
|
+
if (typeof entry === 'number')
|
|
57
|
+
return { host: entry, container: entry };
|
|
58
|
+
if (typeof entry !== 'string') {
|
|
59
|
+
const obj = asRecord(entry);
|
|
60
|
+
if (obj && typeof obj.target === 'number') {
|
|
61
|
+
return { host: Number(obj.published ?? obj.target), container: obj.target };
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const parts = entry.split('/')[0].split(':').filter(Boolean);
|
|
66
|
+
const nums = parts.map(Number).filter((n) => Number.isFinite(n));
|
|
67
|
+
if (!nums.length)
|
|
68
|
+
continue;
|
|
69
|
+
if (nums.length === 1)
|
|
70
|
+
return { host: nums[0], container: nums[0] };
|
|
71
|
+
return { host: nums[nums.length - 2], container: nums[nums.length - 1] };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The first named volume, plus every mount that was left behind.
|
|
77
|
+
*
|
|
78
|
+
* Both halves are returned because dropping something quietly is the whole
|
|
79
|
+
* problem: a bind mount that disappears is a directory the service expected
|
|
80
|
+
* and will not find, and reporting it only when nothing else matched meant the
|
|
81
|
+
* common case - one real volume beside one bind mount - said nothing at all.
|
|
82
|
+
*/
|
|
83
|
+
function readVolumes(raw) {
|
|
84
|
+
const skipped = [];
|
|
85
|
+
let volume = null;
|
|
86
|
+
if (!Array.isArray(raw))
|
|
87
|
+
return { volume, skipped };
|
|
88
|
+
for (const entry of raw) {
|
|
89
|
+
if (typeof entry !== 'string') {
|
|
90
|
+
const obj = asRecord(entry);
|
|
91
|
+
if (obj?.type === 'volume' && typeof obj.source === 'string' && typeof obj.target === 'string') {
|
|
92
|
+
if (!volume)
|
|
93
|
+
volume = { name: obj.source, path: obj.target };
|
|
94
|
+
else
|
|
95
|
+
skipped.push(`${obj.source}:${obj.target}`);
|
|
96
|
+
}
|
|
97
|
+
else if (obj?.type === 'bind') {
|
|
98
|
+
skipped.push(String(obj.source ?? 'bind mount'));
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const [source, target] = entry.split(':');
|
|
103
|
+
if (!source || !target)
|
|
104
|
+
continue;
|
|
105
|
+
// A path is a bind mount of the developer's own machine, and means nothing
|
|
106
|
+
// on a node that has never seen that directory.
|
|
107
|
+
if (source.startsWith('.') || source.startsWith('/') || source.startsWith('~')) {
|
|
108
|
+
skipped.push(entry);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!volume)
|
|
112
|
+
volume = { name: source, path: target };
|
|
113
|
+
else
|
|
114
|
+
skipped.push(entry);
|
|
115
|
+
}
|
|
116
|
+
return { volume, skipped };
|
|
117
|
+
}
|
|
118
|
+
/** compose memory limits are "512m", "2g", or a byte count. */
|
|
119
|
+
function readMemory(raw) {
|
|
120
|
+
if (raw == null)
|
|
121
|
+
return null;
|
|
122
|
+
const s = String(raw).trim().toLowerCase();
|
|
123
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmg])?b?$/);
|
|
124
|
+
if (!m)
|
|
125
|
+
return null;
|
|
126
|
+
const n = Number(m[1]);
|
|
127
|
+
switch (m[2]) {
|
|
128
|
+
case 'g': return `${Math.round(n * 1024)}Mi`;
|
|
129
|
+
case 'm': return `${Math.round(n)}Mi`;
|
|
130
|
+
case 'k': return `${Math.max(1, Math.round(n / 1024))}Mi`;
|
|
131
|
+
default: return `${Math.max(1, Math.round(n / (1024 * 1024)))}Mi`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** A health path out of a compose healthcheck, when one is discoverable. */
|
|
135
|
+
function readHealthPath(raw) {
|
|
136
|
+
const hc = asRecord(raw);
|
|
137
|
+
if (!hc)
|
|
138
|
+
return null;
|
|
139
|
+
const test = hc.test;
|
|
140
|
+
const line = Array.isArray(test) ? test.join(' ') : typeof test === 'string' ? test : '';
|
|
141
|
+
const url = line.match(/https?:\/\/[^\s"']+/);
|
|
142
|
+
if (url) {
|
|
143
|
+
try {
|
|
144
|
+
return new URL(url[0]).pathname || '/';
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const engineFor = (image) => {
|
|
153
|
+
const name = image.split('@')[0].split(':')[0].toLowerCase();
|
|
154
|
+
const bare = name.replace(/^(docker\.io|library)\//, '');
|
|
155
|
+
return ENGINE_IMAGES[bare] ?? ENGINE_IMAGES[bare.split('/').pop() ?? ''] ?? null;
|
|
156
|
+
};
|
|
157
|
+
const versionOf = (image) => {
|
|
158
|
+
const tag = image.split('@')[0].split(':')[1];
|
|
159
|
+
if (!tag || tag === 'latest')
|
|
160
|
+
return null;
|
|
161
|
+
const major = tag.match(/^(\d+)/);
|
|
162
|
+
return major ? major[1] : null;
|
|
163
|
+
};
|
|
164
|
+
/** YAML-safe scalar. Quoted unless it is unambiguously a bare word. */
|
|
165
|
+
const scalar = (v) => /^[A-Za-z0-9._/-]+$/.test(v) && v !== '' ? v : JSON.stringify(v);
|
|
166
|
+
export function composeToFleet(source, opts = {}) {
|
|
167
|
+
const notes = [];
|
|
168
|
+
const questions = [];
|
|
169
|
+
let doc;
|
|
170
|
+
try {
|
|
171
|
+
doc = parseYaml(source);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw new Error(`that file is not valid YAML: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
const root = asRecord(doc);
|
|
177
|
+
const servicesRaw = asRecord(root?.services);
|
|
178
|
+
if (!servicesRaw || !Object.keys(servicesRaw).length) {
|
|
179
|
+
throw new Error('no services found — is this a docker-compose file?');
|
|
180
|
+
}
|
|
181
|
+
const fleetName = opts.fleet ?? 'homelab';
|
|
182
|
+
const dbNode = opts.node ?? null;
|
|
183
|
+
const services = [];
|
|
184
|
+
const databases = [];
|
|
185
|
+
/** Compose names that became databases, so `depends_on` can point at them. */
|
|
186
|
+
const asDatabase = new Set();
|
|
187
|
+
// Databases first, so a service's `uses` can reference one by name.
|
|
188
|
+
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
189
|
+
const svc = (asRecord(raw) ?? {});
|
|
190
|
+
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
191
|
+
const engine = image ? engineFor(image) : null;
|
|
192
|
+
if (engine)
|
|
193
|
+
asDatabase.add(name);
|
|
194
|
+
}
|
|
195
|
+
for (const [name, raw] of Object.entries(servicesRaw)) {
|
|
196
|
+
const svc = (asRecord(raw) ?? {});
|
|
197
|
+
const image = typeof svc.image === 'string' ? svc.image : '';
|
|
198
|
+
const env = readEnv(svc.environment);
|
|
199
|
+
if (asDatabase.has(name)) {
|
|
200
|
+
const engine = engineFor(image);
|
|
201
|
+
const major = versionOf(image);
|
|
202
|
+
const lines = [` ${name}:`];
|
|
203
|
+
lines.push(` engine: ${major ? `${engine}@${major}` : engine}`);
|
|
204
|
+
if (dbNode)
|
|
205
|
+
lines.push(` node: ${scalar(dbNode)}`);
|
|
206
|
+
else {
|
|
207
|
+
lines.push(` node: CHANGE_ME`);
|
|
208
|
+
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.`);
|
|
209
|
+
}
|
|
210
|
+
// Compose states these as env; Fleet takes them as fields and manages
|
|
211
|
+
// the credential itself, so they are lifted rather than copied.
|
|
212
|
+
const dbName = env.POSTGRES_DB ?? env.MYSQL_DATABASE ?? env.MARIADB_DATABASE ?? env.MONGO_INITDB_DATABASE;
|
|
213
|
+
const dbUser = env.POSTGRES_USER ?? env.MYSQL_USER ?? env.MARIADB_USER ?? env.MONGO_INITDB_ROOT_USERNAME;
|
|
214
|
+
if (dbName)
|
|
215
|
+
lines.push(` database: ${scalar(dbName)}`);
|
|
216
|
+
if (dbUser)
|
|
217
|
+
lines.push(` user: ${scalar(dbUser)}`);
|
|
218
|
+
lines.push(` backup: daily`);
|
|
219
|
+
databases.push(lines.join('\n'));
|
|
220
|
+
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.`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const lines = [` ${name}:`];
|
|
224
|
+
// build or image, never both: the schema rejects it, and they mean
|
|
225
|
+
// different things.
|
|
226
|
+
if (svc.build != null && !image) {
|
|
227
|
+
const b = asRecord(svc.build);
|
|
228
|
+
const context = typeof svc.build === 'string' ? svc.build : typeof b?.context === 'string' ? b.context : '.';
|
|
229
|
+
lines.push(` build: ${scalar(context)}`);
|
|
230
|
+
}
|
|
231
|
+
else if (image) {
|
|
232
|
+
lines.push(` image: ${scalar(image)}`);
|
|
233
|
+
if (svc.build != null) {
|
|
234
|
+
notes.push(`${name}: compose set both build and image; kept image, because that is what compose would have run.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
lines.push(` build: .`);
|
|
239
|
+
questions.push(`${name}: compose named neither an image nor a build context — check the build path.`);
|
|
240
|
+
}
|
|
241
|
+
lines.push(' placement: flexible');
|
|
242
|
+
const ports = readPorts(svc.ports) ?? readPorts(svc.expose);
|
|
243
|
+
if (ports) {
|
|
244
|
+
lines.push(` port: ${ports.host}`);
|
|
245
|
+
if (ports.container !== ports.host)
|
|
246
|
+
lines.push(` container_port: ${ports.container}`);
|
|
247
|
+
}
|
|
248
|
+
const mem = readMemory(asRecord(asRecord(asRecord(svc.deploy)?.resources)?.limits)?.memory ?? svc.mem_limit);
|
|
249
|
+
const cpus = asRecord(asRecord(asRecord(svc.deploy)?.resources)?.limits)?.cpus ?? svc.cpus;
|
|
250
|
+
const ram = mem ?? '512Mi';
|
|
251
|
+
const cpu = cpus != null && Number.isFinite(Number(cpus)) ? Number(cpus) : 0.5;
|
|
252
|
+
lines.push(` resources: { ram: ${ram}, cpu: ${cpu} }`);
|
|
253
|
+
if (!mem)
|
|
254
|
+
notes.push(`${name}: compose set no memory limit, so 512Mi was assumed — the scheduler needs a number to place against.`);
|
|
255
|
+
const health = readHealthPath(svc.healthcheck);
|
|
256
|
+
lines.push(` health: { path: ${health ?? '/'} }`);
|
|
257
|
+
if (svc.healthcheck && !health) {
|
|
258
|
+
notes.push(`${name}: its healthcheck is a command Fleet cannot reuse, so health falls back to "/" — set a real path if that is wrong.`);
|
|
259
|
+
}
|
|
260
|
+
const { volume: vol, skipped } = readVolumes(svc.volumes);
|
|
261
|
+
if (vol)
|
|
262
|
+
lines.push(` volume: { name: ${scalar(vol.name)}, path: ${scalar(vol.path)} }`);
|
|
263
|
+
if (skipped.length) {
|
|
264
|
+
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.`);
|
|
265
|
+
}
|
|
266
|
+
// env vs secrets. A value compose leaves to interpolation is not in the
|
|
267
|
+
// file at all, so it cannot be copied - it becomes a secret to supply.
|
|
268
|
+
const plain = [];
|
|
269
|
+
const secrets = [];
|
|
270
|
+
for (const [k, v] of Object.entries(env)) {
|
|
271
|
+
if (!ENV_NAME.test(k)) {
|
|
272
|
+
notes.push(`${name}: dropped env key "${k}" — not a usable variable name.`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const unresolved = v === '' || /^\$\{?[A-Za-z_]/.test(v);
|
|
276
|
+
if (SECRET_HINT.test(k) || unresolved)
|
|
277
|
+
secrets.push(k);
|
|
278
|
+
else
|
|
279
|
+
plain.push([k, v]);
|
|
280
|
+
}
|
|
281
|
+
if (plain.length) {
|
|
282
|
+
lines.push(' env:');
|
|
283
|
+
for (const [k, v] of plain)
|
|
284
|
+
lines.push(` ${k}: ${scalar(v)}`);
|
|
285
|
+
}
|
|
286
|
+
if (secrets.length) {
|
|
287
|
+
lines.push(` secrets: [${secrets.join(', ')}]`);
|
|
288
|
+
notes.push(`${name}: ${secrets.join(', ')} moved to secrets — set them with \`fleet secret set\`, so no credential lives in this file.`);
|
|
289
|
+
}
|
|
290
|
+
// `uses` names databases, not services — the parser rejects anything else
|
|
291
|
+
// with "is not a database in this manifest". compose's depends_on covers
|
|
292
|
+
// both, so it has to be split: the database half becomes `uses`, and the
|
|
293
|
+
// service half is dropped, because Fleet works out deploy order itself
|
|
294
|
+
// rather than taking it from the file.
|
|
295
|
+
const deps = Array.isArray(svc.depends_on)
|
|
296
|
+
? svc.depends_on.filter((d) => typeof d === 'string')
|
|
297
|
+
: Object.keys(asRecord(svc.depends_on) ?? {});
|
|
298
|
+
const dbDeps = deps.filter((d) => asDatabase.has(d));
|
|
299
|
+
const svcDeps = deps.filter((d) => !asDatabase.has(d));
|
|
300
|
+
if (dbDeps.length)
|
|
301
|
+
lines.push(` uses: [${dbDeps.join(', ')}]`);
|
|
302
|
+
if (svcDeps.length) {
|
|
303
|
+
notes.push(`${name}: depends_on ${svcDeps.join(', ')} was dropped — "uses" declares databases, and Fleet decides deploy order from the manifest rather than being told.`);
|
|
304
|
+
}
|
|
305
|
+
const replicas = asRecord(svc.deploy)?.replicas;
|
|
306
|
+
if (typeof replicas === 'number' && replicas > 1)
|
|
307
|
+
lines.push(` replicas: ${replicas}`);
|
|
308
|
+
services.push(lines.join('\n'));
|
|
309
|
+
}
|
|
310
|
+
// A manifest requires at least one service - "a manifest with no services
|
|
311
|
+
// has nothing to deploy" is the product's rule, not an accident. A compose
|
|
312
|
+
// file that is only databases would otherwise emit an empty `services:` key,
|
|
313
|
+
// which YAML reads as null and the parser rejects with a message about
|
|
314
|
+
// records that explains nothing about what the reader actually did.
|
|
315
|
+
if (!services.length) {
|
|
316
|
+
const only = [...asDatabase].join(', ');
|
|
317
|
+
throw new Error(`every service in that file is a database (${only}), and a manifest needs something to deploy. ` +
|
|
318
|
+
`Fleet manages those engines for you — declare them alongside the application that uses them.`);
|
|
319
|
+
}
|
|
320
|
+
const out = [`fleet: ${scalar(fleetName)}`, '', 'services:', services.join('\n\n')];
|
|
321
|
+
if (databases.length)
|
|
322
|
+
out.push('', 'databases:', databases.join('\n\n'));
|
|
323
|
+
return { manifest: out.join('\n') + '\n', notes, questions };
|
|
324
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const GROUPS = [
|
|
|
17
17
|
[
|
|
18
18
|
['up [service]', 'Deploy the whole fleet.yaml, in dependency order'],
|
|
19
19
|
['init', 'Scaffold a fleet.yaml and Dockerfile from this repository'],
|
|
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'],
|