dokku-compose 0.14.2 → 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/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-WM244P42.js";
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": z.union([z.string(), z.number(), z.boolean()]).optional()
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`), which dokku-compose does not manage yet; a per-app value never applies.
284
- archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-files <n>`" }),
285
- archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-size <n>`" })
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.string()).optional(),
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: z.array(z.string()).optional(),
316
- network: z.object({
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: z.union([z.string(), z.object({ selected: z.string().optional(), shell: z.string().optional() }).strict()]).optional(),
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: z.object({
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: z.object({
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 credentials; absent leaves them alone
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: z.array(z.string()).optional(),
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
- nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
397
- // The top-level block is also applied with `--global`, which takes two more keys.
398
- logs: propertyMap("logs", LOGS_GLOBAL_KEYS, StringOrNumber).optional(),
399
- git: GitSchema.optional()
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
- function maskValue(value) {
455
- if (value.length <= 4) return "****";
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
- let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => {
460
- return `${key}=${maskValue(value)}`;
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
- /^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
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
- /^(\S+:backup-auth\s+\S+\s+)(\S+)(\s+)(\S+)/,
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
- masked[index] = maskValue(value);
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
- const m = masked[i].match(/^([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(.+)$/i);
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
- masked[i] = `${m[1]}=${maskValue(m[2])}`;
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
- return { masked, secrets };
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 (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
513
- result[key] = maskValue(value);
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((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
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((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
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
- function parseDeployMounts(raw) {
869
- return raw.split(/\s+/).filter((s) => s && s !== "-v");
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
- const raw = await ctx.query("storage:report", target, "--storage-deploy-mounts");
875
- return parseDeployMounts(raw);
876
- },
877
- readAll: async (ctx) => {
878
- const raw = await ctx.query("storage:report");
879
- const bulk = parseBulkReport(raw, "storage");
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 result;
2010
+ return parseAttachments(raw);
885
2011
  },
886
- onChange: async (ctx, target, { added, removed }) => {
887
- for (const m of removed) {
888
- await ctx.run("storage:unmount", target, m);
889
- ctx.needsRestart(target, "storage");
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
- for (const m of added) {
892
- await ctx.run("storage:mount", target, m);
893
- ctx.needsRestart(target, "storage");
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 words = [];
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
- words.push(cur);
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) words.push(cur);
1093
- return words;
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 words = shellSplit(raw) ?? raw.trim().split(/\s+/);
1106
- if (words[0] !== "--build-arg" || words.length < 2) continue;
1107
- const m = words.slice(1).join(" ").match(/^([^=\s]+)=?(.*)$/s);
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 wanted = new Set(desired?.[phase] ?? []);
1253
- return (current?.[phase] ?? []).filter((o) => !wanted.has(o)).map((o) => `${phase}: ${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
- removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !NOT_REMOVABLE.has(k) && !(desired && k in desired)),
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
- onChange: async (ctx, target, { after }) => {
1519
- if (after.attach_post_create !== void 0 && after.attach_post_create !== false) {
1520
- const nets = Array.isArray(after.attach_post_create) ? after.attach_post_create : [after.attach_post_create];
1521
- await ctx.run("network:set", target, "attach-post-create", ...nets);
1522
- ctx.needsRebuild(target, "network");
1523
- }
1524
- if (after.initial_network !== void 0 && after.initial_network !== false) {
1525
- await ctx.run("network:set", target, "initial-network", after.initial_network);
1526
- ctx.needsRebuild(target, "network");
1527
- }
1528
- if (after.bind_all_interfaces !== void 0) {
1529
- await ctx.run("network:set", target, "bind-all-interfaces", String(after.bind_all_interfaces));
1530
- ctx.needsRestart(target, "network");
1531
- }
1532
- if (after.static_web_listener !== void 0 && after.static_web_listener !== false) {
1533
- await ctx.run("network:set", target, "static-web-listener", after.static_web_listener);
1534
- }
1535
- if (after.tld !== void 0 && after.tld !== false) {
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 createHash2("sha256").update(`${firstLine(accessKeyId)}
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;
1755
3378
  }
1756
- function backupMismatches(info, backup) {
1757
- for (const key of ["backup-authenticated", "backup-auth-fingerprint", "backup-schedule"]) {
1758
- if (!(key in info)) throw new Error("dokku-postgres too old to report backup state (needs 2.3.1+)");
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;
3389
+ }
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]);
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]);
1759
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 describeServerBackup(info) {
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
- const parts = [];
1790
- if (schedule) parts.push(`scheduled ${schedule} to ${field(info, "backup-bucket")}`);
1791
- if (field(info, "backup-authenticated") === "true") parts.push("credentials stored");
1792
- return parts.join(", ") || "none";
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,85 +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
- await ctx.run(...args);
1861
- created.add(name);
1862
- logDone();
1863
- }
1864
- return created;
1865
- }
1866
- async function ensurePostgresBackups(ctx, services, opts = {}) {
1867
- for (const [name, config] of Object.entries(services)) {
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
- const { schedule, bucket, auth } = backup;
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
- if (!opts.dryRun) {
1929
- const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
1930
- if (!parseBackupSchedule(cronOutput)) {
1931
- throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
1932
- }
1933
- }
1934
- } else if (fields.has("backup.bucket")) {
1935
- await ctx.run("postgres:set", name, "backup-bucket", bucket);
1936
- }
3678
+ created.add(name);
1937
3679
  logDone();
1938
3680
  }
3681
+ return created;
1939
3682
  }
1940
3683
  async function removeBackupHashKeys(ctx) {
1941
3684
  let env;
@@ -1966,14 +3709,7 @@ async function destroyPostgres(ctx, services) {
1966
3709
  logDone();
1967
3710
  }
1968
3711
  }
1969
- function parseBackupSchedule(cronLine) {
1970
- const parts = cronLine.trim().split(/\s+/);
1971
- if (parts.length < 8) return null;
1972
- const schedule = parts.slice(0, 5).join(" ");
1973
- const bucket = parts[parts.length - 1];
1974
- return { schedule, bucket };
1975
- }
1976
- async function exportPostgres(ctx) {
3712
+ async function exportPostgres(ctx, mountSpecs) {
1977
3713
  const services = {};
1978
3714
  const listOutput = await ctx.query("postgres:list");
1979
3715
  const lines = listOutput.split("\n").slice(1);
@@ -1994,33 +3730,19 @@ async function exportPostgres(ctx) {
1994
3730
  } else {
1995
3731
  config.version = versionField;
1996
3732
  }
1997
- try {
1998
- const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
1999
- if (cronOutput) {
2000
- const parsed = parseBackupSchedule(cronOutput);
2001
- if (parsed) {
2002
- config.backup = {
2003
- schedule: parsed.schedule,
2004
- bucket: parsed.bucket,
2005
- auth: {
2006
- access_key_id: "REPLACE_ME",
2007
- secret_access_key: "REPLACE_ME",
2008
- region: "REPLACE_ME",
2009
- signature_version: "REPLACE_ME",
2010
- endpoint: "REPLACE_ME"
2011
- }
2012
- };
2013
- }
2014
- }
2015
- } catch {
2016
- }
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);
2017
3738
  services[name] = config;
2018
3739
  }
2019
3740
  return services;
2020
3741
  }
2021
3742
 
2022
3743
  // src/modules/redis.ts
2023
- async function ensureRedis(ctx, services) {
3744
+ async function ensureRedis(ctx, services, volumes, opts = {}) {
3745
+ const created = /* @__PURE__ */ new Set();
2024
3746
  for (const [name, config] of Object.entries(services)) {
2025
3747
  logAction("services", `Ensuring ${name}`);
2026
3748
  const exists = await ctx.check("redis:exists", name);
@@ -2037,12 +3759,23 @@ async function ensureRedis(ctx, services) {
2037
3759
  }
2038
3760
  continue;
2039
3761
  }
2040
- const args = ["redis:create", name];
2041
- if (config.image) args.push("--image", config.image);
2042
- if (config.version) args.push("--image-version", config.version);
2043
- await ctx.run(...args);
3762
+ const args = ["redis:create", name];
3763
+ if (config.image) args.push("--image", config.image);
3764
+ if (config.version) args.push("--image-version", config.version);
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);
2044
3776
  logDone();
2045
3777
  }
3778
+ return created;
2046
3779
  }
2047
3780
  async function destroyRedis(ctx, services) {
2048
3781
  for (const [name] of Object.entries(services)) {
@@ -2056,7 +3789,7 @@ async function destroyRedis(ctx, services) {
2056
3789
  logDone();
2057
3790
  }
2058
3791
  }
2059
- async function exportRedis(ctx) {
3792
+ async function exportRedis(ctx, mountSpecs) {
2060
3793
  const services = {};
2061
3794
  const listOutput = await ctx.query("redis:list");
2062
3795
  const lines = listOutput.split("\n").slice(1);
@@ -2077,171 +3810,16 @@ async function exportRedis(ctx) {
2077
3810
  } else {
2078
3811
  config.version = versionField;
2079
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);
2080
3818
  services[name] = config;
2081
3819
  }
2082
3820
  return services;
2083
3821
  }
2084
3822
 
2085
- // src/modules/links.ts
2086
- function normalizeLink(entry) {
2087
- return typeof entry === "string" ? { service: entry } : entry;
2088
- }
2089
- function resolveServicePlugin(name, config) {
2090
- if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
2091
- if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
2092
- return void 0;
2093
- }
2094
- function allServices(config) {
2095
- const entries = [];
2096
- for (const name of Object.keys(config.postgres ?? {})) entries.push([name, "postgres"]);
2097
- for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
2098
- return entries;
2099
- }
2100
- var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
2101
- function linkEnvVar(plugin, link) {
2102
- if (link.env_var) return link.env_var;
2103
- if (link.alias) return `${link.alias}_URL`;
2104
- return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
2105
- }
2106
- function linkFlags(link) {
2107
- const flags = [];
2108
- if (link.alias) flags.push("--alias", link.alias);
2109
- if (link.env_var) flags.push("--env-var", link.env_var);
2110
- if (link.querystring) flags.push("--querystring", link.querystring);
2111
- return flags;
2112
- }
2113
- function readEnv(ctx, app, cache) {
2114
- let read = cache?.get(app);
2115
- if (!read) {
2116
- read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
2117
- cache?.set(app, read);
2118
- }
2119
- return read;
2120
- }
2121
- function findLinkVariables(env, plugin, service) {
2122
- const host = `dokku-${plugin}-${service}`;
2123
- const found = [];
2124
- for (const [name, value] of Object.entries(env)) {
2125
- if (typeof value !== "string") continue;
2126
- const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
2127
- if (m && m[1] === host) found.push({ name, value });
2128
- }
2129
- return found;
2130
- }
2131
- function isFallbackName(plugin, name) {
2132
- return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
2133
- }
2134
- function querystringOf(url) {
2135
- const q = url.indexOf("?");
2136
- return q === -1 ? "" : url.slice(q + 1);
2137
- }
2138
- function show(name, qs) {
2139
- return qs ? `${name}?${maskQuerystring(qs)}` : name;
2140
- }
2141
- async function readLinkOptions(ctx, app, plugin, link, cache) {
2142
- let env;
2143
- try {
2144
- env = await readEnv(ctx, app, cache);
2145
- } catch (err) {
2146
- return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
2147
- }
2148
- const found = findLinkVariables(env, plugin, link.service);
2149
- if (found.length === 0) {
2150
- return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
2151
- }
2152
- const name = linkEnvVar(plugin, link);
2153
- const desired = link.querystring ?? "";
2154
- const plain = !link.alias && !link.env_var;
2155
- const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
2156
- if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
2157
- const current = querystringOf(hit.value);
2158
- if (current === desired) return { state: "same" };
2159
- return { state: "differs", current: show(name, current), desired: show(name, desired) };
2160
- }
2161
- async function readServiceLinks(ctx, app, desiredLinks, config) {
2162
- const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
2163
- const states = [];
2164
- for (const [service, plugin] of allServices(config)) {
2165
- states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
2166
- }
2167
- return states;
2168
- }
2169
- function servicesToUnlink(states) {
2170
- return states.filter((s) => !s.link && s.isLinked);
2171
- }
2172
- async function ensureAppLinks(ctx, app, desiredLinks, config) {
2173
- const states = await readServiceLinks(ctx, app, desiredLinks, config);
2174
- const unlink = new Set(servicesToUnlink(states));
2175
- for (const state of states) {
2176
- const { service: serviceName, plugin, link, isLinked } = state;
2177
- if (link && !isLinked) {
2178
- logAction(app, `Linking ${serviceName}`);
2179
- await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
2180
- ctx.needsRestart(app, "links");
2181
- logDone();
2182
- } else if (link && isLinked) {
2183
- const opts = await readLinkOptions(ctx, app, plugin, link);
2184
- if (opts.state === "differs") {
2185
- logAction(app, `Relinking ${serviceName}`);
2186
- await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
2187
- try {
2188
- await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
2189
- } catch (err) {
2190
- logWarn(app, `${app} is now unlinked from ${serviceName}`);
2191
- throw err;
2192
- }
2193
- ctx.needsRestart(app, "links");
2194
- logDone();
2195
- }
2196
- } else if (unlink.has(state)) {
2197
- logAction(app, `Unlinking ${serviceName}`);
2198
- await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
2199
- ctx.needsRestart(app, "links");
2200
- logDone();
2201
- }
2202
- }
2203
- }
2204
- async function destroyAppLinks(ctx, app, links, config) {
2205
- for (const serviceName of links.map((l) => normalizeLink(l).service)) {
2206
- const resolved = resolveServicePlugin(serviceName, config);
2207
- if (!resolved) continue;
2208
- const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
2209
- if (isLinked) {
2210
- await ctx.run(`${resolved.plugin}:unlink`, serviceName, app, "--no-restart");
2211
- }
2212
- }
2213
- }
2214
- async function exportAppLinks(ctx, app, config) {
2215
- const linked = [];
2216
- for (const [serviceName, plugin] of allServices(config)) {
2217
- if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
2218
- let found = [];
2219
- try {
2220
- found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
2221
- } catch {
2222
- }
2223
- const fallback = linkEnvVar(plugin, { service: serviceName });
2224
- const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
2225
- if (!v) {
2226
- linked.push(serviceName);
2227
- continue;
2228
- }
2229
- const querystring = querystringOf(v.value);
2230
- if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
2231
- linked.push(serviceName);
2232
- continue;
2233
- }
2234
- const entry = { service: serviceName };
2235
- if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
2236
- if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
2237
- else entry.env_var = v.name;
2238
- }
2239
- if (querystring) entry.querystring = querystring;
2240
- linked.push(entry);
2241
- }
2242
- return linked;
2243
- }
2244
-
2245
3823
  // src/modules/domains.ts
2246
3824
  async function readGlobalDomains(ctx) {
2247
3825
  const raw = await ctx.queryStrict("domains:report", "--global", "--domains-global-vhosts");
@@ -2265,46 +3843,20 @@ async function ensureGlobalDomains(ctx, domains) {
2265
3843
  logDone();
2266
3844
  }
2267
3845
 
2268
- // src/modules/logs.ts
2269
- async function readGlobalLogs(ctx) {
2270
- return parseJsonReport(await ctx.queryStrict("logs:report", "--global", "--format", "json"), "global");
2271
- }
2272
- async function ensureGlobalLogs(ctx, logs) {
2273
- logAction("global", "Configuring logs");
2274
- const current = await readGlobalLogs(ctx);
2275
- let changed = false;
2276
- for (const [key, value] of Object.entries(logs)) {
2277
- if (current[key] === String(value)) continue;
2278
- await ctx.run("logs:set", "--global", key, String(value));
2279
- changed = true;
2280
- }
2281
- if (changed) logDone();
2282
- else logSkip();
2283
- }
2284
-
2285
- // src/modules/nginx.ts
2286
- async function readGlobalNginx(ctx) {
2287
- return parseJsonReport(await ctx.queryStrict("nginx:report", "--global", "--format", "json"), "global");
2288
- }
2289
- async function ensureGlobalNginx(ctx, nginx) {
2290
- logAction("global", "Configuring nginx");
2291
- const current = await readGlobalNginx(ctx);
2292
- let changed = false;
2293
- for (const [key, value] of Object.entries(nginx)) {
2294
- if (current[key] === String(value)) continue;
2295
- await ctx.run("nginx:set", "--global", key, String(value));
2296
- changed = true;
2297
- }
2298
- if (changed) logDone();
2299
- else logSkip();
2300
- }
2301
-
2302
3846
  // src/commands/up.ts
2303
3847
  function markGlobalEnvRestart(ctx, apps, reason = "global env") {
2304
3848
  for (const app of apps) ctx.needsRestart(app, reason);
2305
3849
  }
2306
3850
  function printRestartSummary(ctx, dryRun = false) {
2307
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
+ }
2308
3860
  if (restarts.length === 0 && ctx.rebuilds.size === 0) return;
2309
3861
  console.log(`
2310
3862
  # Apps that ${dryRun ? "would need" : "need"} a restart or rebuild:`);
@@ -2313,8 +3865,9 @@ function printRestartSummary(ctx, dryRun = false) {
2313
3865
  }
2314
3866
  var UpFailedError = class extends Error {
2315
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)";
2316
3869
  super(
2317
- `up failed for ${failures.length} app(s): ${failures.map((f) => f.app).join(", ")}`
3870
+ `up failed for ${failures.length} ${kinds}: ${failures.map((f) => f.app).join(", ")}`
2318
3871
  );
2319
3872
  this.failures = failures;
2320
3873
  this.name = "UpFailedError";
@@ -2326,7 +3879,7 @@ var AppStepsError = class extends AggregateError {
2326
3879
  this.name = "AppStepsError";
2327
3880
  }
2328
3881
  };
2329
- 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()) {
2330
3883
  const errors = [];
2331
3884
  let leftOnServer = 0;
2332
3885
  const count = prune ? void 0 : (removed) => {
@@ -2352,7 +3905,13 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
2352
3905
  await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
2353
3906
  }
2354
3907
  await step(() => reconcile(Certs, ctx, app, appConfig.ssl));
2355
- await step(() => reconcile(Storage, ctx, app, appConfig.storage));
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
+ });
2356
3915
  await step(() => reconcile(Nginx, ctx, app, appConfig.nginx, count, prune));
