@flisk-sh/flisk-export 0.4.2 → 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 +2 -1
- package/dist/compose.js +106 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,9 +45,10 @@ flisk-export compose --check # exit 1 when that file is out of date (for CI)
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Writes a **standalone** `docker-compose.yml` from the bring-your-own `service.json` files (`image` or `dockerfile`, `env`,
|
|
48
|
-
`secrets.required`, `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
|
|
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
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
|
+
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.
|
|
51
52
|
`healthCheck.command` becomes a Compose `healthcheck`; a service that lists a checked service in `dependsOn` waits for `service_healthy`, 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.
|
package/dist/compose.js
CHANGED
|
@@ -7,14 +7,29 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { relative, sep } from "node:path";
|
|
9
9
|
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_]
|
|
10
|
+
/** extraPorts `publish`: digits, dots, colons and `${NAME:-default}` / `${NAME-default}`, as flisk core accepts it (flisk#2947). */
|
|
11
|
+
const PUBLISH = /^(?:[0-9.:]|\$\{[A-Za-z_][A-Za-z0-9_]*:?-[0-9.:]+\})+$/;
|
|
12
|
+
/** An image reference, with Compose variables `${NAME}`, `${NAME:-default}` and `${NAME-default}` kept for Compose to resolve. */
|
|
13
|
+
const IMAGE = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._/:@-]*)?\})+$/;
|
|
12
14
|
/** The only resource type the Compose export covers: it is the one whose service.json is a Compose service. */
|
|
13
15
|
export const COMPOSE_TYPE = "bring-your-own";
|
|
14
16
|
const RESTART_POLICIES = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
17
|
+
/** `on-failure:N` with a retry limit from 1 to 100. */
|
|
18
|
+
const RESTART_LIMIT = /^on-failure:([1-9][0-9]?|100)$/;
|
|
19
|
+
/** A Compose duration such as 30s, 1m30s or 500ms. */
|
|
20
|
+
const DURATION = /^(?:[0-9]+(?:\.[0-9]+)?(?:h|ms|us|m|s))+$/;
|
|
21
|
+
/** A DNS name: letters, digits, '-', '_' and '.'. */
|
|
22
|
+
const DNS_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/;
|
|
23
|
+
/** An extra_hosts address: host-gateway, IPv4, or IPv6 (optionally in brackets). */
|
|
24
|
+
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:.]*\]?)$/;
|
|
25
|
+
const ULIMIT_NAME = /^[a-z]+$/;
|
|
15
26
|
const SERVICE_NAME = /^[a-z0-9][a-z0-9_.-]*$/i;
|
|
16
27
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
17
28
|
const SHM_SIZE = /^\d+(b|k|kb|m|mb|g|gb)?$/i;
|
|
29
|
+
const TMPFS = /^\/[A-Za-z0-9_./-]*(:[a-z0-9=,_-]+)?$/;
|
|
30
|
+
const CPUS = /^[0-9]+(\.[0-9]{1,2})?$/;
|
|
31
|
+
const MEMORY = /^[0-9]+([bBkKmMgG][bB]?)?$/;
|
|
32
|
+
const PROFILE = /^[a-z0-9][a-z0-9-]*$/;
|
|
18
33
|
const DOCKER_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
19
34
|
export function isComposeExportable(resource) {
|
|
20
35
|
return resource.type === COMPOSE_TYPE && typeof resource.config.command !== "string";
|
|
@@ -58,6 +73,11 @@ function serviceLines(resource, outDir, services) {
|
|
|
58
73
|
const image = typeof c.image === "string" && c.image ? c.image : undefined;
|
|
59
74
|
const dockerfile = typeof c.dockerfile === "string" && c.dockerfile ? c.dockerfile : undefined;
|
|
60
75
|
const buildContext = typeof c.buildContext === "string" ? c.buildContext : undefined;
|
|
76
|
+
if (image && !IMAGE.test(image)) {
|
|
77
|
+
throw new ExportError(`${name}: image "${image}" is not an image reference`, [
|
|
78
|
+
"Use letters, digits, . _ / : @ - and Compose variables ${NAME}, ${NAME:-default} or ${NAME-default}",
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
61
81
|
if (image)
|
|
62
82
|
lines.push(` image: ${q(image)}`);
|
|
63
83
|
if (dockerfile || !image) {
|
|
@@ -70,7 +90,7 @@ function serviceLines(resource, outDir, services) {
|
|
|
70
90
|
if (container.init !== false)
|
|
71
91
|
lines.push(" init: true");
|
|
72
92
|
if (typeof c.restart === "string") {
|
|
73
|
-
if (!RESTART_POLICIES.has(c.restart)) {
|
|
93
|
+
if (!RESTART_POLICIES.has(c.restart) && !RESTART_LIMIT.test(c.restart)) {
|
|
74
94
|
throw new ExportError(`${name}: restart "${c.restart}" is not a Compose restart policy`);
|
|
75
95
|
}
|
|
76
96
|
lines.push(` restart: ${q(c.restart)}`);
|
|
@@ -85,6 +105,18 @@ function serviceLines(resource, outDir, services) {
|
|
|
85
105
|
}
|
|
86
106
|
lines.push(` network_mode: ${q(`service:${sidecarOf}`)}`);
|
|
87
107
|
}
|
|
108
|
+
// Every exported service is on the default network, so its aliases go there. A sidecar has no network of its own.
|
|
109
|
+
const aliases = strings(container.networkAliases);
|
|
110
|
+
for (const alias of aliases) {
|
|
111
|
+
if (!DNS_NAME.test(alias)) {
|
|
112
|
+
throw new ExportError(`${name}: network alias "${alias}" is not a DNS name`, [
|
|
113
|
+
"Use letters, digits, '-', '_' and '.'",
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (aliases.length > 0 && !sidecarOf) {
|
|
118
|
+
lines.push(" networks:", " default:", " aliases:", ...aliases.map((a) => ` - ${q(a)}`));
|
|
119
|
+
}
|
|
88
120
|
const env = record(c.env);
|
|
89
121
|
// Secrets are listed by name only in service.json; Compose reads their values from the project .env.
|
|
90
122
|
const values = new Map();
|
|
@@ -192,6 +224,77 @@ function serviceLines(resource, outDir, services) {
|
|
|
192
224
|
}
|
|
193
225
|
lines.push(` shm_size: ${q(container.shmSize)}`);
|
|
194
226
|
}
|
|
227
|
+
if (container.stopGracePeriod !== undefined) {
|
|
228
|
+
const period = container.stopGracePeriod;
|
|
229
|
+
if (typeof period !== "string" || !DURATION.test(period)) {
|
|
230
|
+
throw new ExportError(`${name}: stopGracePeriod ${JSON.stringify(period)} is not a duration such as 30s or 1m30s`);
|
|
231
|
+
}
|
|
232
|
+
lines.push(` stop_grace_period: ${q(period)}`);
|
|
233
|
+
}
|
|
234
|
+
const extraHosts = strings(container.extraHosts);
|
|
235
|
+
for (const host of extraHosts) {
|
|
236
|
+
const at = host.indexOf(":");
|
|
237
|
+
if (at < 1 || !DNS_NAME.test(host.slice(0, at)) || !HOST_ADDRESS.test(host.slice(at + 1))) {
|
|
238
|
+
throw new ExportError(`${name}: extraHosts "${host}" is not name:address (an IP or host-gateway)`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (extraHosts.length > 0)
|
|
242
|
+
lines.push(" extra_hosts:", ...extraHosts.map((h) => ` - ${q(h)}`));
|
|
243
|
+
const ulimits = Object.entries(record(container.ulimits));
|
|
244
|
+
if (ulimits.length > 0) {
|
|
245
|
+
lines.push(" ulimits:");
|
|
246
|
+
for (const [limit, value] of ulimits) {
|
|
247
|
+
const pair = record(value);
|
|
248
|
+
if (!ULIMIT_NAME.test(limit)) {
|
|
249
|
+
throw new ExportError(`${name}: ulimit "${limit}" is not a ulimit name`);
|
|
250
|
+
}
|
|
251
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
252
|
+
lines.push(` ${limit}: ${value}`);
|
|
253
|
+
}
|
|
254
|
+
else if (Number.isInteger(pair.soft) && Number.isInteger(pair.hard)) {
|
|
255
|
+
lines.push(` ${limit}:`, ` soft: ${pair.soft}`, ` hard: ${pair.hard}`);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
throw new ExportError(`${name}: ulimit "${limit}" must be a whole number or { soft, hard } whole numbers`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const tmpfs = strings(container.tmpfs);
|
|
263
|
+
if (tmpfs.length > 0) {
|
|
264
|
+
for (const t of tmpfs) {
|
|
265
|
+
if (!TMPFS.test(t))
|
|
266
|
+
throw new ExportError(`${name}: tmpfs "${t}" is not /path or /path:options`);
|
|
267
|
+
}
|
|
268
|
+
lines.push(" tmpfs:", ...tmpfs.map((t) => ` - ${q(t)}`));
|
|
269
|
+
}
|
|
270
|
+
const cpus = container.cpus === undefined ? undefined : String(container.cpus);
|
|
271
|
+
const memory = typeof container.memory === "string" ? container.memory : undefined;
|
|
272
|
+
if (cpus !== undefined && !CPUS.test(cpus))
|
|
273
|
+
throw new ExportError(`${name}: cpus "${cpus}" is not a number of CPUs`);
|
|
274
|
+
if (memory !== undefined && !MEMORY.test(memory))
|
|
275
|
+
throw new ExportError(`${name}: memory "${memory}" is not a size such as 2g`);
|
|
276
|
+
if (cpus !== undefined || memory !== undefined) {
|
|
277
|
+
lines.push(" deploy:", " resources:", " limits:", ...(cpus !== undefined ? [` cpus: ${q(cpus)}`] : []), ...(memory !== undefined ? [` memory: ${q(memory)}`] : []));
|
|
278
|
+
}
|
|
279
|
+
// Paths in envFile are relative to the service directory; a missing file is skipped, as Flisk does.
|
|
280
|
+
const envFiles = strings(c.envFile);
|
|
281
|
+
if (envFiles.length > 0) {
|
|
282
|
+
lines.push(" env_file:");
|
|
283
|
+
for (const file of envFiles) {
|
|
284
|
+
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(file)) {
|
|
285
|
+
throw new ExportError(`${name}: envFile "${file}" is not a relative path`);
|
|
286
|
+
}
|
|
287
|
+
lines.push(` - path: ${q(hostPath(outDir, resolveFrom(resource.path, file)))}`, " required: false");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const profiles = strings(c.profiles);
|
|
291
|
+
if (profiles.length > 0) {
|
|
292
|
+
for (const p of profiles) {
|
|
293
|
+
if (!PROFILE.test(p))
|
|
294
|
+
throw new ExportError(`${name}: profile "${p}" is not a profile name`);
|
|
295
|
+
}
|
|
296
|
+
lines.push(" profiles:", ...profiles.map((p) => ` - ${q(p)}`));
|
|
297
|
+
}
|
|
195
298
|
return { lines, namedVolumes, models };
|
|
196
299
|
}
|
|
197
300
|
/** What Compose waits for before starting a dependent: a passing check, a finished one-shot job, or the start. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flisk-sh/flisk-export",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|