flisk-import 0.5.0 → 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 CHANGED
@@ -14,7 +14,7 @@ flisk import . --yes # write service.json files and project setup
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
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`.
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
+ - **Environment:** values that cannot be secrets go into the committed `service.json` `env`: numbers, `true`/`false`, another imported service's address without a user, password or query (`http://invidious:3000`, `redis:6379`), and pure `${VAR}` references. Every other name, and any name that looks like a credential (`PASSWORD`, `TOKEN`, `KEY`, ...), goes into `secrets.required`, with its literal value copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept.
18
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
19
  - **Does not start anything.** Run `flisk up` with Flisk core **1.4.24 or later**. The importer does not check the core version.
20
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.`).
@@ -211,6 +211,24 @@ function envValues(environment) {
211
211
  }
212
212
  return out;
213
213
  }
214
+ /** A value that is nothing but Compose interpolation, such as `${URL}` or `${PORT:-80}`. Holds no secret itself. */
215
+ const ONLY_REFERENCES = /^(\$\{[A-Za-z_][A-Za-z0-9_]*(?::?[-?+][^}\n]*)?\})+$/;
216
+ /** Values that are only `${VAR}` references, which service.json `env` can keep as they are (Flisk 1.4.24). */
217
+ function envReferences(environment) {
218
+ const pairs = Array.isArray(environment)
219
+ ? environment
220
+ .filter((e) => typeof e === "string" && e.includes("="))
221
+ .map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)])
222
+ : isObj(environment)
223
+ ? Object.entries(environment)
224
+ : [];
225
+ const out = {};
226
+ for (const [key, raw] of pairs) {
227
+ if (typeof raw === "string" && ONLY_REFERENCES.test(raw))
228
+ out[key] = raw;
229
+ }
230
+ return out;
231
+ }
214
232
  function commandOf(command) {
215
233
  if (typeof command === "string")
216
234
  return command;
@@ -347,6 +365,7 @@ registerDetector({
347
365
  const command = commandOf(raw.command);
348
366
  const envKeys = envNames(raw.environment);
349
367
  const env = envValues(raw.environment);
368
+ const envRefs = envReferences(raw.environment);
350
369
  const foundHealthPath = healthPathOf(raw.healthcheck);
351
370
  const healthCheckPath = foundHealthPath && HEALTH_CHECK_PATH_PATTERN.test(foundHealthPath)
352
371
  ? foundHealthPath
@@ -424,6 +443,7 @@ registerDetector({
424
443
  exposeViaProxy,
425
444
  ...(envKeys.length ? { envKeys } : {}),
426
445
  ...(Object.keys(env).length ? { env } : {}),
446
+ ...(Object.keys(envRefs).length ? { envRefs } : {}),
427
447
  ...(dependsOn.length ? { dependsOn } : {}),
428
448
  ...(container ? { container } : {}),
429
449
  ...(extraPorts.length ? { extraPorts } : {}),
package/dist/merge.js CHANGED
@@ -63,6 +63,7 @@ function mergeGroup(name, dir, members, resolve) {
63
63
  kind: ordered[0].kind,
64
64
  envKeys: [...new Set(ordered.flatMap((c) => c.envKeys ?? []))].sort(),
65
65
  env: Object.assign({}, ...[...ordered].reverse().map((c) => c.env ?? {})),
66
+ envRefs: Object.assign({}, ...[...ordered].reverse().map((c) => c.envRefs ?? {})),
66
67
  dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
67
68
  exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
68
69
  // Only Compose states these, so the first source that has one wins.
package/dist/write.js CHANGED
@@ -6,6 +6,31 @@ import { toResourceName } from "./merge.js";
6
6
  import { usedManifestPorts } from "./plan.js";
7
7
  /** Secret names the service schema accepts (`secrets.required`). */
8
8
  const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
9
+ /** Names that may hold a credential whatever their value looks like; these always stay secrets. */
10
+ const CREDENTIAL_NAME = /PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL|AUTH|HMAC|SALT|CERT|SESSION|COOKIE/i;
11
+ /**
12
+ * Whether a literal Compose value cannot be a secret, so it may be committed in service.json `env`: a number, true or false,
13
+ * or the address of another service in this import (a URL or host:port with no user, password, query or fragment).
14
+ */
15
+ function isPlainValue(value, serviceHosts) {
16
+ if (/^-?\d+(\.\d+)?$/.test(value) || /^(true|false)$/i.test(value))
17
+ return true;
18
+ const hostPort = value.match(/^([a-z0-9][a-z0-9._-]*):(\d{1,5})$/);
19
+ if (hostPort)
20
+ return serviceHosts.has(hostPort[1]);
21
+ try {
22
+ const url = new URL(value);
23
+ return (serviceHosts.has(url.hostname) &&
24
+ !url.username &&
25
+ !url.password &&
26
+ !url.search &&
27
+ !url.hash &&
28
+ /^[A-Za-z0-9._~/-]*$/.test(url.pathname));
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
9
34
  function readEnvKeys(path) {
10
35
  if (!existsSync(path))
11
36
  return new Set();
@@ -78,6 +103,8 @@ export function planWrites(plan, force) {
78
103
  return !reason;
79
104
  });
80
105
  const names = new Set(accepted.map((s) => s.name));
106
+ // Hostnames other services use for these: the resource name, which stays the container hostname, and the Compose name.
107
+ const serviceHosts = new Set(plan.services.flatMap((s) => (s.originalName ? [s.name, s.originalName] : [s.name])));
81
108
  for (const service of accepted) {
82
109
  const path = resolve(servicesDir, stack, service.name, "service.json");
83
110
  const exists = existsSync(path);
@@ -133,8 +160,28 @@ export function planWrites(plan, force) {
133
160
  notes.push("no health path: the service does not serve HTTP (non-HTTP image or job)");
134
161
  if (!isPrivate && !nonHttp && !service.healthCheckPath)
135
162
  notes.push("health path / is assumed: no HTTP health path could be read from Compose. Traefik routes only while it answers 2xx or 3xx");
136
- // Names go into service.json as secrets, so Flisk injects them; values go to the gitignored project .env.
137
- const secretNames = service.envKeys.filter((key) => ENV_NAME.test(key));
163
+ // Values that cannot be secrets (numbers, booleans, another service's address, pure ${VAR} references) go into the
164
+ // committed service.json `env` (Flisk 1.4.24). Every other name is a secret: Flisk injects it from the gitignored .env.
165
+ const validNames = service.envKeys.filter((key) => ENV_NAME.test(key));
166
+ const env = {};
167
+ for (const key of validNames) {
168
+ if (CREDENTIAL_NAME.test(key))
169
+ continue;
170
+ const literal = service.env[key];
171
+ const reference = service.envRefs[key];
172
+ if (literal !== undefined && isPlainValue(literal, serviceHosts))
173
+ env[key] = literal;
174
+ else if (literal === undefined && reference !== undefined)
175
+ env[key] = reference;
176
+ }
177
+ const referenced = [
178
+ ...new Set(Object.values(env).flatMap((value) => [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)?/g)]
179
+ .filter((m) => !m[2])
180
+ .map((m) => m[1]))),
181
+ ].filter((name) => !envKept.has(name) && !(name in envAdd));
182
+ if (referenced.length)
183
+ notes.push(`env reads ${referenced.join(", ")} from the project .env; add them there`);
184
+ const secretNames = validNames.filter((key) => !(key in env));
138
185
  const skippedNames = service.envKeys.filter((key) => !ENV_NAME.test(key));
139
186
  if (skippedNames.length)
140
187
  notes.push(`environment names not written (Flisk accepts A-Z, 0-9 and _): ${skippedNames.join(", ")}`);
@@ -196,6 +243,9 @@ export function planWrites(plan, force) {
196
243
  ...source,
197
244
  ...(isPrivate ? { exposeViaProxy: false } : {}),
198
245
  ...(dependsOn.length ? { dependsOn } : {}),
246
+ ...(Object.keys(env).length
247
+ ? { env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) }
248
+ : {}),
199
249
  ...(secretNames.length ? { secrets: { required: secretNames } } : {}),
200
250
  ...(service.restart ? { restart: service.restart } : {}),
201
251
  ...(service.container ? { container: service.container } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flisk-import",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Import a repository's root docker-compose.yml into Flisk service.json files, or register its Tiltfile.",
5
5
  "type": "module",
6
6
  "license": "MIT",