2357
3916
  await step(() => reconcile(Checks, ctx, app, appConfig.checks, void 0, prune));
2358
3917
  await step(() => reconcile(Logs, ctx, app, appConfig.logs, count, prune));
@@ -2362,7 +3921,7 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
2362
3921
  await step(() => reconcile(Scale, ctx, app, appConfig.scale));
2363
3922
  await step(() => reconcile(Config, ctx, app, appConfig.env || {}));
2364
3923
  await step(() => reconcile(Builder, ctx, app, appConfig.build, count, prune));
2365
- await step(() => reconcile(Git, ctx, app, appConfig.git ?? config.git));
3924
+ await step(() => reconcile(Git, ctx, app, appConfig.git, count, prune, config.git));
2366
3925
  await step(() => reconcile(DockerOptions, ctx, app, appConfig.docker_options));
2367
3926
  if (leftOnServer > 0) {
2368
3927
  logWarn(app, `${leftOnServer} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
@@ -2388,25 +3947,57 @@ async function runUpPhases(ctx, config, appFilter, opts) {
2388
3947
  if (await ensureGlobalConfig(ctx, config.env)) {
2389
3948
  markGlobalEnvRestart(ctx, apps.filter((app) => config.apps[app]));
2390
3949
  }
2391
- if (config.logs !== void 0) await ensureGlobalLogs(ctx, config.logs);
2392
- if (config.nginx !== void 0) await ensureGlobalNginx(ctx, config.nginx);
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
+ }
2393
3954
  if (config.networks) await ensureNetworks(ctx, config.networks);
2394
- const createdPostgres = config.postgres ? await ensurePostgres(ctx, config.postgres) : /* @__PURE__ */ new Set();
2395
- if (config.redis) await ensureRedis(ctx, config.redis);
2396
- if (config.postgres) {
2397
- await ensurePostgresBackups(ctx, config.postgres, {
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, {
2398
3966
  prune: opts.prune,
2399
3967
  dryRun: opts.dryRun,
2400
3968
  dryRunCreated: opts.dryRun ? createdPostgres : void 0
2401
3969
  });
2402
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);
2403
3994
  await removeBackupHashKeys(ctx);
2404
- const failures = [];
3995
+ const failures = [...serviceFailures];
2405
3996
  for (const app of apps) {
2406
3997
  const appConfig = config.apps[app];
2407
3998
  if (!appConfig) continue;
2408
3999
  try {
2409
- await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
4000
+ await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune, failedVolumes);
2410
4001
  } catch (error) {
2411
4002
  if (opts.failFast) throw error;
2412
4003
  if (!(error instanceof AppStepsError)) {
@@ -2418,6 +4009,9 @@ async function runUpPhases(ctx, config, appFilter, opts) {
2418
4009
  if (failures.length > 0) {
2419
4010
  throw new UpFailedError(failures);
2420
4011
  }
4012
+ if (failedVolumes.size > 0) {
4013
+ throw new Error(`up failed for storage entr${failedVolumes.size === 1 ? "y" : "ies"}: ${[...failedVolumes.keys()].join(", ")}`);
4014
+ }
2421
4015
  }
2422
4016
 
2423
4017
  // src/modules/apps.ts
@@ -2525,9 +4119,22 @@ async function runExport(ctx, opts) {
2525
4119
  const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx);
2526
4120
  const networks = await exportNetworks(ctx);
2527
4121
  if (networks.length > 0) config.networks = networks;
2528
- const postgres = await exportPostgres(ctx);
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();
2529
4135
  if (Object.keys(postgres).length > 0) config.postgres = postgres;
2530
- 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 });
2531
4138
  if (Object.keys(redis).length > 0) config.redis = redis;
2532
4139
  const prefetched = /* @__PURE__ */ new Map();
2533
4140
  await Promise.all(
@@ -2538,12 +4145,21 @@ async function runExport(ctx, opts) {
2538
4145
  }
2539
4146
  })
2540
4147
  );
4148
+ const mounts = /* @__PURE__ */ new Map();
2541
4149
  for (const app of apps) {
2542
4150
  const appConfig = {};
2543
4151
  for (const resource of ALL_APP_RESOURCES) {
2544
4152
  if (resource.key.startsWith("_")) continue;
2545
4153
  if (resource.forceApply) continue;
2546
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
+ }
2547
4163
  const bulk = prefetched.get(resource.key);
2548
4164
  let value;
2549
4165
  try {
@@ -2571,8 +4187,97 @@ async function runExport(ctx, opts) {
2571
4187
  }
2572
4188
  config.apps[app] = appConfig;
2573
4189
  }
4190
+ await exportStorage(ctx, config, mounts, !!opts.appFilter?.length, serviceMounts);
2574
4191
  return config;
2575
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
+ }
2576
4281
 
2577
4282
  // src/modules/env-notes.ts
2578
4283
  var MANAGED_KEYS_VAR3 = "DOKKU_COMPOSE_MANAGED_KEYS";
@@ -2620,7 +4325,7 @@ function isEmptyValue(value) {
2620
4325
  return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
2621
4326
  }
2622
4327
  async function computeDiff(ctx, config) {
2623
- 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: [] };
2624
4329
  const appNames = Object.keys(config.apps);
2625
4330
  const serverVersion = await assertDokkuSupported(ctx);
2626
4331
  const fileVersion = config.dokku?.version;
@@ -2666,13 +4371,14 @@ async function computeDiff(ctx, config) {
2666
4371
  let desired;
2667
4372
  if (resource.key === "proxy") {
2668
4373
  desired = appConfig.proxy?.enabled;
2669
- } else if (resource.key === "git") {
2670
- desired = appConfig.git ?? config.git;
2671
4374
  } else if (resource.key === "proxy_props") {
2672
4375
  desired = proxyProps(appConfig.proxy);
4376
+ } else if (resource.key === "storage") {
4377
+ desired = desiredSpecs(appConfig.storage);
2673
4378
  } else {
2674
4379
  desired = appConfig[resource.key];
2675
4380
  }
4381
+ const inherited = resource.inheritsGlobal ? config[resource.key] : void 0;
2676
4382
  const checkRemovalsOnly = desired === void 0;
2677
4383
  if (checkRemovalsOnly && (!resource.removals || !appExists.get(app))) continue;
2678
4384
  if (resource.forceApply && !resource.removals) continue;
@@ -2692,7 +4398,7 @@ async function computeDiff(ctx, config) {
2692
4398
  result.readWarnings.push(`could not read '${resource.key}' for app '${app}' to look for removed settings: ${err instanceof Error ? err.message : String(err)}`);
2693
4399
  continue;
2694
4400
  }
2695
- const removed2 = resource.removals(current, void 0);
4401
+ const removed2 = resource.removals(current, void 0, inherited);
2696
4402
  if (removed2.length > 0) {
2697
4403
  appDiff[resource.key] = { status: "extra", current, removed: removed2 };
2698
4404
  result.inSync = false;
@@ -2708,15 +4414,15 @@ async function computeDiff(ctx, config) {
2708
4414
  current = await resource.read(ctx, app);
2709
4415
  }
2710
4416
  if (resource.inSync) {
2711
- const removedHere = resource.removals ? resource.removals(current, desired) : [];
4417
+ const removedHere = resource.removals ? resource.removals(current, desired, inherited) : [];
2712
4418
  let status;
2713
4419
  if (current === void 0) {
2714
4420
  status = "unknown";
2715
4421
  result.readWarnings.push(`could not read '${resource.key}' for app '${app}'`);
2716
4422
  } else {
2717
- const same = safeInSync(resource, current, desired, app, result.readWarnings);
2718
- if (same === void 0) status = "unknown";
2719
- else if (same) status = removedHere.length > 0 ? "extra" : "in-sync";
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";
2720
4426
  else status = current === false || isEmptyValue(current) ? "missing" : "changed";
2721
4427
  }
2722
4428
  appDiff[resource.key] = {
@@ -2728,7 +4434,7 @@ async function computeDiff(ctx, config) {
2728
4434
  if (status !== "in-sync") result.inSync = false;
2729
4435
  continue;
2730
4436
  }
2731
- let removed = resource.removals ? resource.removals(current, desired) : [];
4437
+ let removed = resource.removals ? resource.removals(current, desired, inherited) : [];
2732
4438
  if (resource.forceApply) {
2733
4439
  if (removed.length > 0) {
2734
4440
  appDiff[resource.key] = { status: "extra", desired, current, removed };
@@ -2883,24 +4589,30 @@ async function computeDiff(ctx, config) {
2883
4589
  result.inSync = false;
2884
4590
  }
2885
4591
  }
2886
- for (const [name, declared, read] of [
2887
- ["logs", config.logs, readGlobalLogs],
2888
- ["nginx", config.nginx, readGlobalNginx]
2889
- ]) {
4592
+ for (const name of GLOBAL_NAMES) {
4593
+ const declared = config[name];
2890
4594
  if (declared === void 0) continue;
2891
4595
  let current;
2892
4596
  try {
2893
- current = await read(ctx);
4597
+ current = await readGlobal(ctx, name);
2894
4598
  } catch (err) {
2895
4599
  result.global[name] = { status: "unknown", desired: declared };
2896
4600
  result.readWarnings.push(`could not read global '${name}': ${err instanceof Error ? err.message : String(err)}`);
2897
4601
  result.inSync = false;
2898
4602
  continue;
2899
4603
  }
2900
- const differs = Object.entries(declared).filter(([k, v]) => current[k] !== String(v));
2901
- if (differs.length === 0) continue;
2902
- const shown = Object.fromEntries(Object.keys(declared).filter((k) => k in current).map((k) => [k, current[k]]));
2903
- result.global[name] = { status: differs.every(([k]) => !(k in current)) ? "missing" : "changed", desired: declared, current: shown };
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
+ };
2904
4616
  result.inSync = false;
2905
4617
  }
2906
4618
  for (const net of config.networks ?? []) {
@@ -2908,6 +4620,35 @@ async function computeDiff(ctx, config) {
2908
4620
  result.networks[net] = { status: "missing" };
2909
4621
  result.inSync = false;
2910
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
+ }
2911
4652
  for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
2912
4653
  for (const [svc, declared] of Object.entries(services ?? {})) {
2913
4654
  const exists = await ctx.check(`${plugin}:exists`, svc);
@@ -2916,17 +4657,18 @@ async function computeDiff(ctx, config) {
2916
4657
  result.inSync = false;
2917
4658
  continue;
2918
4659
  }
2919
- const backup = plugin === "postgres" ? declared.backup : void 0;
2920
- const compares = declared?.version !== void 0 || declared?.image !== void 0 || backup !== void 0;
2921
- if (!compares && plugin === "redis") {
2922
- result.services[svc] = { status: "in-sync", mismatches: [] };
2923
- continue;
2924
- }
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;
2925
4662
  try {
2926
4663
  const info = await readServiceInfo(ctx, plugin, svc);
2927
- const mismatches = [...versionMismatches(info, declared), ...backup !== void 0 ? backupMismatches(info, backup) : []];
2928
- const note = plugin === "postgres" && backup === void 0 && hasBackup(info) ? `backup on the server is not in the file (${describeServerBackup(info)}); up leaves it, backup: false removes it` : void 0;
2929
- result.services[svc] = { status: mismatches.length ? "changed" : "in-sync", mismatches, note };
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 };
2930
4672
  if (mismatches.length) result.inSync = false;
2931
4673
  } catch (err) {
2932
4674
  result.readWarnings.push(`could not read ${plugin} service '${svc}': ${err instanceof Error ? err.message : String(err)}`);
@@ -2941,10 +4683,18 @@ async function computeDiff(ctx, config) {
2941
4683
  }
2942
4684
  return result;
2943
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
+ }
2944
4694
  function maskDiffResult(diff) {
2945
4695
  const masked = {
2946
4696
  apps: {},
2947
- services: diff.services,
4697
+ services: maskServices(diff.services),
2948
4698
  plugins: diff.plugins,
2949
4699
  dokku: diff.dokku,
2950
4700
  global: Object.fromEntries(Object.entries(diff.global).map(([k, d]) => [k, {
@@ -2953,6 +4703,8 @@ function maskDiffResult(diff) {
2953
4703
  current: maskSensitiveData(d.current)
2954
4704
  }])),
2955
4705
  networks: diff.networks,
4706
+ volumes: diff.volumes,
4707
+ volumeNotes: diff.volumeNotes,
2956
4708
  inSync: diff.inSync,
2957
4709
  envNotes: diff.envNotes,
2958
4710
  readWarnings: diff.readWarnings
@@ -2973,7 +4725,7 @@ function maskDiffResult(diff) {
2973
4725
  return masked;
2974
4726
  }
2975
4727
  function driftCount(diff) {
2976
- 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);
2977
4729
  }
2978
4730
  function formatSummary(diff) {
2979
4731
  const lines = [""];
@@ -2996,15 +4748,23 @@ function formatSummary(diff) {
2996
4748
  lines.push(` dokku`);
2997
4749
  lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
2998
4750
  }
4751
+ if (Object.keys(diff.global).length > 0) lines.push(` global`);
2999
4752
  for (const [name, d] of Object.entries(diff.global)) {
3000
- const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
3001
- lines.push(` global`);
4753
+ const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : d.status === "extra" ? chalk.red("-") : chalk.yellow("~");
3002
4754
  lines.push(` ${sym} ${name}: ${formatFeatureSummary(d)}`);
3003
4755
  }
3004
4756
  for (const net of Object.keys(diff.networks)) {
3005
4757
  lines.push(` networks:`);
3006
4758
  lines.push(` ${chalk.green("+")} ${net}: not created`);
3007
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
+ }
3008
4768
  for (const [name, d] of Object.entries(diff.plugins)) {
3009
4769
  const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
3010
4770
  const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
@@ -3018,7 +4778,7 @@ function formatSummary(diff) {
3018
4778
  }
3019
4779
  if (d.mismatches?.length || d.note) lines.push(` services: ${svc}`);
3020
4780
  for (const m of d.mismatches ?? []) {
3021
- 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}`);
3022
4782
  }
