@flisk-sh/flisk-export 0.6.0 → 0.8.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 +2 -2
- package/dist/compose.js +69 -10
- package/dist/main.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,9 +47,9 @@ flisk-export compose --check # exit 1 when that file is out of date (for CI)
|
|
|
47
47
|
Writes a **standalone** `docker-compose.yml` from the bring-your-own `service.json` files (`image` or `dockerfile`, `env`,
|
|
48
48
|
`secrets.required`, `envFile`, `profiles`, `models`, `volumes`, `extraPorts`, `dependsOn`, `healthCheck`, `sidecarOf`, `container`). It runs without Flisk (`docker compose --env-file .env -f .autogenerated/docker-compose.yml up -d` from the project root, since Compose looks for `.env` next to the file it reads): no external
|
|
49
49
|
networks, no `FLISK_*` variables, no generated secrets. `${VAR}` references in `env`, and every name in `secrets.required`, pass through as `${NAME}` for Compose to read from `.env`.
|
|
50
|
-
A volume's `name` (the Docker volume an install already has) is kept, and the project is named after its stack, so `docker compose up` finds the same data as before.
|
|
50
|
+
A volume's `name` (the Docker volume an install already has) is kept, and the project is named after its stack (or, when `.flisk/project.json` has `composeProjectNames.<stack>` from flisk-import, the Compose project name it had before, such as `pl_mail`), so `docker compose up` finds the same data as before.
|
|
51
51
|
An `image` with Compose variables (`postgres:${POSTGRES_VERSION:-18}-alpine`) is written as it is, for Compose to resolve; `restart: "on-failure:N"` keeps N. From `container`: `stopGracePeriod` becomes `stop_grace_period`, `networkAliases` become `networks: { default: { aliases } }` (not on a `sidecarOf` service, which has no network of its own), `extraHosts` become `extra_hosts` and `ulimits` stay `ulimits`. Each value is checked so it cannot add YAML keys; one that does not fit is an error.
|
|
52
|
-
`healthCheck.command` becomes a Compose `healthcheck
|
|
52
|
+
`healthCheck.command` becomes a Compose `healthcheck` (with `startPeriod` as `start_period`); a service that lists a checked service in `dependsOn` waits for `service_healthy` (only when that healthcheck is written: a service with only `healthCheckPath`, or `enabled: false`, is waited for with `service_started`), and a `dependsOn` entry written as `{ name, condition }` (flisk#2983) keeps its condition. `buildArgs` become `build.args`. A service with no `restart` gets none (Compose's default), as the project's own Compose file had it. and one that lists a `restart: "no"` job waits for `service_completed_successfully`.
|
|
53
53
|
Other resource types and `command` (native) services are not exported. The file is generated: change `service.json` and run it
|
|
54
54
|
again with `--force`. It never starts a container or calls an API.
|
|
55
55
|
|
package/dist/compose.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* generated secrets. `${VAR}` references in `env` pass through for Compose to resolve from the project `.env`. These
|
|
6
6
|
* functions only return text; nothing here starts a container, deploys or calls an API.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join, relative, sep } from "node:path";
|
|
9
10
|
import { ExportError } from "./errors.js";
|
|
10
11
|
/** extraPorts `publish`: digits, dots, colons and `${NAME:-default}` / `${NAME-default}`, as flisk core accepts it (flisk#2947). */
|
|
11
12
|
const PUBLISH = /^(?:[0-9.:]|\$\{[A-Za-z_][A-Za-z0-9_]*:?-[0-9.:]+\})+$/;
|
|
@@ -14,6 +15,11 @@ const IMAGE = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9.
|
|
|
14
15
|
/** The only resource type the Compose export covers: it is the one whose service.json is a Compose service. */
|
|
15
16
|
export const COMPOSE_TYPE = "bring-your-own";
|
|
16
17
|
const RESTART_POLICIES = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
18
|
+
const CONDITIONS = new Set([
|
|
19
|
+
"service_started",
|
|
20
|
+
"service_healthy",
|
|
21
|
+
"service_completed_successfully",
|
|
22
|
+
]);
|
|
17
23
|
/** `on-failure:N` with a retry limit from 1 to 100. */
|
|
18
24
|
const RESTART_LIMIT = /^on-failure:([1-9][0-9]?|100)$/;
|
|
19
25
|
/** A Compose duration such as 30s, 1m30s or 500ms. */
|
|
@@ -85,6 +91,16 @@ function serviceLines(resource, outDir, services) {
|
|
|
85
91
|
lines.push(" build:", ` context: ${q(context)}`);
|
|
86
92
|
if (dockerfile)
|
|
87
93
|
lines.push(` dockerfile: ${q(dockerfile)}`);
|
|
94
|
+
// buildArgs (flisk#2983): NAME to a string, which may use Compose variables such as ${NODE_VERSION:-22}.
|
|
95
|
+
const args = Object.entries(record(c.buildArgs));
|
|
96
|
+
for (const [arg, value] of args) {
|
|
97
|
+
if (!ENV_NAME.test(arg) || typeof value !== "string") {
|
|
98
|
+
throw new ExportError(`${name}: buildArgs "${arg}" needs a name of letters, digits and _ and a string`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (args.length > 0) {
|
|
102
|
+
lines.push(" args:", ...args.map(([arg, value]) => ` ${arg}: ${q(value)}`));
|
|
103
|
+
}
|
|
88
104
|
}
|
|
89
105
|
const container = record(c.container);
|
|
90
106
|
if (container.init !== false)
|
|
@@ -95,9 +111,7 @@ function serviceLines(resource, outDir, services) {
|
|
|
95
111
|
}
|
|
96
112
|
lines.push(` restart: ${q(c.restart)}`);
|
|
97
113
|
}
|
|
98
|
-
|
|
99
|
-
lines.push(' restart: "unless-stopped"');
|
|
100
|
-
}
|
|
114
|
+
// No restart in service.json is written as none: Compose's default, as the project's own Compose file had it.
|
|
101
115
|
const sidecarOf = typeof c.sidecarOf === "string" ? c.sidecarOf : undefined;
|
|
102
116
|
if (sidecarOf) {
|
|
103
117
|
if (!known.has(sidecarOf)) {
|
|
@@ -181,11 +195,26 @@ function serviceLines(resource, outDir, services) {
|
|
|
181
195
|
lines.push(` - ${q(`${bind}:${hostPort}:${port}/${protocol}`)}`);
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
|
-
|
|
198
|
+
// dependsOn entries are names or { name, condition } (flisk#2983); a stated condition wins over the guess.
|
|
199
|
+
const dependsOn = (Array.isArray(c.dependsOn) ? c.dependsOn : [])
|
|
200
|
+
.map((entry) => typeof entry === "string"
|
|
201
|
+
? { name: entry, condition: undefined }
|
|
202
|
+
: { name: record(entry).name, condition: record(entry).condition })
|
|
203
|
+
.filter((d) => typeof d.name === "string" && known.has(d.name));
|
|
185
204
|
if (dependsOn.length > 0) {
|
|
186
205
|
lines.push(" depends_on:");
|
|
187
206
|
for (const d of dependsOn) {
|
|
188
|
-
|
|
207
|
+
const dep = known.get(d.name);
|
|
208
|
+
if (d.condition !== undefined && !CONDITIONS.has(d.condition)) {
|
|
209
|
+
throw new ExportError(`${name}: dependsOn "${d.name}" condition is not a Compose condition`, ["Use service_started, service_healthy or service_completed_successfully"]);
|
|
210
|
+
}
|
|
211
|
+
// service_healthy needs a healthcheck in this file; without one Compose would refuse to start.
|
|
212
|
+
const condition = d.condition === undefined
|
|
213
|
+
? dependencyCondition(dep)
|
|
214
|
+
: d.condition === "service_healthy" && !hasProbeCommand(dep.config.healthCheck)
|
|
215
|
+
? "service_started"
|
|
216
|
+
: d.condition;
|
|
217
|
+
lines.push(` ${d.name}:`, ` condition: ${condition}`);
|
|
189
218
|
}
|
|
190
219
|
}
|
|
191
220
|
lines.push(...healthcheckLines(name, c.healthCheck));
|
|
@@ -305,7 +334,10 @@ function dependencyCondition(dep) {
|
|
|
305
334
|
return "service_completed_successfully";
|
|
306
335
|
return "service_started";
|
|
307
336
|
}
|
|
337
|
+
/** Whether the output has a healthcheck for this value: a command, and not turned off. Only then can a dependent wait for service_healthy. */
|
|
308
338
|
function hasProbeCommand(value) {
|
|
339
|
+
if (value === false || record(value).enabled === false)
|
|
340
|
+
return false;
|
|
309
341
|
return strings(record(value).command).length > 0;
|
|
310
342
|
}
|
|
311
343
|
/** A Compose `healthcheck` from `healthCheck.command` (exit 0 is ready); `healthCheck: false` turns it off. */
|
|
@@ -318,14 +350,18 @@ function healthcheckLines(name, value) {
|
|
|
318
350
|
if (command.length === 0)
|
|
319
351
|
return [];
|
|
320
352
|
const lines = [" healthcheck:", ` test: [${['"CMD"', ...command.map(q)].join(", ")}]`];
|
|
321
|
-
for (const key of [
|
|
353
|
+
for (const [key, composeKey] of [
|
|
354
|
+
["interval", "interval"],
|
|
355
|
+
["timeout", "timeout"],
|
|
356
|
+
["startPeriod", "start_period"],
|
|
357
|
+
]) {
|
|
322
358
|
if (h[key] === undefined)
|
|
323
359
|
continue;
|
|
324
360
|
const seconds = Number(h[key]);
|
|
325
361
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
326
362
|
throw new ExportError(`${name}: healthCheck.${key} must be a number of seconds above 0`);
|
|
327
363
|
}
|
|
328
|
-
lines.push(` ${
|
|
364
|
+
lines.push(` ${composeKey}: ${q(`${seconds}s`)}`);
|
|
329
365
|
}
|
|
330
366
|
if (h.retries !== undefined) {
|
|
331
367
|
const retries = Number(h.retries);
|
|
@@ -352,8 +388,26 @@ function joinPosix(base, rel) {
|
|
|
352
388
|
}
|
|
353
389
|
return (base.startsWith("/") ? "/" : "") + out.join("/");
|
|
354
390
|
}
|
|
391
|
+
/** A Compose project name as `docker compose` accepts it. */
|
|
392
|
+
const PROJECT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
393
|
+
/**
|
|
394
|
+
* `.flisk/project.json` `composeProjectNames`: the project name each stack had as a Compose project before the import
|
|
395
|
+
* (flisk-import records it), so the export keeps its container names (pl_mail-php-1). Empty when absent or unreadable.
|
|
396
|
+
*/
|
|
397
|
+
export function composeProjectNames(projectRoot) {
|
|
398
|
+
const path = join(projectRoot, ".flisk", "project.json");
|
|
399
|
+
if (!existsSync(path))
|
|
400
|
+
return {};
|
|
401
|
+
try {
|
|
402
|
+
const names = record(record(JSON.parse(readFileSync(path, "utf8"))).composeProjectNames);
|
|
403
|
+
return Object.fromEntries(Object.entries(names).filter((entry) => typeof entry[1] === "string" && PROJECT_NAME.test(entry[1])));
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
355
409
|
/** The text of a docker-compose.yml for `resources`, written for a file in `outDir`. */
|
|
356
|
-
export function buildCompose(outDir, resources) {
|
|
410
|
+
export function buildCompose(outDir, resources, options = {}) {
|
|
357
411
|
const services = resources.filter(isComposeExportable);
|
|
358
412
|
if (services.length === 0) {
|
|
359
413
|
throw new ExportError("No bring-your-own resource was found to export", [
|
|
@@ -364,6 +418,10 @@ export function buildCompose(outDir, resources) {
|
|
|
364
418
|
const stacks = new Set(services
|
|
365
419
|
.map((s) => s.config.stack)
|
|
366
420
|
.filter((v) => typeof v === "string" && /^[a-z0-9][a-z0-9_-]*$/.test(v)));
|
|
421
|
+
const projectName = (stack) => {
|
|
422
|
+
const recorded = options.composeProjectNames?.[stack];
|
|
423
|
+
return recorded && PROJECT_NAME.test(recorded) ? recorded : stack;
|
|
424
|
+
};
|
|
367
425
|
const body = [];
|
|
368
426
|
const volumes = new Map();
|
|
369
427
|
const models = new Map();
|
|
@@ -392,7 +450,8 @@ export function buildCompose(outDir, resources) {
|
|
|
392
450
|
"",
|
|
393
451
|
// Without a name Compose takes the folder's (autogenerated), so containers and unnamed volumes would not match the
|
|
394
452
|
// project the app had before. One stack gives the project its name.
|
|
395
|
-
|
|
453
|
+
// The Compose project name the stack had before the import, when flisk-import recorded it, else the stack.
|
|
454
|
+
...(stacks.size === 1 ? [`name: ${q(projectName([...stacks][0]))}`, ""] : []),
|
|
396
455
|
"services:",
|
|
397
456
|
];
|
|
398
457
|
const tail = [
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { Command, CommanderError } from "commander";
|
|
8
|
-
import { buildCompose } from "./compose.js";
|
|
8
|
+
import { buildCompose, composeProjectNames } from "./compose.js";
|
|
9
9
|
import { discoverResources, findProjectRoot, findRepoRoot, findResource, } from "./discover.js";
|
|
10
10
|
import { ExportError } from "./errors.js";
|
|
11
11
|
import { assertExportable, buildRenderBlueprint, buildWranglerToml, EXPORTABLE_TYPES, isExportable, skippedEnvNames, } from "./generate.js";
|
|
@@ -94,7 +94,9 @@ function program(io) {
|
|
|
94
94
|
const outDir = options.out
|
|
95
95
|
? realDir(resolve(io.cwd, options.out))
|
|
96
96
|
: join(projectRoot, ".autogenerated");
|
|
97
|
-
const content = buildCompose(outDir, discover(projectRoot)
|
|
97
|
+
const content = buildCompose(outDir, discover(projectRoot), {
|
|
98
|
+
composeProjectNames: composeProjectNames(projectRoot),
|
|
99
|
+
});
|
|
98
100
|
const file = join(outDir, "docker-compose.yml");
|
|
99
101
|
if (options.check) {
|
|
100
102
|
const shown = relative(realDir(io.cwd), file) || file;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flisk-sh/flisk-export",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Write a Render Blueprint (render.yaml), a Cloudflare wrangler.toml or a standalone docker-compose.yml from Flisk service.json files. Never deploys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|