dokku-compose 0.14.1 → 0.15.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 +88 -45
- package/dist/{chunk-WM244P42.js → chunk-TP2BXE5O.js} +2 -1
- package/dist/index.js +2289 -487
- package/dist/{ps-QQAVQTEV.js → ps-4ESSTYJT.js} +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
SEMVER_RE,
|
|
3
4
|
assertDokkuSupported,
|
|
4
5
|
compareToFile,
|
|
5
6
|
enforceFileVersion,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
logError,
|
|
9
10
|
logSkip,
|
|
10
11
|
logWarn
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-TP2BXE5O.js";
|
|
12
13
|
|
|
13
14
|
// src/index.ts
|
|
14
15
|
import { Command } from "commander";
|
|
@@ -246,17 +247,470 @@ var Scheduler = {
|
|
|
246
247
|
}
|
|
247
248
|
};
|
|
248
249
|
|
|
250
|
+
// src/core/volume-rules.ts
|
|
251
|
+
var STORAGE_ROOT = "/var/lib/dokku/data/storage";
|
|
252
|
+
var DEFAULT_SCOPE = "_default_";
|
|
253
|
+
var LEGACY_PREFIX = "legacy-";
|
|
254
|
+
var MAX_ENTRY_NAME = 45;
|
|
255
|
+
var CHOWN_PRESETS = ["herokuish", "heroku", "paketo", "root"];
|
|
256
|
+
var DEFAULT_CHOWN = "herokuish";
|
|
257
|
+
var ENTRY_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
|
|
258
|
+
var DOCKER_VOLUME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
|
|
259
|
+
var PROCESS_TYPE_RE = /^[A-Za-z0-9_-]+$/;
|
|
260
|
+
var ALL_PHASES = ["deploy", "run"];
|
|
261
|
+
function defaultPath(name) {
|
|
262
|
+
return `${STORAGE_ROOT}/${name}`;
|
|
263
|
+
}
|
|
264
|
+
function isLegacyName(name) {
|
|
265
|
+
return name.startsWith(LEGACY_PREFIX);
|
|
266
|
+
}
|
|
267
|
+
function isValidEntryName(name) {
|
|
268
|
+
return name.length <= MAX_ENTRY_NAME && ENTRY_NAME_RE.test(name);
|
|
269
|
+
}
|
|
270
|
+
function entryNameError(name) {
|
|
271
|
+
if (!isValidEntryName(name)) {
|
|
272
|
+
return `'${name}' is not a valid entry name: use lowercase letters, digits and dashes (not at the start or end), at most ${MAX_ENTRY_NAME} characters`;
|
|
273
|
+
}
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
function isNormalizedAbsolute(p) {
|
|
277
|
+
if (!p.startsWith("/")) return false;
|
|
278
|
+
return p.split("/").slice(1).every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
279
|
+
}
|
|
280
|
+
function pathError(p) {
|
|
281
|
+
if (typeof p !== "string" || p === "") return "path must be a non-empty string";
|
|
282
|
+
if (p.startsWith("/")) {
|
|
283
|
+
return isNormalizedAbsolute(p) ? void 0 : `path '${p}' must be normalised: no trailing slash and no empty, '.' or '..' segment`;
|
|
284
|
+
}
|
|
285
|
+
return DOCKER_VOLUME_RE.test(p) ? void 0 : `path '${p}' must be an absolute directory or a Docker volume name`;
|
|
286
|
+
}
|
|
287
|
+
function usesDefaultLocation(name, path2) {
|
|
288
|
+
return path2 === void 0 || path2 === defaultPath(name);
|
|
289
|
+
}
|
|
290
|
+
function chownError(value) {
|
|
291
|
+
if (value === false) return void 0;
|
|
292
|
+
if (typeof value === "number") {
|
|
293
|
+
return Number.isInteger(value) && value >= 0 && value <= 65535 ? void 0 : `chown uid ${value} must be an integer from 0 to 65535`;
|
|
294
|
+
}
|
|
295
|
+
if (typeof value === "string") {
|
|
296
|
+
if (CHOWN_PRESETS.includes(value)) return void 0;
|
|
297
|
+
if (/^\d+$/.test(value) && Number(value) <= 65535) return void 0;
|
|
298
|
+
}
|
|
299
|
+
return `chown must be herokuish, heroku, paketo, root, a uid from 0 to 65535, or false (got ${JSON.stringify(value)})`;
|
|
300
|
+
}
|
|
301
|
+
function chownValue(value) {
|
|
302
|
+
return String(value);
|
|
303
|
+
}
|
|
304
|
+
function modeError(value) {
|
|
305
|
+
if (typeof value === "number") {
|
|
306
|
+
return `mode must be a quoted octal string: write mode: "${String(value).padStart(4, "0")}" (YAML reads an unquoted ${value} as a number)`;
|
|
307
|
+
}
|
|
308
|
+
if (typeof value === "string" && /^[0-7]{3,4}$/.test(value)) return void 0;
|
|
309
|
+
return `mode must be a quoted octal string of 3 or 4 digits, e.g. "0750" (got ${JSON.stringify(value)})`;
|
|
310
|
+
}
|
|
311
|
+
function normalizeMode(mode) {
|
|
312
|
+
return mode.length === 3 ? `0${mode}` : mode;
|
|
313
|
+
}
|
|
314
|
+
function parseMountString(s) {
|
|
315
|
+
if (s.startsWith("/")) {
|
|
316
|
+
return `'${s}' is a host path, which storage no longer takes: declare an entry under volumes: (with path: for an existing directory) and mount it as <entry>:<container-dir>`;
|
|
317
|
+
}
|
|
318
|
+
const parts = s.split(":");
|
|
319
|
+
if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
|
|
320
|
+
return `'${s}' must be <entry>:<container-dir>; options need the map form (entry, container_dir, ...)`;
|
|
321
|
+
}
|
|
322
|
+
return { entry: parts[0], container_dir: parts[1] };
|
|
323
|
+
}
|
|
324
|
+
function mountIssues(m) {
|
|
325
|
+
const issues = [];
|
|
326
|
+
if (!isNormalizedAbsolute(m.container_dir) || m.container_dir.includes(":")) {
|
|
327
|
+
issues.push(`container_dir '${m.container_dir}' must be a normalised absolute path without ':'`);
|
|
328
|
+
}
|
|
329
|
+
if (m.process_type !== void 0) {
|
|
330
|
+
if (m.process_type === DEFAULT_SCOPE) issues.push(`process_type '${DEFAULT_SCOPE}' is the default: leave process_type out`);
|
|
331
|
+
else if (!PROCESS_TYPE_RE.test(m.process_type)) issues.push(`process_type '${m.process_type}' must be a Procfile process type (letters, digits, '-' and '_')`);
|
|
332
|
+
}
|
|
333
|
+
for (const o of m.options ?? []) {
|
|
334
|
+
if (o === "") issues.push("options must not hold an empty item");
|
|
335
|
+
else if (o === "ro" || o === "rw") issues.push(`options must not hold '${o}': use readonly`);
|
|
336
|
+
else if (/[,=:\s]/.test(o)) issues.push(`option '${o}' must not contain ',', '=', ':' or whitespace (Dokku reads 'key=value' tokens as its own keys)`);
|
|
337
|
+
}
|
|
338
|
+
const phases = m.phases ?? [];
|
|
339
|
+
if (new Set(phases).size !== phases.length) issues.push("phases must not repeat a phase");
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
function normalizeMount(m) {
|
|
343
|
+
const phases = m.phases && m.phases.length > 0 ? m.phases : ALL_PHASES;
|
|
344
|
+
return {
|
|
345
|
+
entry: m.entry,
|
|
346
|
+
container_dir: m.container_dir,
|
|
347
|
+
readonly: m.readonly ?? false,
|
|
348
|
+
phases: ALL_PHASES.filter((p) => phases.includes(p)),
|
|
349
|
+
process_type: m.process_type ?? DEFAULT_SCOPE,
|
|
350
|
+
options: m.options ?? []
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function scopesOverlap(a, b) {
|
|
354
|
+
return a === DEFAULT_SCOPE || b === DEFAULT_SCOPE || a === b;
|
|
355
|
+
}
|
|
356
|
+
function parseServiceMountString(s) {
|
|
357
|
+
if (s.startsWith("/")) {
|
|
358
|
+
return `'${s}' is a host path, which mounts no longer take: declare the directory as an entry under volumes: (with path:) and mount it as <entry>:<container-dir>`;
|
|
359
|
+
}
|
|
360
|
+
const parsed = parseMountString(s);
|
|
361
|
+
return typeof parsed === "string" ? parsed.replace("storage", "a service mount") : { entry: parsed.entry, container_dir: parsed.container_dir };
|
|
362
|
+
}
|
|
363
|
+
function serviceMountIssues(m) {
|
|
364
|
+
return mountIssues(m);
|
|
365
|
+
}
|
|
366
|
+
function normalizeServiceMount(m) {
|
|
367
|
+
return { entry: m.entry, container_dir: m.container_dir, readonly: m.readonly ?? false, options: m.options ?? [] };
|
|
368
|
+
}
|
|
369
|
+
function declaredServiceMounts(mounts) {
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const item of mounts ?? []) {
|
|
372
|
+
const parsed = typeof item === "string" ? parseServiceMountString(item) : item;
|
|
373
|
+
if (typeof parsed !== "string") out.push(normalizeServiceMount(parsed));
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
function declaredMounts(storage) {
|
|
378
|
+
const mounts = [];
|
|
379
|
+
for (const item of storage ?? []) {
|
|
380
|
+
const parsed = typeof item === "string" ? parseMountString(item) : item;
|
|
381
|
+
if (typeof parsed === "string") continue;
|
|
382
|
+
mounts.push(normalizeMount(parsed));
|
|
383
|
+
}
|
|
384
|
+
return mounts;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/modules/global.ts
|
|
388
|
+
var same = (name, cmd, keys, kind = "scalar") => keys.map((key) => ({ yaml: key, cmd, key, report: name, kind }));
|
|
389
|
+
var REGISTRY_GLOBAL_KEYS = ["image-repo-template", "push-extra-tags", "push-on-release", "server"];
|
|
390
|
+
var PS_GLOBAL_KEYS = ["procfile-path", "restart-policy", "skip-deploy", "stop-timeout-seconds"];
|
|
391
|
+
var GLOBAL_SPECS = {
|
|
392
|
+
nginx: { name: "nginx", keys: same("nginx", "nginx:set", NGINX_KEYS) },
|
|
393
|
+
logs: { name: "logs", keys: same("logs", "logs:set", LOGS_GLOBAL_KEYS) },
|
|
394
|
+
scheduler: { name: "scheduler", keys: same("scheduler", "scheduler:set", ["selected", "shell"]) },
|
|
395
|
+
registry: { name: "registry", keys: same("registry", "registry:set", REGISTRY_GLOBAL_KEYS) },
|
|
396
|
+
checks: { name: "checks", keys: same("checks", "checks:set", ["wait-to-retire"]) },
|
|
397
|
+
ps: { name: "ps", keys: same("ps", "ps:set", PS_GLOBAL_KEYS) },
|
|
398
|
+
build: {
|
|
399
|
+
name: "build",
|
|
400
|
+
keys: [
|
|
401
|
+
{ yaml: "selected", cmd: "builder:set", key: "selected", report: "builder", kind: "scalar", effect: "rebuild" },
|
|
402
|
+
{ yaml: "context", cmd: "builder:set", key: "build-dir", report: "builder", kind: "scalar", effect: "rebuild" },
|
|
403
|
+
{ yaml: "skip_cleanup", cmd: "builder:set", key: "skip-cleanup", report: "builder", kind: "scalar" },
|
|
404
|
+
{ yaml: "dockerfile", cmd: "builder-dockerfile:set", key: "dockerfile-path", report: "builder-dockerfile", kind: "scalar", effect: "rebuild" },
|
|
405
|
+
{ yaml: "app_json", cmd: "app-json:set", key: "appjson-path", report: "app-json", kind: "scalar", effect: "rebuild" }
|
|
406
|
+
]
|
|
407
|
+
},
|
|
408
|
+
network: {
|
|
409
|
+
name: "network",
|
|
410
|
+
keys: [
|
|
411
|
+
{ yaml: "attach_post_create", cmd: "network:set", key: "attach-post-create", report: "network", kind: "list", effect: "rebuild" },
|
|
412
|
+
{ yaml: "attach_post_deploy", cmd: "network:set", key: "attach-post-deploy", report: "network", kind: "list", effect: "rebuild", keep: true },
|
|
413
|
+
{ yaml: "initial_network", cmd: "network:set", key: "initial-network", report: "network", kind: "clearable", effect: "rebuild" },
|
|
414
|
+
{ yaml: "bind_all_interfaces", cmd: "network:set", key: "bind-all-interfaces", report: "network", kind: "flag", effect: "restart" },
|
|
415
|
+
{ yaml: "tld", cmd: "network:set", key: "tld", report: "network", kind: "clearable", effect: "restart" }
|
|
416
|
+
]
|
|
417
|
+
},
|
|
418
|
+
// `enabled` (`proxy:enable`/`proxy:disable`) has no global form.
|
|
419
|
+
proxy: { name: "proxy", keys: [
|
|
420
|
+
{ yaml: "type", cmd: "proxy:set", key: "type", report: "proxy", kind: "scalar" },
|
|
421
|
+
{ yaml: "proxy_port", cmd: "proxy:set", key: "proxy-port", report: "proxy", kind: "scalar" },
|
|
422
|
+
{ yaml: "proxy_ssl_port", cmd: "proxy:set", key: "proxy-ssl-port", report: "proxy", kind: "scalar" }
|
|
423
|
+
] },
|
|
424
|
+
git: {
|
|
425
|
+
name: "git",
|
|
426
|
+
keys: [
|
|
427
|
+
{ yaml: "deploy_branch", cmd: "git:set", key: "deploy-branch", report: "git", kind: "scalar" },
|
|
428
|
+
{ yaml: "keep_git_dir", cmd: "git:set", key: "keep-git-dir", report: "git", kind: "scalar" },
|
|
429
|
+
{ yaml: "archive_max_files", cmd: "git:set", key: "archive-max-files", report: "git", kind: "scalar" },
|
|
430
|
+
{ yaml: "archive_max_size", cmd: "git:set", key: "archive-max-size", report: "git", kind: "scalar" }
|
|
431
|
+
]
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
var GLOBAL_NAMES = Object.keys(GLOBAL_SPECS);
|
|
435
|
+
var GLOBAL_UP_ORDER = ["logs", "nginx", "scheduler", "registry", "checks", "ps", "build", "proxy", "git"];
|
|
436
|
+
function declaredMap(name, block) {
|
|
437
|
+
if (typeof block === "string") return name === "scheduler" ? { selected: block } : {};
|
|
438
|
+
return block ?? {};
|
|
439
|
+
}
|
|
440
|
+
async function readGlobal(ctx, name) {
|
|
441
|
+
const spec = GLOBAL_SPECS[name];
|
|
442
|
+
const byReport = /* @__PURE__ */ new Map();
|
|
443
|
+
for (const report of new Set(spec.keys.map((k) => k.report))) {
|
|
444
|
+
byReport.set(report, parseJsonReport(await ctx.queryStrict(`${report}:report`, "--global", "--format", "json"), "global"));
|
|
445
|
+
}
|
|
446
|
+
const current = {};
|
|
447
|
+
for (const gk of spec.keys) {
|
|
448
|
+
const value = byReport.get(gk.report)[gk.key];
|
|
449
|
+
if (value !== void 0) current[gk.yaml] = value;
|
|
450
|
+
}
|
|
451
|
+
return current;
|
|
452
|
+
}
|
|
453
|
+
var words = (value) => (value ?? "").split(/\s+/).filter(Boolean);
|
|
454
|
+
var sameSet = (a, b) => a.length === b.length && a.every((v) => b.includes(v));
|
|
455
|
+
function wanted(gk, value) {
|
|
456
|
+
if (gk.kind === "list") {
|
|
457
|
+
const names = Array.isArray(value) ? value.map(String) : [];
|
|
458
|
+
return { args: names, inSync: (current) => sameSet(words(current), names) };
|
|
459
|
+
}
|
|
460
|
+
if (gk.kind === "clearable" && value === false) {
|
|
461
|
+
return { args: [], inSync: (current) => current === void 0 };
|
|
462
|
+
}
|
|
463
|
+
if (gk.kind === "flag") {
|
|
464
|
+
const on = value === true || value === "true";
|
|
465
|
+
return { args: [String(on)], inSync: (current) => on ? current === "true" : current !== "true" };
|
|
466
|
+
}
|
|
467
|
+
return { args: [String(value)], inSync: (current) => current === String(value) };
|
|
468
|
+
}
|
|
469
|
+
var BOOLEAN_KEYS = /* @__PURE__ */ new Set(["push-on-release", "skip-deploy", "skip_cleanup", "keep_git_dir"]);
|
|
470
|
+
async function exportGlobal(ctx, name) {
|
|
471
|
+
const current = await readGlobal(ctx, name);
|
|
472
|
+
const block = {};
|
|
473
|
+
for (const gk of GLOBAL_SPECS[name].keys) {
|
|
474
|
+
const value = current[gk.yaml];
|
|
475
|
+
if (value === void 0) continue;
|
|
476
|
+
if (gk.kind === "list") block[gk.yaml] = words(value);
|
|
477
|
+
else if (gk.kind === "flag" || BOOLEAN_KEYS.has(gk.yaml) && (value === "true" || value === "false")) block[gk.yaml] = value === "true";
|
|
478
|
+
else block[gk.yaml] = value;
|
|
479
|
+
}
|
|
480
|
+
const keys = Object.keys(block);
|
|
481
|
+
if (keys.length === 0) return void 0;
|
|
482
|
+
if (name === "scheduler" && keys.length === 1 && keys[0] === "selected") return block.selected;
|
|
483
|
+
return block;
|
|
484
|
+
}
|
|
485
|
+
function planGlobal(name, block, current) {
|
|
486
|
+
const declared = declaredMap(name, block);
|
|
487
|
+
const keys = GLOBAL_SPECS[name].keys;
|
|
488
|
+
const plan = { changes: [], removals: [] };
|
|
489
|
+
for (const yaml4 of Object.keys(declared)) {
|
|
490
|
+
const gk = keys.find((k) => k.yaml === yaml4);
|
|
491
|
+
if (!gk || declared[yaml4] === void 0) continue;
|
|
492
|
+
const want = wanted(gk, declared[yaml4]);
|
|
493
|
+
if (!want.inSync(current[yaml4])) plan.changes.push({ gk, args: want.args });
|
|
494
|
+
}
|
|
495
|
+
for (const gk of keys) {
|
|
496
|
+
if (!gk.keep && declared[gk.yaml] === void 0 && current[gk.yaml] !== void 0) plan.removals.push(gk);
|
|
497
|
+
}
|
|
498
|
+
return plan;
|
|
499
|
+
}
|
|
500
|
+
function appSets(name, gk, app) {
|
|
501
|
+
if (name === "network") {
|
|
502
|
+
if (gk.yaml === "attach_post_deploy") return app?.networks !== void 0;
|
|
503
|
+
return app?.network?.[gk.yaml] !== void 0;
|
|
504
|
+
}
|
|
505
|
+
if (name === "build") return app?.build?.[gk.yaml] !== void 0;
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
function markApps(ctx, name, gk, opts) {
|
|
509
|
+
if (!gk.effect) return;
|
|
510
|
+
for (const app of opts.apps ?? []) {
|
|
511
|
+
if (appSets(name, gk, opts.appConfigs?.[app])) continue;
|
|
512
|
+
if (gk.effect === "rebuild") ctx.needsRebuild(app, `global ${name}`);
|
|
513
|
+
else ctx.needsRestart(app, `global ${name}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async function ensureGlobal(ctx, name, block, opts = {}) {
|
|
517
|
+
logAction("global", `Configuring ${name}`);
|
|
518
|
+
const plan = planGlobal(name, block, await readGlobal(ctx, name));
|
|
519
|
+
for (const { gk, args } of plan.changes) {
|
|
520
|
+
await ctx.run(gk.cmd, "--global", gk.key, ...args);
|
|
521
|
+
markApps(ctx, name, gk, opts);
|
|
522
|
+
}
|
|
523
|
+
if (opts.prune) {
|
|
524
|
+
for (const gk of plan.removals) {
|
|
525
|
+
await ctx.run(gk.cmd, "--global", gk.key);
|
|
526
|
+
markApps(ctx, name, gk, opts);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const changed = plan.changes.length > 0 || !!opts.prune && plan.removals.length > 0;
|
|
530
|
+
if (changed) logDone();
|
|
531
|
+
else logSkip();
|
|
532
|
+
if (!opts.prune && plan.removals.length > 0) {
|
|
533
|
+
logWarn("global", `${plan.removals.length} global ${name} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
|
|
534
|
+
}
|
|
535
|
+
return changed;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/modules/service-property-keys.ts
|
|
539
|
+
import { posix } from "path";
|
|
540
|
+
var MANAGED_PROPERTIES = {
|
|
541
|
+
"initial-network": { kind: "string", createFlag: "initial-network", restart: true },
|
|
542
|
+
"post-create-network": { kind: "list", createFlag: "post-create-network", restart: true },
|
|
543
|
+
"post-start-network": { kind: "list", createFlag: "post-start-network", restart: true },
|
|
544
|
+
"log-driver": { kind: "string", createFlag: "log-driver", restart: true },
|
|
545
|
+
"log-opt": { kind: "list", createFlag: "log-opt", restart: true },
|
|
546
|
+
"restart-policy": { kind: "string", createFlag: "restart", restart: true },
|
|
547
|
+
"wait-timeout": { kind: "number", createFlag: "wait-timeout", restart: false },
|
|
548
|
+
"volume-targets": { kind: "targets", createFlag: "volume-target", repeat: true, restart: true },
|
|
549
|
+
"config-options": { kind: "string", createFlag: "config-options", restart: true },
|
|
550
|
+
"custom-env": { kind: "map", createFlag: "custom-env", restart: true },
|
|
551
|
+
memory: { kind: "number", createFlag: "memory", restart: true },
|
|
552
|
+
"shm-size": { kind: "string", createFlag: "shm-size", restart: true },
|
|
553
|
+
password: { kind: "secret", createFlag: "password", restart: false }
|
|
554
|
+
};
|
|
555
|
+
var COMMON_SET_KEYS = [
|
|
556
|
+
"initial-network",
|
|
557
|
+
"post-create-network",
|
|
558
|
+
"post-start-network",
|
|
559
|
+
"backup-keyserver",
|
|
560
|
+
"backup-storage-class",
|
|
561
|
+
"backup-object-name",
|
|
562
|
+
"backup-timestamp",
|
|
563
|
+
"backup-mailto",
|
|
564
|
+
"backup-access-key-id",
|
|
565
|
+
"backup-secret-access-key",
|
|
566
|
+
"backup-default-region",
|
|
567
|
+
"backup-signature-version",
|
|
568
|
+
"backup-endpoint-url",
|
|
569
|
+
"backup-encryption-passphrase",
|
|
570
|
+
"backup-public-key-id",
|
|
571
|
+
"backup-bucket",
|
|
572
|
+
"backup-use-iam",
|
|
573
|
+
"log-driver",
|
|
574
|
+
"log-opt",
|
|
575
|
+
"restart-policy",
|
|
576
|
+
"wait-timeout",
|
|
577
|
+
"port-bind-address",
|
|
578
|
+
"port-source-range",
|
|
579
|
+
"expose-host",
|
|
580
|
+
"expose-mode"
|
|
581
|
+
];
|
|
582
|
+
var TAIL_SET_KEYS = ["volume-targets", "config-options", "custom-env", "memory", "shm-size", "password"];
|
|
583
|
+
var PLUGIN_SET_KEYS = {
|
|
584
|
+
postgres: [...COMMON_SET_KEYS, "export-args", "import-args", ...TAIL_SET_KEYS],
|
|
585
|
+
redis: [...COMMON_SET_KEYS, ...TAIL_SET_KEYS]
|
|
586
|
+
};
|
|
587
|
+
var TESTED_PLUGIN_VERSION = "2.4.0";
|
|
588
|
+
function rejectedReason(key) {
|
|
589
|
+
if (key.startsWith("backup-")) return "backup settings belong to the `backup:` block";
|
|
590
|
+
if (key.startsWith("expose-") || key === "port-bind-address" || key === "port-source-range") {
|
|
591
|
+
return "exposing a service is not managed yet";
|
|
592
|
+
}
|
|
593
|
+
switch (key) {
|
|
594
|
+
case "export-args":
|
|
595
|
+
case "import-args":
|
|
596
|
+
return "arguments for ad-hoc exports and imports are not managed";
|
|
597
|
+
case "root-password":
|
|
598
|
+
return "postgres and redis have no root password";
|
|
599
|
+
case "service-memory":
|
|
600
|
+
return "postgres and redis have no service memory";
|
|
601
|
+
case "image":
|
|
602
|
+
case "image-version":
|
|
603
|
+
case "version":
|
|
604
|
+
return "declare `version` and `image` beside `properties`; `up` never upgrades a service";
|
|
605
|
+
case "definition":
|
|
606
|
+
return "the definition a service runs on is changed with upgrade";
|
|
607
|
+
case "mounts":
|
|
608
|
+
return "mounts are declared in the `mounts:` key";
|
|
609
|
+
default:
|
|
610
|
+
return void 0;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
var PRUNABLE_PROPERTIES = Object.keys(MANAGED_PROPERTIES).filter((k) => k !== "password");
|
|
614
|
+
function propertyString(key, value) {
|
|
615
|
+
if (Array.isArray(value)) return value.map(String).join(",");
|
|
616
|
+
if (typeof value === "object") {
|
|
617
|
+
if (MANAGED_PROPERTIES[key]?.kind === "targets") return targetPairs(value).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
618
|
+
return Object.entries(value).map(([k, v]) => `${k}=${v}`).join(";");
|
|
619
|
+
}
|
|
620
|
+
if (MANAGED_PROPERTIES[key]?.kind === "targets") return targetPairs(String(value)).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
621
|
+
return String(value).trim();
|
|
622
|
+
}
|
|
623
|
+
var splitSorted = (value, sep) => value.split(sep).map((s) => s.trim()).filter((s) => s !== "").sort().join(sep);
|
|
624
|
+
function comparableProperty(key, value) {
|
|
625
|
+
switch (MANAGED_PROPERTIES[key]?.kind) {
|
|
626
|
+
case "list":
|
|
627
|
+
return splitSorted(value, ",");
|
|
628
|
+
case "map":
|
|
629
|
+
return splitSorted(value, ";");
|
|
630
|
+
case "targets":
|
|
631
|
+
return targetPairs(value).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
632
|
+
case "number":
|
|
633
|
+
return key === "memory" && value === "" ? "0" : value.trim();
|
|
634
|
+
default:
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function isDefaultProperty(key, current) {
|
|
639
|
+
return key === "memory" ? current === "" || current === "0" : current === "";
|
|
640
|
+
}
|
|
641
|
+
function sameProperty(key, desired, current) {
|
|
642
|
+
return comparableProperty(key, desired) === comparableProperty(key, current);
|
|
643
|
+
}
|
|
644
|
+
function customEnvPairs(value) {
|
|
645
|
+
if (typeof value === "string") {
|
|
646
|
+
return value.split(";").filter((s) => s !== "").map((s) => {
|
|
647
|
+
const i = s.indexOf("=");
|
|
648
|
+
return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return Object.entries(value).map(([k, v]) => [k, String(v)]);
|
|
652
|
+
}
|
|
653
|
+
function cleanPath(p) {
|
|
654
|
+
const n = posix.normalize(p);
|
|
655
|
+
return n.length > 1 ? n.replace(/\/+$/, "") : n;
|
|
656
|
+
}
|
|
657
|
+
function targetPairs(value) {
|
|
658
|
+
const pairs = typeof value === "string" ? value.split(/\s+/).filter((s) => s !== "").map((s) => {
|
|
659
|
+
const i = s.indexOf("=");
|
|
660
|
+
return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
|
|
661
|
+
}) : Object.entries(value).map(([k, v]) => [k, String(v)]);
|
|
662
|
+
return pairs.map(([k, v]) => [k, v.startsWith("/") ? cleanPath(v) : v]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
663
|
+
}
|
|
664
|
+
function normalizeMount2(spec) {
|
|
665
|
+
const parts = spec.split(":");
|
|
666
|
+
const source = parts[0] ?? "";
|
|
667
|
+
const dir = parts[1] ?? "";
|
|
668
|
+
const rest = parts.slice(2).join(":");
|
|
669
|
+
if (rest === "") return `${source}:${dir}`;
|
|
670
|
+
let ro = false;
|
|
671
|
+
let subpath;
|
|
672
|
+
let chown;
|
|
673
|
+
const others = [];
|
|
674
|
+
for (const token of rest.split(",")) {
|
|
675
|
+
if (token === "ro") ro = true;
|
|
676
|
+
else if (token === "rw") continue;
|
|
677
|
+
else if (token.startsWith("volume-subpath=")) subpath = token;
|
|
678
|
+
else if (token.startsWith("volume-chown=")) chown = token;
|
|
679
|
+
else others.push(token);
|
|
680
|
+
}
|
|
681
|
+
const options = [...ro ? ["ro"] : [], ...others, ...subpath ? [subpath] : [], ...chown ? [chown] : []];
|
|
682
|
+
return options.length ? `${source}:${dir}:${options.join(",")}` : `${source}:${dir}`;
|
|
683
|
+
}
|
|
684
|
+
function mountSet(specs) {
|
|
685
|
+
return [...new Set(specs.map(normalizeMount2))].sort();
|
|
686
|
+
}
|
|
687
|
+
function parseInfoMounts(raw) {
|
|
688
|
+
return mountSet(raw.split(/\s+/).filter((s) => s !== ""));
|
|
689
|
+
}
|
|
690
|
+
|
|
249
691
|
// src/core/schema.ts
|
|
250
|
-
function propertyMap(namespace, keys, value) {
|
|
692
|
+
function propertyMap(namespace, keys, value, scope = "") {
|
|
251
693
|
return z.record(z.string(), value).superRefine((map, ctx) => {
|
|
252
694
|
for (const key of Object.keys(map)) {
|
|
253
695
|
if (!keys.includes(key)) {
|
|
254
|
-
ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set property (valid: ${keys.join(", ")})` });
|
|
696
|
+
ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set${scope} property (valid: ${keys.join(", ")})` });
|
|
255
697
|
}
|
|
256
698
|
}
|
|
257
699
|
});
|
|
258
700
|
}
|
|
259
701
|
var StringOrNumber = z.union([z.string(), z.number()]);
|
|
702
|
+
function withHints(hints, notAnObject) {
|
|
703
|
+
return {
|
|
704
|
+
error: (iss) => {
|
|
705
|
+
if (iss.code === "unrecognized_keys" && iss.keys.some((k) => k in hints)) {
|
|
706
|
+
return iss.keys.map((k) => `'${k}' ${hints[k] ?? "is not a recognized key"}`).join("; ");
|
|
707
|
+
}
|
|
708
|
+
if (iss.code === "invalid_type" && iss.expected === "object") return notAnObject?.(iss.input);
|
|
709
|
+
return void 0;
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
var PER_APP_ONLY = "is a per-app setting (Dokku has no global form); put it under apps.<app>";
|
|
260
714
|
var PortSchema = z.string().regex(
|
|
261
715
|
/^(http|https|tcp|udp):\d+:\d+$/,
|
|
262
716
|
"Port must be scheme:host:container (e.g. http:80:3000)"
|
|
@@ -265,13 +719,17 @@ var EnvMapSchema = z.union([
|
|
|
265
719
|
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
266
720
|
z.literal(false)
|
|
267
721
|
]);
|
|
722
|
+
var WaitToRetire = z.preprocess(
|
|
723
|
+
(v) => typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : v,
|
|
724
|
+
z.number({ error: "wait-to-retire is a number of seconds" }).int().min(0)
|
|
725
|
+
);
|
|
268
726
|
var ChecksSchema = z.union([
|
|
269
727
|
z.literal(false),
|
|
270
728
|
z.object({
|
|
271
729
|
disabled: z.array(z.string()).optional(),
|
|
272
730
|
skipped: z.array(z.string()).optional(),
|
|
273
731
|
// The only `checks:set` key (Dokku 0.38.31); anything else is refused.
|
|
274
|
-
"wait-to-retire":
|
|
732
|
+
"wait-to-retire": WaitToRetire.optional()
|
|
275
733
|
}).strict()
|
|
276
734
|
]);
|
|
277
735
|
var GitSchema = z.object({
|
|
@@ -280,10 +738,88 @@ var GitSchema = z.object({
|
|
|
280
738
|
rev_env_var: z.string().optional(),
|
|
281
739
|
source_image: z.string().optional(),
|
|
282
740
|
// Dokku reads `archive-max-files` and `archive-max-size` only from the global setting
|
|
283
|
-
// (`git:set --global`),
|
|
284
|
-
archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting
|
|
285
|
-
archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting
|
|
741
|
+
// (`git:set --global`), so a per-app value never applies: declare them in the top-level `git:`.
|
|
742
|
+
archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting; declare it in the top-level git:" }),
|
|
743
|
+
archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting; declare it in the top-level git:" })
|
|
744
|
+
}).strict();
|
|
745
|
+
var GlobalGitSchema = z.strictObject({
|
|
746
|
+
deploy_branch: z.string().optional(),
|
|
747
|
+
keep_git_dir: z.union([z.string(), z.boolean()]).optional(),
|
|
748
|
+
archive_max_files: StringOrNumber.optional(),
|
|
749
|
+
archive_max_size: StringOrNumber.optional()
|
|
750
|
+
}, withHints({ rev_env_var: `${PER_APP_ONLY}.git`, source_image: `${PER_APP_ONLY}.git` }));
|
|
751
|
+
var SchedulerSchema = z.union([z.string(), z.object({ selected: z.string().optional(), shell: z.string().optional() }).strict()]);
|
|
752
|
+
var PsSchema = z.object({
|
|
753
|
+
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
754
|
+
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
755
|
+
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
756
|
+
"dockerfile-start-cmd": z.union([z.string(), z.number()]).optional(),
|
|
757
|
+
restore: z.union([z.string(), z.boolean()]).optional(),
|
|
758
|
+
"skip-deploy": z.union([z.string(), z.boolean()]).optional(),
|
|
759
|
+
"start-cmd": z.union([z.string(), z.number()]).optional()
|
|
760
|
+
}).strict();
|
|
761
|
+
var GlobalPsSchema = z.strictObject({
|
|
762
|
+
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
763
|
+
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
764
|
+
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
765
|
+
"skip-deploy": z.union([z.string(), z.boolean()]).optional()
|
|
766
|
+
}, withHints(Object.fromEntries(["restore", "start-cmd", "dockerfile-start-cmd"].map((k) => [k, `${PER_APP_ONLY}.ps`]))));
|
|
767
|
+
var GlobalChecksSchema = z.strictObject({
|
|
768
|
+
// The only `checks:set` key (Dokku 0.38.31), and the only one with a global form.
|
|
769
|
+
"wait-to-retire": WaitToRetire.optional()
|
|
770
|
+
}, withHints({
|
|
771
|
+
disabled: `${PER_APP_ONLY}.checks`,
|
|
772
|
+
skipped: `${PER_APP_ONLY}.checks`
|
|
773
|
+
}, (input) => input === false ? "false disables the checks of one app; put it under apps.<app>.checks" : void 0));
|
|
774
|
+
var BuildSchema = z.object({
|
|
775
|
+
dockerfile: z.string().optional(),
|
|
776
|
+
app_json: z.string().optional(),
|
|
777
|
+
context: z.string().optional(),
|
|
778
|
+
selected: z.string().optional(),
|
|
779
|
+
skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
|
|
780
|
+
args: z.record(z.string(), z.string()).optional()
|
|
286
781
|
}).strict();
|
|
782
|
+
var GlobalBuildSchema = z.strictObject({
|
|
783
|
+
dockerfile: z.string().optional(),
|
|
784
|
+
app_json: z.string().optional(),
|
|
785
|
+
context: z.string().optional(),
|
|
786
|
+
selected: z.string().optional(),
|
|
787
|
+
skip_cleanup: z.union([z.string(), z.boolean()]).optional()
|
|
788
|
+
}, withHints({ args: "has no global form (build args are docker options); put them under apps.<app>.build.args" }));
|
|
789
|
+
var NETWORK_LIST_HINT = "is the list of networks to attach; properties such as tld go under network:";
|
|
790
|
+
var NETWORK_PROPS_HINT = "takes properties (tld, bind_all_interfaces, ...); the list of networks to attach goes under networks:";
|
|
791
|
+
var NetworkSchema = z.strictObject({
|
|
792
|
+
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
793
|
+
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
794
|
+
bind_all_interfaces: z.boolean().optional(),
|
|
795
|
+
static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
|
|
796
|
+
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
797
|
+
}, withHints({
|
|
798
|
+
attach_post_deploy: "is not a network: key; per app, attach-post-deploy is the list under networks:",
|
|
799
|
+
networks: "is a sibling of network:, not a key inside it"
|
|
800
|
+
}, (input) => Array.isArray(input) ? NETWORK_PROPS_HINT : void 0));
|
|
801
|
+
var GlobalNetworkSchema = z.strictObject({
|
|
802
|
+
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
803
|
+
attach_post_deploy: z.array(z.string()).optional(),
|
|
804
|
+
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
805
|
+
bind_all_interfaces: z.boolean().optional(),
|
|
806
|
+
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
807
|
+
}, withHints({
|
|
808
|
+
static_web_listener: `${PER_APP_ONLY}.network`,
|
|
809
|
+
networks: "is the list of shared networks to create; it sits beside network:, not inside it"
|
|
810
|
+
}, (input) => Array.isArray(input) ? "takes global properties (tld, attach_post_deploy, ...); the list of shared networks to create goes under networks:" : void 0));
|
|
811
|
+
var GlobalProxySchema = z.strictObject({
|
|
812
|
+
type: z.string().optional(),
|
|
813
|
+
proxy_port: StringOrNumber.optional(),
|
|
814
|
+
proxy_ssl_port: StringOrNumber.optional()
|
|
815
|
+
}, withHints({ enabled: `${PER_APP_ONLY}.proxy` }));
|
|
816
|
+
var isMap = (input) => !!input && typeof input === "object" && !Array.isArray(input);
|
|
817
|
+
var appNetworksList = z.array(z.string(), {
|
|
818
|
+
error: (iss) => iss.code === "invalid_type" && isMap(iss.input) ? NETWORK_LIST_HINT : void 0
|
|
819
|
+
});
|
|
820
|
+
var sharedNetworksList = z.array(z.string(), {
|
|
821
|
+
error: (iss) => iss.code === "invalid_type" && isMap(iss.input) ? "is the list of shared networks to create; global network properties go under network:" : void 0
|
|
822
|
+
});
|
|
287
823
|
var LinkSchema = z.union([
|
|
288
824
|
z.string(),
|
|
289
825
|
z.object({
|
|
@@ -293,6 +829,59 @@ var LinkSchema = z.union([
|
|
|
293
829
|
querystring: z.string().min(1).optional()
|
|
294
830
|
}).strict().refine((l) => !(l.alias && l.env_var), { message: "`alias` and `env_var` cannot be combined" })
|
|
295
831
|
]);
|
|
832
|
+
var VolumeSchema = z.object({
|
|
833
|
+
// Checked below, so a wrong type gets a message that says how to write it.
|
|
834
|
+
chown: z.union([z.literal(false), z.string(), z.number()]).optional(),
|
|
835
|
+
mode: z.union([z.string(), z.number()]).optional(),
|
|
836
|
+
path: z.string().optional()
|
|
837
|
+
}).strict();
|
|
838
|
+
var VolumesSchema = z.record(z.string(), VolumeSchema).superRefine((volumes, ctx) => {
|
|
839
|
+
const bad = (name, key, message) => ctx.addIssue({ code: "custom", path: key ? [name, key] : [name], message });
|
|
840
|
+
for (const [name, v] of Object.entries(volumes)) {
|
|
841
|
+
if (isLegacyName(name)) {
|
|
842
|
+
if (Object.keys(v).length > 0) bad(name, void 0, `'${name}' starts with 'legacy-', which Dokku reserves for entries it made from old host-path mounts: declare such an entry as {}, or give a new entry another name`);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const nameError = entryNameError(name);
|
|
846
|
+
if (nameError) bad(name, void 0, nameError);
|
|
847
|
+
if (v.mode !== void 0) {
|
|
848
|
+
const e = modeError(v.mode);
|
|
849
|
+
if (e) bad(name, "mode", e);
|
|
850
|
+
}
|
|
851
|
+
if (v.chown !== void 0) {
|
|
852
|
+
const e = chownError(v.chown);
|
|
853
|
+
if (e) bad(name, "chown", e);
|
|
854
|
+
}
|
|
855
|
+
if (v.path !== void 0) {
|
|
856
|
+
const e = pathError(v.path);
|
|
857
|
+
if (e) bad(name, "path", e);
|
|
858
|
+
else if (!usesDefaultLocation(name, v.path)) {
|
|
859
|
+
if (v.mode !== void 0) bad(name, "mode", `mode needs the default location: Dokku refuses it on a custom path. Drop path, or manage '${v.path}' yourself`);
|
|
860
|
+
if (v.chown !== void 0 && v.chown !== false) bad(name, "chown", `chown needs the default location: Dokku refuses it on a custom path. Drop path, use chown: false, or manage '${v.path}' yourself`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
var unsupportedMountKey = (key) => z.any().optional().refine((v) => v === void 0, {
|
|
866
|
+
message: `${key} is not supported: on docker-local Dokku stores it but never applies it`
|
|
867
|
+
});
|
|
868
|
+
var MountObjectSchema = z.object({
|
|
869
|
+
entry: z.string().min(1),
|
|
870
|
+
container_dir: z.string().min(1),
|
|
871
|
+
readonly: z.boolean().optional(),
|
|
872
|
+
phases: z.array(z.enum(["deploy", "run"])).min(1).optional(),
|
|
873
|
+
process_type: z.string().optional(),
|
|
874
|
+
options: z.array(z.string()).optional(),
|
|
875
|
+
subpath: unsupportedMountKey("subpath"),
|
|
876
|
+
volume_chown: unsupportedMountKey("volume_chown")
|
|
877
|
+
}).strict().superRefine((m, ctx) => {
|
|
878
|
+
for (const message of mountIssues(m)) ctx.addIssue({ code: "custom", message });
|
|
879
|
+
});
|
|
880
|
+
var MountStringSchema = z.string().superRefine((s, ctx) => {
|
|
881
|
+
const parsed = parseMountString(s);
|
|
882
|
+
if (typeof parsed === "string") ctx.addIssue({ code: "custom", message: parsed });
|
|
883
|
+
else for (const message of mountIssues(parsed)) ctx.addIssue({ code: "custom", message });
|
|
884
|
+
});
|
|
296
885
|
var AppSchema = z.object({
|
|
297
886
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
298
887
|
links: z.array(LinkSchema).optional(),
|
|
@@ -303,100 +892,270 @@ var AppSchema = z.object({
|
|
|
303
892
|
ssl: z.union([
|
|
304
893
|
z.literal(false),
|
|
305
894
|
z.literal(true),
|
|
306
|
-
z.object({ certfile: z.string(), keyfile: z.string() })
|
|
895
|
+
z.object({ certfile: z.string(), keyfile: z.string() }).strict()
|
|
307
896
|
]).optional(),
|
|
308
|
-
storage: z.array(z.
|
|
897
|
+
storage: z.array(z.union([MountStringSchema, MountObjectSchema])).optional(),
|
|
309
898
|
proxy: z.object({
|
|
310
899
|
enabled: z.boolean().optional(),
|
|
311
900
|
type: z.string().optional(),
|
|
312
901
|
proxy_port: StringOrNumber.optional(),
|
|
313
902
|
proxy_ssl_port: StringOrNumber.optional()
|
|
314
903
|
}).strict().optional(),
|
|
315
|
-
networks:
|
|
316
|
-
network:
|
|
317
|
-
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
318
|
-
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
319
|
-
bind_all_interfaces: z.boolean().optional(),
|
|
320
|
-
static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
|
|
321
|
-
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
322
|
-
}).optional(),
|
|
904
|
+
networks: appNetworksList.optional(),
|
|
905
|
+
network: NetworkSchema.optional(),
|
|
323
906
|
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
|
|
324
907
|
logs: propertyMap("logs", LOGS_KEYS, StringOrNumber).optional(),
|
|
325
908
|
// `auth-servers` is report-only: it is a registry login, not a `registry:set` key.
|
|
326
909
|
registry: propertyMap("registry", REGISTRY_KEYS, z.union([z.string(), z.boolean()])).optional(),
|
|
327
|
-
scheduler:
|
|
910
|
+
scheduler: SchedulerSchema.optional(),
|
|
328
911
|
scale: z.record(z.string(), z.preprocess((v) => typeof v === "string" && /^\s*-?\d+\s*$/.test(v) ? Number(v) : v, z.number().int().min(0))).optional(),
|
|
329
|
-
ps:
|
|
330
|
-
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
331
|
-
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
332
|
-
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
333
|
-
"dockerfile-start-cmd": z.union([z.string(), z.number()]).optional(),
|
|
334
|
-
restore: z.union([z.string(), z.boolean()]).optional(),
|
|
335
|
-
"skip-deploy": z.union([z.string(), z.boolean()]).optional(),
|
|
336
|
-
"start-cmd": z.union([z.string(), z.number()]).optional()
|
|
337
|
-
}).strict().optional(),
|
|
912
|
+
ps: PsSchema.optional(),
|
|
338
913
|
checks: ChecksSchema.optional(),
|
|
339
|
-
build:
|
|
340
|
-
dockerfile: z.string().optional(),
|
|
341
|
-
app_json: z.string().optional(),
|
|
342
|
-
context: z.string().optional(),
|
|
343
|
-
selected: z.string().optional(),
|
|
344
|
-
skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
|
|
345
|
-
args: z.record(z.string(), z.string()).optional()
|
|
346
|
-
}).optional(),
|
|
914
|
+
build: BuildSchema.optional(),
|
|
347
915
|
docker_options: z.object({
|
|
348
916
|
build: z.array(z.string()).optional(),
|
|
349
917
|
deploy: z.array(z.string()).optional(),
|
|
350
918
|
run: z.array(z.string()).optional()
|
|
351
|
-
}).optional(),
|
|
919
|
+
}).strict().optional(),
|
|
352
920
|
git: GitSchema.optional()
|
|
353
|
-
});
|
|
921
|
+
}).strict();
|
|
922
|
+
var EXPORT_PLACEHOLDER = "REPLACE_ME";
|
|
923
|
+
var notPlaceholder = (v) => v !== EXPORT_PLACEHOLDER;
|
|
924
|
+
var placeholderMessage = `replace the ${EXPORT_PLACEHOLDER} placeholder written by export`;
|
|
925
|
+
var singleLine = (v) => !/[\r\n]/.test(v);
|
|
354
926
|
var ServiceBackupAuthSchema = z.object({
|
|
355
|
-
access_key_id: z.string().min(1),
|
|
356
|
-
secret_access_key: z.string().min(1),
|
|
927
|
+
access_key_id: z.string().min(1).refine(notPlaceholder, placeholderMessage),
|
|
928
|
+
secret_access_key: z.string().min(1).refine(notPlaceholder, placeholderMessage),
|
|
357
929
|
region: z.string().min(1),
|
|
358
930
|
signature_version: z.string().min(1),
|
|
359
931
|
endpoint: z.string().min(1)
|
|
932
|
+
}).strict();
|
|
933
|
+
var ServiceBackupEncryptionSchema = z.object({
|
|
934
|
+
passphrase: z.string().min(1).refine(singleLine, "must be a single line").refine(notPlaceholder, placeholderMessage).optional(),
|
|
935
|
+
public_key_id: z.string().min(1).refine(singleLine, "must be a single line").optional(),
|
|
936
|
+
keyserver: z.string().min(1).refine(singleLine, "must be a single line").optional()
|
|
937
|
+
}).strict().superRefine((enc, ctx) => {
|
|
938
|
+
if (enc.passphrase !== void 0 && enc.public_key_id !== void 0) {
|
|
939
|
+
ctx.addIssue({ code: "custom", message: "name passphrase or public_key_id, not both" });
|
|
940
|
+
} else if (enc.passphrase === void 0 && enc.public_key_id === void 0) {
|
|
941
|
+
ctx.addIssue({ code: "custom", message: "name passphrase or public_key_id (use encryption: false to clear it)" });
|
|
942
|
+
}
|
|
943
|
+
if (enc.keyserver !== void 0 && enc.public_key_id === void 0) {
|
|
944
|
+
ctx.addIssue({ code: "custom", path: ["keyserver"], message: "keyserver goes with public_key_id" });
|
|
945
|
+
}
|
|
360
946
|
});
|
|
947
|
+
var STORAGE_CLASSES = [
|
|
948
|
+
"STANDARD",
|
|
949
|
+
"REDUCED_REDUNDANCY",
|
|
950
|
+
"STANDARD_IA",
|
|
951
|
+
"ONEZONE_IA",
|
|
952
|
+
"INTELLIGENT_TIERING",
|
|
953
|
+
"GLACIER",
|
|
954
|
+
"DEEP_ARCHIVE",
|
|
955
|
+
"GLACIER_IR"
|
|
956
|
+
];
|
|
957
|
+
function objectNameProblem(name) {
|
|
958
|
+
if (name.length > 512) return "must be at most 512 characters";
|
|
959
|
+
if (!/^[A-Za-z0-9._\-/]+$/.test(name)) return "may hold only letters, numbers, dots, dashes, underscores and slashes";
|
|
960
|
+
if (name.startsWith("/") || name.endsWith("/")) return "must not start or end with a slash";
|
|
961
|
+
if (name.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) return "must not have an empty, . or .. segment";
|
|
962
|
+
return void 0;
|
|
963
|
+
}
|
|
361
964
|
var ServiceBackupSchema = z.object({
|
|
362
965
|
schedule: z.string().min(1),
|
|
363
966
|
bucket: z.string().min(1),
|
|
364
|
-
auth: ServiceBackupAuthSchema
|
|
967
|
+
auth: ServiceBackupAuthSchema,
|
|
968
|
+
// false clears the service's encryption; absent leaves it alone
|
|
969
|
+
encryption: z.union([ServiceBackupEncryptionSchema, z.literal(false)]).optional(),
|
|
970
|
+
storage_class: z.enum(STORAGE_CLASSES).optional(),
|
|
971
|
+
object_name: z.string().superRefine((name, ctx) => {
|
|
972
|
+
const problem = objectNameProblem(name);
|
|
973
|
+
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
|
974
|
+
}).optional(),
|
|
975
|
+
mailto: z.string().min(1).regex(/^\S+$/, "must not contain spaces").optional(),
|
|
976
|
+
// the plugin takes false (no timestamp in the object name) or unset
|
|
977
|
+
timestamp: z.literal(false).optional()
|
|
978
|
+
}).strict();
|
|
979
|
+
var PropertyValueSchema = z.union([
|
|
980
|
+
z.string(),
|
|
981
|
+
z.number(),
|
|
982
|
+
z.array(z.union([z.string(), z.number()])),
|
|
983
|
+
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
984
|
+
]);
|
|
985
|
+
var ServicePropertiesSchema = z.record(z.string(), PropertyValueSchema).superRefine((props, ctx) => {
|
|
986
|
+
const issue = (key, message) => ctx.addIssue({ code: "custom", path: [key], message });
|
|
987
|
+
for (const [key, value] of Object.entries(props)) {
|
|
988
|
+
const rejected = rejectedReason(key);
|
|
989
|
+
if (rejected) {
|
|
990
|
+
issue(key, `'${key}' cannot be set here: ${rejected}`);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const kind = MANAGED_PROPERTIES[key]?.kind;
|
|
994
|
+
if (kind === "map") {
|
|
995
|
+
if (Array.isArray(value)) {
|
|
996
|
+
issue(key, `'${key}' takes a map of names to values`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
for (const [name, v] of customEnvPairs(value)) {
|
|
1000
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) issue(key, `'${name}' is not an environment variable name`);
|
|
1001
|
+
if (/[;\r\n]/.test(v)) issue(key, `the value of ${name} must not contain ';' or a newline`);
|
|
1002
|
+
if (v === EXPORT_PLACEHOLDER) issue(key, `the value of ${name}: ${placeholderMessage}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (typeof value === "string" && /[\r\n]/.test(value)) issue(key, `'${key}' must be a single line`);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (kind === "targets") {
|
|
1008
|
+
if (Array.isArray(value)) {
|
|
1009
|
+
issue(key, `'${key}' takes a map of volumes to container paths`);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const [volume, target] of targetPairs(value)) {
|
|
1014
|
+
if (volume === "" || target === "") {
|
|
1015
|
+
issue(key, `'${volume}' needs a container path (<volume>=<container-path>)`);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (seen.has(volume)) issue(key, `the volume ${volume} is moved more than once`);
|
|
1019
|
+
seen.add(volume);
|
|
1020
|
+
if (!target.startsWith("/")) issue(key, `the container path of ${volume} must be absolute`);
|
|
1021
|
+
else if (/[:,\s]/.test(target)) issue(key, `the container path of ${volume} must not contain a colon, a comma or whitespace`);
|
|
1022
|
+
else if (cleanPath(target) === "/") issue(key, `the volume ${volume} must not be mounted at /`);
|
|
1023
|
+
}
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (kind === "list") {
|
|
1027
|
+
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : void 0;
|
|
1028
|
+
if (!items) {
|
|
1029
|
+
issue(key, `'${key}' takes a list`);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (items.some((i) => String(i).trim() === "" || /[,\s]/.test(String(i).trim()))) issue(key, `'${key}' entries must not be empty or contain commas or spaces`);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof value === "object") {
|
|
1036
|
+
issue(key, `'${key}' takes a string or number`);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const text = String(value);
|
|
1040
|
+
if (text.trim() === "") {
|
|
1041
|
+
issue(key, `'${key}' is empty: leave the key out`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (/[\r\n]/.test(text)) {
|
|
1045
|
+
issue(key, `'${key}' must be a single line`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (kind === "secret") {
|
|
1049
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(text)) issue(key, `'${key}' may hold only letters, digits, '.', '_', '~' and '-'`);
|
|
1050
|
+
if (text === EXPORT_PLACEHOLDER) issue(key, placeholderMessage);
|
|
1051
|
+
} else if (key === "memory") {
|
|
1052
|
+
if (!/^\d+$/.test(text.trim())) issue(key, "memory is a whole number of megabytes (0 for no limit)");
|
|
1053
|
+
} else if (key === "wait-timeout") {
|
|
1054
|
+
if (!/^\d+$/.test(text.trim()) || Number(text) < 1) issue(key, "wait-timeout is a whole number of seconds greater than 0");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
365
1057
|
});
|
|
1058
|
+
var ServiceMountObjectSchema = z.object({
|
|
1059
|
+
entry: z.string().min(1),
|
|
1060
|
+
container_dir: z.string().min(1),
|
|
1061
|
+
readonly: z.boolean().optional(),
|
|
1062
|
+
options: z.array(z.string()).optional()
|
|
1063
|
+
}).strict().superRefine((m, ctx) => {
|
|
1064
|
+
for (const message of serviceMountIssues(m)) ctx.addIssue({ code: "custom", message });
|
|
1065
|
+
});
|
|
1066
|
+
var ServiceMountStringSchema = z.string().superRefine((s, ctx) => {
|
|
1067
|
+
const parsed = parseServiceMountString(s);
|
|
1068
|
+
if (typeof parsed === "string") ctx.addIssue({ code: "custom", message: parsed });
|
|
1069
|
+
else for (const message of serviceMountIssues(parsed)) ctx.addIssue({ code: "custom", message });
|
|
1070
|
+
});
|
|
1071
|
+
var ServiceMountsSchema = z.array(z.union([ServiceMountStringSchema, ServiceMountObjectSchema]));
|
|
366
1072
|
var PostgresSchema = z.object({
|
|
367
1073
|
version: z.string().optional(),
|
|
368
1074
|
image: z.string().optional(),
|
|
369
|
-
// false removes the service's backup schedule and
|
|
370
|
-
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional()
|
|
371
|
-
|
|
1075
|
+
// false removes the service's backup schedule, credentials, encryption and settings; absent leaves them alone
|
|
1076
|
+
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional(),
|
|
1077
|
+
// the plugin's `:set` keys; absent leaves them alone, `--prune` clears the known ones removed from the file
|
|
1078
|
+
properties: ServicePropertiesSchema.optional(),
|
|
1079
|
+
// the whole set of mounts; absent leaves them alone, [] removes them all
|
|
1080
|
+
mounts: ServiceMountsSchema.optional()
|
|
1081
|
+
}).strict();
|
|
372
1082
|
var RedisSchema = z.object({
|
|
373
1083
|
version: z.string().optional(),
|
|
374
|
-
image: z.string().optional()
|
|
375
|
-
|
|
1084
|
+
image: z.string().optional(),
|
|
1085
|
+
// same blocks as postgres
|
|
1086
|
+
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional(),
|
|
1087
|
+
properties: ServicePropertiesSchema.optional(),
|
|
1088
|
+
mounts: ServiceMountsSchema.optional()
|
|
1089
|
+
}).strict();
|
|
376
1090
|
var PluginSchema = z.object({
|
|
377
1091
|
url: z.string().url(),
|
|
378
1092
|
version: z.string().optional()
|
|
379
|
-
});
|
|
1093
|
+
}).strict();
|
|
380
1094
|
var DockerAuthEntrySchema = z.object({
|
|
381
1095
|
username: z.string().min(1),
|
|
382
1096
|
password: z.string().min(1)
|
|
383
|
-
});
|
|
1097
|
+
}).strict();
|
|
384
1098
|
var ConfigSchema = z.object({
|
|
385
1099
|
dokku: z.object({
|
|
386
|
-
version: z.string().optional()
|
|
387
|
-
}).optional(),
|
|
1100
|
+
version: z.string({ error: 'dokku.version must be a quoted string starting with X.Y.Z, e.g. "0.38.31"' }).regex(SEMVER_RE, 'dokku.version must start with X.Y.Z, e.g. "0.38.31"').optional()
|
|
1101
|
+
}).strict().optional(),
|
|
388
1102
|
plugins: z.record(z.string(), PluginSchema).optional(),
|
|
389
1103
|
docker_auth: z.record(z.string(), DockerAuthEntrySchema).optional(),
|
|
390
|
-
networks:
|
|
1104
|
+
networks: sharedNetworksList.optional(),
|
|
1105
|
+
volumes: VolumesSchema.optional(),
|
|
391
1106
|
postgres: z.record(z.string(), PostgresSchema).optional(),
|
|
392
1107
|
redis: z.record(z.string(), RedisSchema).optional(),
|
|
393
1108
|
apps: z.record(z.string(), AppSchema),
|
|
394
1109
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
395
1110
|
env: EnvMapSchema.optional(),
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
logs:
|
|
399
|
-
|
|
1111
|
+
// The top-level blocks below are Dokku's own `--global` settings (`<ns>:set --global`); Dokku does the inheritance.
|
|
1112
|
+
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber, " --global").optional(),
|
|
1113
|
+
// Also takes the two keys only `logs:set --global` accepts.
|
|
1114
|
+
logs: propertyMap("logs", LOGS_GLOBAL_KEYS, StringOrNumber, " --global").optional(),
|
|
1115
|
+
scheduler: SchedulerSchema.optional(),
|
|
1116
|
+
// `image-repo` names one app's repository: per app only, like the report-only `auth-servers`.
|
|
1117
|
+
registry: propertyMap("registry", REGISTRY_GLOBAL_KEYS, z.union([z.string(), z.boolean()]), " --global").optional(),
|
|
1118
|
+
checks: GlobalChecksSchema.optional(),
|
|
1119
|
+
ps: GlobalPsSchema.optional(),
|
|
1120
|
+
build: GlobalBuildSchema.optional(),
|
|
1121
|
+
network: GlobalNetworkSchema.optional(),
|
|
1122
|
+
proxy: GlobalProxySchema.optional(),
|
|
1123
|
+
git: GlobalGitSchema.optional()
|
|
1124
|
+
}).strict().superRefine((config, ctx) => {
|
|
1125
|
+
for (const plugin of ["postgres", "redis"]) {
|
|
1126
|
+
for (const [svc, svcConfig] of Object.entries(config[plugin] ?? {})) {
|
|
1127
|
+
const mounts = (svcConfig.mounts ?? []).flatMap((item, index) => {
|
|
1128
|
+
const [mount] = declaredServiceMounts([item]);
|
|
1129
|
+
return mount ? [{ mount, index }] : [];
|
|
1130
|
+
});
|
|
1131
|
+
mounts.forEach(({ mount: m, index }, i) => {
|
|
1132
|
+
const where = [plugin, svc, "mounts", index];
|
|
1133
|
+
if (!config.volumes || !(m.entry in config.volumes)) {
|
|
1134
|
+
ctx.addIssue({ code: "custom", path: where, message: `entry '${m.entry}' is not declared under volumes: (a host directory is an entry's path:)` });
|
|
1135
|
+
}
|
|
1136
|
+
if (mounts.slice(0, i).some(({ mount: earlier }) => earlier.container_dir === m.container_dir)) {
|
|
1137
|
+
ctx.addIssue({ code: "custom", path: where, message: `container_dir '${m.container_dir}' is mounted twice` });
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const [app, appConfig] of Object.entries(config.apps)) {
|
|
1143
|
+
const mounts = (appConfig.storage ?? []).flatMap((item, index) => {
|
|
1144
|
+
const [mount] = declaredMounts([item]);
|
|
1145
|
+
return mount ? [{ mount, index }] : [];
|
|
1146
|
+
});
|
|
1147
|
+
mounts.forEach(({ mount: m, index }, i) => {
|
|
1148
|
+
const where = ["apps", app, "storage", index];
|
|
1149
|
+
if (!config.volumes || !(m.entry in config.volumes)) {
|
|
1150
|
+
ctx.addIssue({ code: "custom", path: where, message: `entry '${m.entry}' is not declared under volumes:` });
|
|
1151
|
+
}
|
|
1152
|
+
mounts.slice(0, i).forEach(({ mount: earlier }) => {
|
|
1153
|
+
if (earlier.container_dir !== m.container_dir || !scopesOverlap(earlier.process_type, m.process_type)) return;
|
|
1154
|
+
const how = earlier.process_type === m.process_type ? `twice in ${m.process_type === DEFAULT_SCOPE ? "every process" : `process type ${m.process_type}`}` : "in the default scope and in a named process type, which Dokku forbids";
|
|
1155
|
+
ctx.addIssue({ code: "custom", path: where, message: `container_dir '${m.container_dir}' is mounted ${how}` });
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
400
1159
|
});
|
|
401
1160
|
function parseConfig(raw) {
|
|
402
1161
|
return ConfigSchema.parse(raw);
|
|
@@ -450,39 +1209,107 @@ import * as os from "os";
|
|
|
450
1209
|
import * as path from "path";
|
|
451
1210
|
|
|
452
1211
|
// src/core/mask.ts
|
|
453
|
-
var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
|
|
454
|
-
|
|
455
|
-
|
|
1212
|
+
var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)/i;
|
|
1213
|
+
var NOT_SECRET_KEYS = /* @__PURE__ */ new Set(["public_key_id", "keyserver"]);
|
|
1214
|
+
var PLACEHOLDER = "REPLACE_ME";
|
|
1215
|
+
var OPAQUE_RE = /PASSPHRASE/i;
|
|
1216
|
+
function maskValue(value, opaque = false) {
|
|
1217
|
+
if (opaque || value.length <= 4) return "****";
|
|
456
1218
|
return `****${value.slice(-4)}`;
|
|
457
1219
|
}
|
|
1220
|
+
var WORD = String.raw`(?:'[^']*'|\\.|[^\s\\])+`;
|
|
1221
|
+
function unshell(word) {
|
|
1222
|
+
return /^'.*'$/s.test(word) ? word.slice(1, -1).replace(/'\\''/g, "'") : word;
|
|
1223
|
+
}
|
|
1224
|
+
function maskCustomEnv(value) {
|
|
1225
|
+
return value.split(";").filter((p) => p !== "").map((p) => {
|
|
1226
|
+
const i = p.indexOf("=");
|
|
1227
|
+
return i === -1 ? "****" : `${p.slice(0, i)}=****`;
|
|
1228
|
+
}).join(";");
|
|
1229
|
+
}
|
|
458
1230
|
function maskSensitiveArgs(cmd) {
|
|
459
|
-
|
|
460
|
-
|
|
1231
|
+
const original = cmd;
|
|
1232
|
+
cmd = cmd.replace(/^(\S+:set\s+(?:--no-restart\s+)?\S+\s+password\s+(?:--\s+)?).+$/s, (_, prefix) => `${prefix}****`);
|
|
1233
|
+
cmd = cmd.replace(/^(\S+:set\s+(?:--no-restart\s+)?\S+\s+custom-env\s+(?:--\s+)?)(.+)$/s, (_, prefix, rest) => `${prefix}${maskCustomEnv(unshell(rest))}`);
|
|
1234
|
+
cmd = cmd.replace(new RegExp(String.raw`^(\S+:create\s.*?\s--password\s+)(${WORD})`, "s"), (_, prefix) => `${prefix}****`);
|
|
1235
|
+
cmd = cmd.replace(new RegExp(String.raw`^(\S+:create\s.*?\s--custom-env\s+)(${WORD})`, "s"), (_, prefix, word) => `${prefix}${maskCustomEnv(unshell(word))}`);
|
|
1236
|
+
if (cmd !== original) return cmd;
|
|
1237
|
+
let masked = cmd.replace(new RegExp(String.raw`'([^\s='\\]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s='\\]*)=((?:[^']|'\\'')*)'`, "gi"), (_, key, value) => {
|
|
1238
|
+
return `${key}=${maskValue(value.replace(/'\\''/g, "'"), OPAQUE_RE.test(key))}`;
|
|
1239
|
+
});
|
|
1240
|
+
masked = masked.replace(new RegExp(String.raw`([^\s=]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s=]*)=(${WORD})`, "gi"), (_, key, value) => {
|
|
1241
|
+
return `${key}=${maskValue(value, OPAQUE_RE.test(key))}`;
|
|
461
1242
|
});
|
|
462
1243
|
masked = masked.replace(
|
|
463
|
-
/^(
|
|
1244
|
+
/^(\S+:set\s+\S+\s+backup-encryption-passphrase\s+).+$/s,
|
|
1245
|
+
(_, prefix) => `${prefix}****`
|
|
1246
|
+
);
|
|
1247
|
+
masked = masked.replace(
|
|
1248
|
+
/^(\S+:backup-set-encryption\s+\S+\s+).+$/s,
|
|
1249
|
+
(_, prefix) => `${prefix}****`
|
|
1250
|
+
);
|
|
1251
|
+
masked = masked.replace(
|
|
1252
|
+
new RegExp(String.raw`^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(${WORD})`),
|
|
464
1253
|
(_, prefix, password) => `${prefix}${maskValue(password)}`
|
|
465
1254
|
);
|
|
466
1255
|
masked = masked.replace(
|
|
467
|
-
|
|
1256
|
+
new RegExp(String.raw`^(\S+:backup-auth\s+\S+\s+)(${WORD})(\s+)(${WORD})`),
|
|
468
1257
|
(_, prefix, accessKeyId, sep, secretAccessKey) => `${prefix}${maskValue(accessKeyId)}${sep}${maskValue(secretAccessKey)}`
|
|
469
1258
|
);
|
|
470
1259
|
return masked;
|
|
471
1260
|
}
|
|
472
1261
|
function maskSensitiveArgsList(args) {
|
|
473
1262
|
const secrets = [];
|
|
1263
|
+
const opaque = [];
|
|
474
1264
|
const masked = [...args];
|
|
475
|
-
const mark = (index) => {
|
|
1265
|
+
const mark = (index, completely = false) => {
|
|
476
1266
|
const value = masked[index];
|
|
477
1267
|
if (value === void 0 || value === "") return;
|
|
478
1268
|
secrets.push(value);
|
|
479
|
-
|
|
1269
|
+
if (completely) opaque.push(value);
|
|
1270
|
+
masked[index] = maskValue(value, completely);
|
|
1271
|
+
};
|
|
1272
|
+
const handled = /* @__PURE__ */ new Set();
|
|
1273
|
+
const markCustomEnv = (index) => {
|
|
1274
|
+
handled.add(index);
|
|
1275
|
+
const value = masked[index];
|
|
1276
|
+
if (value === void 0 || value === "") return;
|
|
1277
|
+
for (const part of value.split(";")) {
|
|
1278
|
+
const v = part.slice(part.indexOf("=") + 1);
|
|
1279
|
+
if (v.length >= 3) {
|
|
1280
|
+
secrets.push(v);
|
|
1281
|
+
opaque.push(v);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
masked[index] = maskCustomEnv(value);
|
|
480
1285
|
};
|
|
1286
|
+
if (/^\S+:set$/.test(masked[0] ?? "")) {
|
|
1287
|
+
const name = masked[1] === "--no-restart" ? 2 : 1;
|
|
1288
|
+
const key = masked[name + 1];
|
|
1289
|
+
const valueAt = masked[name + 2] === "--" ? name + 3 : name + 2;
|
|
1290
|
+
if (key === "password") {
|
|
1291
|
+
handled.add(valueAt);
|
|
1292
|
+
mark(valueAt, true);
|
|
1293
|
+
}
|
|
1294
|
+
if (key === "custom-env") markCustomEnv(valueAt);
|
|
1295
|
+
}
|
|
1296
|
+
if (/^\S+:create$/.test(masked[0] ?? "")) {
|
|
1297
|
+
for (let i = 1; i < masked.length - 1; i++) {
|
|
1298
|
+
if (masked[i] === "--password") {
|
|
1299
|
+
handled.add(i + 1);
|
|
1300
|
+
mark(i + 1, true);
|
|
1301
|
+
}
|
|
1302
|
+
if (masked[i] === "--custom-env") markCustomEnv(i + 1);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
481
1305
|
for (let i = 0; i < masked.length; i++) {
|
|
482
|
-
|
|
1306
|
+
if (handled.has(i)) continue;
|
|
1307
|
+
const m = masked[i].match(/^([^\s=]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s=]*)=(.+)$/i);
|
|
483
1308
|
if (m) {
|
|
484
1309
|
secrets.push(m[2]);
|
|
485
|
-
|
|
1310
|
+
const completely = OPAQUE_RE.test(m[1]);
|
|
1311
|
+
if (completely) opaque.push(m[2]);
|
|
1312
|
+
masked[i] = `${m[1]}=${maskValue(m[2], completely)}`;
|
|
486
1313
|
}
|
|
487
1314
|
}
|
|
488
1315
|
if (masked[0] === "registry:login" && !masked.includes("--password-stdin")) {
|
|
@@ -492,13 +1319,15 @@ function maskSensitiveArgsList(args) {
|
|
|
492
1319
|
mark(2);
|
|
493
1320
|
mark(3);
|
|
494
1321
|
}
|
|
495
|
-
|
|
1322
|
+
if (/^\S+:set$/.test(masked[0] ?? "") && masked[2] === "backup-encryption-passphrase") mark(3, true);
|
|
1323
|
+
if (/^\S+:backup-set-encryption$/.test(masked[0] ?? "")) mark(2, true);
|
|
1324
|
+
return { masked, secrets, opaque };
|
|
496
1325
|
}
|
|
497
|
-
function maskSecretsInText(text, secrets) {
|
|
1326
|
+
function maskSecretsInText(text, secrets, opaque = []) {
|
|
498
1327
|
let masked = text;
|
|
499
1328
|
for (const secret of secrets) {
|
|
500
1329
|
if (!secret) continue;
|
|
501
|
-
masked = masked.split(secret).join(maskValue(secret));
|
|
1330
|
+
masked = masked.split(secret).join(maskValue(secret, opaque.includes(secret)));
|
|
502
1331
|
}
|
|
503
1332
|
return masked;
|
|
504
1333
|
}
|
|
@@ -509,8 +1338,16 @@ function maskSensitiveData(data) {
|
|
|
509
1338
|
if (typeof data === "object") {
|
|
510
1339
|
const result = {};
|
|
511
1340
|
for (const [key, value] of Object.entries(data)) {
|
|
512
|
-
if (
|
|
513
|
-
result[key] =
|
|
1341
|
+
if (NOT_SECRET_KEYS.has(key.toLowerCase()) || value === PLACEHOLDER) {
|
|
1342
|
+
result[key] = value;
|
|
1343
|
+
} else if (key === "custom-env" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1344
|
+
result[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, v === PLACEHOLDER ? v : "****"]));
|
|
1345
|
+
} else if (key === "properties" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1346
|
+
const props = maskSensitiveData(value);
|
|
1347
|
+
if (typeof props.password === "string" && props.password !== PLACEHOLDER) props.password = "****";
|
|
1348
|
+
result[key] = props;
|
|
1349
|
+
} else if (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
|
|
1350
|
+
result[key] = maskValue(value, OPAQUE_RE.test(key));
|
|
514
1351
|
} else if (typeof value === "object" && value !== null) {
|
|
515
1352
|
result[key] = maskSensitiveData(value);
|
|
516
1353
|
} else {
|
|
@@ -532,9 +1369,9 @@ function maskQuerystring(qs) {
|
|
|
532
1369
|
// src/core/dokku.ts
|
|
533
1370
|
var DokkuCommandError = class extends Error {
|
|
534
1371
|
constructor(args, exitCode, stderr) {
|
|
535
|
-
const { masked, secrets } = maskSensitiveArgsList(args);
|
|
1372
|
+
const { masked, secrets, opaque } = maskSensitiveArgsList(args);
|
|
536
1373
|
const maskedCmd = masked.join(" ");
|
|
537
|
-
const stderrTail = maskSecretsInText(stderr.trim(), secrets);
|
|
1374
|
+
const stderrTail = maskSecretsInText(stderr.trim(), secrets, opaque);
|
|
538
1375
|
super(
|
|
539
1376
|
`dokku ${maskedCmd} failed (exit ${exitCode})` + (stderrTail ? `: ${stderrTail}` : "")
|
|
540
1377
|
);
|
|
@@ -574,7 +1411,7 @@ function createRunner(opts = {}) {
|
|
|
574
1411
|
dryRunLog: log,
|
|
575
1412
|
async run(...args) {
|
|
576
1413
|
if (opts.dryRun) {
|
|
577
|
-
log.push(args.map(
|
|
1414
|
+
log.push(args.map(shellQuote).join(" "));
|
|
578
1415
|
return;
|
|
579
1416
|
}
|
|
580
1417
|
const { ok, exitCode, stderr } = await execDokku(args);
|
|
@@ -601,7 +1438,7 @@ function createRunner(opts = {}) {
|
|
|
601
1438
|
},
|
|
602
1439
|
async runWithInput(input, ...args) {
|
|
603
1440
|
if (opts.dryRun) {
|
|
604
|
-
log.push(args.map(
|
|
1441
|
+
log.push(args.map(shellQuote).join(" "));
|
|
605
1442
|
return;
|
|
606
1443
|
}
|
|
607
1444
|
const { ok, exitCode, stderr } = await execDokku(args, input);
|
|
@@ -623,11 +1460,19 @@ function createContext(runner) {
|
|
|
623
1460
|
const commands = [];
|
|
624
1461
|
const restarts = /* @__PURE__ */ new Map();
|
|
625
1462
|
const rebuilds = /* @__PURE__ */ new Map();
|
|
1463
|
+
const serviceRestarts = /* @__PURE__ */ new Map();
|
|
626
1464
|
return {
|
|
627
1465
|
commands,
|
|
628
1466
|
runner,
|
|
629
1467
|
restarts,
|
|
630
1468
|
rebuilds,
|
|
1469
|
+
serviceRestarts,
|
|
1470
|
+
needsServiceRestart(plugin, service, reason) {
|
|
1471
|
+
const key = `${plugin}:${service}`;
|
|
1472
|
+
const entry = serviceRestarts.get(key) ?? { plugin, service, reasons: [] };
|
|
1473
|
+
if (!entry.reasons.includes(reason)) entry.reasons.push(reason);
|
|
1474
|
+
serviceRestarts.set(key, entry);
|
|
1475
|
+
},
|
|
631
1476
|
needsRebuild(app, reason) {
|
|
632
1477
|
const reasons = rebuilds.get(app) ?? [];
|
|
633
1478
|
if (!reasons.includes(reason)) reasons.push(reason);
|
|
@@ -724,10 +1569,10 @@ function computeAdditiveChange(before, after) {
|
|
|
724
1569
|
}
|
|
725
1570
|
|
|
726
1571
|
// src/core/reconcile.ts
|
|
727
|
-
async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
|
|
1572
|
+
async function reconcile(resource, ctx, target, desired, onRemovals, prune = false, inherited) {
|
|
728
1573
|
const canPrune = prune && !!resource.prune && !!resource.removals;
|
|
729
1574
|
const pruneFrom = async (current) => {
|
|
730
|
-
const removed = resource.removals(current, desired);
|
|
1575
|
+
const removed = resource.removals(current, desired, inherited);
|
|
731
1576
|
if (removed.length === 0) return;
|
|
732
1577
|
logAction(target, `${resource.key} (prune)`);
|
|
733
1578
|
await resource.prune(ctx, target, removed, current, desired);
|
|
@@ -752,14 +1597,14 @@ async function reconcile(resource, ctx, target, desired, onRemovals, prune = fal
|
|
|
752
1597
|
await resource.onChange(ctx, target, { before, after: desired, changed: true });
|
|
753
1598
|
logDone();
|
|
754
1599
|
}
|
|
755
|
-
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
1600
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired, inherited));
|
|
756
1601
|
if (canPrune) await pruneFrom(before);
|
|
757
1602
|
return;
|
|
758
1603
|
}
|
|
759
|
-
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
1604
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired, inherited));
|
|
760
1605
|
if (canPrune) {
|
|
761
1606
|
const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
|
|
762
|
-
const pending = resource.removals(before, desired).length > 0;
|
|
1607
|
+
const pending = resource.removals(before, desired, inherited).length > 0;
|
|
763
1608
|
if (change2.changed) {
|
|
764
1609
|
await resource.onChange(ctx, target, change2, pending);
|
|
765
1610
|
logDone();
|
|
@@ -865,33 +1710,328 @@ var Domains = {
|
|
|
865
1710
|
await ctx.run("domains:set", target, ...after);
|
|
866
1711
|
}
|
|
867
1712
|
};
|
|
868
|
-
|
|
869
|
-
|
|
1713
|
+
|
|
1714
|
+
// src/modules/volumes.ts
|
|
1715
|
+
var DOCKER_LOCAL = "docker-local";
|
|
1716
|
+
async function readEntries(ctx) {
|
|
1717
|
+
const raw = await ctx.queryStrict("storage:list-entries", "--format", "json");
|
|
1718
|
+
let parsed;
|
|
1719
|
+
try {
|
|
1720
|
+
parsed = JSON.parse(raw);
|
|
1721
|
+
} catch {
|
|
1722
|
+
throw new Error(`storage:list-entries --format json did not return JSON: ${raw.slice(0, 80)}`);
|
|
1723
|
+
}
|
|
1724
|
+
if (!Array.isArray(parsed)) throw new Error("storage:list-entries --format json did not return a list");
|
|
1725
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1726
|
+
for (const e of parsed) entries.set(e.name, e);
|
|
1727
|
+
return entries;
|
|
1728
|
+
}
|
|
1729
|
+
function desiredEntry(name, cfg) {
|
|
1730
|
+
if (isLegacyName(name)) return { name, legacy: true };
|
|
1731
|
+
const atDefault = usesDefaultLocation(name, cfg.path);
|
|
1732
|
+
return {
|
|
1733
|
+
name,
|
|
1734
|
+
legacy: false,
|
|
1735
|
+
path: atDefault ? void 0 : cfg.path,
|
|
1736
|
+
// Dokku manages owner and mode only at the default location; there an absent chown means herokuish.
|
|
1737
|
+
chown: atDefault ? cfg.chown === void 0 ? DEFAULT_CHOWN : chownValue(cfg.chown) : void 0,
|
|
1738
|
+
mode: cfg.mode === void 0 ? void 0 : normalizeMode(String(cfg.mode))
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
function desiredEntries(volumes) {
|
|
1742
|
+
return Object.entries(volumes ?? {}).map(([name, cfg]) => desiredEntry(name, cfg));
|
|
1743
|
+
}
|
|
1744
|
+
function hostPathOf(d) {
|
|
1745
|
+
return d.path ?? defaultPath(d.name);
|
|
1746
|
+
}
|
|
1747
|
+
function entryState(d, server) {
|
|
1748
|
+
if (!server) return { kind: "missing" };
|
|
1749
|
+
if (server.scheduler !== DOCKER_LOCAL) return { kind: "scheduler", scheduler: server.scheduler };
|
|
1750
|
+
if (d.legacy) return { kind: "ok" };
|
|
1751
|
+
const want = hostPathOf(d);
|
|
1752
|
+
if ((server.host_path ?? "") !== want) return { kind: "path", server: server.host_path ?? "", want };
|
|
1753
|
+
const chownMatches = d.chown === "false" ? (server.chown ?? "") === "" || server.chown === "false" : (server.chown ?? "") === d.chown;
|
|
1754
|
+
const chown = d.chown !== void 0 && !chownMatches ? { server: server.chown ?? "", want: d.chown } : void 0;
|
|
1755
|
+
const mode = d.mode !== void 0 && (server.mode ?? "") !== d.mode ? { server: server.mode ?? "", want: d.mode } : void 0;
|
|
1756
|
+
return chown || mode ? { kind: "changed", ...chown ? { chown } : {}, ...mode ? { mode } : {} } : { kind: "ok" };
|
|
1757
|
+
}
|
|
1758
|
+
function stateError(d, state) {
|
|
1759
|
+
switch (state.kind) {
|
|
1760
|
+
case "scheduler":
|
|
1761
|
+
return `entry '${d.name}' exists on the server with scheduler ${state.scheduler}; dokku-compose manages only docker-local entries`;
|
|
1762
|
+
case "path":
|
|
1763
|
+
return `entry '${d.name}' is at ${state.server || "(no path)"} on the server but the file says ${state.want}; Dokku cannot change an entry's path. Give the entry a new name in the file, or correct path`;
|
|
1764
|
+
case "missing":
|
|
1765
|
+
return d.legacy ? `entry '${d.name}' is not on the server; a legacy- entry is made by Dokku, never created here` : void 0;
|
|
1766
|
+
default:
|
|
1767
|
+
return void 0;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
function createArgs(d) {
|
|
1771
|
+
const args = ["storage:create", d.name];
|
|
1772
|
+
if (d.path !== void 0) args.push(d.path);
|
|
1773
|
+
if (d.chown !== void 0) args.push("--chown", d.chown);
|
|
1774
|
+
if (d.mode !== void 0) args.push("--mode", d.mode);
|
|
1775
|
+
return args;
|
|
1776
|
+
}
|
|
1777
|
+
var planned = /* @__PURE__ */ new WeakMap();
|
|
1778
|
+
function plannedHostPaths(ctx) {
|
|
1779
|
+
return planned.get(ctx) ?? /* @__PURE__ */ new Map();
|
|
1780
|
+
}
|
|
1781
|
+
async function ensureVolumes(ctx, volumes, failFast = false) {
|
|
1782
|
+
const failed = /* @__PURE__ */ new Map();
|
|
1783
|
+
const server = await readEntries(ctx);
|
|
1784
|
+
for (const d of desiredEntries(volumes)) {
|
|
1785
|
+
logAction("volumes", `Ensuring ${d.name}`);
|
|
1786
|
+
try {
|
|
1787
|
+
const state = entryState(d, server.get(d.name));
|
|
1788
|
+
const problem = stateError(d, state);
|
|
1789
|
+
if (problem) throw new Error(problem);
|
|
1790
|
+
if (state.kind === "missing") {
|
|
1791
|
+
await ctx.run(...createArgs(d));
|
|
1792
|
+
planned.set(ctx, new Map([...plannedHostPaths(ctx), [d.name, hostPathOf(d)]]));
|
|
1793
|
+
logDone();
|
|
1794
|
+
} else if (state.kind === "changed") {
|
|
1795
|
+
if (state.chown) await ctx.run("storage:set", d.name, "chown", state.chown.want);
|
|
1796
|
+
if (state.mode) await ctx.run("storage:set", d.name, "mode", state.mode.want);
|
|
1797
|
+
logDone();
|
|
1798
|
+
} else {
|
|
1799
|
+
logSkip();
|
|
1800
|
+
}
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1803
|
+
console.log("");
|
|
1804
|
+
logError("volumes", message);
|
|
1805
|
+
if (failFast) throw err;
|
|
1806
|
+
failed.set(d.name, message);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return failed;
|
|
1810
|
+
}
|
|
1811
|
+
function sanitizeName(raw) {
|
|
1812
|
+
const trim = (n2) => n2.replace(/^-+|-+$/g, "");
|
|
1813
|
+
let n = trim(raw.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
|
|
1814
|
+
while (n.startsWith(LEGACY_PREFIX)) n = trim(n.slice(LEGACY_PREFIX.length));
|
|
1815
|
+
return n.slice(0, MAX_ENTRY_NAME).replace(/-+$/, "");
|
|
1816
|
+
}
|
|
1817
|
+
function legacyExportNames(legacy, taken) {
|
|
1818
|
+
const names = /* @__PURE__ */ new Map();
|
|
1819
|
+
for (const e of [...legacy].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1820
|
+
const parts = (e.host_path ?? "").split("/").filter(Boolean);
|
|
1821
|
+
const last = parts[parts.length - 1] ?? "";
|
|
1822
|
+
const two = parts.length > 1 ? `${parts[parts.length - 2]}-${last}` : "";
|
|
1823
|
+
const candidates = [sanitizeName(last), sanitizeName(two)].filter((n) => n !== "" && isValidEntryName(n));
|
|
1824
|
+
let chosen = candidates.find((n) => !taken.has(n)) ?? null;
|
|
1825
|
+
if (chosen === null && candidates.length > 0) {
|
|
1826
|
+
for (let i = 2; i < 100 && chosen === null; i++) {
|
|
1827
|
+
const suffix = `-${i}`;
|
|
1828
|
+
const base = candidates[0].slice(0, MAX_ENTRY_NAME - suffix.length).replace(/-+$/, "");
|
|
1829
|
+
if (!taken.has(base + suffix)) chosen = base + suffix;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
if (chosen !== null) taken.add(chosen);
|
|
1833
|
+
names.set(e.name, chosen);
|
|
1834
|
+
}
|
|
1835
|
+
return names;
|
|
1836
|
+
}
|
|
1837
|
+
function exportedChown(stored) {
|
|
1838
|
+
if (stored === void 0 || stored === "" || stored === "false") return false;
|
|
1839
|
+
return /^\d+$/.test(stored) ? Number(stored) : stored;
|
|
1840
|
+
}
|
|
1841
|
+
function exportVolumes(server, mounted, onlyMounted = false) {
|
|
1842
|
+
const volumes = {};
|
|
1843
|
+
const names = /* @__PURE__ */ new Map();
|
|
1844
|
+
const dockerLocal = [...server.values()].filter((e) => e.scheduler === DOCKER_LOCAL);
|
|
1845
|
+
const describe = (name, e) => {
|
|
1846
|
+
const atDefault = (e.host_path ?? "") === defaultPath(name);
|
|
1847
|
+
if (!atDefault) return { path: e.host_path };
|
|
1848
|
+
return { chown: exportedChown(e.chown), ...e.mode ? { mode: e.mode } : {} };
|
|
1849
|
+
};
|
|
1850
|
+
for (const e of dockerLocal.filter((e2) => !isLegacyName(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1851
|
+
if (onlyMounted && !mounted.has(e.name)) continue;
|
|
1852
|
+
volumes[e.name] = describe(e.name, e);
|
|
1853
|
+
names.set(e.name, e.name);
|
|
1854
|
+
}
|
|
1855
|
+
const used = dockerLocal.filter((e) => isLegacyName(e.name) && mounted.has(e.name));
|
|
1856
|
+
const given = legacyExportNames(used, new Set(server.keys()));
|
|
1857
|
+
for (const e of used) {
|
|
1858
|
+
const name = given.get(e.name);
|
|
1859
|
+
if (name == null || pathError(e.host_path) !== void 0) {
|
|
1860
|
+
volumes[e.name] = {};
|
|
1861
|
+
names.set(e.name, e.name);
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
volumes[name] = describe(name, e);
|
|
1865
|
+
names.set(e.name, name);
|
|
1866
|
+
}
|
|
1867
|
+
return { volumes, names };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/resources/storage.ts
|
|
1871
|
+
function formatSpec(s) {
|
|
1872
|
+
const tokens = [];
|
|
1873
|
+
if (s.readonly) tokens.push("ro");
|
|
1874
|
+
if (s.phases.length !== ALL_PHASES.length) for (const p of s.phases) tokens.push(`phase=${p}`);
|
|
1875
|
+
tokens.push(...s.options);
|
|
1876
|
+
if (s.subpath) tokens.push(`volume-subpath=${s.subpath}`);
|
|
1877
|
+
if (s.volumeChown) tokens.push(`volume-chown=${s.volumeChown}`);
|
|
1878
|
+
const prefix = s.scope === DEFAULT_SCOPE ? "" : `${s.scope}=`;
|
|
1879
|
+
return `${prefix}${s.entry}:${s.dir}${tokens.length ? `:${tokens.join(",")}` : ""}`;
|
|
1880
|
+
}
|
|
1881
|
+
function parseSpec(spec) {
|
|
1882
|
+
let scope = DEFAULT_SCOPE;
|
|
1883
|
+
let rest = spec;
|
|
1884
|
+
const scoped = spec.match(/^([A-Za-z0-9_-]+)=(.*)$/s);
|
|
1885
|
+
if (scoped) {
|
|
1886
|
+
scope = scoped[1];
|
|
1887
|
+
rest = scoped[2];
|
|
1888
|
+
}
|
|
1889
|
+
const first = rest.indexOf(":");
|
|
1890
|
+
const entry = rest.slice(0, first);
|
|
1891
|
+
const afterEntry = rest.slice(first + 1);
|
|
1892
|
+
const second = afterEntry.indexOf(":");
|
|
1893
|
+
const dir = second === -1 ? afterEntry : afterEntry.slice(0, second);
|
|
1894
|
+
const tokens = second === -1 ? [] : afterEntry.slice(second + 1).split(",").filter(Boolean);
|
|
1895
|
+
const parsed = { scope, entry, dir, readonly: false, phases: [...ALL_PHASES], options: [] };
|
|
1896
|
+
const phases = [];
|
|
1897
|
+
for (const t of tokens) {
|
|
1898
|
+
if (t === "ro") parsed.readonly = true;
|
|
1899
|
+
else if (t.startsWith("phase=")) phases.push(t.slice("phase=".length));
|
|
1900
|
+
else if (t.startsWith("volume-subpath=")) parsed.subpath = t.slice("volume-subpath=".length);
|
|
1901
|
+
else if (t.startsWith("volume-chown=")) parsed.volumeChown = t.slice("volume-chown=".length);
|
|
1902
|
+
else parsed.options.push(t);
|
|
1903
|
+
}
|
|
1904
|
+
if (phases.length > 0) parsed.phases = ALL_PHASES.filter((p) => phases.includes(p));
|
|
1905
|
+
return parsed;
|
|
1906
|
+
}
|
|
1907
|
+
function mountSpec(m) {
|
|
1908
|
+
return formatSpec({
|
|
1909
|
+
scope: m.process_type,
|
|
1910
|
+
entry: m.entry,
|
|
1911
|
+
dir: m.container_dir,
|
|
1912
|
+
readonly: m.readonly,
|
|
1913
|
+
phases: m.phases,
|
|
1914
|
+
options: m.options
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
function desiredSpecs(storage) {
|
|
1918
|
+
return storage === void 0 ? void 0 : declaredMounts(storage).map(mountSpec);
|
|
1919
|
+
}
|
|
1920
|
+
function parseAttachments(raw) {
|
|
1921
|
+
let report;
|
|
1922
|
+
try {
|
|
1923
|
+
report = JSON.parse(raw);
|
|
1924
|
+
} catch {
|
|
1925
|
+
throw new Error(`storage:report --format json did not return JSON: ${raw.slice(0, 80)}`);
|
|
1926
|
+
}
|
|
1927
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
1928
|
+
for (const [key, value] of Object.entries(report)) {
|
|
1929
|
+
const m = key.match(/^attachment\.(\d+)\.(.+)$/);
|
|
1930
|
+
if (!m) continue;
|
|
1931
|
+
const index = Number(m[1]);
|
|
1932
|
+
byIndex.set(index, { ...byIndex.get(index), [m[2]]: String(value ?? "") });
|
|
1933
|
+
}
|
|
1934
|
+
return [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, a]) => {
|
|
1935
|
+
const phases = (a["phases"] ?? "").split(",").filter((p) => p === "deploy" || p === "run");
|
|
1936
|
+
return formatSpec({
|
|
1937
|
+
scope: a["process-type"] || DEFAULT_SCOPE,
|
|
1938
|
+
entry: a["entry-name"] ?? "",
|
|
1939
|
+
dir: a["container-path"] ?? "",
|
|
1940
|
+
readonly: a["readonly"] === "true",
|
|
1941
|
+
phases: phases.length > 0 ? ALL_PHASES.filter((p) => phases.includes(p)) : [...ALL_PHASES],
|
|
1942
|
+
options: (a["volume-options"] ?? "").split(",").filter(Boolean),
|
|
1943
|
+
subpath: a["subpath"] || void 0,
|
|
1944
|
+
volumeChown: a["volume-chown"] || void 0
|
|
1945
|
+
});
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function replaceArg(spec) {
|
|
1949
|
+
const p = parseSpec(spec);
|
|
1950
|
+
return formatSpec({ ...p, scope: DEFAULT_SCOPE, subpath: void 0, volumeChown: void 0 });
|
|
1951
|
+
}
|
|
1952
|
+
function byScope(specs) {
|
|
1953
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
1954
|
+
for (const spec of specs) {
|
|
1955
|
+
const scope = parseSpec(spec).scope;
|
|
1956
|
+
scopes.set(scope, [...scopes.get(scope) ?? [], spec]);
|
|
1957
|
+
}
|
|
1958
|
+
return scopes;
|
|
1959
|
+
}
|
|
1960
|
+
var sameSet2 = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
|
|
1961
|
+
function orderActions(actions, before) {
|
|
1962
|
+
const held = /* @__PURE__ */ new Map();
|
|
1963
|
+
for (const [scope, specs] of before) held.set(scope, new Set(specs.map((s) => parseSpec(s).dir)));
|
|
1964
|
+
const pending = [...actions];
|
|
1965
|
+
const ordered = [];
|
|
1966
|
+
while (pending.length > 0) {
|
|
1967
|
+
const index = pending.findIndex((a) => a.specs.every((spec) => {
|
|
1968
|
+
const dir = parseSpec(spec).dir;
|
|
1969
|
+
return ![...held].some(([scope, dirs]) => scope !== a.scope && scopesOverlap(scope, a.scope) && dirs.has(dir));
|
|
1970
|
+
}));
|
|
1971
|
+
if (index === -1) {
|
|
1972
|
+
throw new Error(`cannot reorder the storage mounts: scopes ${pending.map((a) => a.scope).join(", ")} each claim a container dir another still holds. Apply the change in two runs`);
|
|
1973
|
+
}
|
|
1974
|
+
const [action] = pending.splice(index, 1);
|
|
1975
|
+
held.set(action.scope, new Set(action.specs.map((s) => parseSpec(s).dir)));
|
|
1976
|
+
ordered.push(action);
|
|
1977
|
+
}
|
|
1978
|
+
return ordered;
|
|
1979
|
+
}
|
|
1980
|
+
function containerView(specs, hosts) {
|
|
1981
|
+
const view = /* @__PURE__ */ new Set();
|
|
1982
|
+
for (const spec of specs) {
|
|
1983
|
+
const s = parseSpec(spec);
|
|
1984
|
+
const host = hosts?.get(s.entry)?.host_path ?? `?${s.entry}`;
|
|
1985
|
+
for (const phase of s.phases) view.add([phase, s.scope, host, s.dir, s.readonly, s.options.join(",")].join("\0"));
|
|
1986
|
+
}
|
|
1987
|
+
return view;
|
|
1988
|
+
}
|
|
1989
|
+
async function entriesOrUndefined(ctx) {
|
|
1990
|
+
try {
|
|
1991
|
+
const entries = await readEntries(ctx);
|
|
1992
|
+
for (const [name, host_path] of plannedHostPaths(ctx)) {
|
|
1993
|
+
if (!entries.has(name)) entries.set(name, { name, scheduler: "docker-local", host_path });
|
|
1994
|
+
}
|
|
1995
|
+
return entries;
|
|
1996
|
+
} catch {
|
|
1997
|
+
return void 0;
|
|
1998
|
+
}
|
|
870
1999
|
}
|
|
871
2000
|
var Storage = {
|
|
872
2001
|
key: "storage",
|
|
873
2002
|
read: async (ctx, target) => {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const result = /* @__PURE__ */ new Map();
|
|
881
|
-
for (const [app, report] of bulk) {
|
|
882
|
-
result.set(app, report["deploy-mounts"] ? parseDeployMounts(report["deploy-mounts"]) : []);
|
|
2003
|
+
let raw;
|
|
2004
|
+
try {
|
|
2005
|
+
raw = await ctx.queryStrict("storage:report", target, "--format", "json");
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
if (!await ctx.check("apps:exists", target)) return [];
|
|
2008
|
+
throw err;
|
|
883
2009
|
}
|
|
884
|
-
return
|
|
2010
|
+
return parseAttachments(raw);
|
|
885
2011
|
},
|
|
886
|
-
onChange: async (ctx, target,
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
2012
|
+
onChange: async (ctx, target, change) => {
|
|
2013
|
+
const hostsBefore = await entriesOrUndefined(ctx);
|
|
2014
|
+
const before = byScope(change.before);
|
|
2015
|
+
const after = byScope(change.after);
|
|
2016
|
+
const actions = [];
|
|
2017
|
+
for (const scope of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
|
|
2018
|
+
const was = before.get(scope) ?? [];
|
|
2019
|
+
const now = after.get(scope) ?? [];
|
|
2020
|
+
if (!sameSet2(was, now)) actions.push({ scope, specs: now });
|
|
890
2021
|
}
|
|
891
|
-
|
|
892
|
-
await ctx.run("storage:
|
|
893
|
-
|
|
2022
|
+
if (change.after.length === 0) {
|
|
2023
|
+
await ctx.run("storage:unmount", "--all", target);
|
|
2024
|
+
} else {
|
|
2025
|
+
for (const { scope, specs } of orderActions(actions, before)) {
|
|
2026
|
+
if (specs.length === 0) await ctx.run("storage:unmount", "--all", target, "--process-type", scope);
|
|
2027
|
+
else await ctx.run("storage:mount", "--replace", target, ...specs.map(replaceArg), ...scope === DEFAULT_SCOPE ? [] : ["--process-type", scope]);
|
|
2028
|
+
}
|
|
894
2029
|
}
|
|
2030
|
+
const hostsAfter = await entriesOrUndefined(ctx);
|
|
2031
|
+
const seenBefore = containerView(change.before, hostsBefore);
|
|
2032
|
+
const seenAfter = containerView(change.after, hostsAfter);
|
|
2033
|
+
const same2 = seenBefore.size === seenAfter.size && [...seenBefore].every((x) => seenAfter.has(x));
|
|
2034
|
+
if (!same2) ctx.needsRestart(target, "storage");
|
|
895
2035
|
}
|
|
896
2036
|
};
|
|
897
2037
|
|
|
@@ -1058,7 +2198,7 @@ var Certs = {
|
|
|
1058
2198
|
|
|
1059
2199
|
// src/core/shellwords.ts
|
|
1060
2200
|
function shellSplit(input) {
|
|
1061
|
-
const
|
|
2201
|
+
const words2 = [];
|
|
1062
2202
|
let cur = "";
|
|
1063
2203
|
let inWord = false;
|
|
1064
2204
|
let quote = null;
|
|
@@ -1079,7 +2219,7 @@ function shellSplit(input) {
|
|
|
1079
2219
|
inWord = true;
|
|
1080
2220
|
} else if (/\s/.test(c)) {
|
|
1081
2221
|
if (inWord) {
|
|
1082
|
-
|
|
2222
|
+
words2.push(cur);
|
|
1083
2223
|
cur = "";
|
|
1084
2224
|
inWord = false;
|
|
1085
2225
|
}
|
|
@@ -1089,8 +2229,8 @@ function shellSplit(input) {
|
|
|
1089
2229
|
}
|
|
1090
2230
|
}
|
|
1091
2231
|
if (quote) return void 0;
|
|
1092
|
-
if (inWord)
|
|
1093
|
-
return
|
|
2232
|
+
if (inWord) words2.push(cur);
|
|
2233
|
+
return words2;
|
|
1094
2234
|
}
|
|
1095
2235
|
function quoteShellArg(s) {
|
|
1096
2236
|
if (s === "") return "''";
|
|
@@ -1102,9 +2242,9 @@ function quoteShellArg(s) {
|
|
|
1102
2242
|
function parseBuildOptions(buildList) {
|
|
1103
2243
|
const out = [];
|
|
1104
2244
|
for (const raw of buildList) {
|
|
1105
|
-
const
|
|
1106
|
-
if (
|
|
1107
|
-
const m =
|
|
2245
|
+
const words2 = shellSplit(raw) ?? raw.trim().split(/\s+/);
|
|
2246
|
+
if (words2[0] !== "--build-arg" || words2.length < 2) continue;
|
|
2247
|
+
const m = words2.slice(1).join(" ").match(/^([^=\s]+)=?(.*)$/s);
|
|
1108
2248
|
if (m) out.push({ key: m[1], value: m[2], raw });
|
|
1109
2249
|
}
|
|
1110
2250
|
return out;
|
|
@@ -1249,8 +2389,8 @@ var DockerOptions = {
|
|
|
1249
2389
|
key: "docker_options",
|
|
1250
2390
|
// Pruned by `up` within a declared block only, so a missing block is not shown.
|
|
1251
2391
|
removals: (current, desired) => !desired ? [] : PHASES.flatMap((phase) => {
|
|
1252
|
-
const
|
|
1253
|
-
return (current?.[phase] ?? []).filter((o) => !
|
|
2392
|
+
const wanted2 = new Set(desired?.[phase] ?? []);
|
|
2393
|
+
return (current?.[phase] ?? []).filter((o) => !wanted2.has(o)).map((o) => `${phase}: ${o}`);
|
|
1254
2394
|
}),
|
|
1255
2395
|
async read(ctx, target) {
|
|
1256
2396
|
const raw = await ctx.query("docker-options:report", target);
|
|
@@ -1365,6 +2505,9 @@ var Ps = {
|
|
|
1365
2505
|
// src/resources/git.ts
|
|
1366
2506
|
var toDokku = (key) => key.replaceAll("_", "-");
|
|
1367
2507
|
var NOT_REMOVABLE = /* @__PURE__ */ new Set(["deploy_branch", "source_image", "rev_env_var"]);
|
|
2508
|
+
function isCopiedDeployBranch(current, inherited) {
|
|
2509
|
+
return inherited?.deploy_branch !== void 0 && current.deploy_branch === String(inherited.deploy_branch);
|
|
2510
|
+
}
|
|
1368
2511
|
function fromReport(report) {
|
|
1369
2512
|
const config = {};
|
|
1370
2513
|
if (report["deploy-branch"]) config.deploy_branch = report["deploy-branch"];
|
|
@@ -1376,7 +2519,12 @@ function fromReport(report) {
|
|
|
1376
2519
|
var Git = {
|
|
1377
2520
|
key: "git",
|
|
1378
2521
|
additive: true,
|
|
1379
|
-
|
|
2522
|
+
inheritsGlobal: true,
|
|
2523
|
+
removals: (current, desired, inherited) => Object.keys(current ?? {}).filter((k) => {
|
|
2524
|
+
if (desired && k in desired) return false;
|
|
2525
|
+
if (k === "deploy_branch") return isCopiedDeployBranch(current, inherited);
|
|
2526
|
+
return !NOT_REMOVABLE.has(k);
|
|
2527
|
+
}),
|
|
1380
2528
|
prune: async (ctx, target, removed) => {
|
|
1381
2529
|
for (const key of removed) await ctx.run("git:set", target, toDokku(key));
|
|
1382
2530
|
},
|
|
@@ -1515,29 +2663,38 @@ var NetworkProps = {
|
|
|
1515
2663
|
}
|
|
1516
2664
|
return result;
|
|
1517
2665
|
},
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
await ctx.run("network:set", target, "
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
await ctx.run("network:set", target, "tld", after.tld);
|
|
1537
|
-
ctx.needsRestart(target, "network");
|
|
2666
|
+
// `false` clears a property, so it is in sync when the server has no value; a
|
|
2667
|
+
// declared value must match what the server reports.
|
|
2668
|
+
inSync: (current, desired) => Object.keys(desired).every((k) => propInSync(current, desired, k)),
|
|
2669
|
+
onChange: async (ctx, target, { before, after }) => {
|
|
2670
|
+
const todo = (k) => after[k] !== void 0 && !propInSync(before, after, k);
|
|
2671
|
+
for (const [key, restart] of [
|
|
2672
|
+
["attach_post_create", false],
|
|
2673
|
+
["initial_network", false],
|
|
2674
|
+
["bind_all_interfaces", true],
|
|
2675
|
+
["static_web_listener", null],
|
|
2676
|
+
["tld", true]
|
|
2677
|
+
]) {
|
|
2678
|
+
if (!todo(key)) continue;
|
|
2679
|
+
const value = after[key];
|
|
2680
|
+
const args = value === false && key !== "bind_all_interfaces" ? [] : Array.isArray(value) ? value : [String(value)];
|
|
2681
|
+
await ctx.run("network:set", target, key.replaceAll("_", "-"), ...args);
|
|
2682
|
+
if (restart === false) ctx.needsRebuild(target, "network");
|
|
2683
|
+
else if (restart) ctx.needsRestart(target, "network");
|
|
1538
2684
|
}
|
|
1539
2685
|
}
|
|
1540
2686
|
};
|
|
2687
|
+
function propInSync(current, desired, key) {
|
|
2688
|
+
const want = desired[key];
|
|
2689
|
+
const have = current?.[key];
|
|
2690
|
+
if (want === void 0) return true;
|
|
2691
|
+
if (key === "bind_all_interfaces") return want === true === (have === true);
|
|
2692
|
+
if (want === false) return have === void 0;
|
|
2693
|
+
if (have === void 0 || have === false) return false;
|
|
2694
|
+
const w = Array.isArray(want) ? want : [want];
|
|
2695
|
+
const h = Array.isArray(have) ? have : [have];
|
|
2696
|
+
return w.length === h.length && w.every((v) => h.includes(v));
|
|
2697
|
+
}
|
|
1541
2698
|
|
|
1542
2699
|
// src/modules/plugins.ts
|
|
1543
2700
|
function parsePluginList(raw) {
|
|
@@ -1704,7 +2861,6 @@ async function exportNetworks(ctx) {
|
|
|
1704
2861
|
}
|
|
1705
2862
|
|
|
1706
2863
|
// src/modules/service-drift.ts
|
|
1707
|
-
import { createHash as createHash2 } from "crypto";
|
|
1708
2864
|
var infoReads = /* @__PURE__ */ new WeakMap();
|
|
1709
2865
|
function readServiceInfo(ctx, plugin, name) {
|
|
1710
2866
|
let reads = infoReads.get(ctx);
|
|
@@ -1721,6 +2877,9 @@ function readServiceInfo(ctx, plugin, name) {
|
|
|
1721
2877
|
}
|
|
1722
2878
|
return read;
|
|
1723
2879
|
}
|
|
2880
|
+
function forgetServiceInfo(ctx, plugin, name) {
|
|
2881
|
+
infoReads.get(ctx)?.delete(`${plugin}\0${name}`);
|
|
2882
|
+
}
|
|
1724
2883
|
async function fetchServiceInfo(ctx, plugin, name) {
|
|
1725
2884
|
const raw = await ctx.queryStrict(`${plugin}:info`, name, "--format", "json");
|
|
1726
2885
|
try {
|
|
@@ -1745,18 +2904,514 @@ async function readServiceMismatches(ctx, plugin, name, declared) {
|
|
|
1745
2904
|
if (declared.version === void 0 && declared.image === void 0) return [];
|
|
1746
2905
|
return versionMismatches(await readServiceInfo(ctx, plugin, name), declared);
|
|
1747
2906
|
}
|
|
2907
|
+
|
|
2908
|
+
// src/modules/service-properties.ts
|
|
2909
|
+
import { createHash as createHash2 } from "crypto";
|
|
2910
|
+
|
|
2911
|
+
// src/modules/service-mounts.ts
|
|
2912
|
+
async function entryHostPaths(ctx, volumes, names) {
|
|
2913
|
+
const paths = /* @__PURE__ */ new Map();
|
|
2914
|
+
let server;
|
|
2915
|
+
for (const name of names) {
|
|
2916
|
+
const declared = volumes?.[name];
|
|
2917
|
+
if (declared === void 0) throw new Error(`entry '${name}' is not declared under volumes:`);
|
|
2918
|
+
if (isLegacyName(name)) {
|
|
2919
|
+
server ??= await readEntries(ctx);
|
|
2920
|
+
const hostPath = server.get(name)?.host_path;
|
|
2921
|
+
if (!hostPath) throw new Error(`entry '${name}' is not on the server, so its host path is unknown`);
|
|
2922
|
+
paths.set(name, hostPath);
|
|
2923
|
+
} else {
|
|
2924
|
+
paths.set(name, declared.path ?? defaultPath(name));
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return paths;
|
|
2928
|
+
}
|
|
2929
|
+
async function resolveServiceMounts(ctx, volumes, mounts, failedEntries) {
|
|
2930
|
+
if (mounts === void 0) return void 0;
|
|
2931
|
+
const declared = declaredServiceMounts(mounts);
|
|
2932
|
+
for (const m of declared) {
|
|
2933
|
+
const reason = failedEntries?.get(m.entry);
|
|
2934
|
+
if (reason !== void 0) throw new Error(`entry '${m.entry}' failed: ${reason}`);
|
|
2935
|
+
}
|
|
2936
|
+
const paths = await entryHostPaths(ctx, volumes, [...new Set(declared.map((m) => m.entry))]);
|
|
2937
|
+
return declared.map((m) => {
|
|
2938
|
+
const tokens = [...m.readonly ? ["ro"] : [], ...m.options];
|
|
2939
|
+
return `${paths.get(m.entry)}:${m.container_dir}${tokens.length ? `:${tokens.join(",")}` : ""}`;
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// src/modules/links.ts
|
|
2944
|
+
function normalizeLink(entry) {
|
|
2945
|
+
return typeof entry === "string" ? { service: entry } : entry;
|
|
2946
|
+
}
|
|
2947
|
+
function resolveServicePlugin(name, config) {
|
|
2948
|
+
if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
|
|
2949
|
+
if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
|
|
2950
|
+
return void 0;
|
|
2951
|
+
}
|
|
2952
|
+
function allServices(config) {
|
|
2953
|
+
const entries = [];
|
|
2954
|
+
for (const name of Object.keys(config.postgres ?? {})) entries.push([name, "postgres"]);
|
|
2955
|
+
for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
|
|
2956
|
+
return entries;
|
|
2957
|
+
}
|
|
2958
|
+
var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
2959
|
+
function linkEnvVar(plugin, link) {
|
|
2960
|
+
if (link.env_var) return link.env_var;
|
|
2961
|
+
if (link.alias) return `${link.alias}_URL`;
|
|
2962
|
+
return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
|
|
2963
|
+
}
|
|
2964
|
+
function linkFlags(link) {
|
|
2965
|
+
const flags = [];
|
|
2966
|
+
if (link.alias) flags.push("--alias", link.alias);
|
|
2967
|
+
if (link.env_var) flags.push("--env-var", link.env_var);
|
|
2968
|
+
if (link.querystring) flags.push("--querystring", link.querystring);
|
|
2969
|
+
return flags;
|
|
2970
|
+
}
|
|
2971
|
+
function readEnv(ctx, app, cache) {
|
|
2972
|
+
let read = cache?.get(app);
|
|
2973
|
+
if (!read) {
|
|
2974
|
+
read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
|
|
2975
|
+
cache?.set(app, read);
|
|
2976
|
+
}
|
|
2977
|
+
return read;
|
|
2978
|
+
}
|
|
2979
|
+
function findLinkVariables(env, plugin, service) {
|
|
2980
|
+
const host = `dokku-${plugin}-${service}`;
|
|
2981
|
+
const found = [];
|
|
2982
|
+
for (const [name, value] of Object.entries(env)) {
|
|
2983
|
+
if (typeof value !== "string") continue;
|
|
2984
|
+
const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
|
|
2985
|
+
if (m && m[1] === host) found.push({ name, value });
|
|
2986
|
+
}
|
|
2987
|
+
return found;
|
|
2988
|
+
}
|
|
2989
|
+
function isFallbackName(plugin, name) {
|
|
2990
|
+
return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
|
|
2991
|
+
}
|
|
2992
|
+
function querystringOf(url) {
|
|
2993
|
+
const q = url.indexOf("?");
|
|
2994
|
+
return q === -1 ? "" : url.slice(q + 1);
|
|
2995
|
+
}
|
|
2996
|
+
function show(name, qs) {
|
|
2997
|
+
return qs ? `${name}?${maskQuerystring(qs)}` : name;
|
|
2998
|
+
}
|
|
2999
|
+
async function readLinkOptions(ctx, app, plugin, link, cache) {
|
|
3000
|
+
let env;
|
|
3001
|
+
try {
|
|
3002
|
+
env = await readEnv(ctx, app, cache);
|
|
3003
|
+
} catch (err) {
|
|
3004
|
+
return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
|
|
3005
|
+
}
|
|
3006
|
+
const found = findLinkVariables(env, plugin, link.service);
|
|
3007
|
+
if (found.length === 0) {
|
|
3008
|
+
return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
|
|
3009
|
+
}
|
|
3010
|
+
const name = linkEnvVar(plugin, link);
|
|
3011
|
+
const desired = link.querystring ?? "";
|
|
3012
|
+
const plain = !link.alias && !link.env_var;
|
|
3013
|
+
const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
|
|
3014
|
+
if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
|
|
3015
|
+
const current = querystringOf(hit.value);
|
|
3016
|
+
if (current === desired) return { state: "same" };
|
|
3017
|
+
return { state: "differs", current: show(name, current), desired: show(name, desired) };
|
|
3018
|
+
}
|
|
3019
|
+
async function readServiceLinks(ctx, app, desiredLinks, config) {
|
|
3020
|
+
const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
|
|
3021
|
+
const states = [];
|
|
3022
|
+
for (const [service, plugin] of allServices(config)) {
|
|
3023
|
+
states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
|
|
3024
|
+
}
|
|
3025
|
+
return states;
|
|
3026
|
+
}
|
|
3027
|
+
function servicesToUnlink(states) {
|
|
3028
|
+
return states.filter((s) => !s.link && s.isLinked);
|
|
3029
|
+
}
|
|
3030
|
+
async function ensureAppLinks(ctx, app, desiredLinks, config) {
|
|
3031
|
+
const states = await readServiceLinks(ctx, app, desiredLinks, config);
|
|
3032
|
+
const unlink = new Set(servicesToUnlink(states));
|
|
3033
|
+
for (const state of states) {
|
|
3034
|
+
const { service: serviceName, plugin, link, isLinked } = state;
|
|
3035
|
+
if (link && !isLinked) {
|
|
3036
|
+
logAction(app, `Linking ${serviceName}`);
|
|
3037
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
3038
|
+
ctx.needsRestart(app, "links");
|
|
3039
|
+
logDone();
|
|
3040
|
+
} else if (link && isLinked) {
|
|
3041
|
+
const opts = await readLinkOptions(ctx, app, plugin, link);
|
|
3042
|
+
if (opts.state === "differs") {
|
|
3043
|
+
logAction(app, `Relinking ${serviceName}`);
|
|
3044
|
+
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3045
|
+
try {
|
|
3046
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
3047
|
+
} catch (err) {
|
|
3048
|
+
logWarn(app, `${app} is now unlinked from ${serviceName}`);
|
|
3049
|
+
throw err;
|
|
3050
|
+
}
|
|
3051
|
+
ctx.needsRestart(app, "links");
|
|
3052
|
+
logDone();
|
|
3053
|
+
}
|
|
3054
|
+
} else if (unlink.has(state)) {
|
|
3055
|
+
logAction(app, `Unlinking ${serviceName}`);
|
|
3056
|
+
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3057
|
+
ctx.needsRestart(app, "links");
|
|
3058
|
+
logDone();
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
async function destroyAppLinks(ctx, app, links, config) {
|
|
3063
|
+
for (const serviceName of links.map((l) => normalizeLink(l).service)) {
|
|
3064
|
+
const resolved = resolveServicePlugin(serviceName, config);
|
|
3065
|
+
if (!resolved) continue;
|
|
3066
|
+
const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
|
|
3067
|
+
if (isLinked) {
|
|
3068
|
+
await ctx.run(`${resolved.plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
async function exportAppLinks(ctx, app, config) {
|
|
3073
|
+
const linked = [];
|
|
3074
|
+
for (const [serviceName, plugin] of allServices(config)) {
|
|
3075
|
+
if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
|
|
3076
|
+
let found = [];
|
|
3077
|
+
try {
|
|
3078
|
+
found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
|
|
3079
|
+
} catch {
|
|
3080
|
+
}
|
|
3081
|
+
const fallback = linkEnvVar(plugin, { service: serviceName });
|
|
3082
|
+
const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
|
|
3083
|
+
if (!v) {
|
|
3084
|
+
linked.push(serviceName);
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
const querystring = querystringOf(v.value);
|
|
3088
|
+
if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
|
|
3089
|
+
linked.push(serviceName);
|
|
3090
|
+
continue;
|
|
3091
|
+
}
|
|
3092
|
+
const entry = { service: serviceName };
|
|
3093
|
+
if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
|
|
3094
|
+
if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
|
|
3095
|
+
else entry.env_var = v.name;
|
|
3096
|
+
}
|
|
3097
|
+
if (querystring) entry.querystring = querystring;
|
|
3098
|
+
linked.push(entry);
|
|
3099
|
+
}
|
|
3100
|
+
return linked;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// src/modules/service-properties.ts
|
|
3104
|
+
function createArgs2(declared, mounts) {
|
|
3105
|
+
const args = [];
|
|
3106
|
+
for (const [key, value] of declaredEntries(declared.properties)) {
|
|
3107
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3108
|
+
if (!spec) continue;
|
|
3109
|
+
if (spec.repeat) for (const pair of value.split(" ")) args.push(`--${spec.createFlag}`, pair);
|
|
3110
|
+
else args.push(`--${spec.createFlag}`, value);
|
|
3111
|
+
}
|
|
3112
|
+
for (const mount of mounts ?? []) args.push("--volume", mount);
|
|
3113
|
+
return args;
|
|
3114
|
+
}
|
|
3115
|
+
var sha256 = (v) => createHash2("sha256").update(v).digest("hex");
|
|
3116
|
+
function declaredEntries(properties) {
|
|
3117
|
+
return Object.entries(properties ?? {}).map(([key, value]) => [key, propertyString(key, value)]);
|
|
3118
|
+
}
|
|
3119
|
+
function propertyChanges(plugin, info, properties, prune) {
|
|
3120
|
+
const changes = [];
|
|
3121
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3122
|
+
for (const [key, desired] of declaredEntries(properties)) {
|
|
3123
|
+
declared.add(key);
|
|
3124
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3125
|
+
if (spec?.kind === "secret") {
|
|
3126
|
+
if (!(`${key}-fingerprint` in info)) throw new Error(`dokku-${plugin} too old to report the ${key} fingerprint`);
|
|
3127
|
+
if (field(info, `${key}-fingerprint`) !== sha256(desired)) {
|
|
3128
|
+
changes.push({ key, value: desired, noRestart: true, restart: false, password: true });
|
|
3129
|
+
}
|
|
3130
|
+
continue;
|
|
3131
|
+
}
|
|
3132
|
+
if (!sameProperty(key, desired, field(info, key))) {
|
|
3133
|
+
changes.push({ key, value: desired, restart: spec?.restart ?? false, password: false });
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
if (prune) {
|
|
3137
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3138
|
+
if (declared.has(key) || isDefaultProperty(key, field(info, key))) continue;
|
|
3139
|
+
changes.push({ key, restart: MANAGED_PROPERTIES[key].restart, password: false });
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return changes;
|
|
3143
|
+
}
|
|
3144
|
+
function serverMounts(plugin, info) {
|
|
3145
|
+
if (!("mounts" in info)) throw new Error(`dokku-${plugin} too old to report mounts`);
|
|
3146
|
+
return parseInfoMounts(field(info, "mounts"));
|
|
3147
|
+
}
|
|
3148
|
+
function mountCommand(plugin, name, info, mounts) {
|
|
3149
|
+
if (mounts === void 0) return void 0;
|
|
3150
|
+
const desired = mountSet(mounts);
|
|
3151
|
+
const current = serverMounts(plugin, info);
|
|
3152
|
+
if (desired.join(" ") === current.join(" ")) return void 0;
|
|
3153
|
+
if (desired.length === 0) return [`${plugin}:unmount`, "--all", name];
|
|
3154
|
+
return [`${plugin}:mount`, "--replace", name, ...new Set(mounts)];
|
|
3155
|
+
}
|
|
3156
|
+
function mountMismatches(plugin, info, mounts) {
|
|
3157
|
+
if (mounts === void 0) return [];
|
|
3158
|
+
const desired = mountSet(mounts);
|
|
3159
|
+
const current = serverMounts(plugin, info);
|
|
3160
|
+
return desired.join(" ") === current.join(" ") ? [] : [{ field: "mounts", desired: desired.join(" ") || "(none)", current: current.join(" ") || "(none)" }];
|
|
3161
|
+
}
|
|
3162
|
+
function linkedApps(info, name, config, dryRun = false) {
|
|
3163
|
+
const apps = new Set(field(info, "links").split(/[\s,]+/).filter((a) => a !== ""));
|
|
3164
|
+
if (dryRun) {
|
|
3165
|
+
for (const [app, appConfig] of Object.entries(config.apps)) {
|
|
3166
|
+
if ((appConfig.links ?? []).some((l) => normalizeLink(l).service === name)) apps.add(app);
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
return [...apps];
|
|
3170
|
+
}
|
|
3171
|
+
function setArgs(plugin, name, change) {
|
|
3172
|
+
return [
|
|
3173
|
+
`${plugin}:set`,
|
|
3174
|
+
...change.noRestart ? ["--no-restart"] : [],
|
|
3175
|
+
name,
|
|
3176
|
+
change.key,
|
|
3177
|
+
...change.value === void 0 ? [] : ["--", change.value]
|
|
3178
|
+
];
|
|
3179
|
+
}
|
|
3180
|
+
async function ensureServiceProperties(ctx, plugin, services, config, opts = {}) {
|
|
3181
|
+
const failures = [];
|
|
3182
|
+
for (const [name, declared] of Object.entries(services)) {
|
|
3183
|
+
if (declared.properties === void 0 && declared.mounts === void 0 && !opts.prune) continue;
|
|
3184
|
+
if (opts.dryRunCreated?.has(name)) {
|
|
3185
|
+
for (const [key, value] of declaredEntries(declared.properties)) {
|
|
3186
|
+
if (!MANAGED_PROPERTIES[key]) await ctx.run(`${plugin}:set`, name, key, "--", value);
|
|
3187
|
+
}
|
|
3188
|
+
continue;
|
|
3189
|
+
}
|
|
3190
|
+
try {
|
|
3191
|
+
const info = await readServiceInfo(ctx, plugin, name);
|
|
3192
|
+
const mountSpecs = await resolveServiceMounts(ctx, config.volumes, declared.mounts, opts.failedVolumes);
|
|
3193
|
+
const changes = propertyChanges(plugin, info, declared.properties, opts.prune ?? false);
|
|
3194
|
+
const mount = mountCommand(plugin, name, info, mountSpecs);
|
|
3195
|
+
logAction("services", `Configuring properties for ${name}`);
|
|
3196
|
+
if (changes.length === 0 && !mount) {
|
|
3197
|
+
logSkip();
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
for (const change of changes) {
|
|
3201
|
+
await ctx.run(...setArgs(plugin, name, change));
|
|
3202
|
+
forgetServiceInfo(ctx, plugin, name);
|
|
3203
|
+
if (change.restart) ctx.needsServiceRestart(plugin, name, change.key);
|
|
3204
|
+
if (change.password) for (const app of linkedApps(info, name, config, opts.dryRun)) ctx.needsRestart(app, `${name} ${change.key} changed`);
|
|
3205
|
+
}
|
|
3206
|
+
if (mount) {
|
|
3207
|
+
await ctx.run(...mount);
|
|
3208
|
+
forgetServiceInfo(ctx, plugin, name);
|
|
3209
|
+
ctx.needsServiceRestart(plugin, name, "mounts");
|
|
3210
|
+
}
|
|
3211
|
+
logDone();
|
|
3212
|
+
} catch (error) {
|
|
3213
|
+
console.log("");
|
|
3214
|
+
logError(name, error instanceof Error ? error.message : String(error));
|
|
3215
|
+
failures.push({ service: name, error });
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return failures;
|
|
3219
|
+
}
|
|
3220
|
+
function propertyMismatches(plugin, info, properties) {
|
|
3221
|
+
const out = [];
|
|
3222
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3223
|
+
for (const [key, desired] of declaredEntries(properties)) {
|
|
3224
|
+
declared.add(key);
|
|
3225
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3226
|
+
if (spec?.kind === "secret") {
|
|
3227
|
+
if (!(`${key}-fingerprint` in info)) throw new Error(`dokku-${plugin} too old to report the ${key} fingerprint`);
|
|
3228
|
+
const stored = field(info, `${key}-fingerprint`);
|
|
3229
|
+
if (stored !== sha256(desired)) {
|
|
3230
|
+
out.push({ field: `properties.${key}`, desired: `the ${key} from the file`, current: stored === "" ? `no ${key}` : `another ${key}` });
|
|
3231
|
+
}
|
|
3232
|
+
continue;
|
|
3233
|
+
}
|
|
3234
|
+
const current = field(info, key);
|
|
3235
|
+
if (!sameProperty(key, desired, current)) out.push({ field: `properties.${key}`, desired, current });
|
|
3236
|
+
}
|
|
3237
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3238
|
+
const current = field(info, key);
|
|
3239
|
+
if (declared.has(key) || isDefaultProperty(key, current)) continue;
|
|
3240
|
+
out.push({ field: `properties.${key}`, desired: "(not in the file)", current, removed: true });
|
|
3241
|
+
}
|
|
3242
|
+
return out;
|
|
3243
|
+
}
|
|
3244
|
+
async function exportServiceProperties(ctx, plugin, name) {
|
|
3245
|
+
let info;
|
|
3246
|
+
try {
|
|
3247
|
+
info = await readServiceInfo(ctx, plugin, name);
|
|
3248
|
+
} catch {
|
|
3249
|
+
return {};
|
|
3250
|
+
}
|
|
3251
|
+
const properties = {};
|
|
3252
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3253
|
+
const current = field(info, key);
|
|
3254
|
+
if (isDefaultProperty(key, current)) continue;
|
|
3255
|
+
switch (MANAGED_PROPERTIES[key].kind) {
|
|
3256
|
+
case "list":
|
|
3257
|
+
properties[key] = current.split(",").filter((v) => v !== "");
|
|
3258
|
+
break;
|
|
3259
|
+
case "map":
|
|
3260
|
+
properties[key] = Object.fromEntries(current.split(";").filter((v) => v !== "").map((p) => [p.split("=")[0], EXPORT_PLACEHOLDER]));
|
|
3261
|
+
break;
|
|
3262
|
+
case "number":
|
|
3263
|
+
properties[key] = Number(current);
|
|
3264
|
+
break;
|
|
3265
|
+
case "targets":
|
|
3266
|
+
properties[key] = Object.fromEntries(targetPairs(current));
|
|
3267
|
+
break;
|
|
3268
|
+
default:
|
|
3269
|
+
properties[key] = current;
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const mounts = "mounts" in info ? parseInfoMounts(field(info, "mounts")) : [];
|
|
3273
|
+
return {
|
|
3274
|
+
...Object.keys(properties).length > 0 ? { properties } : {},
|
|
3275
|
+
...mounts.length > 0 ? { mountSpecs: mounts } : {}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// src/modules/service-backup.ts
|
|
3280
|
+
import { createHash as createHash3 } from "crypto";
|
|
1748
3281
|
function backupAuthFingerprint(accessKeyId, secretAccessKey) {
|
|
1749
3282
|
const firstLine = (v) => v.trim().split("\n")[0].trim();
|
|
1750
|
-
return
|
|
3283
|
+
return createHash3("sha256").update(`${firstLine(accessKeyId)}
|
|
1751
3284
|
${firstLine(secretAccessKey)}`).digest("hex");
|
|
1752
3285
|
}
|
|
3286
|
+
var sha2562 = (v) => createHash3("sha256").update(v).digest("hex");
|
|
3287
|
+
var SETTINGS = [
|
|
3288
|
+
{ key: "storage_class", property: "backup-storage-class" },
|
|
3289
|
+
{ key: "object_name", property: "backup-object-name" },
|
|
3290
|
+
{ key: "mailto", property: "backup-mailto" }
|
|
3291
|
+
];
|
|
3292
|
+
function otherScheduleIds(info) {
|
|
3293
|
+
const raw = info["backup-schedules"];
|
|
3294
|
+
const ids = Array.isArray(raw) ? raw.map((r) => String(r?.id ?? "")) : String(raw ?? "").split(";").map((r) => r.split("|")[0]);
|
|
3295
|
+
return ids.filter((id) => id !== "" && id !== "default");
|
|
3296
|
+
}
|
|
3297
|
+
var passphraseStored = (info) => field(info, "backup-encryption-fingerprint") !== "";
|
|
3298
|
+
var publicKeyStored = (info) => field(info, "backup-public-key-id") !== "";
|
|
3299
|
+
var encryptionStored = (info) => passphraseStored(info) || publicKeyStored(info) || field(info, "backup-keyserver") !== "";
|
|
3300
|
+
function settingsStored(info) {
|
|
3301
|
+
const out = SETTINGS.filter((s) => field(info, s.property) !== "").map((s) => s.key);
|
|
3302
|
+
if (field(info, "backup-timestamp") === "false") out.push("timestamp");
|
|
3303
|
+
return out;
|
|
3304
|
+
}
|
|
1753
3305
|
function hasBackup(info) {
|
|
1754
|
-
return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true";
|
|
3306
|
+
return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true" || encryptionStored(info) || settingsStored(info).length > 0;
|
|
3307
|
+
}
|
|
3308
|
+
function describeServerBackup(info) {
|
|
3309
|
+
const schedule = field(info, "backup-schedule");
|
|
3310
|
+
const parts = [];
|
|
3311
|
+
if (schedule) parts.push(`scheduled ${schedule} to ${field(info, "backup-bucket")}`);
|
|
3312
|
+
if (field(info, "backup-authenticated") === "true") parts.push("credentials stored");
|
|
3313
|
+
if (encryptionStored(info)) parts.push("encrypted");
|
|
3314
|
+
const settings = settingsStored(info);
|
|
3315
|
+
if (settings.length > 0) parts.push(`with ${settings.join(", ")} set`);
|
|
3316
|
+
return parts.join(", ") || "none";
|
|
3317
|
+
}
|
|
3318
|
+
function declaresNewerKeys(backup) {
|
|
3319
|
+
return backup.encryption !== void 0 || backup.timestamp !== void 0 || SETTINGS.some((s) => backup[s.key] !== void 0);
|
|
3320
|
+
}
|
|
3321
|
+
function assertReportsBackup(plugin, info, backup) {
|
|
3322
|
+
const needed = ["backup-auth-fingerprint"];
|
|
3323
|
+
if (backup !== false && declaresNewerKeys(backup)) needed.push("backup-timestamp", "backup-mailto");
|
|
3324
|
+
if (needed.some((key) => !(key in info))) throw new Error(`dokku-${plugin} too old to report backup state (needs 2.3.1+)`);
|
|
3325
|
+
}
|
|
3326
|
+
function encryptionMismatch(info, enc) {
|
|
3327
|
+
const fingerprint = field(info, "backup-encryption-fingerprint");
|
|
3328
|
+
const keyId = field(info, "backup-public-key-id");
|
|
3329
|
+
const keyserver = field(info, "backup-keyserver");
|
|
3330
|
+
const stored = [];
|
|
3331
|
+
if (enc === false) {
|
|
3332
|
+
if (passphraseStored(info)) stored.push("a passphrase");
|
|
3333
|
+
if (keyId !== "") stored.push(`public key ${keyId}`);
|
|
3334
|
+
if (keyserver !== "") stored.push(`keyserver ${keyserver}`);
|
|
3335
|
+
return stored.length ? { field: "backup.encryption", desired: "none", current: stored.join(", ") } : void 0;
|
|
3336
|
+
}
|
|
3337
|
+
if (enc.passphrase !== void 0) {
|
|
3338
|
+
if (fingerprint !== sha2562(enc.passphrase)) stored.push(fingerprint === "" ? "no passphrase" : "a different passphrase");
|
|
3339
|
+
if (keyId !== "") stored.push(`public key ${keyId}`);
|
|
3340
|
+
if (keyserver !== "") stored.push(`keyserver ${keyserver}`);
|
|
3341
|
+
return stored.length ? { field: "backup.encryption", desired: "passphrase", current: stored.join(", ") } : void 0;
|
|
3342
|
+
}
|
|
3343
|
+
if (keyId !== enc.public_key_id) stored.push(keyId === "" ? "no public key" : `public key ${keyId}`);
|
|
3344
|
+
if (keyserver !== (enc.keyserver ?? "")) stored.push(`keyserver ${keyserver || "none"}`);
|
|
3345
|
+
if (passphraseStored(info)) stored.push("a passphrase");
|
|
3346
|
+
return stored.length ? {
|
|
3347
|
+
field: "backup.encryption",
|
|
3348
|
+
desired: `public key ${enc.public_key_id}${enc.keyserver ? ` on ${enc.keyserver}` : ""}`,
|
|
3349
|
+
current: stored.join(", ")
|
|
3350
|
+
} : void 0;
|
|
3351
|
+
}
|
|
3352
|
+
function encryptionCommands(plugin, name, info, enc) {
|
|
3353
|
+
const set = (property, value) => [`${plugin}:set`, name, property, ...value === void 0 ? [] : [value]];
|
|
3354
|
+
const cmds = [];
|
|
3355
|
+
const clearPassphrase = () => {
|
|
3356
|
+
if (passphraseStored(info)) cmds.push(set("backup-encryption-passphrase"));
|
|
3357
|
+
};
|
|
3358
|
+
const clearKeyId = () => {
|
|
3359
|
+
if (publicKeyStored(info)) cmds.push(set("backup-public-key-id"));
|
|
3360
|
+
};
|
|
3361
|
+
const clearKeyserver = () => {
|
|
3362
|
+
if (field(info, "backup-keyserver") !== "") cmds.push(set("backup-keyserver"));
|
|
3363
|
+
};
|
|
3364
|
+
if (enc === false) {
|
|
3365
|
+
clearPassphrase();
|
|
3366
|
+
clearKeyId();
|
|
3367
|
+
clearKeyserver();
|
|
3368
|
+
} else if (enc.passphrase !== void 0) {
|
|
3369
|
+
if (field(info, "backup-encryption-fingerprint") !== sha2562(enc.passphrase)) cmds.push(set("backup-encryption-passphrase", enc.passphrase));
|
|
3370
|
+
clearKeyId();
|
|
3371
|
+
clearKeyserver();
|
|
3372
|
+
} else {
|
|
3373
|
+
if (field(info, "backup-keyserver") !== (enc.keyserver ?? "")) cmds.push(set("backup-keyserver", enc.keyserver));
|
|
3374
|
+
if (field(info, "backup-public-key-id") !== enc.public_key_id) cmds.push(set("backup-public-key-id", enc.public_key_id));
|
|
3375
|
+
clearPassphrase();
|
|
3376
|
+
}
|
|
3377
|
+
return cmds;
|
|
3378
|
+
}
|
|
3379
|
+
function settingMismatches(info, backup) {
|
|
3380
|
+
const out = [];
|
|
3381
|
+
for (const { key, property } of SETTINGS) {
|
|
3382
|
+
const desired = backup[key];
|
|
3383
|
+
if (desired !== void 0 && desired !== field(info, property)) out.push({ field: `backup.${key}`, desired, current: field(info, property) });
|
|
3384
|
+
}
|
|
3385
|
+
if (backup.timestamp === false && field(info, "backup-timestamp") !== "false") {
|
|
3386
|
+
out.push({ field: "backup.timestamp", desired: "false", current: field(info, "backup-timestamp") || "true" });
|
|
3387
|
+
}
|
|
3388
|
+
return out;
|
|
1755
3389
|
}
|
|
1756
|
-
function
|
|
1757
|
-
|
|
1758
|
-
|
|
3390
|
+
function settingCommands(plugin, name, info, backup) {
|
|
3391
|
+
const cmds = [];
|
|
3392
|
+
for (const { key, property } of SETTINGS) {
|
|
3393
|
+
const desired = backup[key];
|
|
3394
|
+
if (desired !== void 0 && desired !== field(info, property)) cmds.push([`${plugin}:set`, name, property, desired]);
|
|
1759
3395
|
}
|
|
3396
|
+
if (backup.timestamp === false && field(info, "backup-timestamp") !== "false") cmds.push([`${plugin}:set`, name, "backup-timestamp", "false"]);
|
|
3397
|
+
return cmds;
|
|
3398
|
+
}
|
|
3399
|
+
function pruneCommands(plugin, name, info, backup) {
|
|
3400
|
+
const cmds = [];
|
|
3401
|
+
for (const { key, property } of SETTINGS) {
|
|
3402
|
+
if (backup[key] === void 0 && field(info, property) !== "") cmds.push([`${plugin}:set`, name, property]);
|
|
3403
|
+
}
|
|
3404
|
+
if (backup.timestamp === void 0 && field(info, "backup-timestamp") === "false") cmds.push([`${plugin}:set`, name, "backup-timestamp"]);
|
|
3405
|
+
return cmds;
|
|
3406
|
+
}
|
|
3407
|
+
function clearAllCommands(plugin, name, info) {
|
|
3408
|
+
const cmds = encryptionCommands(plugin, name, info, false);
|
|
3409
|
+
for (const { property } of SETTINGS) if (field(info, property) !== "") cmds.push([`${plugin}:set`, name, property]);
|
|
3410
|
+
if (field(info, "backup-timestamp") === "false") cmds.push([`${plugin}:set`, name, "backup-timestamp"]);
|
|
3411
|
+
return cmds;
|
|
3412
|
+
}
|
|
3413
|
+
function backupMismatches(plugin, info, backup) {
|
|
3414
|
+
assertReportsBackup(plugin, info, backup);
|
|
1760
3415
|
if (backup === false) {
|
|
1761
3416
|
return hasBackup(info) ? [{ field: "backup", desired: "none", current: describeServerBackup(info) }] : [];
|
|
1762
3417
|
}
|
|
@@ -1782,14 +3437,167 @@ function backupMismatches(info, backup) {
|
|
|
1782
3437
|
if (field(info, "backup-bucket") !== backup.bucket) {
|
|
1783
3438
|
out.push({ field: "backup.bucket", desired: backup.bucket, current: field(info, "backup-bucket") });
|
|
1784
3439
|
}
|
|
3440
|
+
if (backup.encryption !== void 0) {
|
|
3441
|
+
const m = encryptionMismatch(info, backup.encryption);
|
|
3442
|
+
if (m) out.push(m);
|
|
3443
|
+
}
|
|
3444
|
+
out.push(...settingMismatches(info, backup));
|
|
1785
3445
|
return out;
|
|
1786
3446
|
}
|
|
1787
|
-
function
|
|
3447
|
+
function backupNotes(info, backup) {
|
|
3448
|
+
const notes = [];
|
|
3449
|
+
if (backup === void 0) {
|
|
3450
|
+
if (hasBackup(info)) notes.push(`backup on the server is not in the file (${describeServerBackup(info)}); up leaves it, backup: false removes it`);
|
|
3451
|
+
} else if (backup !== false) {
|
|
3452
|
+
if (backup.encryption === void 0 && encryptionStored(info)) {
|
|
3453
|
+
notes.push("backup encryption on the server is not in the file; up leaves it, encryption: false removes it");
|
|
3454
|
+
}
|
|
3455
|
+
const extra = [
|
|
3456
|
+
...SETTINGS.filter((s) => backup[s.key] === void 0 && field(info, s.property) !== "").map((s) => s.key),
|
|
3457
|
+
...backup.timestamp === void 0 && field(info, "backup-timestamp") === "false" ? ["timestamp"] : []
|
|
3458
|
+
];
|
|
3459
|
+
if (extra.length > 0) notes.push(`backup ${extra.join(", ")} on the server ${extra.length === 1 ? "is" : "are"} not in the file; up leaves ${extra.length === 1 ? "it" : "them"}, up --prune clears ${extra.length === 1 ? "it" : "them"}`);
|
|
3460
|
+
}
|
|
3461
|
+
const others = otherScheduleIds(info);
|
|
3462
|
+
if (others.length > 0) notes.push(`other backup schedules (${others.join(", ")}) are not managed; up never touches them`);
|
|
3463
|
+
return notes;
|
|
3464
|
+
}
|
|
3465
|
+
async function ensureServiceBackups(ctx, plugin, services, opts = {}) {
|
|
3466
|
+
for (const [name, config] of Object.entries(services)) {
|
|
3467
|
+
const backup = config.backup;
|
|
3468
|
+
if (backup === void 0 && !opts.prune) continue;
|
|
3469
|
+
if (backup === void 0) {
|
|
3470
|
+
let info2;
|
|
3471
|
+
try {
|
|
3472
|
+
info2 = await readServiceInfo(ctx, plugin, name);
|
|
3473
|
+
} catch (err) {
|
|
3474
|
+
logWarn("services", `could not read backup state for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
if (hasBackup(info2)) {
|
|
3478
|
+
logWarn("services", `${name}: has a backup the file does not declare (${describeServerBackup(info2)}); --prune leaves it, set backup: false to remove it`);
|
|
3479
|
+
}
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
if (opts.dryRunCreated?.has(name)) {
|
|
3483
|
+
logAction("services", `Configuring backup for ${name}`);
|
|
3484
|
+
if (backup !== false) {
|
|
3485
|
+
const { schedule: schedule2, bucket: bucket2, auth: auth2 } = backup;
|
|
3486
|
+
await ctx.run(
|
|
3487
|
+
`${plugin}:backup-auth`,
|
|
3488
|
+
name,
|
|
3489
|
+
auth2.access_key_id,
|
|
3490
|
+
auth2.secret_access_key,
|
|
3491
|
+
auth2.region,
|
|
3492
|
+
auth2.signature_version,
|
|
3493
|
+
auth2.endpoint
|
|
3494
|
+
);
|
|
3495
|
+
await ctx.run(`${plugin}:backup-schedule`, name, schedule2, bucket2);
|
|
3496
|
+
const nothing = {};
|
|
3497
|
+
const cmds2 = [
|
|
3498
|
+
...backup.encryption !== void 0 ? encryptionCommands(plugin, name, nothing, backup.encryption) : [],
|
|
3499
|
+
...settingCommands(plugin, name, nothing, backup)
|
|
3500
|
+
];
|
|
3501
|
+
for (const cmd of cmds2) await ctx.run(...cmd);
|
|
3502
|
+
}
|
|
3503
|
+
logDone();
|
|
3504
|
+
continue;
|
|
3505
|
+
}
|
|
3506
|
+
const info = await readServiceInfo(ctx, plugin, name);
|
|
3507
|
+
logAction("services", `Configuring backup for ${name}`);
|
|
3508
|
+
const fields = new Set(backupMismatches(plugin, info, backup).map((m) => m.field));
|
|
3509
|
+
const prune = opts.prune && backup !== false ? pruneCommands(plugin, name, info, backup) : [];
|
|
3510
|
+
if (opts.prune && backup !== false && backup.encryption === void 0 && encryptionStored(info)) {
|
|
3511
|
+
logWarn("services", `${name}: has backup encryption the file does not declare; --prune leaves it, set encryption: false to remove it`);
|
|
3512
|
+
}
|
|
3513
|
+
if (fields.size === 0 && prune.length === 0) {
|
|
3514
|
+
logSkip();
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
if (backup === false) {
|
|
3518
|
+
if (field(info, "backup-schedule") !== "") await ctx.run(`${plugin}:backup-unschedule`, name, "--id", "default");
|
|
3519
|
+
if (field(info, "backup-authenticated") === "true") await ctx.run(`${plugin}:backup-deauth`, name);
|
|
3520
|
+
for (const cmd of clearAllCommands(plugin, name, info)) await ctx.run(...cmd);
|
|
3521
|
+
logDone();
|
|
3522
|
+
continue;
|
|
3523
|
+
}
|
|
3524
|
+
const { schedule, bucket, auth } = backup;
|
|
3525
|
+
if (fields.has("backup.auth")) {
|
|
3526
|
+
await ctx.run(
|
|
3527
|
+
`${plugin}:backup-auth`,
|
|
3528
|
+
name,
|
|
3529
|
+
auth.access_key_id,
|
|
3530
|
+
auth.secret_access_key,
|
|
3531
|
+
auth.region,
|
|
3532
|
+
auth.signature_version,
|
|
3533
|
+
auth.endpoint
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
if (fields.has("backup.schedule")) {
|
|
3537
|
+
await ctx.run(`${plugin}:backup-schedule`, name, schedule, bucket);
|
|
3538
|
+
if (!opts.dryRun) {
|
|
3539
|
+
const cronOutput = await ctx.query(`${plugin}:backup-schedule-cat`, name);
|
|
3540
|
+
if (!parseBackupSchedule(cronOutput)) {
|
|
3541
|
+
throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
} else if (fields.has("backup.bucket")) {
|
|
3545
|
+
if (otherScheduleIds(info).length > 0) await ctx.run(`${plugin}:backup-schedule`, name, schedule, bucket);
|
|
3546
|
+
else await ctx.run(`${plugin}:set`, name, "backup-bucket", bucket);
|
|
3547
|
+
}
|
|
3548
|
+
const cmds = [
|
|
3549
|
+
...backup.encryption !== void 0 ? encryptionCommands(plugin, name, info, backup.encryption) : [],
|
|
3550
|
+
...settingCommands(plugin, name, info, backup),
|
|
3551
|
+
...prune
|
|
3552
|
+
];
|
|
3553
|
+
for (const cmd of cmds) await ctx.run(...cmd);
|
|
3554
|
+
logDone();
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
function parseBackupSchedule(cronLine) {
|
|
3558
|
+
const parts = cronLine.trim().split(/\s+/);
|
|
3559
|
+
if (parts.length < 8) return null;
|
|
3560
|
+
const schedule = parts.slice(0, 5).join(" ");
|
|
3561
|
+
const bucket = parts[parts.length - 1];
|
|
3562
|
+
return { schedule, bucket };
|
|
3563
|
+
}
|
|
3564
|
+
async function exportServiceBackup(ctx, plugin, name) {
|
|
3565
|
+
let info;
|
|
3566
|
+
try {
|
|
3567
|
+
info = await readServiceInfo(ctx, plugin, name);
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
logWarn(name, `could not read the backup of ${plugin} service '${name}', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
3570
|
+
return void 0;
|
|
3571
|
+
}
|
|
1788
3572
|
const schedule = field(info, "backup-schedule");
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
3573
|
+
if (schedule === "") {
|
|
3574
|
+
if (hasBackup(info)) {
|
|
3575
|
+
logWarn(name, `${plugin} service '${name}' has backup ${describeServerBackup(info)} but no default schedule; its backup is left out of the export`);
|
|
3576
|
+
}
|
|
3577
|
+
return void 0;
|
|
3578
|
+
}
|
|
3579
|
+
const out = {
|
|
3580
|
+
schedule,
|
|
3581
|
+
bucket: field(info, "backup-bucket"),
|
|
3582
|
+
auth: {
|
|
3583
|
+
access_key_id: EXPORT_PLACEHOLDER,
|
|
3584
|
+
secret_access_key: EXPORT_PLACEHOLDER,
|
|
3585
|
+
region: EXPORT_PLACEHOLDER,
|
|
3586
|
+
signature_version: EXPORT_PLACEHOLDER,
|
|
3587
|
+
endpoint: EXPORT_PLACEHOLDER
|
|
3588
|
+
}
|
|
3589
|
+
};
|
|
3590
|
+
if (publicKeyStored(info)) {
|
|
3591
|
+
out.encryption = { public_key_id: field(info, "backup-public-key-id") };
|
|
3592
|
+
if (field(info, "backup-keyserver") !== "") out.encryption.keyserver = field(info, "backup-keyserver");
|
|
3593
|
+
} else if (passphraseStored(info)) {
|
|
3594
|
+
out.encryption = { passphrase: EXPORT_PLACEHOLDER };
|
|
3595
|
+
}
|
|
3596
|
+
for (const { key, property } of SETTINGS) {
|
|
3597
|
+
if (field(info, property) !== "") out[key] = field(info, property);
|
|
3598
|
+
}
|
|
3599
|
+
if (field(info, "backup-timestamp") === "false") out.timestamp = false;
|
|
3600
|
+
return out;
|
|
1793
3601
|
}
|
|
1794
3602
|
|
|
1795
3603
|
// src/modules/config.ts
|
|
@@ -1836,7 +3644,7 @@ async function readGlobalEnvDrift(ctx, env, current) {
|
|
|
1836
3644
|
}
|
|
1837
3645
|
|
|
1838
3646
|
// src/modules/postgres.ts
|
|
1839
|
-
async function ensurePostgres(ctx, services) {
|
|
3647
|
+
async function ensurePostgres(ctx, services, volumes, opts = {}) {
|
|
1840
3648
|
const created = /* @__PURE__ */ new Set();
|
|
1841
3649
|
for (const [name, config] of Object.entries(services)) {
|
|
1842
3650
|
logAction("services", `Ensuring ${name}`);
|
|
@@ -1857,83 +3665,20 @@ async function ensurePostgres(ctx, services) {
|
|
|
1857
3665
|
const args = ["postgres:create", name];
|
|
1858
3666
|
if (config.image) args.push("--image", config.image);
|
|
1859
3667
|
if (config.version) args.push("--image-version", config.version);
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
const backup = config.backup;
|
|
1869
|
-
if (backup === void 0 && !opts.prune) continue;
|
|
1870
|
-
if (backup === void 0) {
|
|
1871
|
-
let info2;
|
|
1872
|
-
try {
|
|
1873
|
-
info2 = await readServiceInfo(ctx, "postgres", name);
|
|
1874
|
-
} catch (err) {
|
|
1875
|
-
logWarn("services", `could not read backup state for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1876
|
-
continue;
|
|
1877
|
-
}
|
|
1878
|
-
if (hasBackup(info2)) {
|
|
1879
|
-
logWarn("services", `${name}: has a backup the file does not declare (${describeServerBackup(info2)}); --prune leaves it, set backup: false to remove it`);
|
|
1880
|
-
}
|
|
1881
|
-
continue;
|
|
1882
|
-
}
|
|
1883
|
-
if (opts.dryRunCreated?.has(name)) {
|
|
1884
|
-
logAction("services", `Configuring backup for ${name}`);
|
|
1885
|
-
if (backup !== false) {
|
|
1886
|
-
const { schedule: schedule2, bucket: bucket2, auth: auth2 } = backup;
|
|
1887
|
-
await ctx.run(
|
|
1888
|
-
"postgres:backup-auth",
|
|
1889
|
-
name,
|
|
1890
|
-
auth2.access_key_id,
|
|
1891
|
-
auth2.secret_access_key,
|
|
1892
|
-
auth2.region,
|
|
1893
|
-
auth2.signature_version,
|
|
1894
|
-
auth2.endpoint
|
|
1895
|
-
);
|
|
1896
|
-
await ctx.run("postgres:backup-schedule", name, schedule2, bucket2);
|
|
1897
|
-
}
|
|
1898
|
-
logDone();
|
|
1899
|
-
continue;
|
|
1900
|
-
}
|
|
1901
|
-
const info = await readServiceInfo(ctx, "postgres", name);
|
|
1902
|
-
logAction("services", `Configuring backup for ${name}`);
|
|
1903
|
-
const fields = new Set(backupMismatches(info, backup).map((m) => m.field));
|
|
1904
|
-
if (fields.size === 0) {
|
|
1905
|
-
logSkip();
|
|
1906
|
-
continue;
|
|
1907
|
-
}
|
|
1908
|
-
if (backup === false) {
|
|
1909
|
-
if (String(info["backup-schedule"] ?? "") !== "") await ctx.run("postgres:backup-unschedule", name);
|
|
1910
|
-
await ctx.run("postgres:backup-deauth", name);
|
|
1911
|
-
logDone();
|
|
3668
|
+
try {
|
|
3669
|
+
args.push(...createArgs2(config, await resolveServiceMounts(ctx, volumes, config.mounts, opts.failedVolumes)));
|
|
3670
|
+
await ctx.run(...args);
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
if (!opts.failures) throw error;
|
|
3673
|
+
console.log("");
|
|
3674
|
+
logError(name, error instanceof Error ? error.message : String(error));
|
|
3675
|
+
opts.failures.push({ service: name, error });
|
|
1912
3676
|
continue;
|
|
1913
3677
|
}
|
|
1914
|
-
|
|
1915
|
-
if (fields.has("backup.auth")) {
|
|
1916
|
-
await ctx.run(
|
|
1917
|
-
"postgres:backup-auth",
|
|
1918
|
-
name,
|
|
1919
|
-
auth.access_key_id,
|
|
1920
|
-
auth.secret_access_key,
|
|
1921
|
-
auth.region,
|
|
1922
|
-
auth.signature_version,
|
|
1923
|
-
auth.endpoint
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
1926
|
-
if (fields.has("backup.schedule")) {
|
|
1927
|
-
await ctx.run("postgres:backup-schedule", name, schedule, bucket);
|
|
1928
|
-
const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
|
|
1929
|
-
if (!parseBackupSchedule(cronOutput)) {
|
|
1930
|
-
throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
|
|
1931
|
-
}
|
|
1932
|
-
} else if (fields.has("backup.bucket")) {
|
|
1933
|
-
await ctx.run("postgres:set", name, "backup-bucket", bucket);
|
|
1934
|
-
}
|
|
3678
|
+
created.add(name);
|
|
1935
3679
|
logDone();
|
|
1936
3680
|
}
|
|
3681
|
+
return created;
|
|
1937
3682
|
}
|
|
1938
3683
|
async function removeBackupHashKeys(ctx) {
|
|
1939
3684
|
let env;
|
|
@@ -1964,14 +3709,7 @@ async function destroyPostgres(ctx, services) {
|
|
|
1964
3709
|
logDone();
|
|
1965
3710
|
}
|
|
1966
3711
|
}
|
|
1967
|
-
function
|
|
1968
|
-
const parts = cronLine.trim().split(/\s+/);
|
|
1969
|
-
if (parts.length < 8) return null;
|
|
1970
|
-
const schedule = parts.slice(0, 5).join(" ");
|
|
1971
|
-
const bucket = parts[parts.length - 1];
|
|
1972
|
-
return { schedule, bucket };
|
|
1973
|
-
}
|
|
1974
|
-
async function exportPostgres(ctx) {
|
|
3712
|
+
async function exportPostgres(ctx, mountSpecs) {
|
|
1975
3713
|
const services = {};
|
|
1976
3714
|
const listOutput = await ctx.query("postgres:list");
|
|
1977
3715
|
const lines = listOutput.split("\n").slice(1);
|
|
@@ -1992,33 +3730,19 @@ async function exportPostgres(ctx) {
|
|
|
1992
3730
|
} else {
|
|
1993
3731
|
config.version = versionField;
|
|
1994
3732
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
config.backup = {
|
|
2001
|
-
schedule: parsed.schedule,
|
|
2002
|
-
bucket: parsed.bucket,
|
|
2003
|
-
auth: {
|
|
2004
|
-
access_key_id: "REPLACE_ME",
|
|
2005
|
-
secret_access_key: "REPLACE_ME",
|
|
2006
|
-
region: "REPLACE_ME",
|
|
2007
|
-
signature_version: "REPLACE_ME",
|
|
2008
|
-
endpoint: "REPLACE_ME"
|
|
2009
|
-
}
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
} catch {
|
|
2014
|
-
}
|
|
3733
|
+
const backup = await exportServiceBackup(ctx, "postgres", name);
|
|
3734
|
+
if (backup) config.backup = backup;
|
|
3735
|
+
const exported = await exportServiceProperties(ctx, "postgres", name);
|
|
3736
|
+
if (exported.properties) config.properties = exported.properties;
|
|
3737
|
+
if (exported.mountSpecs) mountSpecs?.set(name, exported.mountSpecs);
|
|
2015
3738
|
services[name] = config;
|
|
2016
3739
|
}
|
|
2017
3740
|
return services;
|
|
2018
3741
|
}
|
|
2019
3742
|
|
|
2020
3743
|
// src/modules/redis.ts
|
|
2021
|
-
async function ensureRedis(ctx, services) {
|
|
3744
|
+
async function ensureRedis(ctx, services, volumes, opts = {}) {
|
|
3745
|
+
const created = /* @__PURE__ */ new Set();
|
|
2022
3746
|
for (const [name, config] of Object.entries(services)) {
|
|
2023
3747
|
logAction("services", `Ensuring ${name}`);
|
|
2024
3748
|
const exists = await ctx.check("redis:exists", name);
|
|
@@ -2038,9 +3762,20 @@ async function ensureRedis(ctx, services) {
|
|
|
2038
3762
|
const args = ["redis:create", name];
|
|
2039
3763
|
if (config.image) args.push("--image", config.image);
|
|
2040
3764
|
if (config.version) args.push("--image-version", config.version);
|
|
2041
|
-
|
|
3765
|
+
try {
|
|
3766
|
+
args.push(...createArgs2(config, await resolveServiceMounts(ctx, volumes, config.mounts, opts.failedVolumes)));
|
|
3767
|
+
await ctx.run(...args);
|
|
3768
|
+
} catch (error) {
|
|
3769
|
+
if (!opts.failures) throw error;
|
|
3770
|
+
console.log("");
|
|
3771
|
+
logError(name, error instanceof Error ? error.message : String(error));
|
|
3772
|
+
opts.failures.push({ service: name, error });
|
|
3773
|
+
continue;
|
|
3774
|
+
}
|
|
3775
|
+
created.add(name);
|
|
2042
3776
|
logDone();
|
|
2043
3777
|
}
|
|
3778
|
+
return created;
|
|
2044
3779
|
}
|
|
2045
3780
|
async function destroyRedis(ctx, services) {
|
|
2046
3781
|
for (const [name] of Object.entries(services)) {
|
|
@@ -2054,7 +3789,7 @@ async function destroyRedis(ctx, services) {
|
|
|
2054
3789
|
logDone();
|
|
2055
3790
|
}
|
|
2056
3791
|
}
|
|
2057
|
-
async function exportRedis(ctx) {
|
|
3792
|
+
async function exportRedis(ctx, mountSpecs) {
|
|
2058
3793
|
const services = {};
|
|
2059
3794
|
const listOutput = await ctx.query("redis:list");
|
|
2060
3795
|
const lines = listOutput.split("\n").slice(1);
|
|
@@ -2075,171 +3810,16 @@ async function exportRedis(ctx) {
|
|
|
2075
3810
|
} else {
|
|
2076
3811
|
config.version = versionField;
|
|
2077
3812
|
}
|
|
3813
|
+
const backup = await exportServiceBackup(ctx, "redis", name);
|
|
3814
|
+
if (backup) config.backup = backup;
|
|
3815
|
+
const exported = await exportServiceProperties(ctx, "redis", name);
|
|
3816
|
+
if (exported.properties) config.properties = exported.properties;
|
|
3817
|
+
if (exported.mountSpecs) mountSpecs?.set(name, exported.mountSpecs);
|
|
2078
3818
|
services[name] = config;
|
|
2079
3819
|
}
|
|
2080
3820
|
return services;
|
|
2081
3821
|
}
|
|
2082
3822
|
|
|
2083
|
-
// src/modules/links.ts
|
|
2084
|
-
function normalizeLink(entry) {
|
|
2085
|
-
return typeof entry === "string" ? { service: entry } : entry;
|
|
2086
|
-
}
|
|
2087
|
-
function resolveServicePlugin(name, config) {
|
|
2088
|
-
if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
|
|
2089
|
-
if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
|
|
2090
|
-
return void 0;
|
|
2091
|
-
}
|
|
2092
|
-
function allServices(config) {
|
|
2093
|
-
const entries = [];
|
|
2094
|
-
for (const name of Object.keys(config.postgres ?? {})) entries.push([name, "postgres"]);
|
|
2095
|
-
for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
|
|
2096
|
-
return entries;
|
|
2097
|
-
}
|
|
2098
|
-
var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
2099
|
-
function linkEnvVar(plugin, link) {
|
|
2100
|
-
if (link.env_var) return link.env_var;
|
|
2101
|
-
if (link.alias) return `${link.alias}_URL`;
|
|
2102
|
-
return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
|
|
2103
|
-
}
|
|
2104
|
-
function linkFlags(link) {
|
|
2105
|
-
const flags = [];
|
|
2106
|
-
if (link.alias) flags.push("--alias", link.alias);
|
|
2107
|
-
if (link.env_var) flags.push("--env-var", link.env_var);
|
|
2108
|
-
if (link.querystring) flags.push("--querystring", link.querystring);
|
|
2109
|
-
return flags;
|
|
2110
|
-
}
|
|
2111
|
-
function readEnv(ctx, app, cache) {
|
|
2112
|
-
let read = cache?.get(app);
|
|
2113
|
-
if (!read) {
|
|
2114
|
-
read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
|
|
2115
|
-
cache?.set(app, read);
|
|
2116
|
-
}
|
|
2117
|
-
return read;
|
|
2118
|
-
}
|
|
2119
|
-
function findLinkVariables(env, plugin, service) {
|
|
2120
|
-
const host = `dokku-${plugin}-${service}`;
|
|
2121
|
-
const found = [];
|
|
2122
|
-
for (const [name, value] of Object.entries(env)) {
|
|
2123
|
-
if (typeof value !== "string") continue;
|
|
2124
|
-
const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
|
|
2125
|
-
if (m && m[1] === host) found.push({ name, value });
|
|
2126
|
-
}
|
|
2127
|
-
return found;
|
|
2128
|
-
}
|
|
2129
|
-
function isFallbackName(plugin, name) {
|
|
2130
|
-
return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
|
|
2131
|
-
}
|
|
2132
|
-
function querystringOf(url) {
|
|
2133
|
-
const q = url.indexOf("?");
|
|
2134
|
-
return q === -1 ? "" : url.slice(q + 1);
|
|
2135
|
-
}
|
|
2136
|
-
function show(name, qs) {
|
|
2137
|
-
return qs ? `${name}?${maskQuerystring(qs)}` : name;
|
|
2138
|
-
}
|
|
2139
|
-
async function readLinkOptions(ctx, app, plugin, link, cache) {
|
|
2140
|
-
let env;
|
|
2141
|
-
try {
|
|
2142
|
-
env = await readEnv(ctx, app, cache);
|
|
2143
|
-
} catch (err) {
|
|
2144
|
-
return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
|
|
2145
|
-
}
|
|
2146
|
-
const found = findLinkVariables(env, plugin, link.service);
|
|
2147
|
-
if (found.length === 0) {
|
|
2148
|
-
return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
|
|
2149
|
-
}
|
|
2150
|
-
const name = linkEnvVar(plugin, link);
|
|
2151
|
-
const desired = link.querystring ?? "";
|
|
2152
|
-
const plain = !link.alias && !link.env_var;
|
|
2153
|
-
const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
|
|
2154
|
-
if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
|
|
2155
|
-
const current = querystringOf(hit.value);
|
|
2156
|
-
if (current === desired) return { state: "same" };
|
|
2157
|
-
return { state: "differs", current: show(name, current), desired: show(name, desired) };
|
|
2158
|
-
}
|
|
2159
|
-
async function readServiceLinks(ctx, app, desiredLinks, config) {
|
|
2160
|
-
const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
|
|
2161
|
-
const states = [];
|
|
2162
|
-
for (const [service, plugin] of allServices(config)) {
|
|
2163
|
-
states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
|
|
2164
|
-
}
|
|
2165
|
-
return states;
|
|
2166
|
-
}
|
|
2167
|
-
function servicesToUnlink(states) {
|
|
2168
|
-
return states.filter((s) => !s.link && s.isLinked);
|
|
2169
|
-
}
|
|
2170
|
-
async function ensureAppLinks(ctx, app, desiredLinks, config) {
|
|
2171
|
-
const states = await readServiceLinks(ctx, app, desiredLinks, config);
|
|
2172
|
-
const unlink = new Set(servicesToUnlink(states));
|
|
2173
|
-
for (const state of states) {
|
|
2174
|
-
const { service: serviceName, plugin, link, isLinked } = state;
|
|
2175
|
-
if (link && !isLinked) {
|
|
2176
|
-
logAction(app, `Linking ${serviceName}`);
|
|
2177
|
-
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
2178
|
-
ctx.needsRestart(app, "links");
|
|
2179
|
-
logDone();
|
|
2180
|
-
} else if (link && isLinked) {
|
|
2181
|
-
const opts = await readLinkOptions(ctx, app, plugin, link);
|
|
2182
|
-
if (opts.state === "differs") {
|
|
2183
|
-
logAction(app, `Relinking ${serviceName}`);
|
|
2184
|
-
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
2185
|
-
try {
|
|
2186
|
-
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
2187
|
-
} catch (err) {
|
|
2188
|
-
logWarn(app, `${app} is now unlinked from ${serviceName}`);
|
|
2189
|
-
throw err;
|
|
2190
|
-
}
|
|
2191
|
-
ctx.needsRestart(app, "links");
|
|
2192
|
-
logDone();
|
|
2193
|
-
}
|
|
2194
|
-
} else if (unlink.has(state)) {
|
|
2195
|
-
logAction(app, `Unlinking ${serviceName}`);
|
|
2196
|
-
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
2197
|
-
ctx.needsRestart(app, "links");
|
|
2198
|
-
logDone();
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
async function destroyAppLinks(ctx, app, links, config) {
|
|
2203
|
-
for (const serviceName of links.map((l) => normalizeLink(l).service)) {
|
|
2204
|
-
const resolved = resolveServicePlugin(serviceName, config);
|
|
2205
|
-
if (!resolved) continue;
|
|
2206
|
-
const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
|
|
2207
|
-
if (isLinked) {
|
|
2208
|
-
await ctx.run(`${resolved.plugin}:unlink`, serviceName, app, "--no-restart");
|
|
2209
|
-
}
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
async function exportAppLinks(ctx, app, config) {
|
|
2213
|
-
const linked = [];
|
|
2214
|
-
for (const [serviceName, plugin] of allServices(config)) {
|
|
2215
|
-
if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
|
|
2216
|
-
let found = [];
|
|
2217
|
-
try {
|
|
2218
|
-
found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
|
|
2219
|
-
} catch {
|
|
2220
|
-
}
|
|
2221
|
-
const fallback = linkEnvVar(plugin, { service: serviceName });
|
|
2222
|
-
const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
|
|
2223
|
-
if (!v) {
|
|
2224
|
-
linked.push(serviceName);
|
|
2225
|
-
continue;
|
|
2226
|
-
}
|
|
2227
|
-
const querystring = querystringOf(v.value);
|
|
2228
|
-
if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
|
|
2229
|
-
linked.push(serviceName);
|
|
2230
|
-
continue;
|
|
2231
|
-
}
|
|
2232
|
-
const entry = { service: serviceName };
|
|
2233
|
-
if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
|
|
2234
|
-
if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
|
|
2235
|
-
else entry.env_var = v.name;
|
|
2236
|
-
}
|
|
2237
|
-
if (querystring) entry.querystring = querystring;
|
|
2238
|
-
linked.push(entry);
|
|
2239
|
-
}
|
|
2240
|
-
return linked;
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
3823
|
// src/modules/domains.ts
|
|
2244
3824
|
async function readGlobalDomains(ctx) {
|
|
2245
3825
|
const raw = await ctx.queryStrict("domains:report", "--global", "--domains-global-vhosts");
|
|
@@ -2263,46 +3843,20 @@ async function ensureGlobalDomains(ctx, domains) {
|
|
|
2263
3843
|
logDone();
|
|
2264
3844
|
}
|
|
2265
3845
|
|
|
2266
|
-
// src/modules/logs.ts
|
|
2267
|
-
async function readGlobalLogs(ctx) {
|
|
2268
|
-
return parseJsonReport(await ctx.queryStrict("logs:report", "--global", "--format", "json"), "global");
|
|
2269
|
-
}
|
|
2270
|
-
async function ensureGlobalLogs(ctx, logs) {
|
|
2271
|
-
logAction("global", "Configuring logs");
|
|
2272
|
-
const current = await readGlobalLogs(ctx);
|
|
2273
|
-
let changed = false;
|
|
2274
|
-
for (const [key, value] of Object.entries(logs)) {
|
|
2275
|
-
if (current[key] === String(value)) continue;
|
|
2276
|
-
await ctx.run("logs:set", "--global", key, String(value));
|
|
2277
|
-
changed = true;
|
|
2278
|
-
}
|
|
2279
|
-
if (changed) logDone();
|
|
2280
|
-
else logSkip();
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
// src/modules/nginx.ts
|
|
2284
|
-
async function readGlobalNginx(ctx) {
|
|
2285
|
-
return parseJsonReport(await ctx.queryStrict("nginx:report", "--global", "--format", "json"), "global");
|
|
2286
|
-
}
|
|
2287
|
-
async function ensureGlobalNginx(ctx, nginx) {
|
|
2288
|
-
logAction("global", "Configuring nginx");
|
|
2289
|
-
const current = await readGlobalNginx(ctx);
|
|
2290
|
-
let changed = false;
|
|
2291
|
-
for (const [key, value] of Object.entries(nginx)) {
|
|
2292
|
-
if (current[key] === String(value)) continue;
|
|
2293
|
-
await ctx.run("nginx:set", "--global", key, String(value));
|
|
2294
|
-
changed = true;
|
|
2295
|
-
}
|
|
2296
|
-
if (changed) logDone();
|
|
2297
|
-
else logSkip();
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
3846
|
// src/commands/up.ts
|
|
2301
3847
|
function markGlobalEnvRestart(ctx, apps, reason = "global env") {
|
|
2302
3848
|
for (const app of apps) ctx.needsRestart(app, reason);
|
|
2303
3849
|
}
|
|
2304
3850
|
function printRestartSummary(ctx, dryRun = false) {
|
|
2305
3851
|
const restarts = [...ctx.restarts].filter(([app]) => !ctx.rebuilds.has(app));
|
|
3852
|
+
const services = [...ctx.serviceRestarts?.values() ?? []];
|
|
3853
|
+
if (services.length > 0) {
|
|
3854
|
+
console.log(`
|
|
3855
|
+
# Services that ${dryRun ? "would need" : "need"} a stop and start:`);
|
|
3856
|
+
for (const { plugin, service, reasons } of services) {
|
|
3857
|
+
console.log(`dokku ${plugin}:stop ${service} && dokku ${plugin}:start ${service} # ${reasons.join(", ")}`);
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
2306
3860
|
if (restarts.length === 0 && ctx.rebuilds.size === 0) return;
|
|
2307
3861
|
console.log(`
|
|
2308
3862
|
# Apps that ${dryRun ? "would need" : "need"} a restart or rebuild:`);
|
|
@@ -2311,8 +3865,9 @@ function printRestartSummary(ctx, dryRun = false) {
|
|
|
2311
3865
|
}
|
|
2312
3866
|
var UpFailedError = class extends Error {
|
|
2313
3867
|
constructor(failures) {
|
|
3868
|
+
const kinds = failures.every((f) => f.service) ? "service(s)" : failures.some((f) => f.service) ? "app(s) and service(s)" : "app(s)";
|
|
2314
3869
|
super(
|
|
2315
|
-
`up failed for ${failures.length}
|
|
3870
|
+
`up failed for ${failures.length} ${kinds}: ${failures.map((f) => f.app).join(", ")}`
|
|
2316
3871
|
);
|
|
2317
3872
|
this.failures = failures;
|
|
2318
3873
|
this.name = "UpFailedError";
|
|
@@ -2324,7 +3879,7 @@ var AppStepsError = class extends AggregateError {
|
|
|
2324
3879
|
this.name = "AppStepsError";
|
|
2325
3880
|
}
|
|
2326
3881
|
};
|
|
2327
|
-
async function processApp(ctx, config, app, appConfig, failFast = false, prune = false) {
|
|
3882
|
+
async function processApp(ctx, config, app, appConfig, failFast = false, prune = false, failedVolumes = /* @__PURE__ */ new Map()) {
|
|
2328
3883
|
const errors = [];
|
|
2329
3884
|
let leftOnServer = 0;
|
|
2330
3885
|
const count = prune ? void 0 : (removed) => {
|
|
@@ -2350,7 +3905,13 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
|
|
|
2350
3905
|
await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
|
|
2351
3906
|
}
|
|
2352
3907
|
await step(() => reconcile(Certs, ctx, app, appConfig.ssl));
|
|
2353
|
-
await step(() =>
|
|
3908
|
+
await step(async () => {
|
|
3909
|
+
const failedEntries = [...new Set(declaredMounts(appConfig.storage).map((m) => m.entry))].filter((e) => failedVolumes.has(e));
|
|
3910
|
+
if (failedEntries.length > 0) {
|
|
3911
|
+
throw new Error(`storage skipped: ${failedEntries.map((e) => `entry '${e}' failed (${failedVolumes.get(e)})`).join("; ")}`);
|
|
3912
|
+
}
|
|
3913
|
+
await reconcile(Storage, ctx, app, desiredSpecs(appConfig.storage));
|
|
3914
|
+
});
|
|
2354
3915
|
await step(() => reconcile(Nginx, ctx, app, appConfig.nginx, count, prune));
|
|
2355
3916
|
await step(() => reconcile(Checks, ctx, app, appConfig.checks, void 0, prune));
|
|
2356
3917
|
await step(() => reconcile(Logs, ctx, app, appConfig.logs, count, prune));
|
|
@@ -2360,7 +3921,7 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
|
|
|
2360
3921
|
await step(() => reconcile(Scale, ctx, app, appConfig.scale));
|
|
2361
3922
|
await step(() => reconcile(Config, ctx, app, appConfig.env || {}));
|
|
2362
3923
|
await step(() => reconcile(Builder, ctx, app, appConfig.build, count, prune));
|
|
2363
|
-
await step(() => reconcile(Git, ctx, app, appConfig.git
|
|
3924
|
+
await step(() => reconcile(Git, ctx, app, appConfig.git, count, prune, config.git));
|
|
2364
3925
|
await step(() => reconcile(DockerOptions, ctx, app, appConfig.docker_options));
|
|
2365
3926
|
if (leftOnServer > 0) {
|
|
2366
3927
|
logWarn(app, `${leftOnServer} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
|
|
@@ -2386,24 +3947,57 @@ async function runUpPhases(ctx, config, appFilter, opts) {
|
|
|
2386
3947
|
if (await ensureGlobalConfig(ctx, config.env)) {
|
|
2387
3948
|
markGlobalEnvRestart(ctx, apps.filter((app) => config.apps[app]));
|
|
2388
3949
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
3950
|
+
const globalOpts = { prune: opts.prune, apps: apps.filter((app) => config.apps[app]), appConfigs: config.apps };
|
|
3951
|
+
for (const name of GLOBAL_UP_ORDER) {
|
|
3952
|
+
if (config[name] !== void 0) await ensureGlobal(ctx, name, config[name], globalOpts);
|
|
3953
|
+
}
|
|
2391
3954
|
if (config.networks) await ensureNetworks(ctx, config.networks);
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
3955
|
+
if (config.network !== void 0) await ensureGlobal(ctx, "network", config.network, globalOpts);
|
|
3956
|
+
const failedVolumes = config.volumes ? await ensureVolumes(ctx, config.volumes, opts.failFast) : /* @__PURE__ */ new Map();
|
|
3957
|
+
const createFailures = [];
|
|
3958
|
+
const redisCreateFailures = [];
|
|
3959
|
+
const createdPostgres = config.postgres ? await ensurePostgres(ctx, config.postgres, config.volumes, { failedVolumes, failures: createFailures }) : /* @__PURE__ */ new Set();
|
|
3960
|
+
const createdRedis = config.redis ? await ensureRedis(ctx, config.redis, config.volumes, { failedVolumes, failures: redisCreateFailures }) : /* @__PURE__ */ new Set();
|
|
3961
|
+
const without = (services, failed) => services && Object.fromEntries(Object.entries(services).filter(([name]) => !failed.some((f) => f.service === name)));
|
|
3962
|
+
const postgres = without(config.postgres, createFailures);
|
|
3963
|
+
const redis = without(config.redis, redisCreateFailures);
|
|
3964
|
+
if (postgres) {
|
|
3965
|
+
await ensureServiceBackups(ctx, "postgres", postgres, {
|
|
2396
3966
|
prune: opts.prune,
|
|
3967
|
+
dryRun: opts.dryRun,
|
|
2397
3968
|
dryRunCreated: opts.dryRun ? createdPostgres : void 0
|
|
2398
3969
|
});
|
|
2399
3970
|
}
|
|
3971
|
+
if (redis) {
|
|
3972
|
+
await ensureServiceBackups(ctx, "redis", redis, {
|
|
3973
|
+
prune: opts.prune,
|
|
3974
|
+
dryRun: opts.dryRun,
|
|
3975
|
+
dryRunCreated: opts.dryRun ? createdRedis : void 0
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3978
|
+
const serviceFailures = [
|
|
3979
|
+
...createFailures.map((f) => ({ app: `postgres:${f.service}`, error: f.error, service: true })),
|
|
3980
|
+
...redisCreateFailures.map((f) => ({ app: `redis:${f.service}`, error: f.error, service: true }))
|
|
3981
|
+
];
|
|
3982
|
+
if (opts.failFast && serviceFailures.length > 0) throw new UpFailedError(serviceFailures);
|
|
3983
|
+
for (const [plugin, services, created] of [["postgres", postgres, createdPostgres], ["redis", redis, createdRedis]]) {
|
|
3984
|
+
if (!services) continue;
|
|
3985
|
+
const failed = await ensureServiceProperties(ctx, plugin, services, config, {
|
|
3986
|
+
prune: opts.prune,
|
|
3987
|
+
dryRun: opts.dryRun,
|
|
3988
|
+
dryRunCreated: opts.dryRun ? created : void 0,
|
|
3989
|
+
failedVolumes
|
|
3990
|
+
});
|
|
3991
|
+
serviceFailures.push(...failed.map((f) => ({ app: `${plugin}:${f.service}`, error: f.error, service: true })));
|
|
3992
|
+
}
|
|
3993
|
+
if (opts.failFast && serviceFailures.length > 0) throw new UpFailedError(serviceFailures);
|
|
2400
3994
|
await removeBackupHashKeys(ctx);
|
|
2401
|
-
const failures = [];
|
|
3995
|
+
const failures = [...serviceFailures];
|
|
2402
3996
|
for (const app of apps) {
|
|
2403
3997
|
const appConfig = config.apps[app];
|
|
2404
3998
|
if (!appConfig) continue;
|
|
2405
3999
|
try {
|
|
2406
|
-
await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
|
|
4000
|
+
await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune, failedVolumes);
|
|
2407
4001
|
} catch (error) {
|
|
2408
4002
|
if (opts.failFast) throw error;
|
|
2409
4003
|
if (!(error instanceof AppStepsError)) {
|
|
@@ -2415,6 +4009,9 @@ async function runUpPhases(ctx, config, appFilter, opts) {
|
|
|
2415
4009
|
if (failures.length > 0) {
|
|
2416
4010
|
throw new UpFailedError(failures);
|
|
2417
4011
|
}
|
|
4012
|
+
if (failedVolumes.size > 0) {
|
|
4013
|
+
throw new Error(`up failed for storage entr${failedVolumes.size === 1 ? "y" : "ies"}: ${[...failedVolumes.keys()].join(", ")}`);
|
|
4014
|
+
}
|
|
2418
4015
|
}
|
|
2419
4016
|
|
|
2420
4017
|
// src/modules/apps.ts
|
|
@@ -2522,9 +4119,22 @@ async function runExport(ctx, opts) {
|
|
|
2522
4119
|
const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx);
|
|
2523
4120
|
const networks = await exportNetworks(ctx);
|
|
2524
4121
|
if (networks.length > 0) config.networks = networks;
|
|
2525
|
-
const
|
|
4122
|
+
for (const name of GLOBAL_NAMES) {
|
|
4123
|
+
try {
|
|
4124
|
+
const block = await exportGlobal(ctx, name);
|
|
4125
|
+
if (block !== void 0) config[name] = block;
|
|
4126
|
+
} catch (err) {
|
|
4127
|
+
logWarn("global", `could not read '${name}', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
const serviceMounts = /* @__PURE__ */ new Map();
|
|
4131
|
+
const specsOf = /* @__PURE__ */ new Map();
|
|
4132
|
+
const postgres = await exportPostgres(ctx, specsOf);
|
|
4133
|
+
for (const [name, specs] of specsOf) serviceMounts.set(name, { plugin: "postgres", specs });
|
|
4134
|
+
specsOf.clear();
|
|
2526
4135
|
if (Object.keys(postgres).length > 0) config.postgres = postgres;
|
|
2527
|
-
const redis = await exportRedis(ctx);
|
|
4136
|
+
const redis = await exportRedis(ctx, specsOf);
|
|
4137
|
+
for (const [name, specs] of specsOf) serviceMounts.set(name, { plugin: "redis", specs });
|
|
2528
4138
|
if (Object.keys(redis).length > 0) config.redis = redis;
|
|
2529
4139
|
const prefetched = /* @__PURE__ */ new Map();
|
|
2530
4140
|
await Promise.all(
|
|
@@ -2535,12 +4145,21 @@ async function runExport(ctx, opts) {
|
|
|
2535
4145
|
}
|
|
2536
4146
|
})
|
|
2537
4147
|
);
|
|
4148
|
+
const mounts = /* @__PURE__ */ new Map();
|
|
2538
4149
|
for (const app of apps) {
|
|
2539
4150
|
const appConfig = {};
|
|
2540
4151
|
for (const resource of ALL_APP_RESOURCES) {
|
|
2541
4152
|
if (resource.key.startsWith("_")) continue;
|
|
2542
4153
|
if (resource.forceApply) continue;
|
|
2543
4154
|
if (resource.key === "checks") continue;
|
|
4155
|
+
if (resource.key === "storage") {
|
|
4156
|
+
try {
|
|
4157
|
+
mounts.set(app, await resource.read(ctx, app));
|
|
4158
|
+
} catch (err) {
|
|
4159
|
+
logWarn(app, `could not read 'storage', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
4160
|
+
}
|
|
4161
|
+
continue;
|
|
4162
|
+
}
|
|
2544
4163
|
const bulk = prefetched.get(resource.key);
|
|
2545
4164
|
let value;
|
|
2546
4165
|
try {
|
|
@@ -2568,8 +4187,97 @@ async function runExport(ctx, opts) {
|
|
|
2568
4187
|
}
|
|
2569
4188
|
config.apps[app] = appConfig;
|
|
2570
4189
|
}
|
|
4190
|
+
await exportStorage(ctx, config, mounts, !!opts.appFilter?.length, serviceMounts);
|
|
2571
4191
|
return config;
|
|
2572
4192
|
}
|
|
4193
|
+
async function exportStorage(ctx, config, mounts, onlyMounted, serviceMounts) {
|
|
4194
|
+
let server;
|
|
4195
|
+
try {
|
|
4196
|
+
server = await readEntries(ctx);
|
|
4197
|
+
} catch (err) {
|
|
4198
|
+
logWarn("storage", `could not read the storage entries, storage left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
4199
|
+
for (const name of serviceMounts.keys()) logWarn(name, "its mounts are left out of the export: the storage entries could not be read");
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
const parsed = new Map([...mounts].map(([app, specs]) => [app, specs.map(parseSpec)]));
|
|
4203
|
+
const mounted = new Set([...parsed.values()].flat().map((m) => m.entry));
|
|
4204
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
4205
|
+
for (const e of [...server.values()].filter((e2) => e2.scheduler === DOCKER_LOCAL && e2.host_path).sort((a, b) => Number(isLegacyName(a.name)) - Number(isLegacyName(b.name)) || a.name.localeCompare(b.name))) {
|
|
4206
|
+
if (!byPath.has(e.host_path)) byPath.set(e.host_path, e.name);
|
|
4207
|
+
}
|
|
4208
|
+
const serviceSpecs = new Map([...serviceMounts].map(([name, { specs }]) => [name, specs.map(parseServiceSpec)]));
|
|
4209
|
+
for (const list of serviceSpecs.values()) for (const m of list) {
|
|
4210
|
+
const entry = byPath.get(m.source);
|
|
4211
|
+
if (entry) mounted.add(entry);
|
|
4212
|
+
}
|
|
4213
|
+
const { volumes, names } = exportVolumes(server, mounted, onlyMounted);
|
|
4214
|
+
exportServiceMounts(config, serviceMounts, serviceSpecs, byPath, names, volumes, server);
|
|
4215
|
+
if (Object.keys(volumes).length > 0) config.volumes = volumes;
|
|
4216
|
+
for (const [app, list] of parsed) {
|
|
4217
|
+
const items = [];
|
|
4218
|
+
for (const m of list) {
|
|
4219
|
+
const entry = names.get(m.entry);
|
|
4220
|
+
if (entry === void 0) {
|
|
4221
|
+
logWarn(app, `mount of '${m.entry}' at ${m.dir} left out of the export: dokku-compose manages only docker-local entries`);
|
|
4222
|
+
continue;
|
|
4223
|
+
}
|
|
4224
|
+
if (m.subpath || m.volumeChown) {
|
|
4225
|
+
logWarn(app, `${m.dir}: ${[m.subpath && "volume-subpath", m.volumeChown && "volume-chown"].filter(Boolean).join(" and ")} dropped: docker-local stores it but never applies it`);
|
|
4226
|
+
}
|
|
4227
|
+
const plain = !m.readonly && m.phases.length === ALL_PHASES.length && m.options.length === 0 && m.scope === DEFAULT_SCOPE;
|
|
4228
|
+
if (plain) {
|
|
4229
|
+
items.push(`${entry}:${m.dir}`);
|
|
4230
|
+
continue;
|
|
4231
|
+
}
|
|
4232
|
+
items.push({
|
|
4233
|
+
entry,
|
|
4234
|
+
container_dir: m.dir,
|
|
4235
|
+
...m.readonly ? { readonly: true } : {},
|
|
4236
|
+
...m.phases.length !== ALL_PHASES.length ? { phases: m.phases } : {},
|
|
4237
|
+
...m.scope !== DEFAULT_SCOPE ? { process_type: m.scope } : {},
|
|
4238
|
+
...m.options.length > 0 ? { options: m.options } : {}
|
|
4239
|
+
});
|
|
4240
|
+
}
|
|
4241
|
+
if (items.length > 0) config.apps[app].storage = items;
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
function parseServiceSpec(spec) {
|
|
4245
|
+
const [source = "", dir = "", ...rest] = spec.split(":");
|
|
4246
|
+
const out = { source, dir, readonly: false, options: [], dropped: [] };
|
|
4247
|
+
for (const token of rest.join(":").split(",").filter(Boolean)) {
|
|
4248
|
+
if (token === "ro") out.readonly = true;
|
|
4249
|
+
else if (token.startsWith("volume-subpath=") || token.startsWith("volume-chown=")) out.dropped.push(token.split("=")[0]);
|
|
4250
|
+
else out.options.push(token);
|
|
4251
|
+
}
|
|
4252
|
+
return out;
|
|
4253
|
+
}
|
|
4254
|
+
function exportServiceMounts(config, serviceMounts, parsed, byPath, names, volumes, server) {
|
|
4255
|
+
const taken = /* @__PURE__ */ new Set([...server.keys(), ...Object.keys(volumes)]);
|
|
4256
|
+
const added = /* @__PURE__ */ new Map();
|
|
4257
|
+
for (const [svc, { plugin }] of serviceMounts) {
|
|
4258
|
+
const items = [];
|
|
4259
|
+
for (const m of parsed.get(svc) ?? []) {
|
|
4260
|
+
let entry = byPath.has(m.source) ? names.get(byPath.get(m.source)) : added.get(m.source);
|
|
4261
|
+
if (entry === void 0 && !byPath.has(m.source)) {
|
|
4262
|
+
const given = pathError(m.source) === void 0 ? legacyExportNames([{ name: m.source, scheduler: DOCKER_LOCAL, host_path: m.source }], taken).get(m.source) ?? null : null;
|
|
4263
|
+
if (given === null) {
|
|
4264
|
+
logWarn(svc, `mount of ${m.source} at ${m.dir} left out of the export: no valid volumes: entry can name it`);
|
|
4265
|
+
continue;
|
|
4266
|
+
}
|
|
4267
|
+
volumes[given] = m.source === defaultPath(given) ? { chown: false } : { path: m.source };
|
|
4268
|
+
added.set(m.source, given);
|
|
4269
|
+
entry = given;
|
|
4270
|
+
}
|
|
4271
|
+
if (entry === void 0) {
|
|
4272
|
+
logWarn(svc, `mount of ${m.source} at ${m.dir} left out of the export: dokku-compose manages only docker-local entries`);
|
|
4273
|
+
continue;
|
|
4274
|
+
}
|
|
4275
|
+
if (m.dropped.length > 0) logWarn(svc, `${m.dir}: ${m.dropped.join(" and ")} dropped: not managed for a service mount`);
|
|
4276
|
+
items.push(!m.readonly && m.options.length === 0 ? `${entry}:${m.dir}` : { entry, container_dir: m.dir, ...m.readonly ? { readonly: true } : {}, ...m.options.length > 0 ? { options: m.options } : {} });
|
|
4277
|
+
}
|
|
4278
|
+
if (items.length > 0) config[plugin][svc].mounts = items;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
2573
4281
|
|
|
2574
4282
|
// src/modules/env-notes.ts
|
|
2575
4283
|
var MANAGED_KEYS_VAR3 = "DOKKU_COMPOSE_MANAGED_KEYS";
|
|
@@ -2617,7 +4325,7 @@ function isEmptyValue(value) {
|
|
|
2617
4325
|
return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
|
|
2618
4326
|
}
|
|
2619
4327
|
async function computeDiff(ctx, config) {
|
|
2620
|
-
const result = { apps: {}, services: {}, plugins: {}, global: {}, networks: {}, inSync: true, envNotes: {}, readWarnings: [] };
|
|
4328
|
+
const result = { apps: {}, services: {}, plugins: {}, global: {}, networks: {}, volumes: {}, volumeNotes: [], inSync: true, envNotes: {}, readWarnings: [] };
|
|
2621
4329
|
const appNames = Object.keys(config.apps);
|
|
2622
4330
|
const serverVersion = await assertDokkuSupported(ctx);
|
|
2623
4331
|
const fileVersion = config.dokku?.version;
|
|
@@ -2663,13 +4371,14 @@ async function computeDiff(ctx, config) {
|
|
|
2663
4371
|
let desired;
|
|
2664
4372
|
if (resource.key === "proxy") {
|
|
2665
4373
|
desired = appConfig.proxy?.enabled;
|
|
2666
|
-
} else if (resource.key === "git") {
|
|
2667
|
-
desired = appConfig.git ?? config.git;
|
|
2668
4374
|
} else if (resource.key === "proxy_props") {
|
|
2669
4375
|
desired = proxyProps(appConfig.proxy);
|
|
4376
|
+
} else if (resource.key === "storage") {
|
|
4377
|
+
desired = desiredSpecs(appConfig.storage);
|
|
2670
4378
|
} else {
|
|
2671
4379
|
desired = appConfig[resource.key];
|
|
2672
4380
|
}
|
|
4381
|
+
const inherited = resource.inheritsGlobal ? config[resource.key] : void 0;
|
|
2673
4382
|
const checkRemovalsOnly = desired === void 0;
|
|
2674
4383
|
if (checkRemovalsOnly && (!resource.removals || !appExists.get(app))) continue;
|
|
2675
4384
|
if (resource.forceApply && !resource.removals) continue;
|
|
@@ -2689,7 +4398,7 @@ async function computeDiff(ctx, config) {
|
|
|
2689
4398
|
result.readWarnings.push(`could not read '${resource.key}' for app '${app}' to look for removed settings: ${err instanceof Error ? err.message : String(err)}`);
|
|
2690
4399
|
continue;
|
|
2691
4400
|
}
|
|
2692
|
-
const removed2 = resource.removals(current, void 0);
|
|
4401
|
+
const removed2 = resource.removals(current, void 0, inherited);
|
|
2693
4402
|
if (removed2.length > 0) {
|
|
2694
4403
|
appDiff[resource.key] = { status: "extra", current, removed: removed2 };
|
|
2695
4404
|
result.inSync = false;
|
|
@@ -2705,15 +4414,15 @@ async function computeDiff(ctx, config) {
|
|
|
2705
4414
|
current = await resource.read(ctx, app);
|
|
2706
4415
|
}
|
|
2707
4416
|
if (resource.inSync) {
|
|
2708
|
-
const removedHere = resource.removals ? resource.removals(current, desired) : [];
|
|
4417
|
+
const removedHere = resource.removals ? resource.removals(current, desired, inherited) : [];
|
|
2709
4418
|
let status;
|
|
2710
4419
|
if (current === void 0) {
|
|
2711
4420
|
status = "unknown";
|
|
2712
4421
|
result.readWarnings.push(`could not read '${resource.key}' for app '${app}'`);
|
|
2713
4422
|
} else {
|
|
2714
|
-
const
|
|
2715
|
-
if (
|
|
2716
|
-
else if (
|
|
4423
|
+
const same2 = safeInSync(resource, current, desired, app, result.readWarnings);
|
|
4424
|
+
if (same2 === void 0) status = "unknown";
|
|
4425
|
+
else if (same2) status = removedHere.length > 0 ? "extra" : "in-sync";
|
|
2717
4426
|
else status = current === false || isEmptyValue(current) ? "missing" : "changed";
|
|
2718
4427
|
}
|
|
2719
4428
|
appDiff[resource.key] = {
|
|
@@ -2725,7 +4434,7 @@ async function computeDiff(ctx, config) {
|
|
|
2725
4434
|
if (status !== "in-sync") result.inSync = false;
|
|
2726
4435
|
continue;
|
|
2727
4436
|
}
|
|
2728
|
-
let removed = resource.removals ? resource.removals(current, desired) : [];
|
|
4437
|
+
let removed = resource.removals ? resource.removals(current, desired, inherited) : [];
|
|
2729
4438
|
if (resource.forceApply) {
|
|
2730
4439
|
if (removed.length > 0) {
|
|
2731
4440
|
appDiff[resource.key] = { status: "extra", desired, current, removed };
|
|
@@ -2880,24 +4589,30 @@ async function computeDiff(ctx, config) {
|
|
|
2880
4589
|
result.inSync = false;
|
|
2881
4590
|
}
|
|
2882
4591
|
}
|
|
2883
|
-
for (const
|
|
2884
|
-
|
|
2885
|
-
["nginx", config.nginx, readGlobalNginx]
|
|
2886
|
-
]) {
|
|
4592
|
+
for (const name of GLOBAL_NAMES) {
|
|
4593
|
+
const declared = config[name];
|
|
2887
4594
|
if (declared === void 0) continue;
|
|
2888
4595
|
let current;
|
|
2889
4596
|
try {
|
|
2890
|
-
current = await
|
|
4597
|
+
current = await readGlobal(ctx, name);
|
|
2891
4598
|
} catch (err) {
|
|
2892
4599
|
result.global[name] = { status: "unknown", desired: declared };
|
|
2893
4600
|
result.readWarnings.push(`could not read global '${name}': ${err instanceof Error ? err.message : String(err)}`);
|
|
2894
4601
|
result.inSync = false;
|
|
2895
4602
|
continue;
|
|
2896
4603
|
}
|
|
2897
|
-
const
|
|
2898
|
-
if (
|
|
2899
|
-
const
|
|
2900
|
-
|
|
4604
|
+
const plan = planGlobal(name, declared, current);
|
|
4605
|
+
if (plan.changes.length === 0 && plan.removals.length === 0) continue;
|
|
4606
|
+
const declaredKeys = new Set(typeof declared === "string" ? ["selected"] : Object.keys(declared));
|
|
4607
|
+
const shown = Object.fromEntries(Object.entries(current).filter(([k]) => declaredKeys.has(k)));
|
|
4608
|
+
const removed = plan.removals.map((gk) => gk.yaml);
|
|
4609
|
+
const status = plan.changes.length === 0 ? "extra" : plan.changes.every((c) => !(c.gk.yaml in current)) ? "missing" : "changed";
|
|
4610
|
+
result.global[name] = {
|
|
4611
|
+
status,
|
|
4612
|
+
desired: declared,
|
|
4613
|
+
...Object.keys(shown).length > 0 || status !== "extra" ? { current: shown } : {},
|
|
4614
|
+
...removed.length > 0 ? { removed } : {}
|
|
4615
|
+
};
|
|
2901
4616
|
result.inSync = false;
|
|
2902
4617
|
}
|
|
2903
4618
|
for (const net of config.networks ?? []) {
|
|
@@ -2905,6 +4620,35 @@ async function computeDiff(ctx, config) {
|
|
|
2905
4620
|
result.networks[net] = { status: "missing" };
|
|
2906
4621
|
result.inSync = false;
|
|
2907
4622
|
}
|
|
4623
|
+
if (config.volumes) {
|
|
4624
|
+
try {
|
|
4625
|
+
const server = await readEntries(ctx);
|
|
4626
|
+
const declared = desiredEntries(config.volumes);
|
|
4627
|
+
for (const d of declared) {
|
|
4628
|
+
const state = entryState(d, server.get(d.name));
|
|
4629
|
+
const problem = stateError(d, state);
|
|
4630
|
+
if (state.kind === "ok") continue;
|
|
4631
|
+
if (state.kind === "missing" && !problem) {
|
|
4632
|
+
result.volumes[d.name] = { status: "missing" };
|
|
4633
|
+
} else if (state.kind === "changed") {
|
|
4634
|
+
result.volumes[d.name] = {
|
|
4635
|
+
status: "changed",
|
|
4636
|
+
desired: Object.fromEntries([...state.chown ? [["chown", state.chown.want]] : [], ...state.mode ? [["mode", state.mode.want]] : []]),
|
|
4637
|
+
current: Object.fromEntries([...state.chown ? [["chown", state.chown.server || "(unset)"]] : [], ...state.mode ? [["mode", state.mode.server || "(unset)"]] : []])
|
|
4638
|
+
};
|
|
4639
|
+
} else {
|
|
4640
|
+
result.volumes[d.name] = { status: "changed", note: `${problem}; up stops on it` };
|
|
4641
|
+
}
|
|
4642
|
+
result.inSync = false;
|
|
4643
|
+
}
|
|
4644
|
+
const names = new Set(declared.map((d) => d.name));
|
|
4645
|
+
result.volumeNotes = [...server.values()].filter((e) => e.scheduler === "docker-local" && !isLegacyName(e.name) && !names.has(e.name)).map((e) => e.name);
|
|
4646
|
+
} catch (err) {
|
|
4647
|
+
result.readWarnings.push(`could not read the storage entries: ${err instanceof Error ? err.message : String(err)}`);
|
|
4648
|
+
for (const name of Object.keys(config.volumes)) result.volumes[name] = { status: "unknown" };
|
|
4649
|
+
result.inSync = false;
|
|
4650
|
+
}
|
|
4651
|
+
}
|
|
2908
4652
|
for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
|
|
2909
4653
|
for (const [svc, declared] of Object.entries(services ?? {})) {
|
|
2910
4654
|
const exists = await ctx.check(`${plugin}:exists`, svc);
|
|
@@ -2913,17 +4657,18 @@ async function computeDiff(ctx, config) {
|
|
|
2913
4657
|
result.inSync = false;
|
|
2914
4658
|
continue;
|
|
2915
4659
|
}
|
|
2916
|
-
const backup =
|
|
2917
|
-
const compares = declared?.version !== void 0 || declared?.image !== void 0 || backup !== void 0;
|
|
2918
|
-
if (!compares && plugin === "redis") {
|
|
2919
|
-
result.services[svc] = { status: "in-sync", mismatches: [] };
|
|
2920
|
-
continue;
|
|
2921
|
-
}
|
|
4660
|
+
const backup = declared.backup;
|
|
4661
|
+
const compares = declared?.version !== void 0 || declared?.image !== void 0 || backup !== void 0 || declared?.properties !== void 0 || declared?.mounts !== void 0;
|
|
2922
4662
|
try {
|
|
2923
4663
|
const info = await readServiceInfo(ctx, plugin, svc);
|
|
2924
|
-
const mismatches = [
|
|
2925
|
-
|
|
2926
|
-
|
|
4664
|
+
const mismatches = [
|
|
4665
|
+
...versionMismatches(info, declared),
|
|
4666
|
+
...backup !== void 0 ? backupMismatches(plugin, info, backup) : [],
|
|
4667
|
+
...propertyMismatches(plugin, info, declared.properties),
|
|
4668
|
+
...mountMismatches(plugin, info, await resolveServiceMounts(ctx, config.volumes, declared.mounts))
|
|
4669
|
+
];
|
|
4670
|
+
const notes = backupNotes(info, backup);
|
|
4671
|
+
result.services[svc] = { status: mismatches.length ? "changed" : "in-sync", mismatches, note: notes.length ? notes.join("; ") : void 0 };
|
|
2927
4672
|
if (mismatches.length) result.inSync = false;
|
|
2928
4673
|
} catch (err) {
|
|
2929
4674
|
result.readWarnings.push(`could not read ${plugin} service '${svc}': ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2938,10 +4683,18 @@ async function computeDiff(ctx, config) {
|
|
|
2938
4683
|
}
|
|
2939
4684
|
return result;
|
|
2940
4685
|
}
|
|
4686
|
+
function maskServices(services) {
|
|
4687
|
+
return Object.fromEntries(Object.entries(services).map(([svc, d]) => [svc, {
|
|
4688
|
+
...d,
|
|
4689
|
+
...d.mismatches ? {
|
|
4690
|
+
mismatches: d.mismatches.map((m) => m.field === "properties.custom-env" ? { ...m, desired: m.removed ? m.desired : maskCustomEnv(m.desired), current: maskCustomEnv(m.current) } : m)
|
|
4691
|
+
} : {}
|
|
4692
|
+
}]));
|
|
4693
|
+
}
|
|
2941
4694
|
function maskDiffResult(diff) {
|
|
2942
4695
|
const masked = {
|
|
2943
4696
|
apps: {},
|
|
2944
|
-
services: diff.services,
|
|
4697
|
+
services: maskServices(diff.services),
|
|
2945
4698
|
plugins: diff.plugins,
|
|
2946
4699
|
dokku: diff.dokku,
|
|
2947
4700
|
global: Object.fromEntries(Object.entries(diff.global).map(([k, d]) => [k, {
|
|
@@ -2950,6 +4703,8 @@ function maskDiffResult(diff) {
|
|
|
2950
4703
|
current: maskSensitiveData(d.current)
|
|
2951
4704
|
}])),
|
|
2952
4705
|
networks: diff.networks,
|
|
4706
|
+
volumes: diff.volumes,
|
|
4707
|
+
volumeNotes: diff.volumeNotes,
|
|
2953
4708
|
inSync: diff.inSync,
|
|
2954
4709
|
envNotes: diff.envNotes,
|
|
2955
4710
|
readWarnings: diff.readWarnings
|
|
@@ -2970,7 +4725,7 @@ function maskDiffResult(diff) {
|
|
|
2970
4725
|
return masked;
|
|
2971
4726
|
}
|
|
2972
4727
|
function driftCount(diff) {
|
|
2973
|
-
return Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length + Object.keys(diff.global).length + Object.keys(diff.networks).length + (diff.dokku ? 1 : 0);
|
|
4728
|
+
return Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length + Object.keys(diff.global).length + Object.keys(diff.networks).length + Object.keys(diff.volumes).length + (diff.dokku ? 1 : 0);
|
|
2974
4729
|
}
|
|
2975
4730
|
function formatSummary(diff) {
|
|
2976
4731
|
const lines = [""];
|
|
@@ -2993,15 +4748,23 @@ function formatSummary(diff) {
|
|
|
2993
4748
|
lines.push(` dokku`);
|
|
2994
4749
|
lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
|
|
2995
4750
|
}
|
|
4751
|
+
if (Object.keys(diff.global).length > 0) lines.push(` global`);
|
|
2996
4752
|
for (const [name, d] of Object.entries(diff.global)) {
|
|
2997
|
-
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
|
|
2998
|
-
lines.push(` global`);
|
|
4753
|
+
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : d.status === "extra" ? chalk.red("-") : chalk.yellow("~");
|
|
2999
4754
|
lines.push(` ${sym} ${name}: ${formatFeatureSummary(d)}`);
|
|
3000
4755
|
}
|
|
3001
4756
|
for (const net of Object.keys(diff.networks)) {
|
|
3002
4757
|
lines.push(` networks:`);
|
|
3003
4758
|
lines.push(` ${chalk.green("+")} ${net}: not created`);
|
|
3004
4759
|
}
|
|
4760
|
+
for (const [name, d] of Object.entries(diff.volumes)) {
|
|
4761
|
+
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
|
|
4762
|
+
lines.push(` volumes:`);
|
|
4763
|
+
lines.push(` ${sym} ${name}: ${volumeSummary(d)}`);
|
|
4764
|
+
}
|
|
4765
|
+
if (diff.volumeNotes.length > 0) {
|
|
4766
|
+
lines.push(` note: storage entries not in the compose file (up leaves them): ${diff.volumeNotes.join(", ")}`);
|
|
4767
|
+
}
|
|
3005
4768
|
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
3006
4769
|
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
|
|
3007
4770
|
const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
|
|
@@ -3015,7 +4778,7 @@ function formatSummary(diff) {
|
|
|
3015
4778
|
}
|
|
3016
4779
|
if (d.mismatches?.length || d.note) lines.push(` services: ${svc}`);
|
|
3017
4780
|
for (const m of d.mismatches ?? []) {
|
|
3018
|
-
lines.push(` ${chalk.yellow("~")} ${m.field}: ${m.current || "(unknown)"} \u2192 ${m.desired}`);
|
|
4781
|
+
lines.push(m.removed ? ` ${chalk.red("-")} ${m.field}: ${m.current} (not in the file)` : ` ${chalk.yellow("~")} ${m.field}: ${m.current || "(unknown)"} \u2192 ${m.desired}`);
|
|
3019
4782
|
}
|
|
3020
4783
|
if (d.note) lines.push(` note: ${d.note}`);
|
|
3021
4784
|
}
|
|
@@ -3034,6 +4797,14 @@ function formatSummary(diff) {
|
|
|
3034
4797
|
lines.push("");
|
|
3035
4798
|
return lines.join("\n");
|
|
3036
4799
|
}
|
|
4800
|
+
function volumeSummary(d) {
|
|
4801
|
+
if (d.status === "missing") return "not created";
|
|
4802
|
+
if (d.status === "unknown") return "(could not read server state)";
|
|
4803
|
+
if (d.note) return d.note;
|
|
4804
|
+
const cur = d.current;
|
|
4805
|
+
const want = d.desired;
|
|
4806
|
+
return Object.keys(want).map((k) => `${k} ${cur[k]} \u2192 ${want[k]}`).join(", ");
|
|
4807
|
+
}
|
|
3037
4808
|
function showNote(n) {
|
|
3038
4809
|
return n.label ? `${n.key} (${n.label})` : n.key;
|
|
3039
4810
|
}
|
|
@@ -3102,6 +4873,27 @@ function formatVerbose(diff) {
|
|
|
3102
4873
|
lines.push(`@@ networks @@`);
|
|
3103
4874
|
lines.push(chalk.green(`+ ${net}`));
|
|
3104
4875
|
}
|
|
4876
|
+
for (const [name, d] of Object.entries(diff.volumes)) {
|
|
4877
|
+
lines.push(`@@ volumes / ${name} @@`);
|
|
4878
|
+
if (d.status === "missing") {
|
|
4879
|
+
lines.push(chalk.green(`+ ${name}`));
|
|
4880
|
+
continue;
|
|
4881
|
+
}
|
|
4882
|
+
if (d.status === "unknown") {
|
|
4883
|
+
lines.push(chalk.red(` ? could not read server state`));
|
|
4884
|
+
continue;
|
|
4885
|
+
}
|
|
4886
|
+
if (d.note) {
|
|
4887
|
+
lines.push(` (${d.note})`);
|
|
4888
|
+
continue;
|
|
4889
|
+
}
|
|
4890
|
+
for (const [k, v] of Object.entries(d.current)) lines.push(chalk.red(`- ${k}: ${v}`));
|
|
4891
|
+
for (const [k, v] of Object.entries(d.desired)) lines.push(chalk.green(`+ ${k}: ${v}`));
|
|
4892
|
+
}
|
|
4893
|
+
for (const name of diff.volumeNotes) {
|
|
4894
|
+
lines.push(`@@ volumes / ${name} (note; up leaves it) @@`);
|
|
4895
|
+
lines.push(` entry not in the compose file`);
|
|
4896
|
+
}
|
|
3105
4897
|
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
3106
4898
|
lines.push(`@@ plugin: ${name} @@`);
|
|
3107
4899
|
if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
|
|
@@ -3115,7 +4907,7 @@ function formatVerbose(diff) {
|
|
|
3115
4907
|
for (const m of d.mismatches ?? []) {
|
|
3116
4908
|
lines.push(`@@ services: ${svc} ${m.field} @@`);
|
|
3117
4909
|
lines.push(chalk.red(`- ${m.current}`));
|
|
3118
|
-
lines.push(chalk.green(`+ ${m.desired}`));
|
|
4910
|
+
if (!m.removed) lines.push(chalk.green(`+ ${m.desired}`));
|
|
3119
4911
|
}
|
|
3120
4912
|
if (d.note) lines.push(`@@ services: ${svc} @@
|
|
3121
4913
|
(${d.note})`);
|
|
@@ -3156,7 +4948,7 @@ function validate(filePath) {
|
|
|
3156
4948
|
if (reasons && reasons.length > 0) {
|
|
3157
4949
|
for (const r of reasons) errors.push(`${[issuePath, ...r.path].filter((x) => x !== "").join(".")}: ${r.message}`);
|
|
3158
4950
|
} else {
|
|
3159
|
-
errors.push(`${issuePath}: ${issue.message}`);
|
|
4951
|
+
errors.push(issuePath ? `${issuePath}: ${issue.message}` : issue.message);
|
|
3160
4952
|
}
|
|
3161
4953
|
}
|
|
3162
4954
|
}
|
|
@@ -3180,6 +4972,16 @@ function validate(filePath) {
|
|
|
3180
4972
|
}
|
|
3181
4973
|
}
|
|
3182
4974
|
}
|
|
4975
|
+
for (const plugin of ["postgres", "redis"]) {
|
|
4976
|
+
for (const [svc, cfg] of Object.entries(data?.[plugin] && typeof data[plugin] === "object" ? data[plugin] : {})) {
|
|
4977
|
+
const props = cfg?.properties;
|
|
4978
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) continue;
|
|
4979
|
+
for (const key of Object.keys(props)) {
|
|
4980
|
+
if (PLUGIN_SET_KEYS[plugin].includes(key) || rejectedReason(key)) continue;
|
|
4981
|
+
warnings.push(`${plugin}.${svc}.properties.${key}: not a ${plugin}:set key of dokku-${plugin} ${TESTED_PLUGIN_VERSION}; up passes it on and fails the service if the plugin refuses it`);
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
4984
|
+
}
|
|
3183
4985
|
const pluginNames = data?.plugins ? new Set(Object.keys(data.plugins)) : /* @__PURE__ */ new Set();
|
|
3184
4986
|
if (data?.postgres && pluginNames.size > 0 && !pluginNames.has("postgres")) {
|
|
3185
4987
|
warnings.push(`postgres: "postgres" plugin not declared in plugins (may be pre-installed)`);
|
|
@@ -3317,7 +5119,7 @@ program.command("ps [apps...]").description("Show status of configured apps").op
|
|
|
3317
5119
|
const config = loadConfig(opts.file);
|
|
3318
5120
|
const runner = makeRunner({});
|
|
3319
5121
|
try {
|
|
3320
|
-
const { runPs } = await import("./ps-
|
|
5122
|
+
const { runPs } = await import("./ps-4ESSTYJT.js");
|
|
3321
5123
|
await runPs(runner, config, apps);
|
|
3322
5124
|
} catch (error) {
|
|
3323
5125
|
console.error(chalk2.red(`
|