flisk-import 0.4.1 → 0.6.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
@@ -13,9 +13,10 @@ flisk import . --yes # write service.json files and project setup
13
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.
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
+ - **Drops, and says so:** `command`, `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.
19
+ - **Does not start anything.** Run `flisk up` with Flisk core **1.4.24 or later**. The importer does not check the core version.
19
20
  - **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
21
 
21
22
  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,155 @@ 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",
35
47
  ]);
48
+ const CAPABILITY = /^[A-Z_]+$/;
49
+ const USER = /^[A-Za-z0-9_.:-]+$/;
50
+ const SECURITY_OPT = /^[A-Za-z0-9_.:=/-]+$/;
51
+ const VOLUME_NAME = /^[a-z0-9][a-z0-9_-]*$/;
52
+ const CONTAINER_PATH = /^\/[A-Za-z0-9_./-]*$/;
53
+ const HOST_PATH = /^[A-Za-z0-9_./-]+$/;
54
+ const RESTART = new Set(["no", "on-failure", "unless-stopped", "always"]);
55
+ /** Compose `shm_size` as a size string such as 256mb; a number is bytes. */
56
+ function shmSizeOf(value) {
57
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
58
+ return `${value}b`;
59
+ if (typeof value === "string" && /^[0-9]+([bkmg]b?)?$/i.test(value))
60
+ return value;
61
+ return undefined;
62
+ }
63
+ /** `container` for service.json, and the values that could not be carried over. */
64
+ function containerOf(raw) {
65
+ const container = {};
66
+ const dropped = [];
67
+ for (const [key, field] of [
68
+ ["cap_add", "capAdd"],
69
+ ["cap_drop", "capDrop"],
70
+ ]) {
71
+ if (raw[key] === undefined)
72
+ continue;
73
+ const caps = Array.isArray(raw[key]) ? raw[key] : [];
74
+ const kept = caps.filter((c) => typeof c === "string" && CAPABILITY.test(c));
75
+ if (kept.length !== caps.length || caps.length === 0)
76
+ dropped.push(key);
77
+ if (kept.length)
78
+ container[field] = kept;
79
+ }
80
+ if (raw.init !== undefined) {
81
+ if (typeof raw.init === "boolean")
82
+ container.init = raw.init;
83
+ else
84
+ dropped.push("init");
85
+ }
86
+ if (raw.read_only !== undefined) {
87
+ if (typeof raw.read_only === "boolean")
88
+ container.readOnly = raw.read_only;
89
+ else
90
+ dropped.push("read_only");
91
+ }
92
+ if (raw.user !== undefined) {
93
+ const user = typeof raw.user === "number" ? String(raw.user) : raw.user;
94
+ if (typeof user === "string" && USER.test(user))
95
+ container.user = user;
96
+ else
97
+ dropped.push("user");
98
+ }
99
+ if (raw.security_opt !== undefined) {
100
+ const opts = Array.isArray(raw.security_opt) ? raw.security_opt : [];
101
+ const kept = opts.filter((o) => typeof o === "string" && SECURITY_OPT.test(o));
102
+ if (kept.length !== opts.length || opts.length === 0)
103
+ dropped.push("security_opt");
104
+ if (kept.length)
105
+ container.securityOpt = kept;
106
+ }
107
+ if (raw.shm_size !== undefined) {
108
+ const size = shmSizeOf(raw.shm_size);
109
+ if (size)
110
+ container.shmSize = size;
111
+ else
112
+ dropped.push("shm_size");
113
+ }
114
+ return { ...(Object.keys(container).length ? { container } : {}), dropped };
115
+ }
116
+ /** One published port: container port, host port when Compose names a literal one, and protocol. */
117
+ function publishedPort(entry) {
118
+ const port = containerPort(entry);
119
+ if (port === undefined)
120
+ return undefined;
121
+ let host;
122
+ let protocol = "tcp";
123
+ if (isObj(entry)) {
124
+ const published = typeof entry.published === "string" ? Number(entry.published) : entry.published;
125
+ if (typeof published === "number" && Number.isInteger(published))
126
+ host = published;
127
+ if (entry.protocol === "udp")
128
+ protocol = "udp";
129
+ }
130
+ else if (typeof entry === "string") {
131
+ const [mapping, proto] = entry.split("/");
132
+ if (proto === "udp")
133
+ protocol = "udp";
134
+ const parts = (mapping ?? "").split(":");
135
+ const hostPart = parts.length >= 2 ? parts[parts.length - 2] : undefined;
136
+ if (hostPart && /^\d+$/.test(hostPart))
137
+ host = Number.parseInt(hostPart, 10);
138
+ }
139
+ return {
140
+ port,
141
+ ...(host !== undefined && host >= 1 && host <= 65535 && host !== port
142
+ ? { hostPort: host }
143
+ : {}),
144
+ ...(protocol === "udp" ? { protocol } : {}),
145
+ };
146
+ }
147
+ /** One `volumes:` entry. Bind sources are resolved against the Compose file's directory. */
148
+ function volumeOf(entry, composeDir) {
149
+ let source;
150
+ let target;
151
+ let readOnly = false;
152
+ let type;
153
+ if (typeof entry === "string") {
154
+ const parts = entry.split(":");
155
+ if (parts.length < 2 || parts.length > 3)
156
+ return undefined;
157
+ [source, target] = parts;
158
+ readOnly = parts[2] === "ro";
159
+ if (parts[2] !== undefined && parts[2] !== "ro" && parts[2] !== "rw")
160
+ return undefined;
161
+ }
162
+ else if (isObj(entry)) {
163
+ ({ source, target, type } = entry);
164
+ readOnly = entry.read_only === true;
165
+ if (type !== undefined && type !== "volume" && type !== "bind")
166
+ return undefined;
167
+ }
168
+ else {
169
+ return undefined;
170
+ }
171
+ if (typeof source !== "string" || typeof target !== "string" || !CONTAINER_PATH.test(target))
172
+ return undefined;
173
+ const bind = type === "bind" || source.startsWith(".") || source.startsWith("/");
174
+ if (!bind) {
175
+ return VOLUME_NAME.test(source)
176
+ ? { source, target, ...(readOnly ? { readOnly } : {}), bind }
177
+ : undefined;
178
+ }
179
+ if (isAbsolute(source) || !HOST_PATH.test(source))
180
+ return undefined;
181
+ const resolved = normalize(join(composeDir, source));
182
+ if (resolved === ".." || resolved.startsWith("../"))
183
+ return undefined;
184
+ return { source: resolved, target, ...(readOnly ? { readOnly } : {}), bind };
185
+ }
36
186
  function envNames(environment) {
37
187
  if (Array.isArray(environment)) {
38
188
  return environment
@@ -61,6 +211,24 @@ function envValues(environment) {
61
211
  }
62
212
  return out;
63
213
  }
214
+ /** A value that is nothing but Compose interpolation, such as `${URL}` or `${PORT:-80}`. Holds no secret itself. */
215
+ const ONLY_REFERENCES = /^(\$\{[A-Za-z_][A-Za-z0-9_]*(?::?[-?+][^}\n]*)?\})+$/;
216
+ /** Values that are only `${VAR}` references, which service.json `env` can keep as they are (Flisk 1.4.24). */
217
+ function envReferences(environment) {
218
+ const pairs = Array.isArray(environment)
219
+ ? environment
220
+ .filter((e) => typeof e === "string" && e.includes("="))
221
+ .map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)])
222
+ : isObj(environment)
223
+ ? Object.entries(environment)
224
+ : [];
225
+ const out = {};
226
+ for (const [key, raw] of pairs) {
227
+ if (typeof raw === "string" && ONLY_REFERENCES.test(raw))
228
+ out[key] = raw;
229
+ }
230
+ return out;
231
+ }
64
232
  function commandOf(command) {
65
233
  if (typeof command === "string")
66
234
  return command;
@@ -180,13 +348,13 @@ registerDetector({
180
348
  if (ports.length > 1 && selectedPort === undefined) {
181
349
  skips.push({
182
350
  ...base,
183
- reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified`,
351
+ 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
352
  });
