@flisk-sh/flisk-import 0.4.1 → 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/README.md +6 -4
- package/dist/detectors/compose.js +289 -5
- package/dist/flisk.js +1 -1
- package/dist/merge.js +16 -0
- package/dist/plan.js +9 -13
- package/dist/write.js +118 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,12 +10,14 @@ flisk import . --yes # write service.json files and project setup
|
|
|
10
10
|
|
|
11
11
|
## What to expect
|
|
12
12
|
|
|
13
|
-
- **Reads one file:** the root `docker-compose.yml
|
|
13
|
+
- **Reads one file:** the root `docker-compose.yml`, or `compose.yaml`, `compose.yml` or `docker-compose.yaml` when that is absent. Not override files, nested Compose files, Dockerfiles on their own, `package.json`, or Procfiles.
|
|
14
14
|
- **Writes:** one bring-your-own `service.json` per Compose service, plus `.flisk/project.json` if the repo is not a Flisk project yet. It never edits the Compose file or overwrites a `service.json` without `--force`.
|
|
15
15
|
- **Keeps:** the service names (`db` stays `db`, so it stays the hostname), the build context and Dockerfile (or the image), the container port as written (3000, 80, 5432), `depends_on`, and an HTTP health path. Databases and brokers are private, with no proxy route.
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
16
|
+
- **Runtime keys:** named volumes and bind mounts inside the project (`volumes`), `cap_add`, `cap_drop`, `init`, `read_only`, `user`, `security_opt` and `shm_size` (`container`), extra published ports (`extraPorts`, on 127.0.0.1 only), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
|
|
17
|
+
- **Environment:** values that cannot be secrets go into the committed `service.json` `env`: numbers, `true`/`false`, another imported service's address without a user, password or query (`http://invidious:3000`, `redis:6379`), and pure `${VAR}` references. Every other name, and any name that looks like a credential (`PASSWORD`, `TOKEN`, `KEY`, ...), goes into `secrets.required`, with its literal value copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept.
|
|
18
|
+
- **Keeps:** a `command` on an image as the `CMD` of a one-line `Dockerfile` next to `service.json` (Flisk sets no container command), a `healthcheck` as `healthCheck.command`, a published database port as `extraPorts`, and `restart: on-failure:N` as `on-failure`.
|
|
19
|
+
- **Drops, and says so:** `command` on a service with a `build`, `env_file` and every other untranslated key; anonymous volumes and paths outside the project; `network_mode` other than `service:<name>`; services with `profiles`, `extends`, an out-of-tree build or a `${VAR}` image.
|
|
20
|
+
- **Does not start anything.** Run `flisk up` with Flisk core **1.4.24 or later**. The importer does not check the core version.
|
|
19
21
|
- **Exit codes:** `0` when the plan succeeds (`--dry-run`, files written, or all `service.json` files already exist), `1` when no root `docker-compose.yml` or mappable service is found, and `2` when the write prompt is declined or closed without an answer (`Nothing written.`).
|
|
20
22
|
|
|
21
23
|
See [docs/import.md](docs/import.md) for the full mapping and limits. `flisk import` is the core CLI command backed by this package.
|
|
@@ -22,8 +22,10 @@ const NON_HTTP_PORTS = new Set([
|
|
|
22
22
|
]);
|
|
23
23
|
/**
|
|
24
24
|
* Service keys this detector translates. Every other key is named in the report: Flisk runs a
|
|
25
|
-
* bring-your-own container without
|
|
26
|
-
* `healthcheck` only contributes an HTTP path; `environment` only contributes names.
|
|
25
|
+
* bring-your-own container without command overrides, env files and the rest.
|
|
26
|
+
* `healthcheck` only contributes an HTTP path; `environment` only contributes names. The runtime keys
|
|
27
|
+
* (capabilities, volumes, network_mode, restart) map onto service.json fields; a value that cannot be
|
|
28
|
+
* carried over is named on its own line instead.
|
|
27
29
|
*/
|
|
28
30
|
const TRANSLATED_KEYS = new Set([
|
|
29
31
|
"image",
|
|
@@ -32,7 +34,156 @@ const TRANSLATED_KEYS = new Set([
|
|
|
32
34
|
"environment",
|
|
33
35
|
"depends_on",
|
|
34
36
|
"healthcheck",
|
|
37
|
+
"cap_add",
|
|
38
|
+
"cap_drop",
|
|
39
|
+
"init",
|
|
40
|
+
"read_only",
|
|
41
|
+
"user",
|
|
42
|
+
"security_opt",
|
|
43
|
+
"shm_size",
|
|
44
|
+
"network_mode",
|
|
45
|
+
"volumes",
|
|
46
|
+
"restart",
|
|
47
|
+
"command",
|
|
35
48
|
]);
|
|
49
|
+
const CAPABILITY = /^[A-Z_]+$/;
|
|
50
|
+
const USER = /^[A-Za-z0-9_.:-]+$/;
|
|
51
|
+
const SECURITY_OPT = /^[A-Za-z0-9_.:=/-]+$/;
|
|
52
|
+
const VOLUME_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
53
|
+
const CONTAINER_PATH = /^\/[A-Za-z0-9_./-]*$/;
|
|
54
|
+
const HOST_PATH = /^[A-Za-z0-9_./-]+$/;
|
|
55
|
+
const RESTART = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
56
|
+
/** Compose `shm_size` as a size string such as 256mb; a number is bytes. */
|
|
57
|
+
function shmSizeOf(value) {
|
|
58
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
59
|
+
return `${value}b`;
|
|
60
|
+
if (typeof value === "string" && /^[0-9]+([bkmg]b?)?$/i.test(value))
|
|
61
|
+
return value;
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/** `container` for service.json, and the values that could not be carried over. */
|
|
65
|
+
function containerOf(raw) {
|
|
66
|
+
const container = {};
|
|
67
|
+
const dropped = [];
|
|
68
|
+
for (const [key, field] of [
|
|
69
|
+
["cap_add", "capAdd"],
|
|
70
|
+
["cap_drop", "capDrop"],
|
|
71
|
+
]) {
|
|
72
|
+
if (raw[key] === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const caps = Array.isArray(raw[key]) ? raw[key] : [];
|
|
75
|
+
const kept = caps.filter((c) => typeof c === "string" && CAPABILITY.test(c));
|
|
76
|
+
if (kept.length !== caps.length || caps.length === 0)
|
|
77
|
+
dropped.push(key);
|
|
78
|
+
if (kept.length)
|
|
79
|
+
container[field] = kept;
|
|
80
|
+
}
|
|
81
|
+
if (raw.init !== undefined) {
|
|
82
|
+
if (typeof raw.init === "boolean")
|
|
83
|
+
container.init = raw.init;
|
|
84
|
+
else
|
|
85
|
+
dropped.push("init");
|
|
86
|
+
}
|
|
87
|
+
if (raw.read_only !== undefined) {
|
|
88
|
+
if (typeof raw.read_only === "boolean")
|
|
89
|
+
container.readOnly = raw.read_only;
|
|
90
|
+
else
|
|
91
|
+
dropped.push("read_only");
|
|
92
|
+
}
|
|
93
|
+
if (raw.user !== undefined) {
|
|
94
|
+
const user = typeof raw.user === "number" ? String(raw.user) : raw.user;
|
|
95
|
+
if (typeof user === "string" && USER.test(user))
|
|
96
|
+
container.user = user;
|
|
97
|
+
else
|
|
98
|
+
dropped.push("user");
|
|
99
|
+
}
|
|
100
|
+
if (raw.security_opt !== undefined) {
|
|
101
|
+
const opts = Array.isArray(raw.security_opt) ? raw.security_opt : [];
|
|
102
|
+
const kept = opts.filter((o) => typeof o === "string" && SECURITY_OPT.test(o));
|
|
103
|
+
if (kept.length !== opts.length || opts.length === 0)
|
|
104
|
+
dropped.push("security_opt");
|
|
105
|
+
if (kept.length)
|
|
106
|
+
container.securityOpt = kept;
|
|
107
|
+
}
|
|
108
|
+
if (raw.shm_size !== undefined) {
|
|
109
|
+
const size = shmSizeOf(raw.shm_size);
|
|
110
|
+
if (size)
|
|
111
|
+
container.shmSize = size;
|
|
112
|
+
else
|
|
113
|
+
dropped.push("shm_size");
|
|
114
|
+
}
|
|
115
|
+
return { ...(Object.keys(container).length ? { container } : {}), dropped };
|
|
116
|
+
}
|
|
117
|
+
/** One published port: container port, host port when Compose names a literal one, and protocol. */
|
|
118
|
+
function publishedPort(entry) {
|
|
119
|
+
const port = containerPort(entry);
|
|
120
|
+
if (port === undefined)
|
|
121
|
+
return undefined;
|
|
122
|
+
let host;
|
|
123
|
+
let protocol = "tcp";
|
|
124
|
+
if (isObj(entry)) {
|
|
125
|
+
const published = typeof entry.published === "string" ? Number(entry.published) : entry.published;
|
|
126
|
+
if (typeof published === "number" && Number.isInteger(published))
|
|
127
|
+
host = published;
|
|
128
|
+
if (entry.protocol === "udp")
|
|
129
|
+
protocol = "udp";
|
|
130
|
+
}
|
|
131
|
+
else if (typeof entry === "string") {
|
|
132
|
+
const [mapping, proto] = entry.split("/");
|
|
133
|
+
if (proto === "udp")
|
|
134
|
+
protocol = "udp";
|
|
135
|
+
const parts = (mapping ?? "").split(":");
|
|
136
|
+
const hostPart = parts.length >= 2 ? parts[parts.length - 2] : undefined;
|
|
137
|
+
if (hostPart && /^\d+$/.test(hostPart))
|
|
138
|
+
host = Number.parseInt(hostPart, 10);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
port,
|
|
142
|
+
...(host !== undefined && host >= 1 && host <= 65535 && host !== port
|
|
143
|
+
? { hostPort: host }
|
|
144
|
+
: {}),
|
|
145
|
+
...(protocol === "udp" ? { protocol } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** One `volumes:` entry. Bind sources are resolved against the Compose file's directory. */
|
|
149
|
+
function volumeOf(entry, composeDir) {
|
|
150
|
+
let source;
|
|
151
|
+
let target;
|
|
152
|
+
let readOnly = false;
|
|
153
|
+
let type;
|
|
154
|
+
if (typeof entry === "string") {
|
|
155
|
+
const parts = entry.split(":");
|
|
156
|
+
if (parts.length < 2 || parts.length > 3)
|
|
157
|
+
return undefined;
|
|
158
|
+
[source, target] = parts;
|
|
159
|
+
readOnly = parts[2] === "ro";
|
|
160
|
+
if (parts[2] !== undefined && parts[2] !== "ro" && parts[2] !== "rw")
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
else if (isObj(entry)) {
|
|
164
|
+
({ source, target, type } = entry);
|
|
165
|
+
readOnly = entry.read_only === true;
|
|
166
|
+
if (type !== undefined && type !== "volume" && type !== "bind")
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (typeof source !== "string" || typeof target !== "string" || !CONTAINER_PATH.test(target))
|
|
173
|
+
return undefined;
|
|
174
|
+
const bind = type === "bind" || source.startsWith(".") || source.startsWith("/");
|
|
175
|
+
if (!bind) {
|
|
176
|
+
return VOLUME_NAME.test(source)
|
|
177
|
+
? { source, target, ...(readOnly ? { readOnly } : {}), bind }
|
|
178
|
+
: undefined;
|
|
179
|
+
}
|
|
180
|
+
if (isAbsolute(source) || !HOST_PATH.test(source))
|
|
181
|
+
return undefined;
|
|
182
|
+
const resolved = normalize(join(composeDir, source));
|
|
183
|
+
if (resolved === ".." || resolved.startsWith("../"))
|
|
184
|
+
return undefined;
|
|
185
|
+
return { source: resolved, target, ...(readOnly ? { readOnly } : {}), bind };
|
|
186
|
+
}
|
|
36
187
|
function envNames(environment) {
|
|
37
188
|
if (Array.isArray(environment)) {
|
|
38
189
|
return environment
|
|
@@ -61,6 +212,24 @@ function envValues(environment) {
|
|
|
61
212
|
}
|
|
62
213
|
return out;
|
|
63
214
|
}
|
|
215
|
+
/** A value that is nothing but Compose interpolation, such as `${URL}` or `${PORT:-80}`. Holds no secret itself. */
|
|
216
|
+
const ONLY_REFERENCES = /^(\$\{[A-Za-z_][A-Za-z0-9_]*(?::?[-?+][^}\n]*)?\})+$/;
|
|
217
|
+
/** Values that are only `${VAR}` references, which service.json `env` can keep as they are (Flisk 1.4.24). */
|
|
218
|
+
function envReferences(environment) {
|
|
219
|
+
const pairs = Array.isArray(environment)
|
|
220
|
+
? environment
|
|
221
|
+
.filter((e) => typeof e === "string" && e.includes("="))
|
|
222
|
+
.map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)])
|
|
223
|
+
: isObj(environment)
|
|
224
|
+
? Object.entries(environment)
|
|
225
|
+
: [];
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [key, raw] of pairs) {
|
|
228
|
+
if (typeof raw === "string" && ONLY_REFERENCES.test(raw))
|
|
229
|
+
out[key] = raw;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
64
233
|
function commandOf(command) {
|
|
65
234
|
if (typeof command === "string")
|
|
66
235
|
return command;
|
|
@@ -68,6 +237,55 @@ function commandOf(command) {
|
|
|
68
237
|
return command.join(" ");
|
|
69
238
|
return undefined;
|
|
70
239
|
}
|
|
240
|
+
/** Compose `command` as the argument vector the container runs; a string goes through a shell, as Compose runs it. */
|
|
241
|
+
function containerCommandOf(command) {
|
|
242
|
+
if (typeof command === "string" && command.trim())
|
|
243
|
+
return ["sh", "-c", command];
|
|
244
|
+
if (Array.isArray(command) && command.length > 0 && command.every((c) => typeof c === "string"))
|
|
245
|
+
return command;
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/** Seconds from a Compose duration such as `30s` or `2m`; undefined for anything it cannot read exactly. */
|
|
249
|
+
function secondsOf(value) {
|
|
250
|
+
if (typeof value === "number" && value > 0)
|
|
251
|
+
return value;
|
|
252
|
+
const match = typeof value === "string" ? value.match(/^(\d+)(s|m)?$/) : null;
|
|
253
|
+
if (!match)
|
|
254
|
+
return undefined;
|
|
255
|
+
const seconds = Number(match[1]) * (match[2] === "m" ? 60 : 1);
|
|
256
|
+
return seconds > 0 ? seconds : undefined;
|
|
257
|
+
}
|
|
258
|
+
/** Compose `healthcheck` as a probe command (exit 0 is ready). `NONE` and `disable` give no probe. */
|
|
259
|
+
function healthProbeOf(healthcheck) {
|
|
260
|
+
if (!isObj(healthcheck) || healthcheck.disable === true)
|
|
261
|
+
return undefined;
|
|
262
|
+
const test = healthcheck.test;
|
|
263
|
+
let command;
|
|
264
|
+
if (typeof test === "string")
|
|
265
|
+
command = ["sh", "-c", test];
|
|
266
|
+
else if (Array.isArray(test) && test.every((t) => typeof t === "string")) {
|
|
267
|
+
const [kind, ...rest] = test;
|
|
268
|
+
if (kind === "CMD" && rest.length > 0)
|
|
269
|
+
command = rest;
|
|
270
|
+
else if (kind === "CMD-SHELL" && rest.length > 0)
|
|
271
|
+
command = ["sh", "-c", rest.join(" ")];
|
|
272
|
+
}
|
|
273
|
+
if (!command)
|
|
274
|
+
return undefined;
|
|
275
|
+
const interval = secondsOf(healthcheck.interval);
|
|
276
|
+
const timeout = secondsOf(healthcheck.timeout);
|
|
277
|
+
const retries = typeof healthcheck.retries === "number" &&
|
|
278
|
+
Number.isInteger(healthcheck.retries) &&
|
|
279
|
+
healthcheck.retries > 0
|
|
280
|
+
? healthcheck.retries
|
|
281
|
+
: undefined;
|
|
282
|
+
return {
|
|
283
|
+
command,
|
|
284
|
+
...(interval ? { interval } : {}),
|
|
285
|
+
...(timeout ? { timeout } : {}),
|
|
286
|
+
...(retries ? { retries } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
71
289
|
function healthPathOf(healthcheck) {
|
|
72
290
|
if (!isObj(healthcheck) || !Array.isArray(healthcheck.test))
|
|
73
291
|
return undefined;
|
|
@@ -87,7 +305,7 @@ function healthPathOf(healthcheck) {
|
|
|
87
305
|
}
|
|
88
306
|
registerDetector({
|
|
89
307
|
id: "compose",
|
|
90
|
-
claim: (relPath) =>
|
|
308
|
+
claim: (relPath) => /^(docker-)?compose\.ya?ml$/.test(relPath),
|
|
91
309
|
parse(absPath, relPath) {
|
|
92
310
|
const composeDir = dirname(relPath) === "." ? "" : dirname(relPath);
|
|
93
311
|
const base = { file: relPath, detector: "compose" };
|
|
@@ -180,13 +398,13 @@ registerDetector({
|
|
|
180
398
|
if (ports.length > 1 && selectedPort === undefined) {
|
|
181
399
|
skips.push({
|
|
182
400
|
...base,
|
|
183
|
-
reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified`,
|
|
401
|
+
reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified, so none is routed (all are published on 127.0.0.1 as extraPorts)`,
|
|
184
402
|
});
|
|
185
403
|
}
|
|
186
404
|
else if (ports.length > 1) {
|
|
187
405
|
skips.push({
|
|
188
406
|
...base,
|
|
189
|
-
reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort}`,
|
|
407
|
+
reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort} and publishes the others on 127.0.0.1 as extraPorts`,
|
|
190
408
|
});
|
|
191
409
|
}
|
|
192
410
|
const dependsOn = Array.isArray(raw.depends_on)
|
|
@@ -195,8 +413,11 @@ registerDetector({
|
|
|
195
413
|
? Object.keys(raw.depends_on)
|
|
196
414
|
: [];
|
|
197
415
|
const command = commandOf(raw.command);
|
|
416
|
+
const containerCommand = containerCommandOf(raw.command);
|
|
417
|
+
const healthProbe = healthProbeOf(raw.healthcheck);
|
|
198
418
|
const envKeys = envNames(raw.environment);
|
|
199
419
|
const env = envValues(raw.environment);
|
|
420
|
+
const envRefs = envReferences(raw.environment);
|
|
200
421
|
const foundHealthPath = healthPathOf(raw.healthcheck);
|
|
201
422
|
const healthCheckPath = foundHealthPath && HEALTH_CHECK_PATH_PATTERN.test(foundHealthPath)
|
|
202
423
|
? foundHealthPath
|
|
@@ -207,6 +428,61 @@ registerDetector({
|
|
|
207
428
|
reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
|
|
208
429
|
});
|
|
209
430
|
}
|
|
431
|
+
const { container, dropped } = containerOf(raw);
|
|
432
|
+
for (const key of dropped) {
|
|
433
|
+
skips.push({
|
|
434
|
+
...base,
|
|
435
|
+
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} has values Flisk does not accept; not used`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// Every published port Flisk does not route stays published, on 127.0.0.1 (service.json extraPorts).
|
|
439
|
+
const proxied = selectedPort !== undefined && !NON_HTTP_PORTS.has(selectedPort);
|
|
440
|
+
const extraPorts = (Array.isArray(raw.ports) ? raw.ports : [])
|
|
441
|
+
.map(publishedPort)
|
|
442
|
+
.filter((p) => p !== undefined && (p.port !== selectedPort || !proxied));
|
|
443
|
+
let sidecarOf;
|
|
444
|
+
if (raw.network_mode !== undefined) {
|
|
445
|
+
const mode = typeof raw.network_mode === "string" ? raw.network_mode : "";
|
|
446
|
+
if (mode.startsWith("service:") && mode.length > "service:".length) {
|
|
447
|
+
sidecarOf = mode.slice("service:".length);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
skips.push({
|
|
451
|
+
...base,
|
|
452
|
+
reason: `service "${name}" network_mode "${mode}" is not translated; Flisk only shares another service's network (service:<name>)`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const volumes = [];
|
|
457
|
+
for (const entry of Array.isArray(raw.volumes) ? raw.volumes : []) {
|
|
458
|
+
const volume = volumeOf(entry, composeDir);
|
|
459
|
+
if (volume)
|
|
460
|
+
volumes.push(volume);
|
|
461
|
+
else
|
|
462
|
+
skips.push({
|
|
463
|
+
...base,
|
|
464
|
+
reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
// `on-failure:5` caps the retries; Flisk has the policy without the cap.
|
|
468
|
+
const restartName = typeof raw.restart === "string"
|
|
469
|
+
? raw.restart.replace(/^(on-failure):\d+$/, "$1")
|
|
470
|
+
: undefined;
|
|
471
|
+
const restart = restartName !== undefined && RESTART.has(restartName)
|
|
472
|
+
? restartName
|
|
473
|
+
: undefined;
|
|
474
|
+
if (restart && restartName !== raw.restart) {
|
|
475
|
+
skips.push({
|
|
476
|
+
...base,
|
|
477
|
+
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} keeps "${restart}"; the retry limit is not carried over`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (raw.restart !== undefined && !restart) {
|
|
481
|
+
skips.push({
|
|
482
|
+
...base,
|
|
483
|
+
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
210
486
|
const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
|
|
211
487
|
if (untranslated.length) {
|
|
212
488
|
skips.push({
|
|
@@ -225,12 +501,20 @@ registerDetector({
|
|
|
225
501
|
...(typeof raw.image === "string" ? { image: raw.image } : {}),
|
|
226
502
|
...(dockerfile ? { dockerfile } : {}),
|
|
227
503
|
...(command ? { command } : {}),
|
|
504
|
+
...(containerCommand ? { containerCommand } : {}),
|
|
505
|
+
...(healthProbe ? { healthCheck: healthProbe } : {}),
|
|
228
506
|
...(selectedPort !== undefined ? { port: selectedPort } : {}),
|
|
229
507
|
...(healthCheckPath ? { healthCheckPath } : {}),
|
|
230
508
|
exposeViaProxy,
|
|
231
509
|
...(envKeys.length ? { envKeys } : {}),
|
|
232
510
|
...(Object.keys(env).length ? { env } : {}),
|
|
511
|
+
...(Object.keys(envRefs).length ? { envRefs } : {}),
|
|
233
512
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
513
|
+
...(container ? { container } : {}),
|
|
514
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
515
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
516
|
+
...(volumes.length ? { volumes } : {}),
|
|
517
|
+
...(restart ? { restart } : {}),
|
|
234
518
|
confidence: "high",
|
|
235
519
|
sources: [base],
|
|
236
520
|
});
|
package/dist/flisk.js
CHANGED
|
@@ -9,7 +9,7 @@ export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json
|
|
|
9
9
|
* release after 1.4.10 (flisk#2646) accepts a bring-your-own port outside 4000-5999 and two-letter
|
|
10
10
|
* names, which this importer now writes. Stated, not checked: it does not read the installed version.
|
|
11
11
|
*/
|
|
12
|
-
export const MIN_CORE_VERSION = "1.4.
|
|
12
|
+
export const MIN_CORE_VERSION = "1.4.24";
|
|
13
13
|
/**
|
|
14
14
|
* The core change that runs a registered Tiltfile (`orchestration.source: "tiltfile"`) from `flisk up`. Stated, not
|
|
15
15
|
* checked, like MIN_CORE_VERSION.
|
package/dist/merge.js
CHANGED
|
@@ -49,6 +49,10 @@ const SCALAR_FIELDS = [
|
|
|
49
49
|
function rank(candidate) {
|
|
50
50
|
return Math.min(...candidate.sources.map((s) => precedence(s.detector)));
|
|
51
51
|
}
|
|
52
|
+
function firstOf(ordered, key) {
|
|
53
|
+
const holder = ordered.find((c) => c[key] !== undefined);
|
|
54
|
+
return holder ? { [key]: holder[key] } : {};
|
|
55
|
+
}
|
|
52
56
|
function mergeGroup(name, dir, members, resolve) {
|
|
53
57
|
const ordered = [...members].sort((a, b) => rank(a) - rank(b));
|
|
54
58
|
const originalName = ordered.find((c) => c.name !== undefined && c.name !== name)?.name;
|
|
@@ -59,8 +63,19 @@ function mergeGroup(name, dir, members, resolve) {
|
|
|
59
63
|
kind: ordered[0].kind,
|
|
60
64
|
envKeys: [...new Set(ordered.flatMap((c) => c.envKeys ?? []))].sort(),
|
|
61
65
|
env: Object.assign({}, ...[...ordered].reverse().map((c) => c.env ?? {})),
|
|
66
|
+
envRefs: Object.assign({}, ...[...ordered].reverse().map((c) => c.envRefs ?? {})),
|
|
62
67
|
dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
|
|
63
68
|
exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
|
|
69
|
+
// Only Compose states these, so the first source that has one wins.
|
|
70
|
+
...firstOf(ordered, "container"),
|
|
71
|
+
...firstOf(ordered, "extraPorts"),
|
|
72
|
+
...firstOf(ordered, "volumes"),
|
|
73
|
+
...firstOf(ordered, "restart"),
|
|
74
|
+
...firstOf(ordered, "containerCommand"),
|
|
75
|
+
...firstOf(ordered, "healthCheck"),
|
|
76
|
+
...(ordered.find((c) => c.sidecarOf)?.sidecarOf
|
|
77
|
+
? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
|
|
78
|
+
: {}),
|
|
64
79
|
procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
|
|
65
80
|
conflicts: [],
|
|
66
81
|
sources: [],
|
|
@@ -104,6 +119,7 @@ export function mergeCandidates(candidates, rootName) {
|
|
|
104
119
|
const table = assignResourceNames([
|
|
105
120
|
...candidates.flatMap((c) => (c.name === undefined ? [] : [c.name])),
|
|
106
121
|
...candidates.flatMap((c) => c.dependsOn ?? []),
|
|
122
|
+
...candidates.flatMap((c) => (c.sidecarOf ? [c.sidecarOf] : [])),
|
|
107
123
|
...candidates.flatMap((c) => (c.name === undefined ? [basename(c.dir) || rootName] : [])),
|
|
108
124
|
]);
|
|
109
125
|
const resolve = (raw) => table.get(raw) ?? toResourceName(raw);
|
package/dist/plan.js
CHANGED
|
@@ -7,7 +7,7 @@ import { selectDetectors } from "./registry.js";
|
|
|
7
7
|
import { scanTree } from "./scan.js";
|
|
8
8
|
import { findUnsupported } from "./unsupported.js";
|
|
9
9
|
const COMPOSE_FILE = "docker-compose.yml";
|
|
10
|
-
/** Names `docker compose` also picks up, in its own order of preference
|
|
10
|
+
/** Names `docker compose` also picks up, in its own order of preference. They are read only when docker-compose.yml is absent. */
|
|
11
11
|
const OTHER_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml"];
|
|
12
12
|
/** Files `docker compose up` merges over the main file; none of them is applied. */
|
|
13
13
|
const OVERRIDE_FILES = [
|
|
@@ -16,37 +16,33 @@ const OVERRIDE_FILES = [
|
|
|
16
16
|
"docker-compose.override.yaml",
|
|
17
17
|
"docker-compose.override.yml",
|
|
18
18
|
];
|
|
19
|
-
/** Read the one supported input, root
|
|
19
|
+
/** Read the one supported input, the root Compose file. This function writes nothing. */
|
|
20
20
|
export function buildPlan(dir) {
|
|
21
21
|
const root = resolve(dir);
|
|
22
22
|
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
23
23
|
throw new Error(`${dir} is not a directory`);
|
|
24
24
|
}
|
|
25
|
-
const composePath = COMPOSE_FILE;
|
|
26
25
|
const detectors = selectDetectors(["compose"]);
|
|
27
26
|
const candidates = [];
|
|
28
27
|
const skips = [];
|
|
29
28
|
const isFile = (name) => statSync(resolve(root, name), { throwIfNoEntry: false })?.isFile() ?? false;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`Other Compose file names are not read yet. To import ${others[0]}, link it: ln -s ${others[0]} docker-compose.yml`,
|
|
35
|
-
]
|
|
36
|
-
: ["Run flisk import in the directory that holds docker-compose.yml"]);
|
|
29
|
+
const present = OTHER_COMPOSE_FILES.filter(isFile);
|
|
30
|
+
const composePath = isFile(COMPOSE_FILE) ? COMPOSE_FILE : present[0];
|
|
31
|
+
if (!composePath) {
|
|
32
|
+
throw new ImportError(`No root docker-compose.yml found in ${root} (nor compose.yaml); flisk import reads only the root Compose file`, ["Run flisk import in the directory that holds docker-compose.yml (or compose.yaml)"]);
|
|
37
33
|
}
|
|
38
|
-
for (const name of
|
|
34
|
+
for (const name of present.filter((n) => n !== composePath)) {
|
|
39
35
|
skips.push({
|
|
40
36
|
file: name,
|
|
41
37
|
detector: "compose",
|
|
42
|
-
reason: `not read; \`docker compose\` prefers it over ${
|
|
38
|
+
reason: `not read; \`docker compose\` prefers it over ${composePath}, so what it runs may differ from this plan`,
|
|
43
39
|
});
|
|
44
40
|
}
|
|
45
41
|
for (const name of OVERRIDE_FILES.filter(isFile)) {
|
|
46
42
|
skips.push({
|
|
47
43
|
file: name,
|
|
48
44
|
detector: "compose",
|
|
49
|
-
reason: `not applied; \`docker compose up\` merges it over ${
|
|
45
|
+
reason: `not applied; \`docker compose up\` merges it over ${composePath}, flisk import does not`,
|
|
50
46
|
});
|
|
51
47
|
}
|
|
52
48
|
for (const detector of detectors) {
|
package/dist/write.js
CHANGED
|
@@ -6,6 +6,31 @@ import { toResourceName } from "./merge.js";
|
|
|
6
6
|
import { usedManifestPorts } from "./plan.js";
|
|
7
7
|
/** Secret names the service schema accepts (`secrets.required`). */
|
|
8
8
|
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
9
|
+
/** Names that may hold a credential whatever their value looks like; these always stay secrets. */
|
|
10
|
+
const CREDENTIAL_NAME = /PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL|AUTH|HMAC|SALT|CERT|SESSION|COOKIE/i;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a literal Compose value cannot be a secret, so it may be committed in service.json `env`: a number, true or false,
|
|
13
|
+
* or the address of another service in this import (a URL or host:port with no user, password, query or fragment).
|
|
14
|
+
*/
|
|
15
|
+
function isPlainValue(value, serviceHosts) {
|
|
16
|
+
if (/^-?\d+(\.\d+)?$/.test(value) || /^(true|false)$/i.test(value))
|
|
17
|
+
return true;
|
|
18
|
+
const hostPort = value.match(/^([a-z0-9][a-z0-9._-]*):(\d{1,5})$/);
|
|
19
|
+
if (hostPort)
|
|
20
|
+
return serviceHosts.has(hostPort[1]);
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
return (serviceHosts.has(url.hostname) &&
|
|
24
|
+
!url.username &&
|
|
25
|
+
!url.password &&
|
|
26
|
+
!url.search &&
|
|
27
|
+
!url.hash &&
|
|
28
|
+
/^[A-Za-z0-9._~/-]*$/.test(url.pathname));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
function readEnvKeys(path) {
|
|
10
35
|
if (!existsSync(path))
|
|
11
36
|
return new Set();
|
|
@@ -78,6 +103,8 @@ export function planWrites(plan, force) {
|
|
|
78
103
|
return !reason;
|
|
79
104
|
});
|
|
80
105
|
const names = new Set(accepted.map((s) => s.name));
|
|
106
|
+
// Hostnames other services use for these: the resource name, which stays the container hostname, and the Compose name.
|
|
107
|
+
const serviceHosts = new Set(plan.services.flatMap((s) => (s.originalName ? [s.name, s.originalName] : [s.name])));
|
|
81
108
|
for (const service of accepted) {
|
|
82
109
|
const path = resolve(servicesDir, stack, service.name, "service.json");
|
|
83
110
|
const exists = existsSync(path);
|
|
@@ -133,8 +160,32 @@ export function planWrites(plan, force) {
|
|
|
133
160
|
notes.push("no health path: the service does not serve HTTP (non-HTTP image or job)");
|
|
134
161
|
if (!isPrivate && !nonHttp && !service.healthCheckPath)
|
|
135
162
|
notes.push("health path / is assumed: no HTTP health path could be read from Compose. Traefik routes only while it answers 2xx or 3xx");
|
|
136
|
-
//
|
|
137
|
-
const
|
|
163
|
+
// A probe command is the only way to describe a non-HTTP check; a routed service keeps its HTTP path.
|
|
164
|
+
const healthCheck = service.healthCheck && !(healthCheckPath && service.healthCheckPath)
|
|
165
|
+
? service.healthCheck
|
|
166
|
+
: undefined;
|
|
167
|
+
// Values that cannot be secrets (numbers, booleans, another service's address, pure ${VAR} references) go into the
|
|
168
|
+
// committed service.json `env` (Flisk 1.4.24). Every other name is a secret: Flisk injects it from the gitignored .env.
|
|
169
|
+
const validNames = service.envKeys.filter((key) => ENV_NAME.test(key));
|
|
170
|
+
const env = {};
|
|
171
|
+
for (const key of validNames) {
|
|
172
|
+
if (CREDENTIAL_NAME.test(key))
|
|
173
|
+
continue;
|
|
174
|
+
const literal = service.env[key];
|
|
175
|
+
const reference = service.envRefs[key];
|
|
176
|
+
if (literal !== undefined && isPlainValue(literal, serviceHosts))
|
|
177
|
+
env[key] = literal;
|
|
178
|
+
else if (literal === undefined && reference !== undefined)
|
|
179
|
+
env[key] = reference;
|
|
180
|
+
}
|
|
181
|
+
const referenced = [
|
|
182
|
+
...new Set(Object.values(env).flatMap((value) => [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)?/g)]
|
|
183
|
+
.filter((m) => !m[2])
|
|
184
|
+
.map((m) => m[1]))),
|
|
185
|
+
].filter((name) => !envKept.has(name) && !(name in envAdd));
|
|
186
|
+
if (referenced.length)
|
|
187
|
+
notes.push(`env reads ${referenced.join(", ")} from the project .env; add them there`);
|
|
188
|
+
const secretNames = validNames.filter((key) => !(key in env));
|
|
138
189
|
const skippedNames = service.envKeys.filter((key) => !ENV_NAME.test(key));
|
|
139
190
|
if (skippedNames.length)
|
|
140
191
|
notes.push(`environment names not written (Flisk accepts A-Z, 0-9 and _): ${skippedNames.join(", ")}`);
|
|
@@ -154,12 +205,52 @@ export function planWrites(plan, force) {
|
|
|
154
205
|
notes.push(`image "${service.image}" is not written: with build and image, Flisk builds from ${service.dockerfile} and does not pull the tag`);
|
|
155
206
|
// With both build and image, Compose builds the Dockerfile and tags the result as `image`.
|
|
156
207
|
// Omit `image` from service.json so Flisk builds from the Dockerfile instead of pulling the tag.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
208
|
+
// Flisk has no container `command`, so a Compose `command` on an image becomes the CMD of a one-line Dockerfile.
|
|
209
|
+
const files = [];
|
|
210
|
+
const wrapWithCommand = !service.dockerfile && service.image && service.containerCommand;
|
|
211
|
+
if (wrapWithCommand) {
|
|
212
|
+
files.push({
|
|
213
|
+
path: resolve(dirname(path), "Dockerfile"),
|
|
214
|
+
content: `# From the Compose \`command\` of this service (flisk import). Flisk sets no container command, so it lives here.\nFROM ${service.image}\nCMD ${JSON.stringify(service.containerCommand)}\n`,
|
|
215
|
+
});
|
|
216
|
+
notes.push(`command ${JSON.stringify(service.containerCommand)} is written as the CMD of a Dockerfile on ${service.image}`);
|
|
217
|
+
}
|
|
218
|
+
else if (service.dockerfile && service.containerCommand) {
|
|
219
|
+
notes.push(`command ${JSON.stringify(service.containerCommand)} not written: set it as CMD in ${service.dockerfile}`);
|
|
220
|
+
}
|
|
221
|
+
const source = wrapWithCommand
|
|
222
|
+
? { buildContext: ".", dockerfile: "Dockerfile" }
|
|
223
|
+
: service.dockerfile
|
|
224
|
+
? {
|
|
225
|
+
buildContext: relative(dirname(path), resolve(plan.root, service.dir)).split("\\").join("/") || ".",
|
|
226
|
+
dockerfile: service.dockerfile,
|
|
227
|
+
}
|
|
228
|
+
: { image: service.image };
|
|
229
|
+
// A sidecar shares another written service's network; one whose host is not written stays a normal service.
|
|
230
|
+
const sidecarOf = service.sidecarOf && service.sidecarOf !== service.name && names.has(service.sidecarOf)
|
|
231
|
+
? service.sidecarOf
|
|
232
|
+
: undefined;
|
|
233
|
+
if (service.sidecarOf && !sidecarOf)
|
|
234
|
+
notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
|
|
235
|
+
const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
|
|
236
|
+
if (sidecarOf && service.extraPorts?.length)
|
|
237
|
+
notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
|
|
238
|
+
if (extraPorts.length)
|
|
239
|
+
notes.push(`published on 127.0.0.1 only: ${extraPorts.map((p) => `${p.hostPort ?? p.port}->${p.port}/${p.protocol ?? "tcp"}`).join(", ")}`);
|
|
240
|
+
// Bind mounts are relative to the service directory in service.json, and must stay inside the project.
|
|
241
|
+
const serviceDir = dirname(path);
|
|
242
|
+
const volumes = (service.volumes ?? []).flatMap((volume) => {
|
|
243
|
+
if (!volume.bind)
|
|
244
|
+
return [volume];
|
|
245
|
+
const absolute = resolve(plan.root, volume.source);
|
|
246
|
+
const fromProject = relative(projectRoot, absolute).split("\\").join("/");
|
|
247
|
+
if (fromProject === ".." || fromProject.startsWith("../")) {
|
|
248
|
+
notes.push(`volume ${volume.source}:${volume.target} not written: it is outside the project`);
|
|
249
|
+
return [];
|
|
161
250
|
}
|
|
162
|
-
|
|
251
|
+
const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
|
|
252
|
+
return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
|
|
253
|
+
});
|
|
163
254
|
const manifest = {
|
|
164
255
|
$schema: SERVICE_MANIFEST_SCHEMA_URL,
|
|
165
256
|
schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
|
|
@@ -168,12 +259,29 @@ export function planWrites(plan, force) {
|
|
|
168
259
|
stack,
|
|
169
260
|
port,
|
|
170
261
|
...(healthCheckPath ? { healthCheckPath } : {}),
|
|
262
|
+
...(healthCheck ? { healthCheck } : {}),
|
|
171
263
|
...source,
|
|
172
264
|
...(isPrivate ? { exposeViaProxy: false } : {}),
|
|
173
265
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
266
|
+
...(Object.keys(env).length
|
|
267
|
+
? { env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) }
|
|
268
|
+
: {}),
|
|
174
269
|
...(secretNames.length ? { secrets: { required: secretNames } } : {}),
|
|
270
|
+
...(service.restart ? { restart: service.restart } : {}),
|
|
271
|
+
...(service.container ? { container: service.container } : {}),
|
|
272
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
273
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
274
|
+
...(volumes.length
|
|
275
|
+
? {
|
|
276
|
+
volumes: volumes.map(({ source, target, readOnly }) => ({
|
|
277
|
+
source,
|
|
278
|
+
target,
|
|
279
|
+
...(readOnly ? { readOnly } : {}),
|
|
280
|
+
})),
|
|
281
|
+
}
|
|
282
|
+
: {}),
|
|
175
283
|
};
|
|
176
|
-
items.push({ service, path, manifest, status, notes });
|
|
284
|
+
items.push({ service, path, manifest, files, status, notes });
|
|
177
285
|
}
|
|
178
286
|
return {
|
|
179
287
|
projectRoot,
|
|
@@ -195,6 +303,8 @@ export function applyWrites(writes) {
|
|
|
195
303
|
continue;
|
|
196
304
|
mkdirSync(dirname(item.path), { recursive: true });
|
|
197
305
|
writeFileSync(item.path, `${JSON.stringify(item.manifest, null, 2)}\n`);
|
|
306
|
+
for (const file of item.files)
|
|
307
|
+
writeFileSync(file.path, file.content);
|
|
198
308
|
written.push(item);
|
|
199
309
|
}
|
|
200
310
|
const keys = Object.keys(writes.env.add);
|
package/package.json
CHANGED