@flisk-sh/flisk-export 0.1.0 → 0.2.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 +13 -0
- package/dist/compose.js +199 -0
- package/dist/main.js +29 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -37,6 +37,19 @@ npm install
|
|
|
37
37
|
npm run typecheck && npm run lint && npm run build && npm test
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Compose
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
flisk-export compose # writes .autogenerated/docker-compose.yml
|
|
44
|
+
flisk-export compose --check # exit 1 when that file is out of date (for CI)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Writes a **standalone** `docker-compose.yml` from the bring-your-own `service.json` files (`image` or `dockerfile`, `env`,
|
|
48
|
+
`volumes`, `extraPorts`, `dependsOn`, `sidecarOf`, `container`). It runs with plain `docker compose up` and no Flisk: no external
|
|
49
|
+
networks, no `FLISK_*` variables, no generated secrets. `${VAR}` references in `env` pass through for Compose to read from `.env`.
|
|
50
|
+
Other resource types and `command` (native) services are not exported. The file is generated: change `service.json` and run it
|
|
51
|
+
again with `--force`. It never starts a container or calls an API.
|
|
52
|
+
|
|
40
53
|
## License
|
|
41
54
|
|
|
42
55
|
MIT
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a standalone docker-compose.yml from the bring-your-own service.json files of a Flisk project.
|
|
3
|
+
*
|
|
4
|
+
* The result runs with plain `docker compose up` and no Flisk: no external networks, no injected Flisk variables and no
|
|
5
|
+
* generated secrets. `${VAR}` references in `env` pass through for Compose to resolve from the project `.env`. These
|
|
6
|
+
* functions only return text; nothing here starts a container, deploys or calls an API.
|
|
7
|
+
*/
|
|
8
|
+
import { relative, sep } from "node:path";
|
|
9
|
+
import { ExportError } from "./errors.js";
|
|
10
|
+
/** The only resource type the Compose export covers: it is the one whose service.json is a Compose service. */
|
|
11
|
+
export const COMPOSE_TYPE = "bring-your-own";
|
|
12
|
+
const RESTART_POLICIES = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
13
|
+
const SERVICE_NAME = /^[a-z0-9][a-z0-9_.-]*$/i;
|
|
14
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
15
|
+
const SHM_SIZE = /^\d+(b|k|kb|m|mb|g|gb)?$/i;
|
|
16
|
+
export function isComposeExportable(resource) {
|
|
17
|
+
return resource.type === COMPOSE_TYPE && typeof resource.config.command !== "string";
|
|
18
|
+
}
|
|
19
|
+
function record(value) {
|
|
20
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
21
|
+
? value
|
|
22
|
+
: {};
|
|
23
|
+
}
|
|
24
|
+
function strings(value) {
|
|
25
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
26
|
+
}
|
|
27
|
+
/** A YAML double-quoted scalar. JSON strings are valid ones; `$` is kept so Compose can interpolate. */
|
|
28
|
+
function q(value) {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
function relPath(from, to) {
|
|
32
|
+
const rel = relative(from, to).split(sep).join("/");
|
|
33
|
+
return rel === "" ? "." : rel;
|
|
34
|
+
}
|
|
35
|
+
/** A host path written for a Compose file in `outDir`: `./x` for children, `../x` for parents. */
|
|
36
|
+
function hostPath(outDir, absolute) {
|
|
37
|
+
const rel = relPath(outDir, absolute);
|
|
38
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
39
|
+
}
|
|
40
|
+
function isNamedVolume(source) {
|
|
41
|
+
return !/[\\/]/.test(source) && !source.startsWith(".") && !source.startsWith("~");
|
|
42
|
+
}
|
|
43
|
+
function serviceLines(resource, outDir, known) {
|
|
44
|
+
const c = resource.config;
|
|
45
|
+
const name = resource.name;
|
|
46
|
+
if (!SERVICE_NAME.test(name)) {
|
|
47
|
+
throw new ExportError(`"${name}" is not a valid Compose service name`, [
|
|
48
|
+
"Use letters, digits, dashes, dots and underscores",
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
const lines = [` ${name}:`];
|
|
52
|
+
const namedVolumes = new Set();
|
|
53
|
+
const image = typeof c.image === "string" && c.image ? c.image : undefined;
|
|
54
|
+
const dockerfile = typeof c.dockerfile === "string" && c.dockerfile ? c.dockerfile : undefined;
|
|
55
|
+
const buildContext = typeof c.buildContext === "string" ? c.buildContext : undefined;
|
|
56
|
+
if (image)
|
|
57
|
+
lines.push(` image: ${q(image)}`);
|
|
58
|
+
if (dockerfile || !image) {
|
|
59
|
+
const context = hostPath(outDir, resolveFrom(resource.path, buildContext ?? "."));
|
|
60
|
+
lines.push(" build:", ` context: ${q(context)}`);
|
|
61
|
+
if (dockerfile)
|
|
62
|
+
lines.push(` dockerfile: ${q(dockerfile)}`);
|
|
63
|
+
}
|
|
64
|
+
const container = record(c.container);
|
|
65
|
+
if (container.init !== false)
|
|
66
|
+
lines.push(" init: true");
|
|
67
|
+
if (typeof c.restart === "string") {
|
|
68
|
+
if (!RESTART_POLICIES.has(c.restart)) {
|
|
69
|
+
throw new ExportError(`${name}: restart "${c.restart}" is not a Compose restart policy`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(` restart: ${q(c.restart)}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
lines.push(' restart: "unless-stopped"');
|
|
75
|
+
}
|
|
76
|
+
const sidecarOf = typeof c.sidecarOf === "string" ? c.sidecarOf : undefined;
|
|
77
|
+
if (sidecarOf) {
|
|
78
|
+
if (!known.has(sidecarOf)) {
|
|
79
|
+
throw new ExportError(`${name}: sidecarOf "${sidecarOf}" is not a bring-your-own resource`);
|
|
80
|
+
}
|
|
81
|
+
lines.push(` network_mode: ${q(`service:${sidecarOf}`)}`);
|
|
82
|
+
}
|
|
83
|
+
const env = record(c.env);
|
|
84
|
+
const envNames = Object.keys(env)
|
|
85
|
+
.filter((k) => ENV_NAME.test(k))
|
|
86
|
+
.sort();
|
|
87
|
+
if (envNames.length > 0) {
|
|
88
|
+
lines.push(" environment:");
|
|
89
|
+
for (const key of envNames)
|
|
90
|
+
lines.push(` ${key}: ${q(String(env[key]))}`);
|
|
91
|
+
}
|
|
92
|
+
const volumes = Array.isArray(c.volumes) ? c.volumes : [];
|
|
93
|
+
if (volumes.length > 0) {
|
|
94
|
+
lines.push(" volumes:");
|
|
95
|
+
for (const raw of volumes) {
|
|
96
|
+
const v = record(raw);
|
|
97
|
+
if (typeof v.source !== "string" || typeof v.target !== "string") {
|
|
98
|
+
throw new ExportError(`${name}: a volume needs a source and a target`);
|
|
99
|
+
}
|
|
100
|
+
let source = v.source;
|
|
101
|
+
if (isNamedVolume(source)) {
|
|
102
|
+
namedVolumes.add(source);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
source = hostPath(outDir, resolveFrom(resource.path, source));
|
|
106
|
+
}
|
|
107
|
+
lines.push(` - ${q(`${source}:${v.target}${v.readOnly === true ? ":ro" : ""}`)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const ports = Array.isArray(c.extraPorts) ? c.extraPorts : [];
|
|
111
|
+
if (ports.length > 0) {
|
|
112
|
+
if (sidecarOf) {
|
|
113
|
+
throw new ExportError(`${name}: a sidecar shares another service's network and cannot publish ports`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(" ports:");
|
|
116
|
+
for (const raw of ports) {
|
|
117
|
+
const p = record(raw);
|
|
118
|
+
const port = Number(p.port);
|
|
119
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
120
|
+
throw new ExportError(`${name}: extraPorts needs a port from 1 to 65535`);
|
|
121
|
+
}
|
|
122
|
+
const hostPort = p.hostPort === undefined ? port : Number(p.hostPort);
|
|
123
|
+
const bind = typeof p.bind === "string" && p.bind ? p.bind : "127.0.0.1";
|
|
124
|
+
const protocol = p.protocol === "udp" ? "udp" : "tcp";
|
|
125
|
+
lines.push(` - ${q(`${bind}:${hostPort}:${port}/${protocol}`)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const dependsOn = strings(c.dependsOn).filter((d) => known.has(d));
|
|
129
|
+
if (dependsOn.length > 0) {
|
|
130
|
+
lines.push(" depends_on:", ...dependsOn.map((d) => ` - ${q(d)}`));
|
|
131
|
+
}
|
|
132
|
+
const capAdd = strings(container.capAdd);
|
|
133
|
+
const capDrop = strings(container.capDrop);
|
|
134
|
+
const securityOpt = strings(container.securityOpt);
|
|
135
|
+
if (capAdd.length > 0)
|
|
136
|
+
lines.push(" cap_add:", ...capAdd.map((v) => ` - ${q(v)}`));
|
|
137
|
+
if (capDrop.length > 0)
|
|
138
|
+
lines.push(" cap_drop:", ...capDrop.map((v) => ` - ${q(v)}`));
|
|
139
|
+
if (container.readOnly === true)
|
|
140
|
+
lines.push(" read_only: true");
|
|
141
|
+
if (typeof container.user === "string")
|
|
142
|
+
lines.push(` user: ${q(container.user)}`);
|
|
143
|
+
if (securityOpt.length > 0) {
|
|
144
|
+
lines.push(" security_opt:", ...securityOpt.map((v) => ` - ${q(v)}`));
|
|
145
|
+
}
|
|
146
|
+
if (typeof container.shmSize === "string") {
|
|
147
|
+
if (!SHM_SIZE.test(container.shmSize)) {
|
|
148
|
+
throw new ExportError(`${name}: shmSize "${container.shmSize}" is not a Compose size`);
|
|
149
|
+
}
|
|
150
|
+
lines.push(` shm_size: ${q(container.shmSize)}`);
|
|
151
|
+
}
|
|
152
|
+
return { lines, namedVolumes };
|
|
153
|
+
}
|
|
154
|
+
function resolveFrom(base, rel) {
|
|
155
|
+
return rel.startsWith("/") ? rel : joinPosix(base, rel);
|
|
156
|
+
}
|
|
157
|
+
function joinPosix(base, rel) {
|
|
158
|
+
const parts = base.split(sep).concat(rel.split("/"));
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const part of parts) {
|
|
161
|
+
if (part === "" || part === ".")
|
|
162
|
+
continue;
|
|
163
|
+
if (part === "..")
|
|
164
|
+
out.pop();
|
|
165
|
+
else
|
|
166
|
+
out.push(part);
|
|
167
|
+
}
|
|
168
|
+
return (base.startsWith("/") ? "/" : "") + out.join("/");
|
|
169
|
+
}
|
|
170
|
+
/** The text of a docker-compose.yml for `resources`, written for a file in `outDir`. */
|
|
171
|
+
export function buildCompose(outDir, resources) {
|
|
172
|
+
const services = resources.filter(isComposeExportable);
|
|
173
|
+
if (services.length === 0) {
|
|
174
|
+
throw new ExportError("No bring-your-own resource was found to export", [
|
|
175
|
+
`Exported type: ${COMPOSE_TYPE} (a service.json with an image or a Dockerfile, and no command)`,
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
const known = new Set(services.map((s) => s.name));
|
|
179
|
+
const body = [];
|
|
180
|
+
const volumes = new Set();
|
|
181
|
+
for (const service of services) {
|
|
182
|
+
const built = serviceLines(service, outDir, known);
|
|
183
|
+
body.push(...built.lines, "");
|
|
184
|
+
for (const v of built.namedVolumes)
|
|
185
|
+
volumes.add(v);
|
|
186
|
+
}
|
|
187
|
+
const header = [
|
|
188
|
+
"###############################################################################",
|
|
189
|
+
"# GENERATED by `flisk export compose` from the service.json files. DO NOT EDIT.",
|
|
190
|
+
"# Change service.json and run `flisk export compose --force`; CI can run",
|
|
191
|
+
"# `flisk export compose --check` to fail when this file is out of date.",
|
|
192
|
+
"# Runs with plain `docker compose up`; Flisk is not needed to start it.",
|
|
193
|
+
"###############################################################################",
|
|
194
|
+
"",
|
|
195
|
+
"services:",
|
|
196
|
+
];
|
|
197
|
+
const tail = volumes.size > 0 ? ["volumes:", ...[...volumes].sort().map((v) => ` ${v}:`), ""] : [];
|
|
198
|
+
return [...header, ...body, ...tail].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
199
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `flisk-export render|wrangler`: writes a deploy file from service.json and stops there. It never deploys, never
|
|
3
3
|
* creates DNS records and never calls a cloud API. The Flisk CLI runs it as `flisk export` at a pinned version.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
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
9
|
import { discoverResources, findProjectRoot, findRepoRoot, findResource, } from "./discover.js";
|
|
9
10
|
import { ExportError } from "./errors.js";
|
|
10
11
|
import { assertExportable, buildRenderBlueprint, buildWranglerToml, EXPORTABLE_TYPES, isExportable, skippedEnvNames, } from "./generate.js";
|
|
@@ -80,6 +81,33 @@ function program(io) {
|
|
|
80
81
|
const outDir = options.out ? realDir(resolve(io.cwd, options.out)) : repoRoot;
|
|
81
82
|
emit(io, join(outDir, "render.yaml"), content, options);
|
|
82
83
|
});
|
|
84
|
+
cli
|
|
85
|
+
.command("compose")
|
|
86
|
+
.description("Write a standalone docker-compose.yml from bring-your-own service.json files; runs without Flisk, never deploys")
|
|
87
|
+
.argument("[dir]", "The Flisk project (default: the current directory)")
|
|
88
|
+
.option("--out <dir>", "Directory to write docker-compose.yml in (default: <project>/.autogenerated)")
|
|
89
|
+
.option("--dry-run", "Print docker-compose.yml instead of writing it", false)
|
|
90
|
+
.option("--force", "Replace an existing docker-compose.yml", false)
|
|
91
|
+
.option("--check", "Exit 1 when the file on disk differs from what would be written", false)
|
|
92
|
+
.action((dir, options) => {
|
|
93
|
+
const projectRoot = root(dir);
|
|
94
|
+
const outDir = options.out
|
|
95
|
+
? realDir(resolve(io.cwd, options.out))
|
|
96
|
+
: join(projectRoot, ".autogenerated");
|
|
97
|
+
const content = buildCompose(outDir, discover(projectRoot));
|
|
98
|
+
const file = join(outDir, "docker-compose.yml");
|
|
99
|
+
if (options.check) {
|
|
100
|
+
const shown = relative(realDir(io.cwd), file) || file;
|
|
101
|
+
if (!existsSync(file) || readFileSync(file, "utf-8") !== content) {
|
|
102
|
+
throw new ExportError(`${shown} is out of date`, [
|
|
103
|
+
"Run `flisk export compose --force` and commit the result",
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
io.out(`${shown} is up to date.\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
emit(io, file, content, options);
|
|
110
|
+
});
|
|
83
111
|
cli
|
|
84
112
|
.command("wrangler")
|
|
85
113
|
.description("Write a Cloudflare wrangler.toml for one resource from service.json; never deploys")
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flisk-sh/flisk-export",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Write a Render Blueprint (render.yaml)
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"bin": {
|