@flisk-sh/flisk-export 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/compose.js +102 -8
- package/dist/main.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,8 +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.
|
|
51
|
-
`
|
|
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
|
+
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` (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 one that lists a `restart: "no"` job waits for `service_completed_successfully`.
|
|
52
53
|
Other resource types and `command` (native) services are not exported. The file is generated: change `service.json` and run it
|
|
53
54
|
again with `--force`. It never starts a container or calls an API.
|
|
54
55
|
|
package/dist/compose.js
CHANGED
|
@@ -5,13 +5,25 @@
|
|
|
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
|
-
/** extraPorts `publish`: digits, dots, colons and `${NAME:-default}`, as flisk core accepts it (flisk#2947). */
|
|
11
|
-
const PUBLISH = /^(?:[0-9.:]|\$\{[A-Za-z_][A-Za-z0-9_]
|
|
11
|
+
/** extraPorts `publish`: digits, dots, colons and `${NAME:-default}` / `${NAME-default}`, as flisk core accepts it (flisk#2947). */
|
|
12
|
+
const PUBLISH = /^(?:[0-9.:]|\$\{[A-Za-z_][A-Za-z0-9_]*:?-[0-9.:]+\})+$/;
|
|
13
|
+
/** An image reference, with Compose variables `${NAME}`, `${NAME:-default}` and `${NAME-default}` kept for Compose to resolve. */
|
|
14
|
+
const IMAGE = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._/:@-]*)?\})+$/;
|
|
12
15
|
/** The only resource type the Compose export covers: it is the one whose service.json is a Compose service. */
|
|
13
16
|
export const COMPOSE_TYPE = "bring-your-own";
|
|
14
17
|
const RESTART_POLICIES = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
18
|
+
/** `on-failure:N` with a retry limit from 1 to 100. */
|
|
19
|
+
const RESTART_LIMIT = /^on-failure:([1-9][0-9]?|100)$/;
|
|
20
|
+
/** A Compose duration such as 30s, 1m30s or 500ms. */
|
|
21
|
+
const DURATION = /^(?:[0-9]+(?:\.[0-9]+)?(?:h|ms|us|m|s))+$/;
|
|
22
|
+
/** A DNS name: letters, digits, '-', '_' and '.'. */
|
|
23
|
+
const DNS_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/;
|
|
24
|
+
/** An extra_hosts address: host-gateway, IPv4, or IPv6 (optionally in brackets). */
|
|
25
|
+
const HOST_ADDRESS = /^(?:host-gateway|[0-9]{1,3}(?:\.[0-9]{1,3}){3}|\[?[0-9A-Fa-f.]*:[0-9A-Fa-f.]*:[0-9A-Fa-f:.]*\]?)$/;
|
|
26
|
+
const ULIMIT_NAME = /^[a-z]+$/;
|
|
15
27
|
const SERVICE_NAME = /^[a-z0-9][a-z0-9_.-]*$/i;
|
|
16
28
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
17
29
|
const SHM_SIZE = /^\d+(b|k|kb|m|mb|g|gb)?$/i;
|
|
@@ -62,6 +74,11 @@ function serviceLines(resource, outDir, services) {
|
|
|
62
74
|
const image = typeof c.image === "string" && c.image ? c.image : undefined;
|
|
63
75
|
const dockerfile = typeof c.dockerfile === "string" && c.dockerfile ? c.dockerfile : undefined;
|
|
64
76
|
const buildContext = typeof c.buildContext === "string" ? c.buildContext : undefined;
|
|
77
|
+
if (image && !IMAGE.test(image)) {
|
|
78
|
+
throw new ExportError(`${name}: image "${image}" is not an image reference`, [
|
|
79
|
+
"Use letters, digits, . _ / : @ - and Compose variables ${NAME}, ${NAME:-default} or ${NAME-default}",
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
65
82
|
if (image)
|
|
66
83
|
lines.push(` image: ${q(image)}`);
|
|
67
84
|
if (dockerfile || !image) {
|
|
@@ -74,7 +91,7 @@ function serviceLines(resource, outDir, services) {
|
|
|
74
91
|
if (container.init !== false)
|
|
75
92
|
lines.push(" init: true");
|
|
76
93
|
if (typeof c.restart === "string") {
|
|
77
|
-
if (!RESTART_POLICIES.has(c.restart)) {
|
|
94
|
+
if (!RESTART_POLICIES.has(c.restart) && !RESTART_LIMIT.test(c.restart)) {
|
|
78
95
|
throw new ExportError(`${name}: restart "${c.restart}" is not a Compose restart policy`);
|
|
79
96
|
}
|
|
80
97
|
lines.push(` restart: ${q(c.restart)}`);
|
|
@@ -89,6 +106,18 @@ function serviceLines(resource, outDir, services) {
|
|
|
89
106
|
}
|
|
90
107
|
lines.push(` network_mode: ${q(`service:${sidecarOf}`)}`);
|
|
91
108
|
}
|
|
109
|
+
// Every exported service is on the default network, so its aliases go there. A sidecar has no network of its own.
|
|
110
|
+
const aliases = strings(container.networkAliases);
|
|
111
|
+
for (const alias of aliases) {
|
|
112
|
+
if (!DNS_NAME.test(alias)) {
|
|
113
|
+
throw new ExportError(`${name}: network alias "${alias}" is not a DNS name`, [
|
|
114
|
+
"Use letters, digits, '-', '_' and '.'",
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (aliases.length > 0 && !sidecarOf) {
|
|
119
|
+
lines.push(" networks:", " default:", " aliases:", ...aliases.map((a) => ` - ${q(a)}`));
|
|
120
|
+
}
|
|
92
121
|
const env = record(c.env);
|
|
93
122
|
// Secrets are listed by name only in service.json; Compose reads their values from the project .env.
|
|
94
123
|
const values = new Map();
|
|
@@ -196,6 +225,41 @@ function serviceLines(resource, outDir, services) {
|
|
|
196
225
|
}
|
|
197
226
|
lines.push(` shm_size: ${q(container.shmSize)}`);
|
|
198
227
|
}
|
|
228
|
+
if (container.stopGracePeriod !== undefined) {
|
|
229
|
+
const period = container.stopGracePeriod;
|
|
230
|
+
if (typeof period !== "string" || !DURATION.test(period)) {
|
|
231
|
+
throw new ExportError(`${name}: stopGracePeriod ${JSON.stringify(period)} is not a duration such as 30s or 1m30s`);
|
|
232
|
+
}
|
|
233
|
+
lines.push(` stop_grace_period: ${q(period)}`);
|
|
234
|
+
}
|
|
235
|
+
const extraHosts = strings(container.extraHosts);
|
|
236
|
+
for (const host of extraHosts) {
|
|
237
|
+
const at = host.indexOf(":");
|
|
238
|
+
if (at < 1 || !DNS_NAME.test(host.slice(0, at)) || !HOST_ADDRESS.test(host.slice(at + 1))) {
|
|
239
|
+
throw new ExportError(`${name}: extraHosts "${host}" is not name:address (an IP or host-gateway)`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (extraHosts.length > 0)
|
|
243
|
+
lines.push(" extra_hosts:", ...extraHosts.map((h) => ` - ${q(h)}`));
|
|
244
|
+
const ulimits = Object.entries(record(container.ulimits));
|
|
245
|
+
if (ulimits.length > 0) {
|
|
246
|
+
lines.push(" ulimits:");
|
|
247
|
+
for (const [limit, value] of ulimits) {
|
|
248
|
+
const pair = record(value);
|
|
249
|
+
if (!ULIMIT_NAME.test(limit)) {
|
|
250
|
+
throw new ExportError(`${name}: ulimit "${limit}" is not a ulimit name`);
|
|
251
|
+
}
|
|
252
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
253
|
+
lines.push(` ${limit}: ${value}`);
|
|
254
|
+
}
|
|
255
|
+
else if (Number.isInteger(pair.soft) && Number.isInteger(pair.hard)) {
|
|
256
|
+
lines.push(` ${limit}:`, ` soft: ${pair.soft}`, ` hard: ${pair.hard}`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
throw new ExportError(`${name}: ulimit "${limit}" must be a whole number or { soft, hard } whole numbers`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
199
263
|
const tmpfs = strings(container.tmpfs);
|
|
200
264
|
if (tmpfs.length > 0) {
|
|
201
265
|
for (const t of tmpfs) {
|
|
@@ -242,7 +306,10 @@ function dependencyCondition(dep) {
|
|
|
242
306
|
return "service_completed_successfully";
|
|
243
307
|
return "service_started";
|
|
244
308
|
}
|
|
309
|
+
/** Whether the output has a healthcheck for this value: a command, and not turned off. Only then can a dependent wait for service_healthy. */
|
|
245
310
|
function hasProbeCommand(value) {
|
|
311
|
+
if (value === false || record(value).enabled === false)
|
|
312
|
+
return false;
|
|
246
313
|
return strings(record(value).command).length > 0;
|
|
247
314
|
}
|
|
248
315
|
/** A Compose `healthcheck` from `healthCheck.command` (exit 0 is ready); `healthCheck: false` turns it off. */
|
|
@@ -255,14 +322,18 @@ function healthcheckLines(name, value) {
|
|
|
255
322
|
if (command.length === 0)
|
|
256
323
|
return [];
|
|
257
324
|
const lines = [" healthcheck:", ` test: [${['"CMD"', ...command.map(q)].join(", ")}]`];
|
|
258
|
-
for (const key of [
|
|
325
|
+
for (const [key, composeKey] of [
|
|
326
|
+
["interval", "interval"],
|
|
327
|
+
["timeout", "timeout"],
|
|
328
|
+
["startPeriod", "start_period"],
|
|
329
|
+
]) {
|
|
259
330
|
if (h[key] === undefined)
|
|
260
331
|
continue;
|
|
261
332
|
const seconds = Number(h[key]);
|
|
262
333
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
263
334
|
throw new ExportError(`${name}: healthCheck.${key} must be a number of seconds above 0`);
|
|
264
335
|
}
|
|
265
|
-
lines.push(` ${
|
|
336
|
+
lines.push(` ${composeKey}: ${q(`${seconds}s`)}`);
|
|
266
337
|
}
|
|
267
338
|
if (h.retries !== undefined) {
|
|
268
339
|
const retries = Number(h.retries);
|
|
@@ -289,8 +360,26 @@ function joinPosix(base, rel) {
|
|
|
289
360
|
}
|
|
290
361
|
return (base.startsWith("/") ? "/" : "") + out.join("/");
|
|
291
362
|
}
|
|
363
|
+
/** A Compose project name as `docker compose` accepts it. */
|
|
364
|
+
const PROJECT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
365
|
+
/**
|
|
366
|
+
* `.flisk/project.json` `composeProjectNames`: the project name each stack had as a Compose project before the import
|
|
367
|
+
* (flisk-import records it), so the export keeps its container names (pl_mail-php-1). Empty when absent or unreadable.
|
|
368
|
+
*/
|
|
369
|
+
export function composeProjectNames(projectRoot) {
|
|
370
|
+
const path = join(projectRoot, ".flisk", "project.json");
|
|
371
|
+
if (!existsSync(path))
|
|
372
|
+
return {};
|
|
373
|
+
try {
|
|
374
|
+
const names = record(record(JSON.parse(readFileSync(path, "utf8"))).composeProjectNames);
|
|
375
|
+
return Object.fromEntries(Object.entries(names).filter((entry) => typeof entry[1] === "string" && PROJECT_NAME.test(entry[1])));
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return {};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
292
381
|
/** The text of a docker-compose.yml for `resources`, written for a file in `outDir`. */
|
|
293
|
-
export function buildCompose(outDir, resources) {
|
|
382
|
+
export function buildCompose(outDir, resources, options = {}) {
|
|
294
383
|
const services = resources.filter(isComposeExportable);
|
|
295
384
|
if (services.length === 0) {
|
|
296
385
|
throw new ExportError("No bring-your-own resource was found to export", [
|
|
@@ -301,6 +390,10 @@ export function buildCompose(outDir, resources) {
|
|
|
301
390
|
const stacks = new Set(services
|
|
302
391
|
.map((s) => s.config.stack)
|
|
303
392
|
.filter((v) => typeof v === "string" && /^[a-z0-9][a-z0-9_-]*$/.test(v)));
|
|
393
|
+
const projectName = (stack) => {
|
|
394
|
+
const recorded = options.composeProjectNames?.[stack];
|
|
395
|
+
return recorded && PROJECT_NAME.test(recorded) ? recorded : stack;
|
|
396
|
+
};
|
|
304
397
|
const body = [];
|
|
305
398
|
const volumes = new Map();
|
|
306
399
|
const models = new Map();
|
|
@@ -329,7 +422,8 @@ export function buildCompose(outDir, resources) {
|
|
|
329
422
|
"",
|
|
330
423
|
// Without a name Compose takes the folder's (autogenerated), so containers and unnamed volumes would not match the
|
|
331
424
|
// project the app had before. One stack gives the project its name.
|
|
332
|
-
|
|
425
|
+
// The Compose project name the stack had before the import, when flisk-import recorded it, else the stack.
|
|
426
|
+
...(stacks.size === 1 ? [`name: ${q(projectName([...stacks][0]))}`, ""] : []),
|
|
333
427
|
"services:",
|
|
334
428
|
];
|
|
335
429
|
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.7.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",
|