@flisk-sh/flisk-import 0.11.0 → 0.13.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 +5 -3
- package/dist/detectors/compose.js +318 -26
- package/dist/flisk.js +8 -2
- package/dist/merge.js +2 -0
- package/dist/write.js +40 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,11 +13,13 @@ flisk import . --yes # write service.json files and project setup
|
|
|
13
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 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
|
+
- **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.
|
|
16
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.
|
|
17
|
-
- **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
|
+
- **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`.
|
|
18
20
|
- **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.
|
|
19
|
-
- **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 `healthcheck` as `healthCheck.command`, a published database port as `extraPorts`, and `restart: on-failure:N`
|
|
20
|
-
- **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
|
|
21
|
+
- **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`; only a string with `$` variables 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).
|
|
22
|
+
- **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).
|
|
21
23
|
- **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.
|
|
22
24
|
- **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.`).
|
|
23
25
|
|
|
@@ -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. */
|
|
@@ -47,9 +47,18 @@ const TRANSLATED_KEYS = new Set([
|
|
|
47
47
|
"command",
|
|
48
48
|
"entrypoint",
|
|
49
49
|
"models",
|
|
50
|
-
|
|
50
|
+
"profiles",
|
|
51
|
+
"env_file",
|
|
52
|
+
"tmpfs",
|
|
53
|
+
"deploy",
|
|
54
|
+
"stop_grace_period",
|
|
55
|
+
"extra_hosts",
|
|
56
|
+
"ulimits",
|
|
57
|
+
// Only `aliases` is read from networks: Flisk gives every service its own stack network.
|
|
51
58
|
"networks",
|
|
52
59
|
"expose",
|
|
60
|
+
// Flisk names every container itself; the plan says so.
|
|
61
|
+
"container_name",
|
|
53
62
|
]);
|
|
54
63
|
const CAPABILITY = /^[A-Z_]+$/;
|
|
55
64
|
const USER = /^[A-Za-z0-9_.:-]+$/;
|
|
@@ -58,6 +67,124 @@ const VOLUME_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
|
58
67
|
const CONTAINER_PATH = /^\/[A-Za-z0-9_./-]*$/;
|
|
59
68
|
const HOST_PATH = /^[A-Za-z0-9_./-]+$/;
|
|
60
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
|
+
}
|
|
61
188
|
/** Compose `shm_size` as a size string such as 256mb; a number is bytes. */
|
|
62
189
|
function shmSizeOf(value) {
|
|
63
190
|
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
@@ -120,8 +247,11 @@ function containerOf(raw) {
|
|
|
120
247
|
return { ...(Object.keys(container).length ? { container } : {}), dropped };
|
|
121
248
|
}
|
|
122
249
|
/** One published port: container port, host port when Compose names a literal one, and protocol. */
|
|
123
|
-
/**
|
|
124
|
-
|
|
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.:]+\})+$/;
|
|
125
255
|
/** The host address and port of a published port. Compose publishes on every interface unless an address is given. */
|
|
126
256
|
function hostSideOf(port, hostIp, hostPort) {
|
|
127
257
|
const hostNumber = hostPort && /^\d+$/.test(hostPort) ? Number.parseInt(hostPort, 10) : undefined;
|
|
@@ -294,12 +424,82 @@ function commandOf(command) {
|
|
|
294
424
|
return command.join(" ");
|
|
295
425
|
return undefined;
|
|
296
426
|
}
|
|
297
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* A Compose `command`/`entrypoint` string split into arguments the way Compose splits it (shell words: whitespace,
|
|
429
|
+
* '...' kept as it is, "..." with \" \\ \$ \` escapes, a backslash escaping the next character). Compose runs no shell.
|
|
430
|
+
* Undefined when the quotes or a trailing backslash do not balance.
|
|
431
|
+
*/
|
|
432
|
+
export function shellWords(text) {
|
|
433
|
+
const words = [];
|
|
434
|
+
let word = "";
|
|
435
|
+
let inWord = false;
|
|
436
|
+
for (let i = 0; i < text.length; i++) {
|
|
437
|
+
const ch = text[i];
|
|
438
|
+
if (ch === "'") {
|
|
439
|
+
const end = text.indexOf("'", i + 1);
|
|
440
|
+
if (end === -1)
|
|
441
|
+
return undefined;
|
|
442
|
+
word += text.slice(i + 1, end);
|
|
443
|
+
inWord = true;
|
|
444
|
+
i = end;
|
|
445
|
+
}
|
|
446
|
+
else if (ch === '"') {
|
|
447
|
+
inWord = true;
|
|
448
|
+
let closed = false;
|
|
449
|
+
for (i++; i < text.length; i++) {
|
|
450
|
+
const c = text[i];
|
|
451
|
+
if (c === '"') {
|
|
452
|
+
closed = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
if (c === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) {
|
|
456
|
+
word += text[i + 1];
|
|
457
|
+
i++;
|
|
458
|
+
}
|
|
459
|
+
else
|
|
460
|
+
word += c;
|
|
461
|
+
}
|
|
462
|
+
if (!closed)
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
else if (ch === "\\") {
|
|
466
|
+
if (i + 1 >= text.length)
|
|
467
|
+
return undefined;
|
|
468
|
+
word += text[i + 1];
|
|
469
|
+
inWord = true;
|
|
470
|
+
i++;
|
|
471
|
+
}
|
|
472
|
+
else if (/\s/.test(ch)) {
|
|
473
|
+
if (inWord)
|
|
474
|
+
words.push(word);
|
|
475
|
+
word = "";
|
|
476
|
+
inWord = false;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
word += ch;
|
|
480
|
+
inWord = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (inWord)
|
|
484
|
+
words.push(word);
|
|
485
|
+
return words;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Compose `command`/`entrypoint` as the argument vector the container runs. A list is kept as it is; a string is split
|
|
489
|
+
* into words as Compose splits it, with no shell, so an image ENTRYPOINT (minio) gets `server /export` as its arguments.
|
|
490
|
+
* Only a string that cannot be split, or that reads `$VAR` at run time, runs through `sh -c`; `shell` says why.
|
|
491
|
+
*/
|
|
298
492
|
function containerCommandOf(command) {
|
|
299
|
-
if (typeof command === "string" && command.trim())
|
|
300
|
-
|
|
493
|
+
if (typeof command === "string" && command.trim()) {
|
|
494
|
+
if (command.includes("$"))
|
|
495
|
+
return { argv: ["sh", "-c", command], shell: "uses $ variables" };
|
|
496
|
+
const words = shellWords(command);
|
|
497
|
+
return words?.length
|
|
498
|
+
? { argv: words }
|
|
499
|
+
: { argv: ["sh", "-c", command], shell: "has unbalanced quotes" };
|
|
500
|
+
}
|
|
301
501
|
if (Array.isArray(command) && command.length > 0 && command.every((c) => typeof c === "string"))
|
|
302
|
-
return command;
|
|
502
|
+
return { argv: command };
|
|
303
503
|
return undefined;
|
|
304
504
|
}
|
|
305
505
|
/** Seconds from a Compose duration such as `30s` or `2m`; undefined for anything it cannot read exactly. */
|
|
@@ -357,6 +557,67 @@ function modelsOf(uses, defined) {
|
|
|
357
557
|
}
|
|
358
558
|
return out.length ? out : undefined;
|
|
359
559
|
}
|
|
560
|
+
const PROFILE = /^[a-z0-9][a-z0-9-]*$/;
|
|
561
|
+
const TMPFS = /^\/[A-Za-z0-9_./-]*(:[a-z0-9=,_-]+)?$/;
|
|
562
|
+
function profilesOf(value) {
|
|
563
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
564
|
+
return undefined;
|
|
565
|
+
return value.every((p) => typeof p === "string" && PROFILE.test(p))
|
|
566
|
+
? [...new Set(value)]
|
|
567
|
+
: undefined;
|
|
568
|
+
}
|
|
569
|
+
/** Compose env_file entries (a string, strings, or { path, required }) as paths from the import root; undefined when one leaves it. */
|
|
570
|
+
function envFilesOf(value, composeDir) {
|
|
571
|
+
const entries = typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
|
|
572
|
+
const out = [];
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
const path = typeof entry === "string" ? entry : isObj(entry) ? entry.path : undefined;
|
|
575
|
+
if (typeof path !== "string" || isAbsolute(path) || path.includes("$"))
|
|
576
|
+
return undefined;
|
|
577
|
+
const resolved = normalize(join(composeDir, path));
|
|
578
|
+
if (resolved === ".." || resolved.startsWith("../") || !/^[A-Za-z0-9_./-]+$/.test(resolved))
|
|
579
|
+
return undefined;
|
|
580
|
+
out.push(resolved);
|
|
581
|
+
}
|
|
582
|
+
return out.length ? out : undefined;
|
|
583
|
+
}
|
|
584
|
+
/** tmpfs and deploy.resources.limits, and the parts that could not be carried over. */
|
|
585
|
+
function runtimeLimitsOf(raw) {
|
|
586
|
+
const dropped = [];
|
|
587
|
+
const out = {};
|
|
588
|
+
if (raw.tmpfs !== undefined) {
|
|
589
|
+
const list = typeof raw.tmpfs === "string" ? [raw.tmpfs] : Array.isArray(raw.tmpfs) ? raw.tmpfs : [];
|
|
590
|
+
const kept = list.filter((t) => typeof t === "string" && TMPFS.test(t));
|
|
591
|
+
if (kept.length !== list.length || list.length === 0)
|
|
592
|
+
dropped.push("tmpfs");
|
|
593
|
+
if (kept.length)
|
|
594
|
+
out.tmpfs = kept;
|
|
595
|
+
}
|
|
596
|
+
if (raw.deploy !== undefined) {
|
|
597
|
+
const deploy = isObj(raw.deploy) ? raw.deploy : {};
|
|
598
|
+
const resources = isObj(deploy.resources) ? deploy.resources : {};
|
|
599
|
+
const limits = isObj(resources.limits) ? resources.limits : {};
|
|
600
|
+
const cpus = typeof limits.cpus === "number" ? String(limits.cpus) : limits.cpus;
|
|
601
|
+
if (typeof cpus === "string" && /^[0-9]+(\.[0-9]{1,2})?$/.test(cpus) && Number(cpus) > 0)
|
|
602
|
+
out.cpus = Number.isInteger(Number(cpus)) && Number(cpus) >= 1 ? Number(cpus) : cpus;
|
|
603
|
+
else if (limits.cpus !== undefined)
|
|
604
|
+
dropped.push("deploy.resources.limits.cpus");
|
|
605
|
+
if (typeof limits.memory === "string" && /^[0-9]+([bBkKmMgG][bB]?)?$/.test(limits.memory))
|
|
606
|
+
out.memory = limits.memory;
|
|
607
|
+
else if (limits.memory !== undefined)
|
|
608
|
+
dropped.push("deploy.resources.limits.memory");
|
|
609
|
+
const other = [
|
|
610
|
+
...Object.keys(deploy)
|
|
611
|
+
.filter((k) => k !== "resources")
|
|
612
|
+
.map((k) => `deploy.${k}`),
|
|
613
|
+
...Object.keys(resources)
|
|
614
|
+
.filter((k) => k !== "limits")
|
|
615
|
+
.map((k) => `deploy.resources.${k}`),
|
|
616
|
+
];
|
|
617
|
+
dropped.push(...other);
|
|
618
|
+
}
|
|
619
|
+
return { ...out, dropped };
|
|
620
|
+
}
|
|
360
621
|
function healthPathOf(healthcheck) {
|
|
361
622
|
if (!isObj(healthcheck) || !Array.isArray(healthcheck.test))
|
|
362
623
|
return undefined;
|
|
@@ -423,10 +684,11 @@ registerDetector({
|
|
|
423
684
|
});
|
|
424
685
|
continue;
|
|
425
686
|
}
|
|
426
|
-
|
|
687
|
+
const profiles = profilesOf(raw.profiles);
|
|
688
|
+
if (raw.profiles !== undefined && !profiles) {
|
|
427
689
|
skips.push({
|
|
428
690
|
...base,
|
|
429
|
-
reason: `service "${name}" has profiles ${JSON.stringify(raw.profiles)}
|
|
691
|
+
reason: `service "${name}" has profiles ${JSON.stringify(raw.profiles)} that Flisk cannot name (lowercase letters, digits and '-'); not imported`,
|
|
430
692
|
});
|
|
431
693
|
continue;
|
|
432
694
|
}
|
|
@@ -486,8 +748,20 @@ registerDetector({
|
|
|
486
748
|
: [];
|
|
487
749
|
const command = commandOf(raw.command);
|
|
488
750
|
const models = modelsOf(raw.models, isObj(root) ? root.models : undefined);
|
|
489
|
-
const
|
|
490
|
-
const
|
|
751
|
+
const commandArgs = containerCommandOf(raw.command);
|
|
752
|
+
const entrypointArgs = containerCommandOf(raw.entrypoint);
|
|
753
|
+
const containerCommand = commandArgs?.argv;
|
|
754
|
+
const containerEntrypoint = entrypointArgs?.argv;
|
|
755
|
+
for (const [key, args] of [
|
|
756
|
+
["command", commandArgs],
|
|
757
|
+
["entrypoint", entrypointArgs],
|
|
758
|
+
]) {
|
|
759
|
+
if (args?.shell)
|
|
760
|
+
skips.push({
|
|
761
|
+
...base,
|
|
762
|
+
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} ${args.shell}; it runs through sh -c, which Compose would not do`,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
491
765
|
const healthProbe = healthProbeOf(raw.healthcheck);
|
|
492
766
|
const envKeys = envNames(raw.environment);
|
|
493
767
|
const env = envValues(raw.environment);
|
|
@@ -502,7 +776,31 @@ registerDetector({
|
|
|
502
776
|
reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
|
|
503
777
|
});
|
|
504
778
|
}
|
|
505
|
-
const { container, dropped } = containerOf(raw);
|
|
779
|
+
const { container: options, dropped } = containerOf(raw);
|
|
780
|
+
const limits = runtimeLimitsOf(raw);
|
|
781
|
+
const { dropped: limitsDropped, ...limitValues } = limits;
|
|
782
|
+
const network = networkOptionsOf(raw);
|
|
783
|
+
const merged = { ...(options ?? {}), ...limitValues, ...network.options };
|
|
784
|
+
const container = Object.keys(merged).length ? merged : undefined;
|
|
785
|
+
for (const reason of network.dropped) {
|
|
786
|
+
skips.push({ ...base, reason: `service "${name}" ${reason}` });
|
|
787
|
+
}
|
|
788
|
+
if (raw.container_name !== undefined) {
|
|
789
|
+
skips.push({
|
|
790
|
+
...base,
|
|
791
|
+
reason: `service "${name}" container_name ${JSON.stringify(raw.container_name)} is dropped: Flisk names containers itself; other services reach it by its service name`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
for (const key of limitsDropped) {
|
|
795
|
+
skips.push({ ...base, reason: `service "${name}" ${key} is not carried over; not used` });
|
|
796
|
+
}
|
|
797
|
+
const envFile = envFilesOf(raw.env_file, composeDir);
|
|
798
|
+
if (raw.env_file !== undefined && !envFile) {
|
|
799
|
+
skips.push({
|
|
800
|
+
...base,
|
|
801
|
+
reason: `service "${name}" env_file ${JSON.stringify(raw.env_file)} is outside the imported directory or uses Compose variables; not used`,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
506
804
|
for (const key of dropped) {
|
|
507
805
|
skips.push({
|
|
508
806
|
...base,
|
|
@@ -541,23 +839,15 @@ registerDetector({
|
|
|
541
839
|
reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
|
|
542
840
|
});
|
|
543
841
|
}
|
|
544
|
-
// `on-failure:5` caps the retries;
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const restart = restartName !== undefined && RESTART.has(restartName)
|
|
549
|
-
? restartName
|
|
842
|
+
// `on-failure:5` caps the retries at 5; service.json keeps the cap (1 to 100).
|
|
843
|
+
const restart = typeof raw.restart === "string" &&
|
|
844
|
+
(RESTART.has(raw.restart) || RESTART_LIMIT.test(raw.restart))
|
|
845
|
+
? raw.restart
|
|
550
846
|
: undefined;
|
|
551
|
-
if (restart && restartName !== raw.restart) {
|
|
552
|
-
skips.push({
|
|
553
|
-
...base,
|
|
554
|
-
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} keeps "${restart}"; the retry limit is not carried over`,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
847
|
if (raw.restart !== undefined && !restart) {
|
|
558
848
|
skips.push({
|
|
559
849
|
...base,
|
|
560
|
-
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
|
|
850
|
+
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`,
|
|
561
851
|
});
|
|
562
852
|
}
|
|
563
853
|
const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
|
|
@@ -581,6 +871,8 @@ registerDetector({
|
|
|
581
871
|
...(containerCommand ? { containerCommand } : {}),
|
|
582
872
|
...(containerEntrypoint ? { containerEntrypoint } : {}),
|
|
583
873
|
...(models ? { models } : {}),
|
|
874
|
+
...(profiles ? { profiles } : {}),
|
|
875
|
+
...(envFile ? { envFile } : {}),
|
|
584
876
|
...(healthProbe ? { healthCheck: healthProbe } : {}),
|
|
585
877
|
...(selectedPort !== undefined ? { port: selectedPort } : {}),
|
|
586
878
|
...(healthCheckPath ? { healthCheckPath } : {}),
|
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.26";
|
|
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.
|
|
@@ -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/merge.js
CHANGED
|
@@ -74,6 +74,8 @@ function mergeGroup(name, dir, members, resolve) {
|
|
|
74
74
|
...firstOf(ordered, "containerCommand"),
|
|
75
75
|
...firstOf(ordered, "containerEntrypoint"),
|
|
76
76
|
...firstOf(ordered, "models"),
|
|
77
|
+
...firstOf(ordered, "profiles"),
|
|
78
|
+
...firstOf(ordered, "envFile"),
|
|
77
79
|
...firstOf(ordered, "healthCheck"),
|
|
78
80
|
...(ordered.find((c) => c.sidecarOf)?.sidecarOf
|
|
79
81
|
? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
|
package/dist/write.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
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";
|
|
4
|
+
import { BYO_PORT_RANGE, defaultProjectConfig, findProjectRoot, IMAGE_PATTERN, NETWORK_ALIAS, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
|
|
5
5
|
import { toResourceName } from "./merge.js";
|
|
6
6
|
import { usedManifestPorts } from "./plan.js";
|
|
7
7
|
/** Secret names the service schema accepts (`secrets.required`). */
|
|
@@ -96,7 +96,7 @@ export function planWrites(plan, force) {
|
|
|
96
96
|
: !service.image
|
|
97
97
|
? "Compose gives it neither `image` nor a usable `build`"
|
|
98
98
|
: !IMAGE_PATTERN.test(service.image)
|
|
99
|
-
? `image "${service.image}"
|
|
99
|
+
? `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
100
|
: undefined;
|
|
101
101
|
if (reason)
|
|
102
102
|
rejected.push({ name: service.name, reason });
|
|
@@ -234,6 +234,31 @@ export function planWrites(plan, force) {
|
|
|
234
234
|
: undefined;
|
|
235
235
|
if (service.sidecarOf && !sidecarOf)
|
|
236
236
|
notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
|
|
237
|
+
// A renamed service keeps its Compose name as a network alias, so `http://lychee_api:8000` or `DB_HOST=lychee_db`
|
|
238
|
+
// in another service still resolves. A sidecar has no network of its own, so it gets no aliases.
|
|
239
|
+
const renamed = service.originalName && service.originalName !== service.name
|
|
240
|
+
? service.originalName
|
|
241
|
+
: undefined;
|
|
242
|
+
const keepRenamed = renamed !== undefined && NETWORK_ALIAS.test(renamed);
|
|
243
|
+
if (renamed && !keepRenamed)
|
|
244
|
+
notes.push(`"${renamed}" is not kept as a network alias: it is not a DNS name`);
|
|
245
|
+
const aliases = [
|
|
246
|
+
...new Set([...(service.container?.networkAliases ?? []), ...(keepRenamed ? [renamed] : [])]),
|
|
247
|
+
].filter((alias) => alias !== service.name);
|
|
248
|
+
let container = service.container;
|
|
249
|
+
if (sidecarOf) {
|
|
250
|
+
if (aliases.length)
|
|
251
|
+
notes.push(`network aliases not written (${aliases.join(", ")}): a sidecar shares "${sidecarOf}"'s network`);
|
|
252
|
+
if (container?.networkAliases) {
|
|
253
|
+
const { networkAliases: _aliases, ...rest } = container;
|
|
254
|
+
container = Object.keys(rest).length ? rest : undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (aliases.length) {
|
|
258
|
+
container = { ...(container ?? {}), networkAliases: aliases };
|
|
259
|
+
if (keepRenamed)
|
|
260
|
+
notes.push(`network alias "${renamed}" keeps the Compose name, so other services that call it by that name still reach it`);
|
|
261
|
+
}
|
|
237
262
|
const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
|
|
238
263
|
if (sidecarOf && service.extraPorts?.length)
|
|
239
264
|
notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
|
|
@@ -261,6 +286,16 @@ export function planWrites(plan, force) {
|
|
|
261
286
|
const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
|
|
262
287
|
return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
|
|
263
288
|
});
|
|
289
|
+
// env_file paths are relative to the service directory in service.json, and must stay inside the project.
|
|
290
|
+
const envFile = (service.envFile ?? []).flatMap((file) => {
|
|
291
|
+
const absolute = resolve(plan.root, file);
|
|
292
|
+
const fromProject = relative(projectRoot, absolute).split("\\").join("/");
|
|
293
|
+
if (fromProject === ".." || fromProject.startsWith("../")) {
|
|
294
|
+
notes.push(`env_file ${file} not written: it is outside the project`);
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
return [relative(serviceDir, absolute).split("\\").join("/")];
|
|
298
|
+
});
|
|
264
299
|
const manifest = {
|
|
265
300
|
$schema: SERVICE_MANIFEST_SCHEMA_URL,
|
|
266
301
|
schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
|
|
@@ -278,8 +313,10 @@ export function planWrites(plan, force) {
|
|
|
278
313
|
: {}),
|
|
279
314
|
...(secretNames.length ? { secrets: { required: secretNames } } : {}),
|
|
280
315
|
...(service.restart ? { restart: service.restart } : {}),
|
|
281
|
-
...(
|
|
316
|
+
...(container ? { container } : {}),
|
|
282
317
|
...(service.models ? { models: service.models } : {}),
|
|
318
|
+
...(service.profiles ? { profiles: service.profiles } : {}),
|
|
319
|
+
...(envFile.length ? { envFile } : {}),
|
|
283
320
|
...(extraPorts.length ? { extraPorts } : {}),
|
|
284
321
|
...(sidecarOf ? { sidecarOf } : {}),
|
|
285
322
|
...(volumes.length
|
package/package.json
CHANGED