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 +4 -3
- package/dist/detectors/compose.js +223 -4
- package/dist/flisk.js +1 -1
- package/dist/merge.js +14 -0
- package/dist/write.js +90 -2
- package/package.json +1 -1
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
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
16
|
+
- **Runtime keys:** named volumes and bind mounts inside the project (`volumes`), `cap_add`, `cap_drop`, `init`, `read_only`, `user`, `security_opt` and `shm_size` (`container`), extra published ports (`extraPorts`, on 127.0.0.1 only), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
|
|
17
|
+
- **Environment:** values that cannot be secrets go into the committed `service.json` `env`: numbers, `true`/`false`, another imported service's address without a user, password or query (`http://invidious:3000`, `redis:6379`), and pure `${VAR}` references. Every other name, and any name that looks like a credential (`PASSWORD`, `TOKEN`, `KEY`, ...), goes into `secrets.required`, with its literal value copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept.
|
|
18
|
+
- **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
|
|
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.
|
|
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
|
-
//
|
|
137
|
-
|
|
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
|
}
|