@flisk-sh/flisk-import 0.12.0 → 0.14.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 +7 -4
- package/dist/cli.js +4 -2
- package/dist/detectors/compose.js +376 -41
- package/dist/flisk.js +7 -1
- package/dist/format.js +2 -0
- package/dist/write.js +195 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,14 @@ flisk import . --yes # write service.json files and project setup
|
|
|
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 install's data:** each named volume gets `name`, the Docker volume `docker compose up` created (`<project>_<volume>`, its own `name:`, or an `external` volume), so `flisk up` mounts the same data with no copy (needs the Flisk release with flisk#2964).
|
|
16
16
|
- **Keeps:** `profiles` (the service starts only with `flisk up --profile`), `env_file` as `envFile`, `tmpfs` and `deploy.resources.limits` (`container.tmpfs`, `cpus`, `memory`).
|
|
17
|
+
- **Keeps:** `stop_grace_period` (`container.stopGracePeriod`, such as `30s`), the `aliases` of every network (`container.networkAliases`), `extra_hosts` in list or map form (`container.extraHosts`, such as `host.docker.internal:host-gateway`) and `ulimits` (`container.ulimits`). A service renamed to kebab-case (`lychee_api` becomes `lychee-api`) keeps its Compose name as a network alias, so `http://lychee_api:8000` in another service still resolves. `container_name` is dropped with a note: Flisk names containers itself. These fields, an image with variables and `on-failure:N` need the Flisk core release that reads them; an older core does not apply them.
|
|
17
18
|
- **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.
|
|
18
|
-
- **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 the same host address as in Compose: `"80:80"` stays on every interface, `"127.0.0.1:5432:5432"` on loopback, and another address or a `${VAR:-default}` host side as `publish`), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
|
|
19
|
-
- **Environment:**
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
19
|
+
- **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 the same host address as in Compose: `"80:80"` stays on every interface, `"127.0.0.1:5432:5432"` on loopback, and another address or a `${VAR:-default}` or `${VAR-default}` host side as `publish`), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
|
|
20
|
+
- **Environment:** the committed `service.json` `env` gets every pure Compose expression as written, whatever its name (`${DB_PASSWORD:-password}`, `${PORT-8080}`, `${A:-${B:-file}}`), so a fresh clone keeps the defaults, and every literal (`sqlite`, `worker`, `lychee_db`, `http://invidious:3000`) unless its name looks like a credential (`PASSWORD`, `TOKEN`, `KEY`, ...) or the value holds one (a URL with a user or password, a token query parameter, a long random string, a PEM block). Those go into `secrets.required`, with its literal value copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept.
|
|
21
|
+
- **Round trip:** a routed port that Compose also publishes (`"${APP_PORT:-8000}:8000"`) stays in `extraPorts` too, and a `healthcheck` stays as `healthCheck.command` (with `interval`, `timeout`, `retries` and `start_period` as `startPeriod`) even when an HTTP path was read, so `flisk-export compose` writes a file that `docker compose` starts the same way (LycheeOrg/Lychee round-trips with no difference but names and aliases). A service with no `restart` gets `restart: "no"` (Compose restarts nothing unless asked; Flisk defaults to `unless-stopped`), `healthcheck: { disable: true }` or `test: ["NONE"]` becomes `healthCheck: false`, a value that mixes `${VAR}` with literal text (`${SERVER_NAME:-localhost}, php:80`) stays in `env` unless it could hold a credential, `*_FILE` paths (`POSTGRES_PASSWORD_FILE=/run/secrets/pg`) stay in `env`, and the Compose project name (`name:` or the folder's, such as `pl_mail`) is recorded as `composeProjectNames.<stack>` in `.flisk/project.json`, so `flisk-export compose` keeps the container names. A generated Dockerfile for an image with variables declares them as `ARG NAME=default` before `FROM`.
|
|
22
|
+
- **A tracked `.env`:** a repo that commits its `.env` (Symfony) keeps it as it is, and it is not added to `.gitignore`; values that must stay local go to `.env.local` when git already ignores it, else to `.flisk/.env.import` (added to `.gitignore`), and each service that needs them reads that file as `envFile`.
|
|
23
|
+
- **Keeps:** a `command` or `entrypoint` on an image as the `CMD`/`ENTRYPOINT` of a small `Dockerfile` next to `service.json` (Flisk sets no container command; a string is split into exec-form arguments as Compose splits it, with no shell, so `command: server /export` on `minio/minio` stays the arguments of its `ENTRYPOINT`; `$$` becomes `$` as Compose passes it to the container; only a string with Compose variables (`${VAR}`, filled from `.env` by Compose, by the container's shell here) or unbalanced quotes runs through `sh -c`, and the plan says so), a `healthcheck` as `healthCheck.command`, a published database port as `extraPorts`, and `restart: on-failure:N` with its retry limit (N from 1 to 100).
|
|
24
|
+
- **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 invalid profile names, `extends`, an out-of-tree build, or an image with characters or variable forms other than `${NAME}`, `${NAME:-default}` and `${NAME-default}` (`postgres:${POSTGRES_VERSION:-18}-alpine` is written as it is).
|
|
22
25
|
- **Does not start anything.** Run `flisk up` with Flisk core **1.4.26 or later** (`publish` needs it). The importer does not check the core version.
|
|
23
26
|
- **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.`).
|
|
24
27
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { relative } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import "./detectors/index.js";
|
|
@@ -59,11 +59,13 @@ const program = new Command("flisk-import")
|
|
|
59
59
|
console.log(` wrote ${relative(process.cwd(), item.path) || manifestRelPath(plan, item)}`);
|
|
60
60
|
}
|
|
61
61
|
console.log(`\nWrote ${written.length} service.json file(s)${writes.projectConfig ? " and created .flisk/project.json" : ""}.`);
|
|
62
|
-
const envFile = relative(process.cwd(),
|
|
62
|
+
const envFile = relative(process.cwd(), writes.env.path) || ".env";
|
|
63
63
|
console.log("\nNext:");
|
|
64
64
|
const copied = Object.keys(writes.env.add);
|
|
65
65
|
if (copied.length)
|
|
66
66
|
console.log(` - Copied ${copied.length} environment value(s) from docker-compose.yml to ${envFile} (gitignored).`);
|
|
67
|
+
if (writes.env.tracked && copied.length)
|
|
68
|
+
console.log(` - ${writes.env.tracked} is tracked by git, so it was left as it is and not added to .gitignore; the values went to ${envFile}, which each service that needs them reads as envFile.`);
|
|
67
69
|
if (writes.items.some((item) => item.notes.some((note) => note.startsWith("no literal value"))))
|
|
68
70
|
console.log(` - Add the values listed above as missing to ${envFile}.`);
|
|
69
71
|
console.log(` - Run \`flisk up\` with Flisk core ${MIN_CORE_VERSION} or later. The import did not start anything.`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, normalize } from "node:path/posix";
|
|
3
3
|
import { isAlias, isMap, parseDocument, visit } from "yaml";
|
|
4
|
-
import { HEALTH_CHECK_PATH_PATTERN } from "../flisk.js";
|
|
4
|
+
import { HEALTH_CHECK_PATH_PATTERN, NETWORK_ALIAS } from "../flisk.js";
|
|
5
5
|
import { registerDetector } from "../registry.js";
|
|
6
6
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7
7
|
/** Container-side port of one `ports:` entry, in short or long syntax. */
|
|
@@ -51,9 +51,14 @@ const TRANSLATED_KEYS = new Set([
|
|
|
51
51
|
"env_file",
|
|
52
52
|
"tmpfs",
|
|
53
53
|
"deploy",
|
|
54
|
-
|
|
54
|
+
"stop_grace_period",
|
|
55
|
+
"extra_hosts",
|
|
56
|
+
"ulimits",
|
|
57
|
+
// Only `aliases` is read from networks: Flisk gives every service its own stack network.
|
|
55
58
|
"networks",
|
|
56
59
|
"expose",
|
|
60
|
+
// Flisk names every container itself; the plan says so.
|
|
61
|
+
"container_name",
|
|
57
62
|
]);
|
|
58
63
|
const CAPABILITY = /^[A-Z_]+$/;
|
|
59
64
|
const USER = /^[A-Za-z0-9_.:-]+$/;
|
|
@@ -62,6 +67,124 @@ const VOLUME_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
|
62
67
|
const CONTAINER_PATH = /^\/[A-Za-z0-9_./-]*$/;
|
|
63
68
|
const HOST_PATH = /^[A-Za-z0-9_./-]+$/;
|
|
64
69
|
const RESTART = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
70
|
+
/** `on-failure:N` with a retry limit from 1 to 100. */
|
|
71
|
+
const RESTART_LIMIT = /^on-failure:([1-9][0-9]?|100)$/;
|
|
72
|
+
/** A Compose duration such as 30s, 1m30s or 500ms. */
|
|
73
|
+
const DURATION = /^(?:[0-9]+(?:\.[0-9]+)?(?:h|ms|us|m|s))+$/;
|
|
74
|
+
const EXTRA_HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/;
|
|
75
|
+
/** An extra_hosts address: host-gateway, IPv4, or IPv6 (optionally in brackets). */
|
|
76
|
+
const EXTRA_HOST_ADDRESS = /^(?:host-gateway|[0-9]{1,3}(?:\.[0-9]{1,3}){3}|\[?[0-9A-Fa-f.]*:[0-9A-Fa-f.]*:[0-9A-Fa-f:.]*\]?)$/;
|
|
77
|
+
const ULIMIT_NAME = /^[a-z]+$/;
|
|
78
|
+
/** One extra_hosts entry as `name:address`; undefined when either side is not one Flisk accepts. */
|
|
79
|
+
function extraHostOf(name, address) {
|
|
80
|
+
if (typeof name !== "string" || typeof address !== "string")
|
|
81
|
+
return undefined;
|
|
82
|
+
return EXTRA_HOST_NAME.test(name) && EXTRA_HOST_ADDRESS.test(address)
|
|
83
|
+
? `${name}:${address}`
|
|
84
|
+
: undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Compose extra_hosts in list form (`name:address` or `name=address`) or map form. */
|
|
87
|
+
function extraHostsOf(value) {
|
|
88
|
+
const kept = [];
|
|
89
|
+
const bad = [];
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
for (const entry of value) {
|
|
92
|
+
const at = typeof entry === "string" ? entry.search(/[:=]/) : -1;
|
|
93
|
+
const host = typeof entry === "string" && at > 0
|
|
94
|
+
? extraHostOf(entry.slice(0, at), entry.slice(at + 1))
|
|
95
|
+
: undefined;
|
|
96
|
+
if (host)
|
|
97
|
+
kept.push(host);
|
|
98
|
+
else
|
|
99
|
+
bad.push(entry);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (isObj(value)) {
|
|
103
|
+
for (const [name, address] of Object.entries(value)) {
|
|
104
|
+
const host = extraHostOf(name, address);
|
|
105
|
+
if (host)
|
|
106
|
+
kept.push(host);
|
|
107
|
+
else
|
|
108
|
+
bad.push({ [name]: address });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
bad.push(value);
|
|
113
|
+
}
|
|
114
|
+
return { kept: [...new Set(kept)], bad };
|
|
115
|
+
}
|
|
116
|
+
/** Compose ulimits: each a whole number, or { soft, hard } whole numbers with soft no greater than hard. */
|
|
117
|
+
function ulimitsOf(value) {
|
|
118
|
+
const kept = {};
|
|
119
|
+
const bad = [];
|
|
120
|
+
if (!isObj(value))
|
|
121
|
+
return { kept, bad: [JSON.stringify(value)] };
|
|
122
|
+
for (const [name, limit] of Object.entries(value)) {
|
|
123
|
+
if (!ULIMIT_NAME.test(name))
|
|
124
|
+
bad.push(name);
|
|
125
|
+
else if (typeof limit === "number" && Number.isInteger(limit))
|
|
126
|
+
kept[name] = limit;
|
|
127
|
+
else if (isObj(limit) &&
|
|
128
|
+
Number.isInteger(limit.soft) &&
|
|
129
|
+
Number.isInteger(limit.hard) &&
|
|
130
|
+
Object.keys(limit).every((k) => k === "soft" || k === "hard") &&
|
|
131
|
+
(limit.hard === -1 || limit.soft <= limit.hard))
|
|
132
|
+
kept[name] = { soft: limit.soft, hard: limit.hard };
|
|
133
|
+
else
|
|
134
|
+
bad.push(name);
|
|
135
|
+
}
|
|
136
|
+
return { kept, bad };
|
|
137
|
+
}
|
|
138
|
+
/** Aliases from every network in a `networks` map; the list form names networks only. */
|
|
139
|
+
function networkAliasesOf(value) {
|
|
140
|
+
const kept = [];
|
|
141
|
+
const bad = [];
|
|
142
|
+
if (!isObj(value))
|
|
143
|
+
return { kept, bad };
|
|
144
|
+
for (const network of Object.values(value)) {
|
|
145
|
+
if (!isObj(network) || network.aliases === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const aliases = Array.isArray(network.aliases) ? network.aliases : [network.aliases];
|
|
148
|
+
for (const alias of aliases) {
|
|
149
|
+
if (typeof alias === "string" && NETWORK_ALIAS.test(alias))
|
|
150
|
+
kept.push(alias);
|
|
151
|
+
else
|
|
152
|
+
bad.push(alias);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { kept: [...new Set(kept)], bad };
|
|
156
|
+
}
|
|
157
|
+
/** stop_grace_period, network aliases, extra_hosts and ulimits, and a line for each value that is not carried over. */
|
|
158
|
+
function networkOptionsOf(raw) {
|
|
159
|
+
const options = {};
|
|
160
|
+
const dropped = [];
|
|
161
|
+
if (raw.stop_grace_period !== undefined) {
|
|
162
|
+
if (typeof raw.stop_grace_period === "string" && DURATION.test(raw.stop_grace_period))
|
|
163
|
+
options.stopGracePeriod = raw.stop_grace_period;
|
|
164
|
+
else
|
|
165
|
+
dropped.push(`stop_grace_period ${JSON.stringify(raw.stop_grace_period)} is not a duration such as 30s or 1m30s; not used`);
|
|
166
|
+
}
|
|
167
|
+
const aliases = networkAliasesOf(raw.networks);
|
|
168
|
+
if (aliases.kept.length)
|
|
169
|
+
options.networkAliases = aliases.kept;
|
|
170
|
+
if (aliases.bad.length)
|
|
171
|
+
dropped.push(`network aliases ${JSON.stringify(aliases.bad)} are not DNS names (letters, digits, '-', '_' and '.'); not used`);
|
|
172
|
+
if (raw.extra_hosts !== undefined) {
|
|
173
|
+
const hosts = extraHostsOf(raw.extra_hosts);
|
|
174
|
+
if (hosts.kept.length)
|
|
175
|
+
options.extraHosts = hosts.kept;
|
|
176
|
+
if (hosts.bad.length)
|
|
177
|
+
dropped.push(`extra_hosts ${JSON.stringify(hosts.bad)} are not name:address (an IP or host-gateway); not used`);
|
|
178
|
+
}
|
|
179
|
+
if (raw.ulimits !== undefined) {
|
|
180
|
+
const limits = ulimitsOf(raw.ulimits);
|
|
181
|
+
if (Object.keys(limits.kept).length)
|
|
182
|
+
options.ulimits = limits.kept;
|
|
183
|
+
if (limits.bad.length)
|
|
184
|
+
dropped.push(`ulimits ${limits.bad.join(", ")} are not a whole number or { soft, hard } with soft <= hard; not used`);
|
|
185
|
+
}
|
|
186
|
+
return { options, dropped };
|
|
187
|
+
}
|
|
65
188
|
/** Compose `shm_size` as a size string such as 256mb; a number is bytes. */
|
|
66
189
|
function shmSizeOf(value) {
|
|
67
190
|
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
@@ -124,8 +247,11 @@ function containerOf(raw) {
|
|
|
124
247
|
return { ...(Object.keys(container).length ? { container } : {}), dropped };
|
|
125
248
|
}
|
|
126
249
|
/** One published port: container port, host port when Compose names a literal one, and protocol. */
|
|
127
|
-
/**
|
|
128
|
-
|
|
250
|
+
/**
|
|
251
|
+
* A `publish` value Flisk accepts: digits, dots, colons and `${NAME:-default}` or `${NAME-default}` with a port or IPv4
|
|
252
|
+
* address:port default.
|
|
253
|
+
*/
|
|
254
|
+
const PUBLISH = /^(?:[0-9.:]|\$\{[A-Za-z_][A-Za-z0-9_]*:?-[0-9.:]+\})+$/;
|
|
129
255
|
/** The host address and port of a published port. Compose publishes on every interface unless an address is given. */
|
|
130
256
|
function hostSideOf(port, hostIp, hostPort) {
|
|
131
257
|
const hostNumber = hostPort && /^\d+$/.test(hostPort) ? Number.parseInt(hostPort, 10) : undefined;
|
|
@@ -145,6 +271,12 @@ function hostSideOf(port, hostIp, hostPort) {
|
|
|
145
271
|
const publish = `${hostIp}:${hostNumber ?? port}`;
|
|
146
272
|
return /^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(publish) ? { publish } : undefined;
|
|
147
273
|
}
|
|
274
|
+
/** Whether a `ports:` entry names a host port ("8000:8000", "127.0.0.1:8000:8000", `published:`), not only a container port. */
|
|
275
|
+
function hasHostSide(entry) {
|
|
276
|
+
if (isObj(entry))
|
|
277
|
+
return entry.published !== undefined;
|
|
278
|
+
return typeof entry === "string" && (entry.split("/")[0] ?? "").includes(":");
|
|
279
|
+
}
|
|
148
280
|
function publishedPort(entry) {
|
|
149
281
|
const port = containerPort(entry);
|
|
150
282
|
if (port === undefined)
|
|
@@ -226,7 +358,7 @@ function volumeOf(entry, composeDir) {
|
|
|
226
358
|
return { source: resolved, target, ...(readOnly ? { readOnly } : {}), bind };
|
|
227
359
|
}
|
|
228
360
|
/** Compose's project name: top-level `name`, else the directory name, lowercased with other characters dropped. */
|
|
229
|
-
function projectNameOf(root, absPath) {
|
|
361
|
+
export function projectNameOf(root, absPath) {
|
|
230
362
|
const raw = isObj(root) && typeof root.name === "string" && !root.name.includes("$")
|
|
231
363
|
? root.name
|
|
232
364
|
: basename(dirname(absPath));
|
|
@@ -273,9 +405,76 @@ function envValues(environment) {
|
|
|
273
405
|
}
|
|
274
406
|
return out;
|
|
275
407
|
}
|
|
276
|
-
/**
|
|
277
|
-
|
|
278
|
-
|
|
408
|
+
/** The end of the `${...}` expression that starts at `start`, nested defaults included; undefined when it is not one. */
|
|
409
|
+
function expressionEnd(text, start) {
|
|
410
|
+
if (!text.startsWith("${", start))
|
|
411
|
+
return undefined;
|
|
412
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(start + 2));
|
|
413
|
+
if (!name)
|
|
414
|
+
return undefined;
|
|
415
|
+
let i = start + 2 + name[0].length;
|
|
416
|
+
if (text[i] === "}")
|
|
417
|
+
return i + 1;
|
|
418
|
+
const op = /^:?[-?+]/.exec(text.slice(i));
|
|
419
|
+
if (!op)
|
|
420
|
+
return undefined;
|
|
421
|
+
i += op[0].length;
|
|
422
|
+
while (i < text.length) {
|
|
423
|
+
if (text.startsWith("${", i)) {
|
|
424
|
+
const end = expressionEnd(text, i);
|
|
425
|
+
if (end === undefined)
|
|
426
|
+
return undefined;
|
|
427
|
+
i = end;
|
|
428
|
+
}
|
|
429
|
+
else if (text[i] === "}")
|
|
430
|
+
return i + 1;
|
|
431
|
+
else if (text[i] === "\n" || text[i] === "$")
|
|
432
|
+
return undefined;
|
|
433
|
+
else
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A value that is nothing but Compose interpolation: `${URL}`, `${PORT:-80}`, `${PORT-80}`, nested `${A:-${B:-file}}`.
|
|
440
|
+
* It holds no secret itself (a default is what the project's Compose file already commits), so it stays in `env`.
|
|
441
|
+
*/
|
|
442
|
+
export function isComposeExpression(value) {
|
|
443
|
+
let i = 0;
|
|
444
|
+
while (i < value.length) {
|
|
445
|
+
const end = expressionEnd(value, i);
|
|
446
|
+
if (end === undefined)
|
|
447
|
+
return false;
|
|
448
|
+
i = end;
|
|
449
|
+
}
|
|
450
|
+
return value.length > 0;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* A value that mixes Compose expressions with literal text (`${SERVER_NAME:-localhost, :443}, php:80`), every `${`
|
|
454
|
+
* starting a valid expression. Undefined when it is not one; else the literal text with the expressions taken out.
|
|
455
|
+
*/
|
|
456
|
+
export function composeTemplateText(value) {
|
|
457
|
+
if (!value.includes("${") || value.includes("\n"))
|
|
458
|
+
return undefined;
|
|
459
|
+
let literal = "";
|
|
460
|
+
let i = 0;
|
|
461
|
+
while (i < value.length) {
|
|
462
|
+
if (value.startsWith("${", i)) {
|
|
463
|
+
const end = expressionEnd(value, i);
|
|
464
|
+
if (end === undefined)
|
|
465
|
+
return undefined;
|
|
466
|
+
i = end;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
if (value[i] === "$" && value[i + 1] !== "$")
|
|
470
|
+
return undefined;
|
|
471
|
+
literal += value[i];
|
|
472
|
+
i++;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return literal;
|
|
476
|
+
}
|
|
477
|
+
/** Values with `${VAR}` references (alone or in literal text), which service.json `env` can keep as they are (Flisk 1.4.24). */
|
|
279
478
|
function envReferences(environment) {
|
|
280
479
|
const pairs = Array.isArray(environment)
|
|
281
480
|
? environment
|
|
@@ -286,7 +485,7 @@ function envReferences(environment) {
|
|
|
286
485
|
: [];
|
|
287
486
|
const out = {};
|
|
288
487
|
for (const [key, raw] of pairs) {
|
|
289
|
-
if (typeof raw === "string" &&
|
|
488
|
+
if (typeof raw === "string" && composeTemplateText(raw) !== undefined)
|
|
290
489
|
out[key] = raw;
|
|
291
490
|
}
|
|
292
491
|
return out;
|
|
@@ -298,22 +497,135 @@ function commandOf(command) {
|
|
|
298
497
|
return command.join(" ");
|
|
299
498
|
return undefined;
|
|
300
499
|
}
|
|
301
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* A Compose `command`/`entrypoint` string split into arguments the way Compose splits it (shell words: whitespace,
|
|
502
|
+
* '...' kept as it is, "..." with \" \\ \$ \` escapes, a backslash escaping the next character). Compose runs no shell.
|
|
503
|
+
* Undefined when the quotes or a trailing backslash do not balance.
|
|
504
|
+
*/
|
|
505
|
+
export function shellWords(text) {
|
|
506
|
+
const words = [];
|
|
507
|
+
let word = "";
|
|
508
|
+
let inWord = false;
|
|
509
|
+
for (let i = 0; i < text.length; i++) {
|
|
510
|
+
const ch = text[i];
|
|
511
|
+
if (ch === "'") {
|
|
512
|
+
const end = text.indexOf("'", i + 1);
|
|
513
|
+
if (end === -1)
|
|
514
|
+
return undefined;
|
|
515
|
+
word += text.slice(i + 1, end);
|
|
516
|
+
inWord = true;
|
|
517
|
+
i = end;
|
|
518
|
+
}
|
|
519
|
+
else if (ch === '"') {
|
|
520
|
+
inWord = true;
|
|
521
|
+
let closed = false;
|
|
522
|
+
for (i++; i < text.length; i++) {
|
|
523
|
+
const c = text[i];
|
|
524
|
+
if (c === '"') {
|
|
525
|
+
closed = true;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
if (c === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) {
|
|
529
|
+
word += text[i + 1];
|
|
530
|
+
i++;
|
|
531
|
+
}
|
|
532
|
+
else
|
|
533
|
+
word += c;
|
|
534
|
+
}
|
|
535
|
+
if (!closed)
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
else if (ch === "\\") {
|
|
539
|
+
if (i + 1 >= text.length)
|
|
540
|
+
return undefined;
|
|
541
|
+
word += text[i + 1];
|
|
542
|
+
inWord = true;
|
|
543
|
+
i++;
|
|
544
|
+
}
|
|
545
|
+
else if (/\s/.test(ch)) {
|
|
546
|
+
if (inWord)
|
|
547
|
+
words.push(word);
|
|
548
|
+
word = "";
|
|
549
|
+
inWord = false;
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
word += ch;
|
|
553
|
+
inWord = true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (inWord)
|
|
557
|
+
words.push(word);
|
|
558
|
+
return words;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Compose `command`/`entrypoint` as the argument vector the container runs. A list is kept as it is; a string is split
|
|
562
|
+
* into words as Compose splits it, with no shell, so an image ENTRYPOINT (minio) gets `server /export` as its arguments.
|
|
563
|
+
* Only a string that cannot be split, or that reads `$VAR` at run time, runs through `sh -c`; `shell` says why.
|
|
564
|
+
*/
|
|
302
565
|
function containerCommandOf(command) {
|
|
303
|
-
if (typeof command === "string" && command.trim())
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
566
|
+
if (typeof command === "string" && command.trim()) {
|
|
567
|
+
const { text, interpolates } = unescapeDollars(command);
|
|
568
|
+
if (interpolates)
|
|
569
|
+
return {
|
|
570
|
+
argv: ["sh", "-c", text],
|
|
571
|
+
note: "uses Compose variables, which Compose filled from .env; in the generated Dockerfile it runs through sh -c, so the container's shell fills them from its own environment",
|
|
572
|
+
};
|
|
573
|
+
const words = shellWords(text);
|
|
574
|
+
return words?.length
|
|
575
|
+
? { argv: words }
|
|
576
|
+
: {
|
|
577
|
+
argv: ["sh", "-c", text],
|
|
578
|
+
note: "has unbalanced quotes; it runs through sh -c, which Compose would not do",
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (Array.isArray(command) && command.length > 0 && command.every((c) => typeof c === "string")) {
|
|
582
|
+
const parts = command.map(unescapeDollars);
|
|
583
|
+
return {
|
|
584
|
+
argv: parts.map((p) => p.text),
|
|
585
|
+
...(parts.some((p) => p.interpolates)
|
|
586
|
+
? {
|
|
587
|
+
note: "uses Compose variables, which Compose filled from .env; the generated Dockerfile passes them as written (a sh -c argument fills them from the container's environment)",
|
|
588
|
+
}
|
|
589
|
+
: {}),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
307
592
|
return undefined;
|
|
308
593
|
}
|
|
309
|
-
/**
|
|
594
|
+
/**
|
|
595
|
+
* A Compose command as the container sees it: `$$` is Compose's escape for a literal `$` (so `$$PASSWORD` reaches the
|
|
596
|
+
* container's shell as `$PASSWORD`). `interpolates` says whether a single `$` (`${VAR}`, `$VAR`) asks Compose to fill
|
|
597
|
+
* a value from .env, which a Dockerfile cannot do.
|
|
598
|
+
*/
|
|
599
|
+
function unescapeDollars(value) {
|
|
600
|
+
let text = "";
|
|
601
|
+
let interpolates = false;
|
|
602
|
+
for (let i = 0; i < value.length; i++) {
|
|
603
|
+
if (value[i] === "$" && value[i + 1] === "$") {
|
|
604
|
+
text += "$";
|
|
605
|
+
i++;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (value[i] === "$" && /[{A-Za-z_]/.test(value[i + 1] ?? ""))
|
|
609
|
+
interpolates = true;
|
|
610
|
+
text += value[i];
|
|
611
|
+
}
|
|
612
|
+
return { text, interpolates };
|
|
613
|
+
}
|
|
614
|
+
const DURATION_UNIT = { h: 3600, m: 60, s: 1, ms: 0.001, us: 0.000001 };
|
|
615
|
+
/** Seconds from a Compose duration such as `30s`, `2m` or `1m30s`; undefined for anything it cannot read exactly. */
|
|
310
616
|
function secondsOf(value) {
|
|
311
617
|
if (typeof value === "number" && value > 0)
|
|
312
618
|
return value;
|
|
313
|
-
|
|
314
|
-
|
|
619
|
+
if (typeof value !== "string")
|
|
620
|
+
return undefined;
|
|
621
|
+
if (/^\d+$/.test(value))
|
|
622
|
+
return Number(value) > 0 ? Number(value) : undefined;
|
|
623
|
+
if (!DURATION.test(value))
|
|
315
624
|
return undefined;
|
|
316
|
-
|
|
625
|
+
let seconds = 0;
|
|
626
|
+
for (const [, amount, unit] of value.matchAll(/([0-9]+(?:\.[0-9]+)?)(h|ms|us|m|s)/g))
|
|
627
|
+
seconds += Number(amount) * DURATION_UNIT[unit];
|
|
628
|
+
seconds = Math.round(seconds * 1000) / 1000;
|
|
317
629
|
return seconds > 0 ? seconds : undefined;
|
|
318
630
|
}
|
|
319
631
|
/** Compose `healthcheck` as a probe command (exit 0 is ready). `NONE` and `disable` give no probe. */
|
|
@@ -335,6 +647,7 @@ function healthProbeOf(healthcheck) {
|
|
|
335
647
|
return undefined;
|
|
336
648
|
const interval = secondsOf(healthcheck.interval);
|
|
337
649
|
const timeout = secondsOf(healthcheck.timeout);
|
|
650
|
+
const startPeriod = secondsOf(healthcheck.start_period);
|
|
338
651
|
const retries = typeof healthcheck.retries === "number" &&
|
|
339
652
|
Number.isInteger(healthcheck.retries) &&
|
|
340
653
|
healthcheck.retries > 0
|
|
@@ -345,6 +658,7 @@ function healthProbeOf(healthcheck) {
|
|
|
345
658
|
...(interval ? { interval } : {}),
|
|
346
659
|
...(timeout ? { timeout } : {}),
|
|
347
660
|
...(retries ? { retries } : {}),
|
|
661
|
+
...(startPeriod ? { startPeriod } : {}),
|
|
348
662
|
};
|
|
349
663
|
}
|
|
350
664
|
/** The models a service uses, joined to their top-level definitions. A model without a literal `model` is skipped. */
|
|
@@ -552,9 +866,25 @@ registerDetector({
|
|
|
552
866
|
: [];
|
|
553
867
|
const command = commandOf(raw.command);
|
|
554
868
|
const models = modelsOf(raw.models, isObj(root) ? root.models : undefined);
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
const
|
|
869
|
+
const commandArgs = containerCommandOf(raw.command);
|
|
870
|
+
const entrypointArgs = containerCommandOf(raw.entrypoint);
|
|
871
|
+
const containerCommand = commandArgs?.argv;
|
|
872
|
+
const containerEntrypoint = entrypointArgs?.argv;
|
|
873
|
+
for (const [key, args] of [
|
|
874
|
+
["command", commandArgs],
|
|
875
|
+
["entrypoint", entrypointArgs],
|
|
876
|
+
]) {
|
|
877
|
+
if (args?.note)
|
|
878
|
+
skips.push({
|
|
879
|
+
...base,
|
|
880
|
+
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} ${args.note}`,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
// `disable: true` or `test: ["NONE"]` turns off the image's own HEALTHCHECK too, so it is kept as healthCheck: false.
|
|
884
|
+
const healthOff = isObj(raw.healthcheck) &&
|
|
885
|
+
(raw.healthcheck.disable === true ||
|
|
886
|
+
(Array.isArray(raw.healthcheck.test) && raw.healthcheck.test[0] === "NONE"));
|
|
887
|
+
const healthProbe = healthOff ? false : healthProbeOf(raw.healthcheck);
|
|
558
888
|
const envKeys = envNames(raw.environment);
|
|
559
889
|
const env = envValues(raw.environment);
|
|
560
890
|
const envRefs = envReferences(raw.environment);
|
|
@@ -571,9 +901,18 @@ registerDetector({
|
|
|
571
901
|
const { container: options, dropped } = containerOf(raw);
|
|
572
902
|
const limits = runtimeLimitsOf(raw);
|
|
573
903
|
const { dropped: limitsDropped, ...limitValues } = limits;
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
904
|
+
const network = networkOptionsOf(raw);
|
|
905
|
+
const merged = { ...(options ?? {}), ...limitValues, ...network.options };
|
|
906
|
+
const container = Object.keys(merged).length ? merged : undefined;
|
|
907
|
+
for (const reason of network.dropped) {
|
|
908
|
+
skips.push({ ...base, reason: `service "${name}" ${reason}` });
|
|
909
|
+
}
|
|
910
|
+
if (raw.container_name !== undefined) {
|
|
911
|
+
skips.push({
|
|
912
|
+
...base,
|
|
913
|
+
reason: `service "${name}" container_name ${JSON.stringify(raw.container_name)} is dropped: Flisk names containers itself; other services reach it by its service name`,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
577
916
|
for (const key of limitsDropped) {
|
|
578
917
|
skips.push({ ...base, reason: `service "${name}" ${key} is not carried over; not used` });
|
|
579
918
|
}
|
|
@@ -590,11 +929,14 @@ registerDetector({
|
|
|
590
929
|
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} has values Flisk does not accept; not used`,
|
|
591
930
|
});
|
|
592
931
|
}
|
|
593
|
-
// Every published port
|
|
932
|
+
// Every published port stays published on the same host address (service.json extraPorts). The routed port too,
|
|
933
|
+
// when Compose gives it a host side ("${APP_PORT:-8000}:8000"), so the app is still reachable without Traefik
|
|
934
|
+
// (and in an exported docker-compose.yml); a bare routed "8000" is reached through Traefik only.
|
|
594
935
|
const proxied = selectedPort !== undefined && !NON_HTTP_PORTS.has(selectedPort);
|
|
595
936
|
const extraPorts = (Array.isArray(raw.ports) ? raw.ports : [])
|
|
937
|
+
.filter((entry) => containerPort(entry) !== selectedPort || !proxied || hasHostSide(entry))
|
|
596
938
|
.map(publishedPort)
|
|
597
|
-
.filter((p) => p !== undefined
|
|
939
|
+
.filter((p) => p !== undefined);
|
|
598
940
|
let sidecarOf;
|
|
599
941
|
if (raw.network_mode !== undefined) {
|
|
600
942
|
const mode = typeof raw.network_mode === "string" ? raw.network_mode : "";
|
|
@@ -622,23 +964,15 @@ registerDetector({
|
|
|
622
964
|
reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
|
|
623
965
|
});
|
|
624
966
|
}
|
|
625
|
-
// `on-failure:5` caps the retries;
|
|
626
|
-
const
|
|
627
|
-
|
|
967
|
+
// `on-failure:5` caps the retries at 5; service.json keeps the cap (1 to 100).
|
|
968
|
+
const restart = typeof raw.restart === "string" &&
|
|
969
|
+
(RESTART.has(raw.restart) || RESTART_LIMIT.test(raw.restart))
|
|
970
|
+
? raw.restart
|
|
628
971
|
: undefined;
|
|
629
|
-
const restart = restartName !== undefined && RESTART.has(restartName)
|
|
630
|
-
? restartName
|
|
631
|
-
: undefined;
|
|
632
|
-
if (restart && restartName !== raw.restart) {
|
|
633
|
-
skips.push({
|
|
634
|
-
...base,
|
|
635
|
-
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} keeps "${restart}"; the retry limit is not carried over`,
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
972
|
if (raw.restart !== undefined && !restart) {
|
|
639
973
|
skips.push({
|
|
640
974
|
...base,
|
|
641
|
-
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
|
|
975
|
+
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts (no, always, unless-stopped, on-failure, on-failure:1 to 100); not used`,
|
|
642
976
|
});
|
|
643
977
|
}
|
|
644
978
|
const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
|
|
@@ -664,7 +998,7 @@ registerDetector({
|
|
|
664
998
|
...(models ? { models } : {}),
|
|
665
999
|
...(profiles ? { profiles } : {}),
|
|
666
1000
|
...(envFile ? { envFile } : {}),
|
|
667
|
-
...(healthProbe ? { healthCheck: healthProbe } : {}),
|
|
1001
|
+
...(healthProbe !== undefined ? { healthCheck: healthProbe } : {}),
|
|
668
1002
|
...(selectedPort !== undefined ? { port: selectedPort } : {}),
|
|
669
1003
|
...(healthCheckPath ? { healthCheckPath } : {}),
|
|
670
1004
|
exposeViaProxy,
|
|
@@ -676,7 +1010,8 @@ registerDetector({
|
|
|
676
1010
|
...(extraPorts.length ? { extraPorts } : {}),
|
|
677
1011
|
...(sidecarOf ? { sidecarOf } : {}),
|
|
678
1012
|
...(volumes.length ? { volumes } : {}),
|
|
679
|
-
|
|
1013
|
+
// Compose restarts nothing unless asked; Flisk defaults to unless-stopped, so "no" is written.
|
|
1014
|
+
...(restart ? { restart } : raw.restart === undefined ? { restart: "no" } : {}),
|
|
680
1015
|
confidence: "high",
|
|
681
1016
|
sources: [base],
|
|
682
1017
|
});
|
package/dist/flisk.js
CHANGED
|
@@ -22,7 +22,13 @@ export const BYO_PORT_RANGE = { min: 4000, max: 5999, label: "4000-5999" };
|
|
|
22
22
|
* the manifest (flisk `cli/src/utils/service-manifest.ts`, engine `manifest/parser.star`).
|
|
23
23
|
*/
|
|
24
24
|
export const HEALTH_CHECK_PATH_PATTERN = /^\/[a-z0-9/-]*$/;
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* An image reference, which may hold Compose variables `${NAME}`, `${NAME:-default}` and `${NAME-default}` (postgres:${PG_VERSION:-18}-alpine);
|
|
27
|
+
* Compose resolves them from the project .env. Nothing else: no quotes, spaces or newlines that could change generated YAML.
|
|
28
|
+
*/
|
|
29
|
+
export const IMAGE_PATTERN = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._/:@-]*)?\})+$/;
|
|
30
|
+
/** A DNS name for a network alias: letters, digits, '-', '_' and '.'. */
|
|
31
|
+
export const NETWORK_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/;
|
|
26
32
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
27
33
|
export function validateName(label, name) {
|
|
28
34
|
return KEBAB.test(name)
|
package/dist/format.js
CHANGED
|
@@ -11,6 +11,8 @@ export function formatPlan(plan, writes) {
|
|
|
11
11
|
out.push(`Found ${plan.services.length - jobs} service(s) and ${jobs} job(s) in ${plan.root}`, `Read only: ${plan.files.join(", ")}. Lines below name what was not translated.`, "");
|
|
12
12
|
if (writes.projectConfig)
|
|
13
13
|
out.push(` .flisk/project.json [create Flisk project]`, "");
|
|
14
|
+
if (writes.projectConfigUpdate)
|
|
15
|
+
out.push(` .flisk/project.json [add composeProjectNames.${writes.stack}: "${writes.composeProjectName}", so an export keeps the Compose project name]`, "");
|
|
14
16
|
for (const service of plan.services) {
|
|
15
17
|
const item = writes.items.find((i) => i.service === service);
|
|
16
18
|
out.push(` ${service.name} (${service.kind}${service.dir ? `, ${service.dir}/` : ""})`);
|
package/dist/write.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { composeTemplateText, isComposeExpression, projectNameOf } from "./detectors/compose.js";
|
|
3
6
|
import { ImportError } from "./errors.js";
|
|
4
|
-
import { BYO_PORT_RANGE, defaultProjectConfig, findProjectRoot, IMAGE_PATTERN, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
|
|
7
|
+
import { BYO_PORT_RANGE, defaultProjectConfig, findProjectRoot, IMAGE_PATTERN, NETWORK_ALIAS, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
|
|
5
8
|
import { toResourceName } from "./merge.js";
|
|
6
9
|
import { usedManifestPorts } from "./plan.js";
|
|
7
10
|
/** Secret names the service schema accepts (`secrets.required`). */
|
|
@@ -9,28 +12,47 @@ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
|
9
12
|
/** Names that may hold a credential whatever their value looks like; these always stay secrets. */
|
|
10
13
|
const CREDENTIAL_NAME = /PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL|AUTH|HMAC|SALT|CERT|SESSION|COOKIE/i;
|
|
11
14
|
/**
|
|
12
|
-
* Whether a literal
|
|
13
|
-
* or
|
|
15
|
+
* Whether a literal value may hold a credential although its name does not say so: a URL or `user:pass@host` with a
|
|
16
|
+
* user or password, a query parameter named like a credential, a PEM block, a long random-looking token, or a value
|
|
17
|
+
* that says it is one (SECRET, TOKEN, PASSWORD).
|
|
14
18
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
/** Query parameters that carry a credential (`?token=`, `?api_key=`, `&sig=`); `authSource` and the like do not. */
|
|
20
|
+
const CREDENTIAL_QUERY = /token|secret|pass(word|wd)?$|api[_-]?key|^key$|^sig(nature)?$|access[_-]?key/i;
|
|
21
|
+
function looksSecret(value) {
|
|
22
|
+
if (/^[^\s:@/]+:[^\s@/]+@/.test(value) || value.includes("-----BEGIN"))
|
|
17
23
|
return true;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
if (/SECRET|TOKEN|PASSWORD|PASSWD/i.test(value))
|
|
25
|
+
return true;
|
|
26
|
+
if (/^[A-Za-z0-9+/_=.-]{32,}$/.test(value) && /[0-9]/.test(value) && /[A-Za-z]/.test(value))
|
|
27
|
+
return true;
|
|
28
|
+
if (!value.includes("://"))
|
|
29
|
+
return false;
|
|
21
30
|
try {
|
|
22
31
|
const url = new URL(value);
|
|
23
|
-
return (
|
|
24
|
-
|
|
25
|
-
!url.password &&
|
|
26
|
-
!url.search &&
|
|
27
|
-
!url.hash &&
|
|
28
|
-
/^[A-Za-z0-9._~/-]*$/.test(url.pathname));
|
|
32
|
+
return (Boolean(url.username || url.password) ||
|
|
33
|
+
[...url.searchParams.keys()].some((key) => CREDENTIAL_QUERY.test(key)));
|
|
29
34
|
}
|
|
30
35
|
catch {
|
|
31
|
-
return
|
|
36
|
+
return /:\/\/[^/\s]*@/.test(value);
|
|
32
37
|
}
|
|
33
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* `ARG` lines for the Compose variables in an image, so `FROM postgres:${POSTGRES_VERSION:-18}-alpine` can use them:
|
|
41
|
+
* Docker substitutes only build arguments declared before FROM. The default is the expression's.
|
|
42
|
+
*/
|
|
43
|
+
function buildArgLines(image) {
|
|
44
|
+
const args = new Map();
|
|
45
|
+
for (const m of image.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}/g))
|
|
46
|
+
if (!args.has(m[1]))
|
|
47
|
+
args.set(m[1], m[2]);
|
|
48
|
+
return [...args]
|
|
49
|
+
.map(([name, value]) => (value === undefined ? `ARG ${name}\n` : `ARG ${name}=${value}\n`))
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
/** `POSTGRES_PASSWORD_FILE=/run/secrets/pg`: the path of a mounted secret file, which is not a secret itself. */
|
|
53
|
+
function isSecretFilePath(key, value) {
|
|
54
|
+
return /_FILE$/.test(key) && /^\/[A-Za-z0-9_./-]+$/.test(value);
|
|
55
|
+
}
|
|
34
56
|
function readEnvKeys(path) {
|
|
35
57
|
if (!existsSync(path))
|
|
36
58
|
return new Set();
|
|
@@ -71,16 +93,27 @@ export function planWrites(plan, force) {
|
|
|
71
93
|
}
|
|
72
94
|
const servicesDir = join(projectRoot, "services");
|
|
73
95
|
const projectConfigPath = join(projectRoot, ".flisk", "project.json");
|
|
96
|
+
const composeProjectName = composeProjectNameOf(plan);
|
|
97
|
+
const composeNames = composeProjectName
|
|
98
|
+
? { composeProjectNames: { [stack]: composeProjectName } }
|
|
99
|
+
: {};
|
|
74
100
|
const projectConfig = existsSync(projectConfigPath)
|
|
75
101
|
? undefined
|
|
76
102
|
: {
|
|
77
103
|
path: projectConfigPath,
|
|
78
|
-
content: `${JSON.stringify(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
content: `${JSON.stringify({
|
|
105
|
+
...defaultProjectConfig(projectRoot, {
|
|
106
|
+
description: "Flisk proxy and imported Compose services",
|
|
107
|
+
enabledStacks: ["proxy", stack],
|
|
108
|
+
}),
|
|
109
|
+
...composeNames,
|
|
110
|
+
}, null, 2)}\n`,
|
|
82
111
|
};
|
|
83
|
-
const
|
|
112
|
+
const projectConfigUpdate = projectConfig
|
|
113
|
+
? undefined
|
|
114
|
+
: withComposeProjectName(projectConfigPath, stack, composeProjectName);
|
|
115
|
+
const envTarget = localEnvFile(projectRoot);
|
|
116
|
+
const envPath = envTarget.path;
|
|
84
117
|
const envKept = readEnvKeys(envPath);
|
|
85
118
|
const envAdd = {};
|
|
86
119
|
const used = usedManifestPorts(servicesDir);
|
|
@@ -96,15 +129,13 @@ export function planWrites(plan, force) {
|
|
|
96
129
|
: !service.image
|
|
97
130
|
? "Compose gives it neither `image` nor a usable `build`"
|
|
98
131
|
: !IMAGE_PATTERN.test(service.image)
|
|
99
|
-
? `image "${service.image}"
|
|
132
|
+
? `image "${service.image}" has characters Flisk does not accept (Compose variables may only be \${NAME}, \${NAME:-default} or \${NAME-default}); fix it in docker-compose.yml, or write this service.json by hand`
|
|
100
133
|
: undefined;
|
|
101
134
|
if (reason)
|
|
102
135
|
rejected.push({ name: service.name, reason });
|
|
103
136
|
return !reason;
|
|
104
137
|
});
|
|
105
138
|
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])));
|
|
108
139
|
for (const service of accepted) {
|
|
109
140
|
const path = resolve(servicesDir, stack, service.name, "service.json");
|
|
110
141
|
const exists = existsSync(path);
|
|
@@ -155,28 +186,39 @@ export function planWrites(plan, force) {
|
|
|
155
186
|
// Infrastructure images and one-shot jobs do not serve HTTP, so an assumed "/" would only
|
|
156
187
|
// fail the probe. Leave the path out; the runtime then skips the HTTP probe.
|
|
157
188
|
const nonHttp = service.kind === "job" || (service.image !== undefined && NON_HTTP_IMAGE.test(service.image));
|
|
158
|
-
const
|
|
159
|
-
|
|
189
|
+
const healthOff = service.healthCheck === false;
|
|
190
|
+
const healthCheckPath = isPrivate || nonHttp || healthOff ? undefined : (service.healthCheckPath ?? "/");
|
|
191
|
+
if (healthOff)
|
|
192
|
+
notes.push("healthCheck: false: Compose turns the healthcheck off");
|
|
193
|
+
else if (nonHttp && !isPrivate)
|
|
160
194
|
notes.push("no health path: the service does not serve HTTP (non-HTTP image or job)");
|
|
161
|
-
if (!isPrivate && !nonHttp && !service.healthCheckPath)
|
|
195
|
+
if (!isPrivate && !nonHttp && !healthOff && !service.healthCheckPath)
|
|
162
196
|
notes.push("health path / is assumed: no HTTP health path could be read from Compose. Traefik routes only while it answers 2xx or 3xx");
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// committed service.json `env` (Flisk 1.4.24)
|
|
197
|
+
// The Compose healthcheck stays as healthCheck.command (with its interval, timeout, retries and start period) even
|
|
198
|
+
// when an HTTP path was read too, so an exported docker-compose.yml keeps it and `condition: service_healthy` holds.
|
|
199
|
+
const healthCheck = service.healthCheck;
|
|
200
|
+
if (healthCheck && healthCheckPath && service.healthCheckPath)
|
|
201
|
+
notes.push(`healthCheck.command ${JSON.stringify(healthCheck.command)} is kept as Compose wrote it; Flisk probes with it instead of ${healthCheckPath}`);
|
|
202
|
+
// What goes into the committed service.json `env` (Flisk 1.4.24): a pure Compose expression (`${X}`, `${X:-default}`,
|
|
203
|
+
// `${A:-${B:-file}}`) as it is, whatever its name, since its default is already committed in the Compose file; and
|
|
204
|
+
// a literal whose name does not look like a credential and whose value does not hold one. Every other name is a
|
|
205
|
+
// secret: Flisk injects it from the gitignored .env, which gets the literal value.
|
|
169
206
|
const validNames = service.envKeys.filter((key) => ENV_NAME.test(key));
|
|
170
207
|
const env = {};
|
|
171
208
|
for (const key of validNames) {
|
|
172
|
-
if (CREDENTIAL_NAME.test(key))
|
|
173
|
-
continue;
|
|
174
209
|
const literal = service.env[key];
|
|
175
210
|
const reference = service.envRefs[key];
|
|
176
|
-
if (literal
|
|
211
|
+
if (literal === undefined && reference !== undefined) {
|
|
212
|
+
// A pure expression is committed whatever its name; one mixed with literal text only when that text, with
|
|
213
|
+
// the expressions taken out, could not be a credential (`mongodb://${USER}:${PASSWORD:-x}@db/app` can).
|
|
214
|
+
const text = composeTemplateText(reference) ?? "";
|
|
215
|
+
if (isComposeExpression(reference) || (!CREDENTIAL_NAME.test(key) && !looksSecret(text)))
|
|
216
|
+
env[key] = reference;
|
|
217
|
+
}
|
|
218
|
+
else if (literal !== undefined && isSecretFilePath(key, literal))
|
|
219
|
+
env[key] = literal;
|
|
220
|
+
else if (literal !== undefined && !CREDENTIAL_NAME.test(key) && !looksSecret(literal))
|
|
177
221
|
env[key] = literal;
|
|
178
|
-
else if (literal === undefined && reference !== undefined)
|
|
179
|
-
env[key] = reference;
|
|
180
222
|
}
|
|
181
223
|
const referenced = [
|
|
182
224
|
...new Set(Object.values(env).flatMap((value) => [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)?/g)]
|
|
@@ -213,7 +255,7 @@ export function planWrites(plan, force) {
|
|
|
213
255
|
if (wrapWithCommand) {
|
|
214
256
|
files.push({
|
|
215
257
|
path: resolve(dirname(path), "Dockerfile"),
|
|
216
|
-
content: `# From the Compose \`command\` of this service (flisk import). Flisk sets no container command, so it lives here.\
|
|
258
|
+
content: `# From the Compose \`command\` of this service (flisk import). Flisk sets no container command, so it lives here.\n${buildArgLines(service.image)}FROM ${service.image}\n${service.containerEntrypoint ? `ENTRYPOINT ${JSON.stringify(service.containerEntrypoint)}\n` : ""}${service.containerCommand ? `CMD ${JSON.stringify(service.containerCommand)}\n` : ""}`,
|
|
217
259
|
});
|
|
218
260
|
notes.push(`${service.containerEntrypoint ? "entrypoint" : "command"} ${JSON.stringify(service.containerEntrypoint ?? service.containerCommand)} is written to a Dockerfile on ${service.image}`);
|
|
219
261
|
}
|
|
@@ -234,6 +276,31 @@ export function planWrites(plan, force) {
|
|
|
234
276
|
: undefined;
|
|
235
277
|
if (service.sidecarOf && !sidecarOf)
|
|
236
278
|
notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
|
|
279
|
+
// A renamed service keeps its Compose name as a network alias, so `http://lychee_api:8000` or `DB_HOST=lychee_db`
|
|
280
|
+
// in another service still resolves. A sidecar has no network of its own, so it gets no aliases.
|
|
281
|
+
const renamed = service.originalName && service.originalName !== service.name
|
|
282
|
+
? service.originalName
|
|
283
|
+
: undefined;
|
|
284
|
+
const keepRenamed = renamed !== undefined && NETWORK_ALIAS.test(renamed);
|
|
285
|
+
if (renamed && !keepRenamed)
|
|
286
|
+
notes.push(`"${renamed}" is not kept as a network alias: it is not a DNS name`);
|
|
287
|
+
const aliases = [
|
|
288
|
+
...new Set([...(service.container?.networkAliases ?? []), ...(keepRenamed ? [renamed] : [])]),
|
|
289
|
+
].filter((alias) => alias !== service.name);
|
|
290
|
+
let container = service.container;
|
|
291
|
+
if (sidecarOf) {
|
|
292
|
+
if (aliases.length)
|
|
293
|
+
notes.push(`network aliases not written (${aliases.join(", ")}): a sidecar shares "${sidecarOf}"'s network`);
|
|
294
|
+
if (container?.networkAliases) {
|
|
295
|
+
const { networkAliases: _aliases, ...rest } = container;
|
|
296
|
+
container = Object.keys(rest).length ? rest : undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (aliases.length) {
|
|
300
|
+
container = { ...(container ?? {}), networkAliases: aliases };
|
|
301
|
+
if (keepRenamed)
|
|
302
|
+
notes.push(`network alias "${renamed}" keeps the Compose name, so other services that call it by that name still reach it`);
|
|
303
|
+
}
|
|
237
304
|
const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
|
|
238
305
|
if (sidecarOf && service.extraPorts?.length)
|
|
239
306
|
notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
|
|
@@ -262,6 +329,10 @@ export function planWrites(plan, force) {
|
|
|
262
329
|
return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
|
|
263
330
|
});
|
|
264
331
|
// env_file paths are relative to the service directory in service.json, and must stay inside the project.
|
|
332
|
+
// With the project .env tracked by git, secrets live in another local file, which Flisk then reads as envFile.
|
|
333
|
+
const localEnv = envTarget.git.tracked && secretNames.length
|
|
334
|
+
? [relative(dirname(path), envPath).split("\\").join("/")]
|
|
335
|
+
: [];
|
|
265
336
|
const envFile = (service.envFile ?? []).flatMap((file) => {
|
|
266
337
|
const absolute = resolve(plan.root, file);
|
|
267
338
|
const fromProject = relative(projectRoot, absolute).split("\\").join("/");
|
|
@@ -279,7 +350,7 @@ export function planWrites(plan, force) {
|
|
|
279
350
|
stack,
|
|
280
351
|
port,
|
|
281
352
|
...(healthCheckPath ? { healthCheckPath } : {}),
|
|
282
|
-
...(healthCheck ? { healthCheck } : {}),
|
|
353
|
+
...(healthCheck !== undefined ? { healthCheck } : {}),
|
|
283
354
|
...source,
|
|
284
355
|
...(isPrivate ? { exposeViaProxy: false } : {}),
|
|
285
356
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
@@ -288,10 +359,12 @@ export function planWrites(plan, force) {
|
|
|
288
359
|
: {}),
|
|
289
360
|
...(secretNames.length ? { secrets: { required: secretNames } } : {}),
|
|
290
361
|
...(service.restart ? { restart: service.restart } : {}),
|
|
291
|
-
...(
|
|
362
|
+
...(container ? { container } : {}),
|
|
292
363
|
...(service.models ? { models: service.models } : {}),
|
|
293
364
|
...(service.profiles ? { profiles: service.profiles } : {}),
|
|
294
|
-
...(envFile.length
|
|
365
|
+
...(envFile.length || localEnv.length
|
|
366
|
+
? { envFile: [...new Set([...envFile, ...localEnv])] }
|
|
367
|
+
: {}),
|
|
295
368
|
...(extraPorts.length ? { extraPorts } : {}),
|
|
296
369
|
...(sidecarOf ? { sidecarOf } : {}),
|
|
297
370
|
...(volumes.length
|
|
@@ -313,8 +386,10 @@ export function planWrites(plan, force) {
|
|
|
313
386
|
stack,
|
|
314
387
|
items,
|
|
315
388
|
projectConfig,
|
|
389
|
+
...(projectConfigUpdate ? { projectConfigUpdate } : {}),
|
|
390
|
+
...(composeProjectName ? { composeProjectName } : {}),
|
|
316
391
|
rejected,
|
|
317
|
-
env: { path: envPath, add: envAdd },
|
|
392
|
+
env: { path: envPath, add: envAdd, ...envTarget.git },
|
|
318
393
|
};
|
|
319
394
|
}
|
|
320
395
|
export function applyWrites(writes) {
|
|
@@ -323,6 +398,8 @@ export function applyWrites(writes) {
|
|
|
323
398
|
mkdirSync(dirname(writes.projectConfig.path), { recursive: true });
|
|
324
399
|
writeFileSync(writes.projectConfig.path, writes.projectConfig.content, { flag: "wx" });
|
|
325
400
|
}
|
|
401
|
+
if (writes.projectConfigUpdate)
|
|
402
|
+
writeFileSync(writes.projectConfigUpdate.path, writes.projectConfigUpdate.content);
|
|
326
403
|
for (const item of writes.items) {
|
|
327
404
|
if (item.status === "exists")
|
|
328
405
|
continue;
|
|
@@ -338,14 +415,88 @@ export function applyWrites(writes) {
|
|
|
338
415
|
const lines = keys.map((key) => envLine(key, writes.env.add[key]));
|
|
339
416
|
const block = `${existing && !existing.endsWith("\n") ? "\n" : ""}# From docker-compose.yml (flisk import)\n${lines.join("\n")}\n`;
|
|
340
417
|
writeFileSync(writes.env.path, existing + block);
|
|
341
|
-
|
|
418
|
+
mkdirSync(dirname(writes.env.path), { recursive: true });
|
|
419
|
+
if (writes.env.gitignore)
|
|
420
|
+
ensureGitignored(writes.projectRoot, writes.env.gitignore);
|
|
342
421
|
}
|
|
343
422
|
return written;
|
|
344
423
|
}
|
|
345
424
|
export function manifestRelPath(plan, item) {
|
|
346
425
|
return relative(plan.root, item.path).split("\\").join("/");
|
|
347
426
|
}
|
|
348
|
-
/**
|
|
427
|
+
/** The project name `docker compose` gives the imported Compose file: its `name:`, else its folder's name. */
|
|
428
|
+
function composeProjectNameOf(plan) {
|
|
429
|
+
const file = plan.files.find((f) => /^(docker-)?compose\.ya?ml$/.test(f));
|
|
430
|
+
if (!file)
|
|
431
|
+
return undefined;
|
|
432
|
+
const absPath = join(plan.root, file);
|
|
433
|
+
try {
|
|
434
|
+
return projectNameOf(parseYaml(readFileSync(absPath, "utf8"), { merge: true }), absPath);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** An existing project.json with `composeProjectNames.<stack>` set, or undefined when it has it already or is unreadable. */
|
|
441
|
+
function withComposeProjectName(path, stack, name) {
|
|
442
|
+
if (!name)
|
|
443
|
+
return undefined;
|
|
444
|
+
try {
|
|
445
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
446
|
+
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
447
|
+
return undefined;
|
|
448
|
+
const names = typeof config.composeProjectNames === "object" && config.composeProjectNames !== null
|
|
449
|
+
? config.composeProjectNames
|
|
450
|
+
: {};
|
|
451
|
+
if (names[stack] === name)
|
|
452
|
+
return undefined;
|
|
453
|
+
return {
|
|
454
|
+
path,
|
|
455
|
+
content: `${JSON.stringify({ ...config, composeProjectNames: { ...names, [stack]: name } }, null, 2)}\n`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Whether git in `root` tracks `file` (false outside a git repo, or without git). */
|
|
463
|
+
function gitTracks(root, file) {
|
|
464
|
+
try {
|
|
465
|
+
execFileSync("git", ["-C", root, "ls-files", "--error-unmatch", file], { stdio: "ignore" });
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** Whether git in `root` ignores `file` (false outside a git repo, or without git). */
|
|
473
|
+
function gitIgnores(root, file) {
|
|
474
|
+
try {
|
|
475
|
+
execFileSync("git", ["-C", root, "check-ignore", "-q", file], { stdio: "ignore" });
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The local file that gets values copied from docker-compose.yml: the project .env, never one git tracks. A tracked
|
|
484
|
+
* .env is left as it is, and not added to .gitignore.
|
|
485
|
+
*/
|
|
486
|
+
function localEnvFile(projectRoot) {
|
|
487
|
+
if (!gitTracks(projectRoot, ".env"))
|
|
488
|
+
return { path: join(projectRoot, ".env"), git: { gitignore: ".env" } };
|
|
489
|
+
if (gitIgnores(projectRoot, ".env.local") && !gitTracks(projectRoot, ".env.local"))
|
|
490
|
+
return { path: join(projectRoot, ".env.local"), git: { tracked: ".env" } };
|
|
491
|
+
return {
|
|
492
|
+
path: join(projectRoot, ".flisk", ".env.import"),
|
|
493
|
+
git: {
|
|
494
|
+
tracked: ".env",
|
|
495
|
+
...(gitIgnores(projectRoot, ".flisk/.env.import") ? {} : { gitignore: ".flisk/.env.import" }),
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
/** Keeps the local env file out of git: it now holds values copied from docker-compose.yml. */
|
|
349
500
|
function ensureGitignored(projectRoot, entry) {
|
|
350
501
|
const path = join(projectRoot, ".gitignore");
|
|
351
502
|
const content = existsSync(path) ? readFileSync(path, "utf8") : "";
|
package/package.json
CHANGED