@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 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`. Not `compose.yaml`, override files, nested Compose files, Dockerfiles on their own, `package.json`, or Procfiles.
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
- - **Environment:** names go into `service.json` as `secrets.required`; literal values are copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept, and `${VAR}` values are left for you.
17
- - **Drops, and says so:** volumes, `command`, `env_file` and every other untranslated key; services with `profiles`, `extends`, an out-of-tree build or a `${VAR}` image.
18
- - **Does not start anything.** Run `flisk up` with Flisk core **1.4.11 or later**. The importer does not check the core version.
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 volumes, command overrides, env files and the rest.
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) => relPath === "docker-compose.yml",
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.11";
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; none of them is read. */
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 docker-compose.yml. This function writes nothing. */
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
- if (!isFile(composePath)) {
31
- const others = OTHER_COMPOSE_FILES.filter(isFile);
32
- throw new ImportError(`No root docker-compose.yml found in ${root}${others.length ? ` (found ${others.join(", ")})` : ""}; flisk import reads only that file`, others.length
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 OTHER_COMPOSE_FILES.filter(isFile)) {
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 ${COMPOSE_FILE}, so what it runs may differ from this plan`,
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 ${COMPOSE_FILE}, flisk import does not`,
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
- // Names go into service.json as secrets, so Flisk injects them; values go to the gitignored project .env.
137
- const secretNames = service.envKeys.filter((key) => ENV_NAME.test(key));
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
- const source = service.dockerfile
158
- ? {
159
- buildContext: relative(dirname(path), resolve(plan.root, service.dir)).split("\\").join("/") || ".",
160
- dockerfile: service.dockerfile,
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
- : { image: service.image };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk-sh/flisk-import",
3
- "version": "0.4.1",
3
+ "version": "0.7.0",
4
4
  "description": "Import a repository's root docker-compose.yml into Flisk service.json files, or register its Tiltfile.",
5
5
  "type": "module",
6
6
  "license": "MIT",