flisk-import 0.4.0 → 0.5.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 +203 -4
- package/dist/flisk.js +50 -1
- package/dist/merge.js +13 -0
- package/dist/tiltfile.js +39 -7
- package/dist/write.js +38 -0
- 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
|
+
- **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`.
|
|
16
17
|
- **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:**
|
|
18
|
-
- **Does not start anything.** Run `flisk up` with Flisk core **1.4.
|
|
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.
|
|
@@ -32,7 +33,7 @@ flisk import tiltfile dev/Tiltfile --yes # a named file, or a folder that holds
|
|
|
32
33
|
|
|
33
34
|
- **Path:** `[path]` defaults to `Tiltfile`; a folder means `<folder>/Tiltfile`. It is resolved from the current directory. The project root is the nearest folder with `.flisk/project.json`, else the current directory.
|
|
34
35
|
- **Writes:** only `.flisk/project.json`, with `"orchestration": { "source": "tiltfile", "path": "<path from the project root>" }`. Every other key is kept. Without a `project.json`, it creates the same minimal one as the Compose import, with only the proxy stack. It never edits the Tiltfile.
|
|
35
|
-
- **Refuses (exit 1):** a missing Tiltfile, a path inside `.flisk
|
|
36
|
+
- **Refuses (exit 1):** a missing Tiltfile, a path inside `.flisk/` (also through a symlink), a path with a quote or control character, a `project.json` that is not a JSON object or lacks the fields `flisk up` needs, and a `project.json` that already sets `orchestration` (pass `--force` to replace it).
|
|
36
37
|
- **Exit codes:** the same as the Compose import: `0` for `--dry-run` or a write, `1` for a refusal, `2` when the prompt is declined or closed (`Nothing written.`).
|
|
37
38
|
- **Needs** a Flisk core that runs an imported Tiltfile ([flisk#2849](https://github.com/flisk-sh/flisk/pull/2849)). The importer does not check the core version.
|
|
38
39
|
|
|
@@ -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
|
|
@@ -180,13 +330,13 @@ registerDetector({
|
|
|
180
330
|
if (ports.length > 1 && selectedPort === undefined) {
|
|
181
331
|
skips.push({
|
|
182
332
|
...base,
|
|
183
|
-
reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified`,
|
|
333
|
+
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
334
|
});
|
|
185
335
|
}
|
|
186
336
|
else if (ports.length > 1) {
|
|
187
337
|
skips.push({
|
|
188
338
|
...base,
|
|
189
|
-
reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort}`,
|
|
339
|
+
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
340
|
});
|
|
191
341
|
}
|
|
192
342
|
const dependsOn = Array.isArray(raw.depends_on)
|
|
@@ -207,6 +357,50 @@ registerDetector({
|
|
|
207
357
|
reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
|
|
208
358
|
});
|
|
209
359
|
}
|
|
360
|
+
const { container, dropped } = containerOf(raw);
|
|
361
|
+
for (const key of dropped) {
|
|
362
|
+
skips.push({
|
|
363
|
+
...base,
|
|
364
|
+
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} has values Flisk does not accept; not used`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Every published port Flisk does not route stays published, on 127.0.0.1 (service.json extraPorts).
|
|
368
|
+
const extraPorts = (Array.isArray(raw.ports) ? raw.ports : [])
|
|
369
|
+
.map(publishedPort)
|
|
370
|
+
.filter((p) => p !== undefined && p.port !== selectedPort);
|
|
371
|
+
let sidecarOf;
|
|
372
|
+
if (raw.network_mode !== undefined) {
|
|
373
|
+
const mode = typeof raw.network_mode === "string" ? raw.network_mode : "";
|
|
374
|
+
if (mode.startsWith("service:") && mode.length > "service:".length) {
|
|
375
|
+
sidecarOf = mode.slice("service:".length);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
skips.push({
|
|
379
|
+
...base,
|
|
380
|
+
reason: `service "${name}" network_mode "${mode}" is not translated; Flisk only shares another service's network (service:<name>)`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const volumes = [];
|
|
385
|
+
for (const entry of Array.isArray(raw.volumes) ? raw.volumes : []) {
|
|
386
|
+
const volume = volumeOf(entry, composeDir);
|
|
387
|
+
if (volume)
|
|
388
|
+
volumes.push(volume);
|
|
389
|
+
else
|
|
390
|
+
skips.push({
|
|
391
|
+
...base,
|
|
392
|
+
reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const restart = typeof raw.restart === "string" && RESTART.has(raw.restart)
|
|
396
|
+
? raw.restart
|
|
397
|
+
: undefined;
|
|
398
|
+
if (raw.restart !== undefined && !restart) {
|
|
399
|
+
skips.push({
|
|
400
|
+
...base,
|
|
401
|
+
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
210
404
|
const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
|
|
211
405
|
if (untranslated.length) {
|
|
212
406
|
skips.push({
|
|
@@ -231,6 +425,11 @@ registerDetector({
|
|
|
231
425
|
...(envKeys.length ? { envKeys } : {}),
|
|
232
426
|
...(Object.keys(env).length ? { env } : {}),
|
|
233
427
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
428
|
+
...(container ? { container } : {}),
|
|
429
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
430
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
431
|
+
...(volumes.length ? { volumes } : {}),
|
|
432
|
+
...(restart ? { restart } : {}),
|
|
234
433
|
confidence: "high",
|
|
235
434
|
sources: [base],
|
|
236
435
|
});
|
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.
|
|
@@ -70,3 +70,52 @@ export function defaultProjectConfig(projectRoot, preAlpha) {
|
|
|
70
70
|
overrides: {},
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
74
|
+
const PHASES = ["pre_alpha", "alpha", "beta", "out_of_scope"];
|
|
75
|
+
const OPTIONAL_INFRA = ["monitoring", "elk", "debezium", "golden_image", "verdaccio"];
|
|
76
|
+
/**
|
|
77
|
+
* The fields of an existing `.flisk/project.json` that Flisk core would refuse, missing or of the wrong type, as dotted
|
|
78
|
+
* names such as `project.name`. A whole object that is absent is named once (`optional_infra`), not field by field.
|
|
79
|
+
* Empty when `flisk up` can read the file. A copy of core's `normalizeProjectConfig` (flisk
|
|
80
|
+
* `cli/src/generator/template-engine.ts`), so it can drift from flisk.
|
|
81
|
+
*/
|
|
82
|
+
export function missingProjectFields(config) {
|
|
83
|
+
const missing = [];
|
|
84
|
+
if (typeof config.version !== "string")
|
|
85
|
+
missing.push("version");
|
|
86
|
+
if (!isObject(config.project))
|
|
87
|
+
missing.push("project");
|
|
88
|
+
else
|
|
89
|
+
for (const key of ["name", "version"])
|
|
90
|
+
if (typeof config.project[key] !== "string")
|
|
91
|
+
missing.push(`project.${key}`);
|
|
92
|
+
// Core also reads the older `stacks` shape, with `services` in place of `enabledStacks`.
|
|
93
|
+
const phaseGaps = (value, label, field) => {
|
|
94
|
+
if (!isObject(value))
|
|
95
|
+
return [label];
|
|
96
|
+
return PHASES.filter((phase) => {
|
|
97
|
+
const entry = value[phase];
|
|
98
|
+
return !isObject(entry) || !Array.isArray(entry[field]);
|
|
99
|
+
}).map((phase) => `${label}.${phase}.${field}`);
|
|
100
|
+
};
|
|
101
|
+
const phases = phaseGaps(config.phases, "phases", "enabledStacks");
|
|
102
|
+
if (phases.length && phaseGaps(config.stacks, "stacks", "services").length)
|
|
103
|
+
missing.push(...phases);
|
|
104
|
+
if (!isObject(config.optional_infra))
|
|
105
|
+
missing.push("optional_infra");
|
|
106
|
+
else {
|
|
107
|
+
const infra = config.optional_infra;
|
|
108
|
+
for (const key of OPTIONAL_INFRA)
|
|
109
|
+
if (typeof infra[key] !== "boolean")
|
|
110
|
+
missing.push(`optional_infra.${key}`);
|
|
111
|
+
}
|
|
112
|
+
if (!isObject(config.discovery))
|
|
113
|
+
missing.push("discovery");
|
|
114
|
+
else if (!Array.isArray(config.discovery.paths))
|
|
115
|
+
missing.push("discovery.paths");
|
|
116
|
+
if (config.capabilities !== undefined &&
|
|
117
|
+
!(isObject(config.capabilities) &&
|
|
118
|
+
Object.values(config.capabilities).every((flag) => typeof flag === "boolean")))
|
|
119
|
+
missing.push("capabilities");
|
|
120
|
+
return missing;
|
|
121
|
+
}
|
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;
|
|
@@ -61,6 +65,14 @@ function mergeGroup(name, dir, members, resolve) {
|
|
|
61
65
|
env: Object.assign({}, ...[...ordered].reverse().map((c) => c.env ?? {})),
|
|
62
66
|
dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
|
|
63
67
|
exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
|
|
68
|
+
// Only Compose states these, so the first source that has one wins.
|
|
69
|
+
...firstOf(ordered, "container"),
|
|
70
|
+
...firstOf(ordered, "extraPorts"),
|
|
71
|
+
...firstOf(ordered, "volumes"),
|
|
72
|
+
...firstOf(ordered, "restart"),
|
|
73
|
+
...(ordered.find((c) => c.sidecarOf)?.sidecarOf
|
|
74
|
+
? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
|
|
75
|
+
: {}),
|
|
64
76
|
procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
|
|
65
77
|
conflicts: [],
|
|
66
78
|
sources: [],
|
|
@@ -104,6 +116,7 @@ export function mergeCandidates(candidates, rootName) {
|
|
|
104
116
|
const table = assignResourceNames([
|
|
105
117
|
...candidates.flatMap((c) => (c.name === undefined ? [] : [c.name])),
|
|
106
118
|
...candidates.flatMap((c) => c.dependsOn ?? []),
|
|
119
|
+
...candidates.flatMap((c) => (c.sidecarOf ? [c.sidecarOf] : [])),
|
|
107
120
|
...candidates.flatMap((c) => (c.name === undefined ? [basename(c.dir) || rootName] : [])),
|
|
108
121
|
]);
|
|
109
122
|
const resolve = (raw) => table.get(raw) ?? toResourceName(raw);
|
package/dist/tiltfile.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { ImportError } from "./errors.js";
|
|
4
|
-
import { defaultProjectConfig, findProjectRoot } from "./flisk.js";
|
|
4
|
+
import { defaultProjectConfig, findProjectRoot, missingProjectFields } from "./flisk.js";
|
|
5
5
|
const RERUN = "flisk import tiltfile <path>";
|
|
6
|
+
const RECREATE = "Or recreate it with `flisk project --yes --force` (this resets .flisk/project.json), then run the import again";
|
|
7
|
+
/** The refusal for a Tiltfile inside `.flisk/`; `resolved` names where a symlink leads. */
|
|
8
|
+
function insideFliskError(path, resolved) {
|
|
9
|
+
const via = resolved ? ` (it resolves to ${resolved})` : "";
|
|
10
|
+
return new ImportError(`${path} is inside .flisk/${via}, where Flisk writes its generated files; import your own Tiltfile instead`, [`Point it at the Tiltfile you maintain: ${RERUN}`]);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Where `tiltfile` really is, relative to the real `<projectRoot>/.flisk`, when a symlink on the way leads into it.
|
|
14
|
+
* The lexical check misses `infra/Tiltfile -> ../.flisk/.flisk-out/Tiltfile`, and flisk up would then include its own
|
|
15
|
+
* generated Tiltfile. Undefined when it does not, or when there is no `.flisk/` yet.
|
|
16
|
+
*/
|
|
17
|
+
function linkedIntoFlisk(tiltfile, projectRoot) {
|
|
18
|
+
const fliskDir = join(projectRoot, ".flisk");
|
|
19
|
+
if (!existsSync(fliskDir))
|
|
20
|
+
return undefined;
|
|
21
|
+
const realFlisk = realpathSync(fliskDir);
|
|
22
|
+
const inside = relative(realFlisk, realpathSync(tiltfile));
|
|
23
|
+
if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
|
|
24
|
+
return undefined;
|
|
25
|
+
return [".flisk", ...inside.split(sep)].join("/");
|
|
26
|
+
}
|
|
6
27
|
/** Describes an `orchestration` value for a message, for example `tiltfile: Tiltfile`. */
|
|
7
28
|
export function describeOrchestration(value) {
|
|
8
29
|
if (value && typeof value === "object" && "source" in value) {
|
|
@@ -30,14 +51,16 @@ export function planTiltfileImport(pathArg = "Tiltfile", options = {}) {
|
|
|
30
51
|
if (hasUnsafeChar(path)) {
|
|
31
52
|
throw new ImportError(`Tiltfile path ${JSON.stringify(path)} has a quote or control character`, ["Rename the file or folder, then run: flisk import tiltfile <path>"]);
|
|
32
53
|
}
|
|
33
|
-
if (path === ".flisk" || path.startsWith(".flisk/"))
|
|
34
|
-
throw
|
|
35
|
-
}
|
|
54
|
+
if (path === ".flisk" || path.startsWith(".flisk/"))
|
|
55
|
+
throw insideFliskError(path);
|
|
36
56
|
if (!statSync(tiltfile, { throwIfNoEntry: false })?.isFile()) {
|
|
37
57
|
throw new ImportError(`No Tiltfile found at ${pathArg}`, [
|
|
38
58
|
`Pass the path of an existing Tiltfile, or a folder that holds one: ${RERUN}`,
|
|
39
59
|
]);
|
|
40
60
|
}
|
|
61
|
+
const resolved = linkedIntoFlisk(tiltfile, projectRoot);
|
|
62
|
+
if (resolved)
|
|
63
|
+
throw insideFliskError(path, resolved);
|
|
41
64
|
const projectJson = join(projectRoot, ".flisk", "project.json");
|
|
42
65
|
const orchestration = { source: "tiltfile", path };
|
|
43
66
|
if (!existsSync(projectJson)) {
|
|
@@ -62,14 +85,23 @@ export function planTiltfileImport(pathArg = "Tiltfile", options = {}) {
|
|
|
62
85
|
existing = JSON.parse(readFileSync(projectJson, "utf8"));
|
|
63
86
|
}
|
|
64
87
|
catch (err) {
|
|
65
|
-
throw new ImportError(`Cannot read ${projectJson}: ${err instanceof Error ? err.message : String(err)}`, ["Fix the JSON in .flisk/project.json, then run the import again"]);
|
|
88
|
+
throw new ImportError(`Cannot read ${projectJson}: ${err instanceof Error ? err.message : String(err)}`, ["Fix the JSON in .flisk/project.json, then run the import again", RECREATE]);
|
|
66
89
|
}
|
|
67
90
|
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
|
68
91
|
throw new ImportError(`${projectJson} is not a JSON object`, [
|
|
69
92
|
"Fix .flisk/project.json, then run the import again",
|
|
93
|
+
RECREATE,
|
|
70
94
|
]);
|
|
71
95
|
}
|
|
72
96
|
const config = existing;
|
|
97
|
+
// Writing orchestration into a file flisk up cannot read would report success and then fail at flisk up.
|
|
98
|
+
const missing = missingProjectFields(config);
|
|
99
|
+
if (missing.length) {
|
|
100
|
+
throw new ImportError(`.flisk/project.json is missing fields flisk up needs, or has them with the wrong type: ${missing.join(", ")}`, [
|
|
101
|
+
`Add ${missing.length === 1 ? "it" : "them"} to .flisk/project.json, then run the import again`,
|
|
102
|
+
RECREATE,
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
73
105
|
const replaced = config.orchestration;
|
|
74
106
|
if (replaced !== undefined && !options.force) {
|
|
75
107
|
throw new ImportError(`.flisk/project.json already sets orchestration (${describeOrchestration(replaced)})`, ["Pass --force to replace it; every other key is kept"]);
|
package/dist/write.js
CHANGED
|
@@ -160,6 +160,31 @@ export function planWrites(plan, force) {
|
|
|
160
160
|
dockerfile: service.dockerfile,
|
|
161
161
|
}
|
|
162
162
|
: { image: service.image };
|
|
163
|
+
// A sidecar shares another written service's network; one whose host is not written stays a normal service.
|
|
164
|
+
const sidecarOf = service.sidecarOf && service.sidecarOf !== service.name && names.has(service.sidecarOf)
|
|
165
|
+
? service.sidecarOf
|
|
166
|
+
: undefined;
|
|
167
|
+
if (service.sidecarOf && !sidecarOf)
|
|
168
|
+
notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
|
|
169
|
+
const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
|
|
170
|
+
if (sidecarOf && service.extraPorts?.length)
|
|
171
|
+
notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
|
|
172
|
+
if (extraPorts.length)
|
|
173
|
+
notes.push(`published on 127.0.0.1 only: ${extraPorts.map((p) => `${p.hostPort ?? p.port}->${p.port}/${p.protocol ?? "tcp"}`).join(", ")}`);
|
|
174
|
+
// Bind mounts are relative to the service directory in service.json, and must stay inside the project.
|
|
175
|
+
const serviceDir = dirname(path);
|
|
176
|
+
const volumes = (service.volumes ?? []).flatMap((volume) => {
|
|
177
|
+
if (!volume.bind)
|
|
178
|
+
return [volume];
|
|
179
|
+
const absolute = resolve(plan.root, volume.source);
|
|
180
|
+
const fromProject = relative(projectRoot, absolute).split("\\").join("/");
|
|
181
|
+
if (fromProject === ".." || fromProject.startsWith("../")) {
|
|
182
|
+
notes.push(`volume ${volume.source}:${volume.target} not written: it is outside the project`);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
|
|
186
|
+
return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
|
|
187
|
+
});
|
|
163
188
|
const manifest = {
|
|
164
189
|
$schema: SERVICE_MANIFEST_SCHEMA_URL,
|
|
165
190
|
schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
|
|
@@ -172,6 +197,19 @@ export function planWrites(plan, force) {
|
|
|
172
197
|
...(isPrivate ? { exposeViaProxy: false } : {}),
|
|
173
198
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
174
199
|
...(secretNames.length ? { secrets: { required: secretNames } } : {}),
|
|
200
|
+
...(service.restart ? { restart: service.restart } : {}),
|
|
201
|
+
...(service.container ? { container: service.container } : {}),
|
|
202
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
203
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
204
|
+
...(volumes.length
|
|
205
|
+
? {
|
|
206
|
+
volumes: volumes.map(({ source, target, readOnly }) => ({
|
|
207
|
+
source,
|
|
208
|
+
target,
|
|
209
|
+
...(readOnly ? { readOnly } : {}),
|
|
210
|
+
})),
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
175
213
|
};
|
|
176
214
|
items.push({ service, path, manifest, status, notes });
|
|
177
215
|
}
|