3023
4783
  if (d.note) lines.push(` note: ${d.note}`);
3024
4784
  }
@@ -3037,6 +4797,14 @@ function formatSummary(diff) {
3037
4797
  lines.push("");
3038
4798
  return lines.join("\n");
3039
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
+ }
3040
4808
  function showNote(n) {
3041
4809
  return n.label ? `${n.key} (${n.label})` : n.key;
3042
4810
  }
@@ -3105,6 +4873,27 @@ function formatVerbose(diff) {
3105
4873
  lines.push(`@@ networks @@`);
3106
4874
  lines.push(chalk.green(`+ ${net}`));
3107
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
+ }
3108
4897
  for (const [name, d] of Object.entries(diff.plugins)) {
3109
4898
  lines.push(`@@ plugin: ${name} @@`);
3110
4899
  if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
@@ -3118,7 +4907,7 @@ function formatVerbose(diff) {
3118
4907
  for (const m of d.mismatches ?? []) {
3119
4908
  lines.push(`@@ services: ${svc} ${m.field} @@`);
3120
4909
  lines.push(chalk.red(`- ${m.current}`));
3121
- lines.push(chalk.green(`+ ${m.desired}`));
4910
+ if (!m.removed) lines.push(chalk.green(`+ ${m.desired}`));
3122
4911
  }
3123
4912
  if (d.note) lines.push(`@@ services: ${svc} @@
3124
4913
  (${d.note})`);
@@ -3159,7 +4948,7 @@ function validate(filePath) {
3159
4948
  if (reasons && reasons.length > 0) {
3160
4949
  for (const r of reasons) errors.push(`${[issuePath, ...r.path].filter((x) => x !== "").join(".")}: ${r.message}`);
3161
4950
  } else {
3162
- errors.push(`${issuePath}: ${issue.message}`);
4951
+ errors.push(issuePath ? `${issuePath}: ${issue.message}` : issue.message);
3163
4952
  }
3164
4953
  }
3165
4954
  }
@@ -3183,6 +4972,16 @@ function validate(filePath) {
3183
4972
  }
3184
4973
  }
3185
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
+ }
3186
4985
  const pluginNames = data?.plugins ? new Set(Object.keys(data.plugins)) : /* @__PURE__ */ new Set();
3187
4986
  if (data?.postgres && pluginNames.size > 0 && !pluginNames.has("postgres")) {
3188
4987
  warnings.push(`postgres: "postgres" plugin not declared in plugins (may be pre-installed)`);
@@ -3320,7 +5119,7 @@ program.command("ps [apps...]").description("Show status of configured apps").op
3320
5119
  const config = loadConfig(opts.file);
3321
5120
  const runner = makeRunner({});
3322
5121
  try {
3323
- const { runPs } = await import("./ps-QQAVQTEV.js");
5122
+ const { runPs } = await import("./ps-4ESSTYJT.js");
3324
5123
  await runPs(runner, config, apps);
3325
5124
  } catch (error) {
3326
5125
  console.error(chalk2.red(`