185
353
  }
186
354
  else if (ports.length > 1) {
187
355
  skips.push({
188
356
  ...base,
189
- reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort}`,
357
+ 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
358
  });
191
359
  }
192
360
  const dependsOn = Array.isArray(raw.depends_on)
@@ -197,6 +365,7 @@ registerDetector({
197
365
  const command = commandOf(raw.command);
198
366
  const envKeys = envNames(raw.environment);
199
367
  const env = envValues(raw.environment);
368
+ const envRefs = envReferences(raw.environment);
200
369
  const foundHealthPath = healthPathOf(raw.healthcheck);
201
370
  const healthCheckPath = foundHealthPath && HEALTH_CHECK_PATH_PATTERN.test(foundHealthPath)
202
371
  ? foundHealthPath
@@ -207,6 +376,50 @@ registerDetector({
207
376
  reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
208
377
  });
209
378
  }
379
+ const { container, dropped } = containerOf(raw);
380
+ for (const key of dropped) {
381
+ skips.push({
382
+ ...base,
383
+ reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} has values Flisk does not accept; not used`,
384
+ });
385
+ }
386
+ // Every published port Flisk does not route stays published, on 127.0.0.1 (service.json extraPorts).
387
+ const extraPorts = (Array.isArray(raw.ports) ? raw.ports : [])
388
+ .map(publishedPort)
389
+ .filter((p) => p !== undefined && p.port !== selectedPort);
390
+ let sidecarOf;
391
+ if (raw.network_mode !== undefined) {
392
+ const mode = typeof raw.network_mode === "string" ? raw.network_mode : "";
393
+ if (mode.startsWith("service:") && mode.length > "service:".length) {
394
+ sidecarOf = mode.slice("service:".length);
395
+ }
396
+ else {
397
+ skips.push({
398
+ ...base,
399
+ reason: `service "${name}" network_mode "${mode}" is not translated; Flisk only shares another service's network (service:<name>)`,
400
+ });
401
+ }
402
+ }
403
+ const volumes = [];
404
+ for (const entry of Array.isArray(raw.volumes) ? raw.volumes : []) {
405
+ const volume = volumeOf(entry, composeDir);
406
+ if (volume)
407
+ volumes.push(volume);
408
+ else
409
+ skips.push({
410
+ ...base,
411
+ reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
412
+ });
413
+ }
414
+ const restart = typeof raw.restart === "string" && RESTART.has(raw.restart)
415
+ ? raw.restart
416
+ : undefined;
417
+ if (raw.restart !== undefined && !restart) {
418
+ skips.push({
419
+ ...base,
420
+ reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
421
+ });
422
+ }
210
423
  const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
211
424
  if (untranslated.length) {
212
425
  skips.push({
@@ -230,7 +443,13 @@ registerDetector({
230
443
  exposeViaProxy,
231
444
  ...(envKeys.length ? { envKeys } : {}),
232
445
  ...(Object.keys(env).length ? { env } : {}),
446
+ ...(Object.keys(envRefs).length ? { envRefs } : {}),
233
447
  ...(dependsOn.length ? { dependsOn } : {}),
448
+ ...(container ? { container } : {}),
449
+ ...(extraPorts.length ? { extraPorts } : {}),
450
+ ...(sidecarOf ? { sidecarOf } : {}),
451
+ ...(volumes.length ? { volumes } : {}),
452
+ ...(restart ? { restart } : {}),
234
453
  confidence: "high",
235
454
  sources: [base],
236
455
  });
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,17 @@ 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
+ ...(ordered.find((c) => c.sidecarOf)?.sidecarOf
75
+ ? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
76
+ : {}),
64
77
  procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
65
78
  conflicts: [],
66
79
  sources: [],
@@ -104,6 +117,7 @@ export function mergeCandidates(candidates, rootName) {
104
117
  const table = assignResourceNames([
105
118
  ...candidates.flatMap((c) => (c.name === undefined ? [] : [c.name])),
106
119
  ...candidates.flatMap((c) => c.dependsOn ?? []),
120
+ ...candidates.flatMap((c) => (c.sidecarOf ? [c.sidecarOf] : [])),
107
121
  ...candidates.flatMap((c) => (c.name === undefined ? [basename(c.dir) || rootName] : [])),
108
122
  ]);
109
123
  const resolve = (raw) => table.get(raw) ?? toResourceName(raw);
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,28 @@ 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
+ // Values that cannot be secrets (numbers, booleans, another service's address, pure ${VAR} references) go into the
164
+ // committed service.json `env` (Flisk 1.4.24). Every other name is a secret: Flisk injects it from the gitignored .env.
165
+ const validNames = service.envKeys.filter((key) => ENV_NAME.test(key));
166
+ const env = {};
167
+ for (const key of validNames) {
168
+ if (CREDENTIAL_NAME.test(key))
169
+ continue;
170
+ const literal = service.env[key];
171
+ const reference = service.envRefs[key];
172
+ if (literal !== undefined && isPlainValue(literal, serviceHosts))
173
+ env[key] = literal;
174
+ else if (literal === undefined && reference !== undefined)
175
+ env[key] = reference;
176
+ }
177
+ const referenced = [
178
+ ...new Set(Object.values(env).flatMap((value) => [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)?/g)]
179
+ .filter((m) => !m[2])
180
+ .map((m) => m[1]))),
181
+ ].filter((name) => !envKept.has(name) && !(name in envAdd));
182
+ if (referenced.length)
183
+ notes.push(`env reads ${referenced.join(", ")} from the project .env; add them there`);
184
+ const secretNames = validNames.filter((key) => !(key in env));
138
185
  const skippedNames = service.envKeys.filter((key) => !ENV_NAME.test(key));
139
186
  if (skippedNames.length)
140
187
  notes.push(`environment names not written (Flisk accepts A-Z, 0-9 and _): ${skippedNames.join(", ")}`);
@@ -160,6 +207,31 @@ export function planWrites(plan, force) {
160
207
  dockerfile: service.dockerfile,
161
208
  }
162
209
  : { image: service.image };
210
+ // A sidecar shares another written service's network; one whose host is not written stays a normal service.
211
+ const sidecarOf = service.sidecarOf && service.sidecarOf !== service.name && names.has(service.sidecarOf)
212
+ ? service.sidecarOf
213
+ : undefined;
214
+ if (service.sidecarOf && !sidecarOf)
215
+ notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
216
+ const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
217
+ if (sidecarOf && service.extraPorts?.length)
218
+ notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
219
+ if (extraPorts.length)
220
+ notes.push(`published on 127.0.0.1 only: ${extraPorts.map((p) => `${p.hostPort ?? p.port}->${p.port}/${p.protocol ?? "tcp"}`).join(", ")}`);
221
+ // Bind mounts are relative to the service directory in service.json, and must stay inside the project.
222
+ const serviceDir = dirname(path);
223
+ const volumes = (service.volumes ?? []).flatMap((volume) => {
224
+ if (!volume.bind)
225
+ return [volume];
226
+ const absolute = resolve(plan.root, volume.source);
227
+ const fromProject = relative(projectRoot, absolute).split("\\").join("/");
228
+ if (fromProject === ".." || fromProject.startsWith("../")) {
229
+ notes.push(`volume ${volume.source}:${volume.target} not written: it is outside the project`);
230
+ return [];
231
+ }
232
+ const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
233
+ return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
234
+ });
163
235
  const manifest = {
164
236
  $schema: SERVICE_MANIFEST_SCHEMA_URL,
165
237
  schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
@@ -171,7 +243,23 @@ export function planWrites(plan, force) {
171
243
  ...source,
172
244
  ...(isPrivate ? { exposeViaProxy: false } : {}),
173
245
  ...(dependsOn.length ? { dependsOn } : {}),
246
+ ...(Object.keys(env).length
247
+ ? { env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) }
248
+ : {}),
174
249
  ...(secretNames.length ? { secrets: { required: secretNames } } : {}),
250
+ ...(service.restart ? { restart: service.restart } : {}),
251
+ ...(service.container ? { container: service.container } : {}),
252
+ ...(extraPorts.length ? { extraPorts } : {}),
253
+ ...(sidecarOf ? { sidecarOf } : {}),
254
+ ...(volumes.length
255
+ ? {
256
+ volumes: volumes.map(({ source, target, readOnly }) => ({
257
+ source,
258
+ target,
259
+ ...(readOnly ? { readOnly } : {}),
260
+ })),
261
+ }
262
+ : {}),
175
263
  };
176
264
  items.push({ service, path, manifest, status, notes });
177
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flisk-import",
3
- "version": "0.4.1",
3
+ "version": "0.6.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",