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/README.md +88 -45
- package/dist/{chunk-WM244P42.js → chunk-TP2BXE5O.js} +2 -1
- package/dist/index.js +2291 -492
- package/dist/{ps-QQAVQTEV.js → ps-4ESSTYJT.js} +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
SEMVER_RE,
|
|
3
4
|
assertDokkuSupported,
|
|
4
5
|
compareToFile,
|
|
5
6
|
enforceFileVersion,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
logError,
|
|
9
10
|
logSkip,
|
|
10
11
|
logWarn
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-TP2BXE5O.js";
|
|
12
13
|
|
|
13
14
|
// src/index.ts
|
|
14
15
|
import { Command } from "commander";
|
|
@@ -246,17 +247,470 @@ var Scheduler = {
|
|
|
246
247
|
}
|
|
247
248
|
};
|
|
248
249
|
|
|
250
|
+
// src/core/volume-rules.ts
|
|
251
|
+
var STORAGE_ROOT = "/var/lib/dokku/data/storage";
|
|
252
|
+
var DEFAULT_SCOPE = "_default_";
|
|
253
|
+
var LEGACY_PREFIX = "legacy-";
|
|
254
|
+
var MAX_ENTRY_NAME = 45;
|
|
255
|
+
var CHOWN_PRESETS = ["herokuish", "heroku", "paketo", "root"];
|
|
256
|
+
var DEFAULT_CHOWN = "herokuish";
|
|
257
|
+
var ENTRY_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
|
|
258
|
+
var DOCKER_VOLUME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
|
|
259
|
+
var PROCESS_TYPE_RE = /^[A-Za-z0-9_-]+$/;
|
|
260
|
+
var ALL_PHASES = ["deploy", "run"];
|
|
261
|
+
function defaultPath(name) {
|
|
262
|
+
return `${STORAGE_ROOT}/${name}`;
|
|
263
|
+
}
|
|
264
|
+
function isLegacyName(name) {
|
|
265
|
+
return name.startsWith(LEGACY_PREFIX);
|
|
266
|
+
}
|
|
267
|
+
function isValidEntryName(name) {
|
|
268
|
+
return name.length <= MAX_ENTRY_NAME && ENTRY_NAME_RE.test(name);
|
|
269
|
+
}
|
|
270
|
+
function entryNameError(name) {
|
|
271
|
+
if (!isValidEntryName(name)) {
|
|
272
|
+
return `'${name}' is not a valid entry name: use lowercase letters, digits and dashes (not at the start or end), at most ${MAX_ENTRY_NAME} characters`;
|
|
273
|
+
}
|
|
274
|
+
return void 0;
|
|
275
|
+
}
|
|
276
|
+
function isNormalizedAbsolute(p) {
|
|
277
|
+
if (!p.startsWith("/")) return false;
|
|
278
|
+
return p.split("/").slice(1).every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
279
|
+
}
|
|
280
|
+
function pathError(p) {
|
|
281
|
+
if (typeof p !== "string" || p === "") return "path must be a non-empty string";
|
|
282
|
+
if (p.startsWith("/")) {
|
|
283
|
+
return isNormalizedAbsolute(p) ? void 0 : `path '${p}' must be normalised: no trailing slash and no empty, '.' or '..' segment`;
|
|
284
|
+
}
|
|
285
|
+
return DOCKER_VOLUME_RE.test(p) ? void 0 : `path '${p}' must be an absolute directory or a Docker volume name`;
|
|
286
|
+
}
|
|
287
|
+
function usesDefaultLocation(name, path2) {
|
|
288
|
+
return path2 === void 0 || path2 === defaultPath(name);
|
|
289
|
+
}
|
|
290
|
+
function chownError(value) {
|
|
291
|
+
if (value === false) return void 0;
|
|
292
|
+
if (typeof value === "number") {
|
|
293
|
+
return Number.isInteger(value) && value >= 0 && value <= 65535 ? void 0 : `chown uid ${value} must be an integer from 0 to 65535`;
|
|
294
|
+
}
|
|
295
|
+
if (typeof value === "string") {
|
|
296
|
+
if (CHOWN_PRESETS.includes(value)) return void 0;
|
|
297
|
+
if (/^\d+$/.test(value) && Number(value) <= 65535) return void 0;
|
|
298
|
+
}
|
|
299
|
+
return `chown must be herokuish, heroku, paketo, root, a uid from 0 to 65535, or false (got ${JSON.stringify(value)})`;
|
|
300
|
+
}
|
|
301
|
+
function chownValue(value) {
|
|
302
|
+
return String(value);
|
|
303
|
+
}
|
|
304
|
+
function modeError(value) {
|
|
305
|
+
if (typeof value === "number") {
|
|
306
|
+
return `mode must be a quoted octal string: write mode: "${String(value).padStart(4, "0")}" (YAML reads an unquoted ${value} as a number)`;
|
|
307
|
+
}
|
|
308
|
+
if (typeof value === "string" && /^[0-7]{3,4}$/.test(value)) return void 0;
|
|
309
|
+
return `mode must be a quoted octal string of 3 or 4 digits, e.g. "0750" (got ${JSON.stringify(value)})`;
|
|
310
|
+
}
|
|
311
|
+
function normalizeMode(mode) {
|
|
312
|
+
return mode.length === 3 ? `0${mode}` : mode;
|
|
313
|
+
}
|
|
314
|
+
function parseMountString(s) {
|
|
315
|
+
if (s.startsWith("/")) {
|
|
316
|
+
return `'${s}' is a host path, which storage no longer takes: declare an entry under volumes: (with path: for an existing directory) and mount it as <entry>:<container-dir>`;
|
|
317
|
+
}
|
|
318
|
+
const parts = s.split(":");
|
|
319
|
+
if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
|
|
320
|
+
return `'${s}' must be <entry>:<container-dir>; options need the map form (entry, container_dir, ...)`;
|
|
321
|
+
}
|
|
322
|
+
return { entry: parts[0], container_dir: parts[1] };
|
|
323
|
+
}
|
|
324
|
+
function mountIssues(m) {
|
|
325
|
+
const issues = [];
|
|
326
|
+
if (!isNormalizedAbsolute(m.container_dir) || m.container_dir.includes(":")) {
|
|
327
|
+
issues.push(`container_dir '${m.container_dir}' must be a normalised absolute path without ':'`);
|
|
328
|
+
}
|
|
329
|
+
if (m.process_type !== void 0) {
|
|
330
|
+
if (m.process_type === DEFAULT_SCOPE) issues.push(`process_type '${DEFAULT_SCOPE}' is the default: leave process_type out`);
|
|
331
|
+
else if (!PROCESS_TYPE_RE.test(m.process_type)) issues.push(`process_type '${m.process_type}' must be a Procfile process type (letters, digits, '-' and '_')`);
|
|
332
|
+
}
|
|
333
|
+
for (const o of m.options ?? []) {
|
|
334
|
+
if (o === "") issues.push("options must not hold an empty item");
|
|
335
|
+
else if (o === "ro" || o === "rw") issues.push(`options must not hold '${o}': use readonly`);
|
|
336
|
+
else if (/[,=:\s]/.test(o)) issues.push(`option '${o}' must not contain ',', '=', ':' or whitespace (Dokku reads 'key=value' tokens as its own keys)`);
|
|
337
|
+
}
|
|
338
|
+
const phases = m.phases ?? [];
|
|
339
|
+
if (new Set(phases).size !== phases.length) issues.push("phases must not repeat a phase");
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
function normalizeMount(m) {
|
|
343
|
+
const phases = m.phases && m.phases.length > 0 ? m.phases : ALL_PHASES;
|
|
344
|
+
return {
|
|
345
|
+
entry: m.entry,
|
|
346
|
+
container_dir: m.container_dir,
|
|
347
|
+
readonly: m.readonly ?? false,
|
|
348
|
+
phases: ALL_PHASES.filter((p) => phases.includes(p)),
|
|
349
|
+
process_type: m.process_type ?? DEFAULT_SCOPE,
|
|
350
|
+
options: m.options ?? []
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function scopesOverlap(a, b) {
|
|
354
|
+
return a === DEFAULT_SCOPE || b === DEFAULT_SCOPE || a === b;
|
|
355
|
+
}
|
|
356
|
+
function parseServiceMountString(s) {
|
|
357
|
+
if (s.startsWith("/")) {
|
|
358
|
+
return `'${s}' is a host path, which mounts no longer take: declare the directory as an entry under volumes: (with path:) and mount it as <entry>:<container-dir>`;
|
|
359
|
+
}
|
|
360
|
+
const parsed = parseMountString(s);
|
|
361
|
+
return typeof parsed === "string" ? parsed.replace("storage", "a service mount") : { entry: parsed.entry, container_dir: parsed.container_dir };
|
|
362
|
+
}
|
|
363
|
+
function serviceMountIssues(m) {
|
|
364
|
+
return mountIssues(m);
|
|
365
|
+
}
|
|
366
|
+
function normalizeServiceMount(m) {
|
|
367
|
+
return { entry: m.entry, container_dir: m.container_dir, readonly: m.readonly ?? false, options: m.options ?? [] };
|
|
368
|
+
}
|
|
369
|
+
function declaredServiceMounts(mounts) {
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const item of mounts ?? []) {
|
|
372
|
+
const parsed = typeof item === "string" ? parseServiceMountString(item) : item;
|
|
373
|
+
if (typeof parsed !== "string") out.push(normalizeServiceMount(parsed));
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
function declaredMounts(storage) {
|
|
378
|
+
const mounts = [];
|
|
379
|
+
for (const item of storage ?? []) {
|
|
380
|
+
const parsed = typeof item === "string" ? parseMountString(item) : item;
|
|
381
|
+
if (typeof parsed === "string") continue;
|
|
382
|
+
mounts.push(normalizeMount(parsed));
|
|
383
|
+
}
|
|
384
|
+
return mounts;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/modules/global.ts
|
|
388
|
+
var same = (name, cmd, keys, kind = "scalar") => keys.map((key) => ({ yaml: key, cmd, key, report: name, kind }));
|
|
389
|
+
var REGISTRY_GLOBAL_KEYS = ["image-repo-template", "push-extra-tags", "push-on-release", "server"];
|
|
390
|
+
var PS_GLOBAL_KEYS = ["procfile-path", "restart-policy", "skip-deploy", "stop-timeout-seconds"];
|
|
391
|
+
var GLOBAL_SPECS = {
|
|
392
|
+
nginx: { name: "nginx", keys: same("nginx", "nginx:set", NGINX_KEYS) },
|
|
393
|
+
logs: { name: "logs", keys: same("logs", "logs:set", LOGS_GLOBAL_KEYS) },
|
|
394
|
+
scheduler: { name: "scheduler", keys: same("scheduler", "scheduler:set", ["selected", "shell"]) },
|
|
395
|
+
registry: { name: "registry", keys: same("registry", "registry:set", REGISTRY_GLOBAL_KEYS) },
|
|
396
|
+
checks: { name: "checks", keys: same("checks", "checks:set", ["wait-to-retire"]) },
|
|
397
|
+
ps: { name: "ps", keys: same("ps", "ps:set", PS_GLOBAL_KEYS) },
|
|
398
|
+
build: {
|
|
399
|
+
name: "build",
|
|
400
|
+
keys: [
|
|
401
|
+
{ yaml: "selected", cmd: "builder:set", key: "selected", report: "builder", kind: "scalar", effect: "rebuild" },
|
|
402
|
+
{ yaml: "context", cmd: "builder:set", key: "build-dir", report: "builder", kind: "scalar", effect: "rebuild" },
|
|
403
|
+
{ yaml: "skip_cleanup", cmd: "builder:set", key: "skip-cleanup", report: "builder", kind: "scalar" },
|
|
404
|
+
{ yaml: "dockerfile", cmd: "builder-dockerfile:set", key: "dockerfile-path", report: "builder-dockerfile", kind: "scalar", effect: "rebuild" },
|
|
405
|
+
{ yaml: "app_json", cmd: "app-json:set", key: "appjson-path", report: "app-json", kind: "scalar", effect: "rebuild" }
|
|
406
|
+
]
|
|
407
|
+
},
|
|
408
|
+
network: {
|
|
409
|
+
name: "network",
|
|
410
|
+
keys: [
|
|
411
|
+
{ yaml: "attach_post_create", cmd: "network:set", key: "attach-post-create", report: "network", kind: "list", effect: "rebuild" },
|
|
412
|
+
{ yaml: "attach_post_deploy", cmd: "network:set", key: "attach-post-deploy", report: "network", kind: "list", effect: "rebuild", keep: true },
|
|
413
|
+
{ yaml: "initial_network", cmd: "network:set", key: "initial-network", report: "network", kind: "clearable", effect: "rebuild" },
|
|
414
|
+
{ yaml: "bind_all_interfaces", cmd: "network:set", key: "bind-all-interfaces", report: "network", kind: "flag", effect: "restart" },
|
|
415
|
+
{ yaml: "tld", cmd: "network:set", key: "tld", report: "network", kind: "clearable", effect: "restart" }
|
|
416
|
+
]
|
|
417
|
+
},
|
|
418
|
+
// `enabled` (`proxy:enable`/`proxy:disable`) has no global form.
|
|
419
|
+
proxy: { name: "proxy", keys: [
|
|
420
|
+
{ yaml: "type", cmd: "proxy:set", key: "type", report: "proxy", kind: "scalar" },
|
|
421
|
+
{ yaml: "proxy_port", cmd: "proxy:set", key: "proxy-port", report: "proxy", kind: "scalar" },
|
|
422
|
+
{ yaml: "proxy_ssl_port", cmd: "proxy:set", key: "proxy-ssl-port", report: "proxy", kind: "scalar" }
|
|
423
|
+
] },
|
|
424
|
+
git: {
|
|
425
|
+
name: "git",
|
|
426
|
+
keys: [
|
|
427
|
+
{ yaml: "deploy_branch", cmd: "git:set", key: "deploy-branch", report: "git", kind: "scalar" },
|
|
428
|
+
{ yaml: "keep_git_dir", cmd: "git:set", key: "keep-git-dir", report: "git", kind: "scalar" },
|
|
429
|
+
{ yaml: "archive_max_files", cmd: "git:set", key: "archive-max-files", report: "git", kind: "scalar" },
|
|
430
|
+
{ yaml: "archive_max_size", cmd: "git:set", key: "archive-max-size", report: "git", kind: "scalar" }
|
|
431
|
+
]
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
var GLOBAL_NAMES = Object.keys(GLOBAL_SPECS);
|
|
435
|
+
var GLOBAL_UP_ORDER = ["logs", "nginx", "scheduler", "registry", "checks", "ps", "build", "proxy", "git"];
|
|
436
|
+
function declaredMap(name, block) {
|
|
437
|
+
if (typeof block === "string") return name === "scheduler" ? { selected: block } : {};
|
|
438
|
+
return block ?? {};
|
|
439
|
+
}
|
|
440
|
+
async function readGlobal(ctx, name) {
|
|
441
|
+
const spec = GLOBAL_SPECS[name];
|
|
442
|
+
const byReport = /* @__PURE__ */ new Map();
|
|
443
|
+
for (const report of new Set(spec.keys.map((k) => k.report))) {
|
|
444
|
+
byReport.set(report, parseJsonReport(await ctx.queryStrict(`${report}:report`, "--global", "--format", "json"), "global"));
|
|
445
|
+
}
|
|
446
|
+
const current = {};
|
|
447
|
+
for (const gk of spec.keys) {
|
|
448
|
+
const value = byReport.get(gk.report)[gk.key];
|
|
449
|
+
if (value !== void 0) current[gk.yaml] = value;
|
|
450
|
+
}
|
|
451
|
+
return current;
|
|
452
|
+
}
|
|
453
|
+
var words = (value) => (value ?? "").split(/\s+/).filter(Boolean);
|
|
454
|
+
var sameSet = (a, b) => a.length === b.length && a.every((v) => b.includes(v));
|
|
455
|
+
function wanted(gk, value) {
|
|
456
|
+
if (gk.kind === "list") {
|
|
457
|
+
const names = Array.isArray(value) ? value.map(String) : [];
|
|
458
|
+
return { args: names, inSync: (current) => sameSet(words(current), names) };
|
|
459
|
+
}
|
|
460
|
+
if (gk.kind === "clearable" && value === false) {
|
|
461
|
+
return { args: [], inSync: (current) => current === void 0 };
|
|
462
|
+
}
|
|
463
|
+
if (gk.kind === "flag") {
|
|
464
|
+
const on = value === true || value === "true";
|
|
465
|
+
return { args: [String(on)], inSync: (current) => on ? current === "true" : current !== "true" };
|
|
466
|
+
}
|
|
467
|
+
return { args: [String(value)], inSync: (current) => current === String(value) };
|
|
468
|
+
}
|
|
469
|
+
var BOOLEAN_KEYS = /* @__PURE__ */ new Set(["push-on-release", "skip-deploy", "skip_cleanup", "keep_git_dir"]);
|
|
470
|
+
async function exportGlobal(ctx, name) {
|
|
471
|
+
const current = await readGlobal(ctx, name);
|
|
472
|
+
const block = {};
|
|
473
|
+
for (const gk of GLOBAL_SPECS[name].keys) {
|
|
474
|
+
const value = current[gk.yaml];
|
|
475
|
+
if (value === void 0) continue;
|
|
476
|
+
if (gk.kind === "list") block[gk.yaml] = words(value);
|
|
477
|
+
else if (gk.kind === "flag" || BOOLEAN_KEYS.has(gk.yaml) && (value === "true" || value === "false")) block[gk.yaml] = value === "true";
|
|
478
|
+
else block[gk.yaml] = value;
|
|
479
|
+
}
|
|
480
|
+
const keys = Object.keys(block);
|
|
481
|
+
if (keys.length === 0) return void 0;
|
|
482
|
+
if (name === "scheduler" && keys.length === 1 && keys[0] === "selected") return block.selected;
|
|
483
|
+
return block;
|
|
484
|
+
}
|
|
485
|
+
function planGlobal(name, block, current) {
|
|
486
|
+
const declared = declaredMap(name, block);
|
|
487
|
+
const keys = GLOBAL_SPECS[name].keys;
|
|
488
|
+
const plan = { changes: [], removals: [] };
|
|
489
|
+
for (const yaml4 of Object.keys(declared)) {
|
|
490
|
+
const gk = keys.find((k) => k.yaml === yaml4);
|
|
491
|
+
if (!gk || declared[yaml4] === void 0) continue;
|
|
492
|
+
const want = wanted(gk, declared[yaml4]);
|
|
493
|
+
if (!want.inSync(current[yaml4])) plan.changes.push({ gk, args: want.args });
|
|
494
|
+
}
|
|
495
|
+
for (const gk of keys) {
|
|
496
|
+
if (!gk.keep && declared[gk.yaml] === void 0 && current[gk.yaml] !== void 0) plan.removals.push(gk);
|
|
497
|
+
}
|
|
498
|
+
return plan;
|
|
499
|
+
}
|
|
500
|
+
function appSets(name, gk, app) {
|
|
501
|
+
if (name === "network") {
|
|
502
|
+
if (gk.yaml === "attach_post_deploy") return app?.networks !== void 0;
|
|
503
|
+
return app?.network?.[gk.yaml] !== void 0;
|
|
504
|
+
}
|
|
505
|
+
if (name === "build") return app?.build?.[gk.yaml] !== void 0;
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
function markApps(ctx, name, gk, opts) {
|
|
509
|
+
if (!gk.effect) return;
|
|
510
|
+
for (const app of opts.apps ?? []) {
|
|
511
|
+
if (appSets(name, gk, opts.appConfigs?.[app])) continue;
|
|
512
|
+
if (gk.effect === "rebuild") ctx.needsRebuild(app, `global ${name}`);
|
|
513
|
+
else ctx.needsRestart(app, `global ${name}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async function ensureGlobal(ctx, name, block, opts = {}) {
|
|
517
|
+
logAction("global", `Configuring ${name}`);
|
|
518
|
+
const plan = planGlobal(name, block, await readGlobal(ctx, name));
|
|
519
|
+
for (const { gk, args } of plan.changes) {
|
|
520
|
+
await ctx.run(gk.cmd, "--global", gk.key, ...args);
|
|
521
|
+
markApps(ctx, name, gk, opts);
|
|
522
|
+
}
|
|
523
|
+
if (opts.prune) {
|
|
524
|
+
for (const gk of plan.removals) {
|
|
525
|
+
await ctx.run(gk.cmd, "--global", gk.key);
|
|
526
|
+
markApps(ctx, name, gk, opts);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const changed = plan.changes.length > 0 || !!opts.prune && plan.removals.length > 0;
|
|
530
|
+
if (changed) logDone();
|
|
531
|
+
else logSkip();
|
|
532
|
+
if (!opts.prune && plan.removals.length > 0) {
|
|
533
|
+
logWarn("global", `${plan.removals.length} global ${name} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
|
|
534
|
+
}
|
|
535
|
+
return changed;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/modules/service-property-keys.ts
|
|
539
|
+
import { posix } from "path";
|
|
540
|
+
var MANAGED_PROPERTIES = {
|
|
541
|
+
"initial-network": { kind: "string", createFlag: "initial-network", restart: true },
|
|
542
|
+
"post-create-network": { kind: "list", createFlag: "post-create-network", restart: true },
|
|
543
|
+
"post-start-network": { kind: "list", createFlag: "post-start-network", restart: true },
|
|
544
|
+
"log-driver": { kind: "string", createFlag: "log-driver", restart: true },
|
|
545
|
+
"log-opt": { kind: "list", createFlag: "log-opt", restart: true },
|
|
546
|
+
"restart-policy": { kind: "string", createFlag: "restart", restart: true },
|
|
547
|
+
"wait-timeout": { kind: "number", createFlag: "wait-timeout", restart: false },
|
|
548
|
+
"volume-targets": { kind: "targets", createFlag: "volume-target", repeat: true, restart: true },
|
|
549
|
+
"config-options": { kind: "string", createFlag: "config-options", restart: true },
|
|
550
|
+
"custom-env": { kind: "map", createFlag: "custom-env", restart: true },
|
|
551
|
+
memory: { kind: "number", createFlag: "memory", restart: true },
|
|
552
|
+
"shm-size": { kind: "string", createFlag: "shm-size", restart: true },
|
|
553
|
+
password: { kind: "secret", createFlag: "password", restart: false }
|
|
554
|
+
};
|
|
555
|
+
var COMMON_SET_KEYS = [
|
|
556
|
+
"initial-network",
|
|
557
|
+
"post-create-network",
|
|
558
|
+
"post-start-network",
|
|
559
|
+
"backup-keyserver",
|
|
560
|
+
"backup-storage-class",
|
|
561
|
+
"backup-object-name",
|
|
562
|
+
"backup-timestamp",
|
|
563
|
+
"backup-mailto",
|
|
564
|
+
"backup-access-key-id",
|
|
565
|
+
"backup-secret-access-key",
|
|
566
|
+
"backup-default-region",
|
|
567
|
+
"backup-signature-version",
|
|
568
|
+
"backup-endpoint-url",
|
|
569
|
+
"backup-encryption-passphrase",
|
|
570
|
+
"backup-public-key-id",
|
|
571
|
+
"backup-bucket",
|
|
572
|
+
"backup-use-iam",
|
|
573
|
+
"log-driver",
|
|
574
|
+
"log-opt",
|
|
575
|
+
"restart-policy",
|
|
576
|
+
"wait-timeout",
|
|
577
|
+
"port-bind-address",
|
|
578
|
+
"port-source-range",
|
|
579
|
+
"expose-host",
|
|
580
|
+
"expose-mode"
|
|
581
|
+
];
|
|
582
|
+
var TAIL_SET_KEYS = ["volume-targets", "config-options", "custom-env", "memory", "shm-size", "password"];
|
|
583
|
+
var PLUGIN_SET_KEYS = {
|
|
584
|
+
postgres: [...COMMON_SET_KEYS, "export-args", "import-args", ...TAIL_SET_KEYS],
|
|
585
|
+
redis: [...COMMON_SET_KEYS, ...TAIL_SET_KEYS]
|
|
586
|
+
};
|
|
587
|
+
var TESTED_PLUGIN_VERSION = "2.4.0";
|
|
588
|
+
function rejectedReason(key) {
|
|
589
|
+
if (key.startsWith("backup-")) return "backup settings belong to the `backup:` block";
|
|
590
|
+
if (key.startsWith("expose-") || key === "port-bind-address" || key === "port-source-range") {
|
|
591
|
+
return "exposing a service is not managed yet";
|
|
592
|
+
}
|
|
593
|
+
switch (key) {
|
|
594
|
+
case "export-args":
|
|
595
|
+
case "import-args":
|
|
596
|
+
return "arguments for ad-hoc exports and imports are not managed";
|
|
597
|
+
case "root-password":
|
|
598
|
+
return "postgres and redis have no root password";
|
|
599
|
+
case "service-memory":
|
|
600
|
+
return "postgres and redis have no service memory";
|
|
601
|
+
case "image":
|
|
602
|
+
case "image-version":
|
|
603
|
+
case "version":
|
|
604
|
+
return "declare `version` and `image` beside `properties`; `up` never upgrades a service";
|
|
605
|
+
case "definition":
|
|
606
|
+
return "the definition a service runs on is changed with upgrade";
|
|
607
|
+
case "mounts":
|
|
608
|
+
return "mounts are declared in the `mounts:` key";
|
|
609
|
+
default:
|
|
610
|
+
return void 0;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
var PRUNABLE_PROPERTIES = Object.keys(MANAGED_PROPERTIES).filter((k) => k !== "password");
|
|
614
|
+
function propertyString(key, value) {
|
|
615
|
+
if (Array.isArray(value)) return value.map(String).join(",");
|
|
616
|
+
if (typeof value === "object") {
|
|
617
|
+
if (MANAGED_PROPERTIES[key]?.kind === "targets") return targetPairs(value).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
618
|
+
return Object.entries(value).map(([k, v]) => `${k}=${v}`).join(";");
|
|
619
|
+
}
|
|
620
|
+
if (MANAGED_PROPERTIES[key]?.kind === "targets") return targetPairs(String(value)).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
621
|
+
return String(value).trim();
|
|
622
|
+
}
|
|
623
|
+
var splitSorted = (value, sep) => value.split(sep).map((s) => s.trim()).filter((s) => s !== "").sort().join(sep);
|
|
624
|
+
function comparableProperty(key, value) {
|
|
625
|
+
switch (MANAGED_PROPERTIES[key]?.kind) {
|
|
626
|
+
case "list":
|
|
627
|
+
return splitSorted(value, ",");
|
|
628
|
+
case "map":
|
|
629
|
+
return splitSorted(value, ";");
|
|
630
|
+
case "targets":
|
|
631
|
+
return targetPairs(value).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
632
|
+
case "number":
|
|
633
|
+
return key === "memory" && value === "" ? "0" : value.trim();
|
|
634
|
+
default:
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function isDefaultProperty(key, current) {
|
|
639
|
+
return key === "memory" ? current === "" || current === "0" : current === "";
|
|
640
|
+
}
|
|
641
|
+
function sameProperty(key, desired, current) {
|
|
642
|
+
return comparableProperty(key, desired) === comparableProperty(key, current);
|
|
643
|
+
}
|
|
644
|
+
function customEnvPairs(value) {
|
|
645
|
+
if (typeof value === "string") {
|
|
646
|
+
return value.split(";").filter((s) => s !== "").map((s) => {
|
|
647
|
+
const i = s.indexOf("=");
|
|
648
|
+
return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return Object.entries(value).map(([k, v]) => [k, String(v)]);
|
|
652
|
+
}
|
|
653
|
+
function cleanPath(p) {
|
|
654
|
+
const n = posix.normalize(p);
|
|
655
|
+
return n.length > 1 ? n.replace(/\/+$/, "") : n;
|
|
656
|
+
}
|
|
657
|
+
function targetPairs(value) {
|
|
658
|
+
const pairs = typeof value === "string" ? value.split(/\s+/).filter((s) => s !== "").map((s) => {
|
|
659
|
+
const i = s.indexOf("=");
|
|
660
|
+
return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
|
|
661
|
+
}) : Object.entries(value).map(([k, v]) => [k, String(v)]);
|
|
662
|
+
return pairs.map(([k, v]) => [k, v.startsWith("/") ? cleanPath(v) : v]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
663
|
+
}
|
|
664
|
+
function normalizeMount2(spec) {
|
|
665
|
+
const parts = spec.split(":");
|
|
666
|
+
const source = parts[0] ?? "";
|
|
667
|
+
const dir = parts[1] ?? "";
|
|
668
|
+
const rest = parts.slice(2).join(":");
|
|
669
|
+
if (rest === "") return `${source}:${dir}`;
|
|
670
|
+
let ro = false;
|
|
671
|
+
let subpath;
|
|
672
|
+
let chown;
|
|
673
|
+
const others = [];
|
|
674
|
+
for (const token of rest.split(",")) {
|
|
675
|
+
if (token === "ro") ro = true;
|
|
676
|
+
else if (token === "rw") continue;
|
|
677
|
+
else if (token.startsWith("volume-subpath=")) subpath = token;
|
|
678
|
+
else if (token.startsWith("volume-chown=")) chown = token;
|
|
679
|
+
else others.push(token);
|
|
680
|
+
}
|
|
681
|
+
const options = [...ro ? ["ro"] : [], ...others, ...subpath ? [subpath] : [], ...chown ? [chown] : []];
|
|
682
|
+
return options.length ? `${source}:${dir}:${options.join(",")}` : `${source}:${dir}`;
|
|
683
|
+
}
|
|
684
|
+
function mountSet(specs) {
|
|
685
|
+
return [...new Set(specs.map(normalizeMount2))].sort();
|
|
686
|
+
}
|
|
687
|
+
function parseInfoMounts(raw) {
|
|
688
|
+
return mountSet(raw.split(/\s+/).filter((s) => s !== ""));
|
|
689
|
+
}
|
|
690
|
+
|
|
249
691
|
// src/core/schema.ts
|
|
250
|
-
function propertyMap(namespace, keys, value) {
|
|
692
|
+
function propertyMap(namespace, keys, value, scope = "") {
|
|
251
693
|
return z.record(z.string(), value).superRefine((map, ctx) => {
|
|
252
694
|
for (const key of Object.keys(map)) {
|
|
253
695
|
if (!keys.includes(key)) {
|
|
254
|
-
ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set property (valid: ${keys.join(", ")})` });
|
|
696
|
+
ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set${scope} property (valid: ${keys.join(", ")})` });
|
|
255
697
|
}
|
|
256
698
|
}
|
|
257
699
|
});
|
|
258
700
|
}
|
|
259
701
|
var StringOrNumber = z.union([z.string(), z.number()]);
|
|
702
|
+
function withHints(hints, notAnObject) {
|
|
703
|
+
return {
|
|
704
|
+
error: (iss) => {
|
|
705
|
+
if (iss.code === "unrecognized_keys" && iss.keys.some((k) => k in hints)) {
|
|
706
|
+
return iss.keys.map((k) => `'${k}' ${hints[k] ?? "is not a recognized key"}`).join("; ");
|
|
707
|
+
}
|
|
708
|
+
if (iss.code === "invalid_type" && iss.expected === "object") return notAnObject?.(iss.input);
|
|
709
|
+
return void 0;
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
var PER_APP_ONLY = "is a per-app setting (Dokku has no global form); put it under apps.<app>";
|
|
260
714
|
var PortSchema = z.string().regex(
|
|
261
715
|
/^(http|https|tcp|udp):\d+:\d+$/,
|
|
262
716
|
"Port must be scheme:host:container (e.g. http:80:3000)"
|
|
@@ -265,13 +719,17 @@ var EnvMapSchema = z.union([
|
|
|
265
719
|
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
266
720
|
z.literal(false)
|
|
267
721
|
]);
|
|
722
|
+
var WaitToRetire = z.preprocess(
|
|
723
|
+
(v) => typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : v,
|
|
724
|
+
z.number({ error: "wait-to-retire is a number of seconds" }).int().min(0)
|
|
725
|
+
);
|
|
268
726
|
var ChecksSchema = z.union([
|
|
269
727
|
z.literal(false),
|
|
270
728
|
z.object({
|
|
271
729
|
disabled: z.array(z.string()).optional(),
|
|
272
730
|
skipped: z.array(z.string()).optional(),
|
|
273
731
|
// The only `checks:set` key (Dokku 0.38.31); anything else is refused.
|
|
274
|
-
"wait-to-retire":
|
|
732
|
+
"wait-to-retire": WaitToRetire.optional()
|
|
275
733
|
}).strict()
|
|
276
734
|
]);
|
|
277
735
|
var GitSchema = z.object({
|
|
@@ -280,10 +738,88 @@ var GitSchema = z.object({
|
|
|
280
738
|
rev_env_var: z.string().optional(),
|
|
281
739
|
source_image: z.string().optional(),
|
|
282
740
|
// Dokku reads `archive-max-files` and `archive-max-size` only from the global setting
|
|
283
|
-
// (`git:set --global`),
|
|
284
|
-
archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting
|
|
285
|
-
archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting
|
|
741
|
+
// (`git:set --global`), so a per-app value never applies: declare them in the top-level `git:`.
|
|
742
|
+
archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting; declare it in the top-level git:" }),
|
|
743
|
+
archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting; declare it in the top-level git:" })
|
|
744
|
+
}).strict();
|
|
745
|
+
var GlobalGitSchema = z.strictObject({
|
|
746
|
+
deploy_branch: z.string().optional(),
|
|
747
|
+
keep_git_dir: z.union([z.string(), z.boolean()]).optional(),
|
|
748
|
+
archive_max_files: StringOrNumber.optional(),
|
|
749
|
+
archive_max_size: StringOrNumber.optional()
|
|
750
|
+
}, withHints({ rev_env_var: `${PER_APP_ONLY}.git`, source_image: `${PER_APP_ONLY}.git` }));
|
|
751
|
+
var SchedulerSchema = z.union([z.string(), z.object({ selected: z.string().optional(), shell: z.string().optional() }).strict()]);
|
|
752
|
+
var PsSchema = z.object({
|
|
753
|
+
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
754
|
+
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
755
|
+
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
756
|
+
"dockerfile-start-cmd": z.union([z.string(), z.number()]).optional(),
|
|
757
|
+
restore: z.union([z.string(), z.boolean()]).optional(),
|
|
758
|
+
"skip-deploy": z.union([z.string(), z.boolean()]).optional(),
|
|
759
|
+
"start-cmd": z.union([z.string(), z.number()]).optional()
|
|
760
|
+
}).strict();
|
|
761
|
+
var GlobalPsSchema = z.strictObject({
|
|
762
|
+
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
763
|
+
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
764
|
+
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
765
|
+
"skip-deploy": z.union([z.string(), z.boolean()]).optional()
|
|
766
|
+
}, withHints(Object.fromEntries(["restore", "start-cmd", "dockerfile-start-cmd"].map((k) => [k, `${PER_APP_ONLY}.ps`]))));
|
|
767
|
+
var GlobalChecksSchema = z.strictObject({
|
|
768
|
+
// The only `checks:set` key (Dokku 0.38.31), and the only one with a global form.
|
|
769
|
+
"wait-to-retire": WaitToRetire.optional()
|
|
770
|
+
}, withHints({
|
|
771
|
+
disabled: `${PER_APP_ONLY}.checks`,
|
|
772
|
+
skipped: `${PER_APP_ONLY}.checks`
|
|
773
|
+
}, (input) => input === false ? "false disables the checks of one app; put it under apps.<app>.checks" : void 0));
|
|
774
|
+
var BuildSchema = z.object({
|
|
775
|
+
dockerfile: z.string().optional(),
|
|
776
|
+
app_json: z.string().optional(),
|
|
777
|
+
context: z.string().optional(),
|
|
778
|
+
selected: z.string().optional(),
|
|
779
|
+
skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
|
|
780
|
+
args: z.record(z.string(), z.string()).optional()
|
|
286
781
|
}).strict();
|
|
782
|
+
var GlobalBuildSchema = z.strictObject({
|
|
783
|
+
dockerfile: z.string().optional(),
|
|
784
|
+
app_json: z.string().optional(),
|
|
785
|
+
context: z.string().optional(),
|
|
786
|
+
selected: z.string().optional(),
|
|
787
|
+
skip_cleanup: z.union([z.string(), z.boolean()]).optional()
|
|
788
|
+
}, withHints({ args: "has no global form (build args are docker options); put them under apps.<app>.build.args" }));
|
|
789
|
+
var NETWORK_LIST_HINT = "is the list of networks to attach; properties such as tld go under network:";
|
|
790
|
+
var NETWORK_PROPS_HINT = "takes properties (tld, bind_all_interfaces, ...); the list of networks to attach goes under networks:";
|
|
791
|
+
var NetworkSchema = z.strictObject({
|
|
792
|
+
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
793
|
+
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
794
|
+
bind_all_interfaces: z.boolean().optional(),
|
|
795
|
+
static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
|
|
796
|
+
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
797
|
+
}, withHints({
|
|
798
|
+
attach_post_deploy: "is not a network: key; per app, attach-post-deploy is the list under networks:",
|
|
799
|
+
networks: "is a sibling of network:, not a key inside it"
|
|
800
|
+
}, (input) => Array.isArray(input) ? NETWORK_PROPS_HINT : void 0));
|
|
801
|
+
var GlobalNetworkSchema = z.strictObject({
|
|
802
|
+
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
803
|
+
attach_post_deploy: z.array(z.string()).optional(),
|
|
804
|
+
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
805
|
+
bind_all_interfaces: z.boolean().optional(),
|
|
806
|
+
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
807
|
+
}, withHints({
|
|
808
|
+
static_web_listener: `${PER_APP_ONLY}.network`,
|
|
809
|
+
networks: "is the list of shared networks to create; it sits beside network:, not inside it"
|
|
810
|
+
}, (input) => Array.isArray(input) ? "takes global properties (tld, attach_post_deploy, ...); the list of shared networks to create goes under networks:" : void 0));
|
|
811
|
+
var GlobalProxySchema = z.strictObject({
|
|
812
|
+
type: z.string().optional(),
|
|
813
|
+
proxy_port: StringOrNumber.optional(),
|
|
814
|
+
proxy_ssl_port: StringOrNumber.optional()
|
|
815
|
+
}, withHints({ enabled: `${PER_APP_ONLY}.proxy` }));
|
|
816
|
+
var isMap = (input) => !!input && typeof input === "object" && !Array.isArray(input);
|
|
817
|
+
var appNetworksList = z.array(z.string(), {
|
|
818
|
+
error: (iss) => iss.code === "invalid_type" && isMap(iss.input) ? NETWORK_LIST_HINT : void 0
|
|
819
|
+
});
|
|
820
|
+
var sharedNetworksList = z.array(z.string(), {
|
|
821
|
+
error: (iss) => iss.code === "invalid_type" && isMap(iss.input) ? "is the list of shared networks to create; global network properties go under network:" : void 0
|
|
822
|
+
});
|
|
287
823
|
var LinkSchema = z.union([
|
|
288
824
|
z.string(),
|
|
289
825
|
z.object({
|
|
@@ -293,6 +829,59 @@ var LinkSchema = z.union([
|
|
|
293
829
|
querystring: z.string().min(1).optional()
|
|
294
830
|
}).strict().refine((l) => !(l.alias && l.env_var), { message: "`alias` and `env_var` cannot be combined" })
|
|
295
831
|
]);
|
|
832
|
+
var VolumeSchema = z.object({
|
|
833
|
+
// Checked below, so a wrong type gets a message that says how to write it.
|
|
834
|
+
chown: z.union([z.literal(false), z.string(), z.number()]).optional(),
|
|
835
|
+
mode: z.union([z.string(), z.number()]).optional(),
|
|
836
|
+
path: z.string().optional()
|
|
837
|
+
}).strict();
|
|
838
|
+
var VolumesSchema = z.record(z.string(), VolumeSchema).superRefine((volumes, ctx) => {
|
|
839
|
+
const bad = (name, key, message) => ctx.addIssue({ code: "custom", path: key ? [name, key] : [name], message });
|
|
840
|
+
for (const [name, v] of Object.entries(volumes)) {
|
|
841
|
+
if (isLegacyName(name)) {
|
|
842
|
+
if (Object.keys(v).length > 0) bad(name, void 0, `'${name}' starts with 'legacy-', which Dokku reserves for entries it made from old host-path mounts: declare such an entry as {}, or give a new entry another name`);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const nameError = entryNameError(name);
|
|
846
|
+
if (nameError) bad(name, void 0, nameError);
|
|
847
|
+
if (v.mode !== void 0) {
|
|
848
|
+
const e = modeError(v.mode);
|
|
849
|
+
if (e) bad(name, "mode", e);
|
|
850
|
+
}
|
|
851
|
+
if (v.chown !== void 0) {
|
|
852
|
+
const e = chownError(v.chown);
|
|
853
|
+
if (e) bad(name, "chown", e);
|
|
854
|
+
}
|
|
855
|
+
if (v.path !== void 0) {
|
|
856
|
+
const e = pathError(v.path);
|
|
857
|
+
if (e) bad(name, "path", e);
|
|
858
|
+
else if (!usesDefaultLocation(name, v.path)) {
|
|
859
|
+
if (v.mode !== void 0) bad(name, "mode", `mode needs the default location: Dokku refuses it on a custom path. Drop path, or manage '${v.path}' yourself`);
|
|
860
|
+
if (v.chown !== void 0 && v.chown !== false) bad(name, "chown", `chown needs the default location: Dokku refuses it on a custom path. Drop path, use chown: false, or manage '${v.path}' yourself`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
var unsupportedMountKey = (key) => z.any().optional().refine((v) => v === void 0, {
|
|
866
|
+
message: `${key} is not supported: on docker-local Dokku stores it but never applies it`
|
|
867
|
+
});
|
|
868
|
+
var MountObjectSchema = z.object({
|
|
869
|
+
entry: z.string().min(1),
|
|
870
|
+
container_dir: z.string().min(1),
|
|
871
|
+
readonly: z.boolean().optional(),
|
|
872
|
+
phases: z.array(z.enum(["deploy", "run"])).min(1).optional(),
|
|
873
|
+
process_type: z.string().optional(),
|
|
874
|
+
options: z.array(z.string()).optional(),
|
|
875
|
+
subpath: unsupportedMountKey("subpath"),
|
|
876
|
+
volume_chown: unsupportedMountKey("volume_chown")
|
|
877
|
+
}).strict().superRefine((m, ctx) => {
|
|
878
|
+
for (const message of mountIssues(m)) ctx.addIssue({ code: "custom", message });
|
|
879
|
+
});
|
|
880
|
+
var MountStringSchema = z.string().superRefine((s, ctx) => {
|
|
881
|
+
const parsed = parseMountString(s);
|
|
882
|
+
if (typeof parsed === "string") ctx.addIssue({ code: "custom", message: parsed });
|
|
883
|
+
else for (const message of mountIssues(parsed)) ctx.addIssue({ code: "custom", message });
|
|
884
|
+
});
|
|
296
885
|
var AppSchema = z.object({
|
|
297
886
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
298
887
|
links: z.array(LinkSchema).optional(),
|
|
@@ -303,100 +892,270 @@ var AppSchema = z.object({
|
|
|
303
892
|
ssl: z.union([
|
|
304
893
|
z.literal(false),
|
|
305
894
|
z.literal(true),
|
|
306
|
-
z.object({ certfile: z.string(), keyfile: z.string() })
|
|
895
|
+
z.object({ certfile: z.string(), keyfile: z.string() }).strict()
|
|
307
896
|
]).optional(),
|
|
308
|
-
storage: z.array(z.
|
|
897
|
+
storage: z.array(z.union([MountStringSchema, MountObjectSchema])).optional(),
|
|
309
898
|
proxy: z.object({
|
|
310
899
|
enabled: z.boolean().optional(),
|
|
311
900
|
type: z.string().optional(),
|
|
312
901
|
proxy_port: StringOrNumber.optional(),
|
|
313
902
|
proxy_ssl_port: StringOrNumber.optional()
|
|
314
903
|
}).strict().optional(),
|
|
315
|
-
networks:
|
|
316
|
-
network:
|
|
317
|
-
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
318
|
-
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
319
|
-
bind_all_interfaces: z.boolean().optional(),
|
|
320
|
-
static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
|
|
321
|
-
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
322
|
-
}).optional(),
|
|
904
|
+
networks: appNetworksList.optional(),
|
|
905
|
+
network: NetworkSchema.optional(),
|
|
323
906
|
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
|
|
324
907
|
logs: propertyMap("logs", LOGS_KEYS, StringOrNumber).optional(),
|
|
325
908
|
// `auth-servers` is report-only: it is a registry login, not a `registry:set` key.
|
|
326
909
|
registry: propertyMap("registry", REGISTRY_KEYS, z.union([z.string(), z.boolean()])).optional(),
|
|
327
|
-
scheduler:
|
|
910
|
+
scheduler: SchedulerSchema.optional(),
|
|
328
911
|
scale: z.record(z.string(), z.preprocess((v) => typeof v === "string" && /^\s*-?\d+\s*$/.test(v) ? Number(v) : v, z.number().int().min(0))).optional(),
|
|
329
|
-
ps:
|
|
330
|
-
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
331
|
-
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
332
|
-
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional(),
|
|
333
|
-
"dockerfile-start-cmd": z.union([z.string(), z.number()]).optional(),
|
|
334
|
-
restore: z.union([z.string(), z.boolean()]).optional(),
|
|
335
|
-
"skip-deploy": z.union([z.string(), z.boolean()]).optional(),
|
|
336
|
-
"start-cmd": z.union([z.string(), z.number()]).optional()
|
|
337
|
-
}).strict().optional(),
|
|
912
|
+
ps: PsSchema.optional(),
|
|
338
913
|
checks: ChecksSchema.optional(),
|
|
339
|
-
build:
|
|
340
|
-
dockerfile: z.string().optional(),
|
|
341
|
-
app_json: z.string().optional(),
|
|
342
|
-
context: z.string().optional(),
|
|
343
|
-
selected: z.string().optional(),
|
|
344
|
-
skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
|
|
345
|
-
args: z.record(z.string(), z.string()).optional()
|
|
346
|
-
}).optional(),
|
|
914
|
+
build: BuildSchema.optional(),
|
|
347
915
|
docker_options: z.object({
|
|
348
916
|
build: z.array(z.string()).optional(),
|
|
349
917
|
deploy: z.array(z.string()).optional(),
|
|
350
918
|
run: z.array(z.string()).optional()
|
|
351
|
-
}).optional(),
|
|
919
|
+
}).strict().optional(),
|
|
352
920
|
git: GitSchema.optional()
|
|
353
|
-
});
|
|
921
|
+
}).strict();
|
|
922
|
+
var EXPORT_PLACEHOLDER = "REPLACE_ME";
|
|
923
|
+
var notPlaceholder = (v) => v !== EXPORT_PLACEHOLDER;
|
|
924
|
+
var placeholderMessage = `replace the ${EXPORT_PLACEHOLDER} placeholder written by export`;
|
|
925
|
+
var singleLine = (v) => !/[\r\n]/.test(v);
|
|
354
926
|
var ServiceBackupAuthSchema = z.object({
|
|
355
|
-
access_key_id: z.string().min(1),
|
|
356
|
-
secret_access_key: z.string().min(1),
|
|
927
|
+
access_key_id: z.string().min(1).refine(notPlaceholder, placeholderMessage),
|
|
928
|
+
secret_access_key: z.string().min(1).refine(notPlaceholder, placeholderMessage),
|
|
357
929
|
region: z.string().min(1),
|
|
358
930
|
signature_version: z.string().min(1),
|
|
359
931
|
endpoint: z.string().min(1)
|
|
932
|
+
}).strict();
|
|
933
|
+
var ServiceBackupEncryptionSchema = z.object({
|
|
934
|
+
passphrase: z.string().min(1).refine(singleLine, "must be a single line").refine(notPlaceholder, placeholderMessage).optional(),
|
|
935
|
+
public_key_id: z.string().min(1).refine(singleLine, "must be a single line").optional(),
|
|
936
|
+
keyserver: z.string().min(1).refine(singleLine, "must be a single line").optional()
|
|
937
|
+
}).strict().superRefine((enc, ctx) => {
|
|
938
|
+
if (enc.passphrase !== void 0 && enc.public_key_id !== void 0) {
|
|
939
|
+
ctx.addIssue({ code: "custom", message: "name passphrase or public_key_id, not both" });
|
|
940
|
+
} else if (enc.passphrase === void 0 && enc.public_key_id === void 0) {
|
|
941
|
+
ctx.addIssue({ code: "custom", message: "name passphrase or public_key_id (use encryption: false to clear it)" });
|
|
942
|
+
}
|
|
943
|
+
if (enc.keyserver !== void 0 && enc.public_key_id === void 0) {
|
|
944
|
+
ctx.addIssue({ code: "custom", path: ["keyserver"], message: "keyserver goes with public_key_id" });
|
|
945
|
+
}
|
|
360
946
|
});
|
|
947
|
+
var STORAGE_CLASSES = [
|
|
948
|
+
"STANDARD",
|
|
949
|
+
"REDUCED_REDUNDANCY",
|
|
950
|
+
"STANDARD_IA",
|
|
951
|
+
"ONEZONE_IA",
|
|
952
|
+
"INTELLIGENT_TIERING",
|
|
953
|
+
"GLACIER",
|
|
954
|
+
"DEEP_ARCHIVE",
|
|
955
|
+
"GLACIER_IR"
|
|
956
|
+
];
|
|
957
|
+
function objectNameProblem(name) {
|
|
958
|
+
if (name.length > 512) return "must be at most 512 characters";
|
|
959
|
+
if (!/^[A-Za-z0-9._\-/]+$/.test(name)) return "may hold only letters, numbers, dots, dashes, underscores and slashes";
|
|
960
|
+
if (name.startsWith("/") || name.endsWith("/")) return "must not start or end with a slash";
|
|
961
|
+
if (name.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) return "must not have an empty, . or .. segment";
|
|
962
|
+
return void 0;
|
|
963
|
+
}
|
|
361
964
|
var ServiceBackupSchema = z.object({
|
|
362
965
|
schedule: z.string().min(1),
|
|
363
966
|
bucket: z.string().min(1),
|
|
364
|
-
auth: ServiceBackupAuthSchema
|
|
967
|
+
auth: ServiceBackupAuthSchema,
|
|
968
|
+
// false clears the service's encryption; absent leaves it alone
|
|
969
|
+
encryption: z.union([ServiceBackupEncryptionSchema, z.literal(false)]).optional(),
|
|
970
|
+
storage_class: z.enum(STORAGE_CLASSES).optional(),
|
|
971
|
+
object_name: z.string().superRefine((name, ctx) => {
|
|
972
|
+
const problem = objectNameProblem(name);
|
|
973
|
+
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
|
974
|
+
}).optional(),
|
|
975
|
+
mailto: z.string().min(1).regex(/^\S+$/, "must not contain spaces").optional(),
|
|
976
|
+
// the plugin takes false (no timestamp in the object name) or unset
|
|
977
|
+
timestamp: z.literal(false).optional()
|
|
978
|
+
}).strict();
|
|
979
|
+
var PropertyValueSchema = z.union([
|
|
980
|
+
z.string(),
|
|
981
|
+
z.number(),
|
|
982
|
+
z.array(z.union([z.string(), z.number()])),
|
|
983
|
+
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
984
|
+
]);
|
|
985
|
+
var ServicePropertiesSchema = z.record(z.string(), PropertyValueSchema).superRefine((props, ctx) => {
|
|
986
|
+
const issue = (key, message) => ctx.addIssue({ code: "custom", path: [key], message });
|
|
987
|
+
for (const [key, value] of Object.entries(props)) {
|
|
988
|
+
const rejected = rejectedReason(key);
|
|
989
|
+
if (rejected) {
|
|
990
|
+
issue(key, `'${key}' cannot be set here: ${rejected}`);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const kind = MANAGED_PROPERTIES[key]?.kind;
|
|
994
|
+
if (kind === "map") {
|
|
995
|
+
if (Array.isArray(value)) {
|
|
996
|
+
issue(key, `'${key}' takes a map of names to values`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
for (const [name, v] of customEnvPairs(value)) {
|
|
1000
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) issue(key, `'${name}' is not an environment variable name`);
|
|
1001
|
+
if (/[;\r\n]/.test(v)) issue(key, `the value of ${name} must not contain ';' or a newline`);
|
|
1002
|
+
if (v === EXPORT_PLACEHOLDER) issue(key, `the value of ${name}: ${placeholderMessage}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (typeof value === "string" && /[\r\n]/.test(value)) issue(key, `'${key}' must be a single line`);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (kind === "targets") {
|
|
1008
|
+
if (Array.isArray(value)) {
|
|
1009
|
+
issue(key, `'${key}' takes a map of volumes to container paths`);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const [volume, target] of targetPairs(value)) {
|
|
1014
|
+
if (volume === "" || target === "") {
|
|
1015
|
+
issue(key, `'${volume}' needs a container path (<volume>=<container-path>)`);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (seen.has(volume)) issue(key, `the volume ${volume} is moved more than once`);
|
|
1019
|
+
seen.add(volume);
|
|
1020
|
+
if (!target.startsWith("/")) issue(key, `the container path of ${volume} must be absolute`);
|
|
1021
|
+
else if (/[:,\s]/.test(target)) issue(key, `the container path of ${volume} must not contain a colon, a comma or whitespace`);
|
|
1022
|
+
else if (cleanPath(target) === "/") issue(key, `the volume ${volume} must not be mounted at /`);
|
|
1023
|
+
}
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (kind === "list") {
|
|
1027
|
+
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : void 0;
|
|
1028
|
+
if (!items) {
|
|
1029
|
+
issue(key, `'${key}' takes a list`);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (items.some((i) => String(i).trim() === "" || /[,\s]/.test(String(i).trim()))) issue(key, `'${key}' entries must not be empty or contain commas or spaces`);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof value === "object") {
|
|
1036
|
+
issue(key, `'${key}' takes a string or number`);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const text = String(value);
|
|
1040
|
+
if (text.trim() === "") {
|
|
1041
|
+
issue(key, `'${key}' is empty: leave the key out`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (/[\r\n]/.test(text)) {
|
|
1045
|
+
issue(key, `'${key}' must be a single line`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (kind === "secret") {
|
|
1049
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(text)) issue(key, `'${key}' may hold only letters, digits, '.', '_', '~' and '-'`);
|
|
1050
|
+
if (text === EXPORT_PLACEHOLDER) issue(key, placeholderMessage);
|
|
1051
|
+
} else if (key === "memory") {
|
|
1052
|
+
if (!/^\d+$/.test(text.trim())) issue(key, "memory is a whole number of megabytes (0 for no limit)");
|
|
1053
|
+
} else if (key === "wait-timeout") {
|
|
1054
|
+
if (!/^\d+$/.test(text.trim()) || Number(text) < 1) issue(key, "wait-timeout is a whole number of seconds greater than 0");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
365
1057
|
});
|
|
1058
|
+
var ServiceMountObjectSchema = z.object({
|
|
1059
|
+
entry: z.string().min(1),
|
|
1060
|
+
container_dir: z.string().min(1),
|
|
1061
|
+
readonly: z.boolean().optional(),
|
|
1062
|
+
options: z.array(z.string()).optional()
|
|
1063
|
+
}).strict().superRefine((m, ctx) => {
|
|
1064
|
+
for (const message of serviceMountIssues(m)) ctx.addIssue({ code: "custom", message });
|
|
1065
|
+
});
|
|
1066
|
+
var ServiceMountStringSchema = z.string().superRefine((s, ctx) => {
|
|
1067
|
+
const parsed = parseServiceMountString(s);
|
|
1068
|
+
if (typeof parsed === "string") ctx.addIssue({ code: "custom", message: parsed });
|
|
1069
|
+
else for (const message of serviceMountIssues(parsed)) ctx.addIssue({ code: "custom", message });
|
|
1070
|
+
});
|
|
1071
|
+
var ServiceMountsSchema = z.array(z.union([ServiceMountStringSchema, ServiceMountObjectSchema]));
|
|
366
1072
|
var PostgresSchema = z.object({
|
|
367
1073
|
version: z.string().optional(),
|
|
368
1074
|
image: z.string().optional(),
|
|
369
|
-
// false removes the service's backup schedule and
|
|
370
|
-
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional()
|
|
371
|
-
|
|
1075
|
+
// false removes the service's backup schedule, credentials, encryption and settings; absent leaves them alone
|
|
1076
|
+
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional(),
|
|
1077
|
+
// the plugin's `:set` keys; absent leaves them alone, `--prune` clears the known ones removed from the file
|
|
1078
|
+
properties: ServicePropertiesSchema.optional(),
|
|
1079
|
+
// the whole set of mounts; absent leaves them alone, [] removes them all
|
|
1080
|
+
mounts: ServiceMountsSchema.optional()
|
|
1081
|
+
}).strict();
|
|
372
1082
|
var RedisSchema = z.object({
|
|
373
1083
|
version: z.string().optional(),
|
|
374
|
-
image: z.string().optional()
|
|
375
|
-
|
|
1084
|
+
image: z.string().optional(),
|
|
1085
|
+
// same blocks as postgres
|
|
1086
|
+
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional(),
|
|
1087
|
+
properties: ServicePropertiesSchema.optional(),
|
|
1088
|
+
mounts: ServiceMountsSchema.optional()
|
|
1089
|
+
}).strict();
|
|
376
1090
|
var PluginSchema = z.object({
|
|
377
1091
|
url: z.string().url(),
|
|
378
1092
|
version: z.string().optional()
|
|
379
|
-
});
|
|
1093
|
+
}).strict();
|
|
380
1094
|
var DockerAuthEntrySchema = z.object({
|
|
381
1095
|
username: z.string().min(1),
|
|
382
1096
|
password: z.string().min(1)
|
|
383
|
-
});
|
|
1097
|
+
}).strict();
|
|
384
1098
|
var ConfigSchema = z.object({
|
|
385
1099
|
dokku: z.object({
|
|
386
|
-
version: z.string().optional()
|
|
387
|
-
}).optional(),
|
|
1100
|
+
version: z.string({ error: 'dokku.version must be a quoted string starting with X.Y.Z, e.g. "0.38.31"' }).regex(SEMVER_RE, 'dokku.version must start with X.Y.Z, e.g. "0.38.31"').optional()
|
|
1101
|
+
}).strict().optional(),
|
|
388
1102
|
plugins: z.record(z.string(), PluginSchema).optional(),
|
|
389
1103
|
docker_auth: z.record(z.string(), DockerAuthEntrySchema).optional(),
|
|
390
|
-
networks:
|
|
1104
|
+
networks: sharedNetworksList.optional(),
|
|
1105
|
+
volumes: VolumesSchema.optional(),
|
|
391
1106
|
postgres: z.record(z.string(), PostgresSchema).optional(),
|
|
392
1107
|
redis: z.record(z.string(), RedisSchema).optional(),
|
|
393
1108
|
apps: z.record(z.string(), AppSchema),
|
|
394
1109
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
395
1110
|
env: EnvMapSchema.optional(),
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
logs:
|
|
399
|
-
|
|
1111
|
+
// The top-level blocks below are Dokku's own `--global` settings (`<ns>:set --global`); Dokku does the inheritance.
|
|
1112
|
+
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber, " --global").optional(),
|
|
1113
|
+
// Also takes the two keys only `logs:set --global` accepts.
|
|
1114
|
+
logs: propertyMap("logs", LOGS_GLOBAL_KEYS, StringOrNumber, " --global").optional(),
|
|
1115
|
+
scheduler: SchedulerSchema.optional(),
|
|
1116
|
+
// `image-repo` names one app's repository: per app only, like the report-only `auth-servers`.
|
|
1117
|
+
registry: propertyMap("registry", REGISTRY_GLOBAL_KEYS, z.union([z.string(), z.boolean()]), " --global").optional(),
|
|
1118
|
+
checks: GlobalChecksSchema.optional(),
|
|
1119
|
+
ps: GlobalPsSchema.optional(),
|
|
1120
|
+
build: GlobalBuildSchema.optional(),
|
|
1121
|
+
network: GlobalNetworkSchema.optional(),
|
|
1122
|
+
proxy: GlobalProxySchema.optional(),
|
|
1123
|
+
git: GlobalGitSchema.optional()
|
|
1124
|
+
}).strict().superRefine((config, ctx) => {
|
|
1125
|
+
for (const plugin of ["postgres", "redis"]) {
|
|
1126
|
+
for (const [svc, svcConfig] of Object.entries(config[plugin] ?? {})) {
|
|
1127
|
+
const mounts = (svcConfig.mounts ?? []).flatMap((item, index) => {
|
|
1128
|
+
const [mount] = declaredServiceMounts([item]);
|
|
1129
|
+
return mount ? [{ mount, index }] : [];
|
|
1130
|
+
});
|
|
1131
|
+
mounts.forEach(({ mount: m, index }, i) => {
|
|
1132
|
+
const where = [plugin, svc, "mounts", index];
|
|
1133
|
+
if (!config.volumes || !(m.entry in config.volumes)) {
|
|
1134
|
+
ctx.addIssue({ code: "custom", path: where, message: `entry '${m.entry}' is not declared under volumes: (a host directory is an entry's path:)` });
|
|
1135
|
+
}
|
|
1136
|
+
if (mounts.slice(0, i).some(({ mount: earlier }) => earlier.container_dir === m.container_dir)) {
|
|
1137
|
+
ctx.addIssue({ code: "custom", path: where, message: `container_dir '${m.container_dir}' is mounted twice` });
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const [app, appConfig] of Object.entries(config.apps)) {
|
|
1143
|
+
const mounts = (appConfig.storage ?? []).flatMap((item, index) => {
|
|
1144
|
+
const [mount] = declaredMounts([item]);
|
|
1145
|
+
return mount ? [{ mount, index }] : [];
|
|
1146
|
+
});
|
|
1147
|
+
mounts.forEach(({ mount: m, index }, i) => {
|
|
1148
|
+
const where = ["apps", app, "storage", index];
|
|
1149
|
+
if (!config.volumes || !(m.entry in config.volumes)) {
|
|
1150
|
+
ctx.addIssue({ code: "custom", path: where, message: `entry '${m.entry}' is not declared under volumes:` });
|
|
1151
|
+
}
|
|
1152
|
+
mounts.slice(0, i).forEach(({ mount: earlier }) => {
|
|
1153
|
+
if (earlier.container_dir !== m.container_dir || !scopesOverlap(earlier.process_type, m.process_type)) return;
|
|
1154
|
+
const how = earlier.process_type === m.process_type ? `twice in ${m.process_type === DEFAULT_SCOPE ? "every process" : `process type ${m.process_type}`}` : "in the default scope and in a named process type, which Dokku forbids";
|
|
1155
|
+
ctx.addIssue({ code: "custom", path: where, message: `container_dir '${m.container_dir}' is mounted ${how}` });
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
400
1159
|
});
|
|
401
1160
|
function parseConfig(raw) {
|
|
402
1161
|
return ConfigSchema.parse(raw);
|
|
@@ -450,39 +1209,107 @@ import * as os from "os";
|
|
|
450
1209
|
import * as path from "path";
|
|
451
1210
|
|
|
452
1211
|
// src/core/mask.ts
|
|
453
|
-
var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)/i;
|
|
454
|
-
|
|
455
|
-
|
|
1212
|
+
var SENSITIVE_RE = /(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)/i;
|
|
1213
|
+
var NOT_SECRET_KEYS = /* @__PURE__ */ new Set(["public_key_id", "keyserver"]);
|
|
1214
|
+
var PLACEHOLDER = "REPLACE_ME";
|
|
1215
|
+
var OPAQUE_RE = /PASSPHRASE/i;
|
|
1216
|
+
function maskValue(value, opaque = false) {
|
|
1217
|
+
if (opaque || value.length <= 4) return "****";
|
|
456
1218
|
return `****${value.slice(-4)}`;
|
|
457
1219
|
}
|
|
1220
|
+
var WORD = String.raw`(?:'[^']*'|\\.|[^\s\\])+`;
|
|
1221
|
+
function unshell(word) {
|
|
1222
|
+
return /^'.*'$/s.test(word) ? word.slice(1, -1).replace(/'\\''/g, "'") : word;
|
|
1223
|
+
}
|
|
1224
|
+
function maskCustomEnv(value) {
|
|
1225
|
+
return value.split(";").filter((p) => p !== "").map((p) => {
|
|
1226
|
+
const i = p.indexOf("=");
|
|
1227
|
+
return i === -1 ? "****" : `${p.slice(0, i)}=****`;
|
|
1228
|
+
}).join(";");
|
|
1229
|
+
}
|
|
458
1230
|
function maskSensitiveArgs(cmd) {
|
|
459
|
-
|
|
460
|
-
|
|
1231
|
+
const original = cmd;
|
|
1232
|
+
cmd = cmd.replace(/^(\S+:set\s+(?:--no-restart\s+)?\S+\s+password\s+(?:--\s+)?).+$/s, (_, prefix) => `${prefix}****`);
|
|
1233
|
+
cmd = cmd.replace(/^(\S+:set\s+(?:--no-restart\s+)?\S+\s+custom-env\s+(?:--\s+)?)(.+)$/s, (_, prefix, rest) => `${prefix}${maskCustomEnv(unshell(rest))}`);
|
|
1234
|
+
cmd = cmd.replace(new RegExp(String.raw`^(\S+:create\s.*?\s--password\s+)(${WORD})`, "s"), (_, prefix) => `${prefix}****`);
|
|
1235
|
+
cmd = cmd.replace(new RegExp(String.raw`^(\S+:create\s.*?\s--custom-env\s+)(${WORD})`, "s"), (_, prefix, word) => `${prefix}${maskCustomEnv(unshell(word))}`);
|
|
1236
|
+
if (cmd !== original) return cmd;
|
|
1237
|
+
let masked = cmd.replace(new RegExp(String.raw`'([^\s='\\]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s='\\]*)=((?:[^']|'\\'')*)'`, "gi"), (_, key, value) => {
|
|
1238
|
+
return `${key}=${maskValue(value.replace(/'\\''/g, "'"), OPAQUE_RE.test(key))}`;
|
|
1239
|
+
});
|
|
1240
|
+
masked = masked.replace(new RegExp(String.raw`([^\s=]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s=]*)=(${WORD})`, "gi"), (_, key, value) => {
|
|
1241
|
+
return `${key}=${maskValue(value, OPAQUE_RE.test(key))}`;
|
|
461
1242
|
});
|
|
462
1243
|
masked = masked.replace(
|
|
463
|
-
/^(
|
|
1244
|
+
/^(\S+:set\s+\S+\s+backup-encryption-passphrase\s+).+$/s,
|
|
1245
|
+
(_, prefix) => `${prefix}****`
|
|
1246
|
+
);
|
|
1247
|
+
masked = masked.replace(
|
|
1248
|
+
/^(\S+:backup-set-encryption\s+\S+\s+).+$/s,
|
|
1249
|
+
(_, prefix) => `${prefix}****`
|
|
1250
|
+
);
|
|
1251
|
+
masked = masked.replace(
|
|
1252
|
+
new RegExp(String.raw`^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(${WORD})`),
|
|
464
1253
|
(_, prefix, password) => `${prefix}${maskValue(password)}`
|
|
465
1254
|
);
|
|
466
1255
|
masked = masked.replace(
|
|
467
|
-
|
|
1256
|
+
new RegExp(String.raw`^(\S+:backup-auth\s+\S+\s+)(${WORD})(\s+)(${WORD})`),
|
|
468
1257
|
(_, prefix, accessKeyId, sep, secretAccessKey) => `${prefix}${maskValue(accessKeyId)}${sep}${maskValue(secretAccessKey)}`
|
|
469
1258
|
);
|
|
470
1259
|
return masked;
|
|
471
1260
|
}
|
|
472
1261
|
function maskSensitiveArgsList(args) {
|
|
473
1262
|
const secrets = [];
|
|
1263
|
+
const opaque = [];
|
|
474
1264
|
const masked = [...args];
|
|
475
|
-
const mark = (index) => {
|
|
1265
|
+
const mark = (index, completely = false) => {
|
|
476
1266
|
const value = masked[index];
|
|
477
1267
|
if (value === void 0 || value === "") return;
|
|
478
1268
|
secrets.push(value);
|
|
479
|
-
|
|
1269
|
+
if (completely) opaque.push(value);
|
|
1270
|
+
masked[index] = maskValue(value, completely);
|
|
1271
|
+
};
|
|
1272
|
+
const handled = /* @__PURE__ */ new Set();
|
|
1273
|
+
const markCustomEnv = (index) => {
|
|
1274
|
+
handled.add(index);
|
|
1275
|
+
const value = masked[index];
|
|
1276
|
+
if (value === void 0 || value === "") return;
|
|
1277
|
+
for (const part of value.split(";")) {
|
|
1278
|
+
const v = part.slice(part.indexOf("=") + 1);
|
|
1279
|
+
if (v.length >= 3) {
|
|
1280
|
+
secrets.push(v);
|
|
1281
|
+
opaque.push(v);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
masked[index] = maskCustomEnv(value);
|
|
480
1285
|
};
|
|
1286
|
+
if (/^\S+:set$/.test(masked[0] ?? "")) {
|
|
1287
|
+
const name = masked[1] === "--no-restart" ? 2 : 1;
|
|
1288
|
+
const key = masked[name + 1];
|
|
1289
|
+
const valueAt = masked[name + 2] === "--" ? name + 3 : name + 2;
|
|
1290
|
+
if (key === "password") {
|
|
1291
|
+
handled.add(valueAt);
|
|
1292
|
+
mark(valueAt, true);
|
|
1293
|
+
}
|
|
1294
|
+
if (key === "custom-env") markCustomEnv(valueAt);
|
|
1295
|
+
}
|
|
1296
|
+
if (/^\S+:create$/.test(masked[0] ?? "")) {
|
|
1297
|
+
for (let i = 1; i < masked.length - 1; i++) {
|
|
1298
|
+
if (masked[i] === "--password") {
|
|
1299
|
+
handled.add(i + 1);
|
|
1300
|
+
mark(i + 1, true);
|
|
1301
|
+
}
|
|
1302
|
+
if (masked[i] === "--custom-env") markCustomEnv(i + 1);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
481
1305
|
for (let i = 0; i < masked.length; i++) {
|
|
482
|
-
|
|
1306
|
+
if (handled.has(i)) continue;
|
|
1307
|
+
const m = masked[i].match(/^([^\s=]*(?:TOKEN|SECRET|PASSWORD|PASSPHRASE|KEY|AUTH|CREDENTIAL)[^\s=]*)=(.+)$/i);
|
|
483
1308
|
if (m) {
|
|
484
1309
|
secrets.push(m[2]);
|
|
485
|
-
|
|
1310
|
+
const completely = OPAQUE_RE.test(m[1]);
|
|
1311
|
+
if (completely) opaque.push(m[2]);
|
|
1312
|
+
masked[i] = `${m[1]}=${maskValue(m[2], completely)}`;
|
|
486
1313
|
}
|
|
487
1314
|
}
|
|
488
1315
|
if (masked[0] === "registry:login" && !masked.includes("--password-stdin")) {
|
|
@@ -492,13 +1319,15 @@ function maskSensitiveArgsList(args) {
|
|
|
492
1319
|
mark(2);
|
|
493
1320
|
mark(3);
|
|
494
1321
|
}
|
|
495
|
-
|
|
1322
|
+
if (/^\S+:set$/.test(masked[0] ?? "") && masked[2] === "backup-encryption-passphrase") mark(3, true);
|
|
1323
|
+
if (/^\S+:backup-set-encryption$/.test(masked[0] ?? "")) mark(2, true);
|
|
1324
|
+
return { masked, secrets, opaque };
|
|
496
1325
|
}
|
|
497
|
-
function maskSecretsInText(text, secrets) {
|
|
1326
|
+
function maskSecretsInText(text, secrets, opaque = []) {
|
|
498
1327
|
let masked = text;
|
|
499
1328
|
for (const secret of secrets) {
|
|
500
1329
|
if (!secret) continue;
|
|
501
|
-
masked = masked.split(secret).join(maskValue(secret));
|
|
1330
|
+
masked = masked.split(secret).join(maskValue(secret, opaque.includes(secret)));
|
|
502
1331
|
}
|
|
503
1332
|
return masked;
|
|
504
1333
|
}
|
|
@@ -509,8 +1338,16 @@ function maskSensitiveData(data) {
|
|
|
509
1338
|
if (typeof data === "object") {
|
|
510
1339
|
const result = {};
|
|
511
1340
|
for (const [key, value] of Object.entries(data)) {
|
|
512
|
-
if (
|
|
513
|
-
result[key] =
|
|
1341
|
+
if (NOT_SECRET_KEYS.has(key.toLowerCase()) || value === PLACEHOLDER) {
|
|
1342
|
+
result[key] = value;
|
|
1343
|
+
} else if (key === "custom-env" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1344
|
+
result[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, v === PLACEHOLDER ? v : "****"]));
|
|
1345
|
+
} else if (key === "properties" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1346
|
+
const props = maskSensitiveData(value);
|
|
1347
|
+
if (typeof props.password === "string" && props.password !== PLACEHOLDER) props.password = "****";
|
|
1348
|
+
result[key] = props;
|
|
1349
|
+
} else if (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
|
|
1350
|
+
result[key] = maskValue(value, OPAQUE_RE.test(key));
|
|
514
1351
|
} else if (typeof value === "object" && value !== null) {
|
|
515
1352
|
result[key] = maskSensitiveData(value);
|
|
516
1353
|
} else {
|
|
@@ -532,9 +1369,9 @@ function maskQuerystring(qs) {
|
|
|
532
1369
|
// src/core/dokku.ts
|
|
533
1370
|
var DokkuCommandError = class extends Error {
|
|
534
1371
|
constructor(args, exitCode, stderr) {
|
|
535
|
-
const { masked, secrets } = maskSensitiveArgsList(args);
|
|
1372
|
+
const { masked, secrets, opaque } = maskSensitiveArgsList(args);
|
|
536
1373
|
const maskedCmd = masked.join(" ");
|
|
537
|
-
const stderrTail = maskSecretsInText(stderr.trim(), secrets);
|
|
1374
|
+
const stderrTail = maskSecretsInText(stderr.trim(), secrets, opaque);
|
|
538
1375
|
super(
|
|
539
1376
|
`dokku ${maskedCmd} failed (exit ${exitCode})` + (stderrTail ? `: ${stderrTail}` : "")
|
|
540
1377
|
);
|
|
@@ -574,7 +1411,7 @@ function createRunner(opts = {}) {
|
|
|
574
1411
|
dryRunLog: log,
|
|
575
1412
|
async run(...args) {
|
|
576
1413
|
if (opts.dryRun) {
|
|
577
|
-
log.push(args.map(
|
|
1414
|
+
log.push(args.map(shellQuote).join(" "));
|
|
578
1415
|
return;
|
|
579
1416
|
}
|
|
580
1417
|
const { ok, exitCode, stderr } = await execDokku(args);
|
|
@@ -601,7 +1438,7 @@ function createRunner(opts = {}) {
|
|
|
601
1438
|
},
|
|
602
1439
|
async runWithInput(input, ...args) {
|
|
603
1440
|
if (opts.dryRun) {
|
|
604
|
-
log.push(args.map(
|
|
1441
|
+
log.push(args.map(shellQuote).join(" "));
|
|
605
1442
|
return;
|
|
606
1443
|
}
|
|
607
1444
|
const { ok, exitCode, stderr } = await execDokku(args, input);
|
|
@@ -623,11 +1460,19 @@ function createContext(runner) {
|
|
|
623
1460
|
const commands = [];
|
|
624
1461
|
const restarts = /* @__PURE__ */ new Map();
|
|
625
1462
|
const rebuilds = /* @__PURE__ */ new Map();
|
|
1463
|
+
const serviceRestarts = /* @__PURE__ */ new Map();
|
|
626
1464
|
return {
|
|
627
1465
|
commands,
|
|
628
1466
|
runner,
|
|
629
1467
|
restarts,
|
|
630
1468
|
rebuilds,
|
|
1469
|
+
serviceRestarts,
|
|
1470
|
+
needsServiceRestart(plugin, service, reason) {
|
|
1471
|
+
const key = `${plugin}:${service}`;
|
|
1472
|
+
const entry = serviceRestarts.get(key) ?? { plugin, service, reasons: [] };
|
|
1473
|
+
if (!entry.reasons.includes(reason)) entry.reasons.push(reason);
|
|
1474
|
+
serviceRestarts.set(key, entry);
|
|
1475
|
+
},
|
|
631
1476
|
needsRebuild(app, reason) {
|
|
632
1477
|
const reasons = rebuilds.get(app) ?? [];
|
|
633
1478
|
if (!reasons.includes(reason)) reasons.push(reason);
|
|
@@ -724,10 +1569,10 @@ function computeAdditiveChange(before, after) {
|
|
|
724
1569
|
}
|
|
725
1570
|
|
|
726
1571
|
// src/core/reconcile.ts
|
|
727
|
-
async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
|
|
1572
|
+
async function reconcile(resource, ctx, target, desired, onRemovals, prune = false, inherited) {
|
|
728
1573
|
const canPrune = prune && !!resource.prune && !!resource.removals;
|
|
729
1574
|
const pruneFrom = async (current) => {
|
|
730
|
-
const removed = resource.removals(current, desired);
|
|
1575
|
+
const removed = resource.removals(current, desired, inherited);
|
|
731
1576
|
if (removed.length === 0) return;
|
|
732
1577
|
logAction(target, `${resource.key} (prune)`);
|
|
733
1578
|
await resource.prune(ctx, target, removed, current, desired);
|
|
@@ -752,14 +1597,14 @@ async function reconcile(resource, ctx, target, desired, onRemovals, prune = fal
|
|
|
752
1597
|
await resource.onChange(ctx, target, { before, after: desired, changed: true });
|
|
753
1598
|
logDone();
|
|
754
1599
|
}
|
|
755
|
-
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
1600
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired, inherited));
|
|
756
1601
|
if (canPrune) await pruneFrom(before);
|
|
757
1602
|
return;
|
|
758
1603
|
}
|
|
759
|
-
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
1604
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired, inherited));
|
|
760
1605
|
if (canPrune) {
|
|
761
1606
|
const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
|
|
762
|
-
const pending = resource.removals(before, desired).length > 0;
|
|
1607
|
+
const pending = resource.removals(before, desired, inherited).length > 0;
|
|
763
1608
|
if (change2.changed) {
|
|
764
1609
|
await resource.onChange(ctx, target, change2, pending);
|
|
765
1610
|
logDone();
|
|
@@ -865,33 +1710,328 @@ var Domains = {
|
|
|
865
1710
|
await ctx.run("domains:set", target, ...after);
|
|
866
1711
|
}
|
|
867
1712
|
};
|
|
868
|
-
|
|
869
|
-
|
|
1713
|
+
|
|
1714
|
+
// src/modules/volumes.ts
|
|
1715
|
+
var DOCKER_LOCAL = "docker-local";
|
|
1716
|
+
async function readEntries(ctx) {
|
|
1717
|
+
const raw = await ctx.queryStrict("storage:list-entries", "--format", "json");
|
|
1718
|
+
let parsed;
|
|
1719
|
+
try {
|
|
1720
|
+
parsed = JSON.parse(raw);
|
|
1721
|
+
} catch {
|
|
1722
|
+
throw new Error(`storage:list-entries --format json did not return JSON: ${raw.slice(0, 80)}`);
|
|
1723
|
+
}
|
|
1724
|
+
if (!Array.isArray(parsed)) throw new Error("storage:list-entries --format json did not return a list");
|
|
1725
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1726
|
+
for (const e of parsed) entries.set(e.name, e);
|
|
1727
|
+
return entries;
|
|
1728
|
+
}
|
|
1729
|
+
function desiredEntry(name, cfg) {
|
|
1730
|
+
if (isLegacyName(name)) return { name, legacy: true };
|
|
1731
|
+
const atDefault = usesDefaultLocation(name, cfg.path);
|
|
1732
|
+
return {
|
|
1733
|
+
name,
|
|
1734
|
+
legacy: false,
|
|
1735
|
+
path: atDefault ? void 0 : cfg.path,
|
|
1736
|
+
// Dokku manages owner and mode only at the default location; there an absent chown means herokuish.
|
|
1737
|
+
chown: atDefault ? cfg.chown === void 0 ? DEFAULT_CHOWN : chownValue(cfg.chown) : void 0,
|
|
1738
|
+
mode: cfg.mode === void 0 ? void 0 : normalizeMode(String(cfg.mode))
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
function desiredEntries(volumes) {
|
|
1742
|
+
return Object.entries(volumes ?? {}).map(([name, cfg]) => desiredEntry(name, cfg));
|
|
1743
|
+
}
|
|
1744
|
+
function hostPathOf(d) {
|
|
1745
|
+
return d.path ?? defaultPath(d.name);
|
|
1746
|
+
}
|
|
1747
|
+
function entryState(d, server) {
|
|
1748
|
+
if (!server) return { kind: "missing" };
|
|
1749
|
+
if (server.scheduler !== DOCKER_LOCAL) return { kind: "scheduler", scheduler: server.scheduler };
|
|
1750
|
+
if (d.legacy) return { kind: "ok" };
|
|
1751
|
+
const want = hostPathOf(d);
|
|
1752
|
+
if ((server.host_path ?? "") !== want) return { kind: "path", server: server.host_path ?? "", want };
|
|
1753
|
+
const chownMatches = d.chown === "false" ? (server.chown ?? "") === "" || server.chown === "false" : (server.chown ?? "") === d.chown;
|
|
1754
|
+
const chown = d.chown !== void 0 && !chownMatches ? { server: server.chown ?? "", want: d.chown } : void 0;
|
|
1755
|
+
const mode = d.mode !== void 0 && (server.mode ?? "") !== d.mode ? { server: server.mode ?? "", want: d.mode } : void 0;
|
|
1756
|
+
return chown || mode ? { kind: "changed", ...chown ? { chown } : {}, ...mode ? { mode } : {} } : { kind: "ok" };
|
|
1757
|
+
}
|
|
1758
|
+
function stateError(d, state) {
|
|
1759
|
+
switch (state.kind) {
|
|
1760
|
+
case "scheduler":
|
|
1761
|
+
return `entry '${d.name}' exists on the server with scheduler ${state.scheduler}; dokku-compose manages only docker-local entries`;
|
|
1762
|
+
case "path":
|
|
1763
|
+
return `entry '${d.name}' is at ${state.server || "(no path)"} on the server but the file says ${state.want}; Dokku cannot change an entry's path. Give the entry a new name in the file, or correct path`;
|
|
1764
|
+
case "missing":
|
|
1765
|
+
return d.legacy ? `entry '${d.name}' is not on the server; a legacy- entry is made by Dokku, never created here` : void 0;
|
|
1766
|
+
default:
|
|
1767
|
+
return void 0;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
function createArgs(d) {
|
|
1771
|
+
const args = ["storage:create", d.name];
|
|
1772
|
+
if (d.path !== void 0) args.push(d.path);
|
|
1773
|
+
if (d.chown !== void 0) args.push("--chown", d.chown);
|
|
1774
|
+
if (d.mode !== void 0) args.push("--mode", d.mode);
|
|
1775
|
+
return args;
|
|
1776
|
+
}
|
|
1777
|
+
var planned = /* @__PURE__ */ new WeakMap();
|
|
1778
|
+
function plannedHostPaths(ctx) {
|
|
1779
|
+
return planned.get(ctx) ?? /* @__PURE__ */ new Map();
|
|
1780
|
+
}
|
|
1781
|
+
async function ensureVolumes(ctx, volumes, failFast = false) {
|
|
1782
|
+
const failed = /* @__PURE__ */ new Map();
|
|
1783
|
+
const server = await readEntries(ctx);
|
|
1784
|
+
for (const d of desiredEntries(volumes)) {
|
|
1785
|
+
logAction("volumes", `Ensuring ${d.name}`);
|
|
1786
|
+
try {
|
|
1787
|
+
const state = entryState(d, server.get(d.name));
|
|
1788
|
+
const problem = stateError(d, state);
|
|
1789
|
+
if (problem) throw new Error(problem);
|
|
1790
|
+
if (state.kind === "missing") {
|
|
1791
|
+
await ctx.run(...createArgs(d));
|
|
1792
|
+
planned.set(ctx, new Map([...plannedHostPaths(ctx), [d.name, hostPathOf(d)]]));
|
|
1793
|
+
logDone();
|
|
1794
|
+
} else if (state.kind === "changed") {
|
|
1795
|
+
if (state.chown) await ctx.run("storage:set", d.name, "chown", state.chown.want);
|
|
1796
|
+
if (state.mode) await ctx.run("storage:set", d.name, "mode", state.mode.want);
|
|
1797
|
+
logDone();
|
|
1798
|
+
} else {
|
|
1799
|
+
logSkip();
|
|
1800
|
+
}
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1803
|
+
console.log("");
|
|
1804
|
+
logError("volumes", message);
|
|
1805
|
+
if (failFast) throw err;
|
|
1806
|
+
failed.set(d.name, message);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return failed;
|
|
1810
|
+
}
|
|
1811
|
+
function sanitizeName(raw) {
|
|
1812
|
+
const trim = (n2) => n2.replace(/^-+|-+$/g, "");
|
|
1813
|
+
let n = trim(raw.toLowerCase().replace(/[^a-z0-9]+/g, "-"));
|
|
1814
|
+
while (n.startsWith(LEGACY_PREFIX)) n = trim(n.slice(LEGACY_PREFIX.length));
|
|
1815
|
+
return n.slice(0, MAX_ENTRY_NAME).replace(/-+$/, "");
|
|
1816
|
+
}
|
|
1817
|
+
function legacyExportNames(legacy, taken) {
|
|
1818
|
+
const names = /* @__PURE__ */ new Map();
|
|
1819
|
+
for (const e of [...legacy].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1820
|
+
const parts = (e.host_path ?? "").split("/").filter(Boolean);
|
|
1821
|
+
const last = parts[parts.length - 1] ?? "";
|
|
1822
|
+
const two = parts.length > 1 ? `${parts[parts.length - 2]}-${last}` : "";
|
|
1823
|
+
const candidates = [sanitizeName(last), sanitizeName(two)].filter((n) => n !== "" && isValidEntryName(n));
|
|
1824
|
+
let chosen = candidates.find((n) => !taken.has(n)) ?? null;
|
|
1825
|
+
if (chosen === null && candidates.length > 0) {
|
|
1826
|
+
for (let i = 2; i < 100 && chosen === null; i++) {
|
|
1827
|
+
const suffix = `-${i}`;
|
|
1828
|
+
const base = candidates[0].slice(0, MAX_ENTRY_NAME - suffix.length).replace(/-+$/, "");
|
|
1829
|
+
if (!taken.has(base + suffix)) chosen = base + suffix;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
if (chosen !== null) taken.add(chosen);
|
|
1833
|
+
names.set(e.name, chosen);
|
|
1834
|
+
}
|
|
1835
|
+
return names;
|
|
1836
|
+
}
|
|
1837
|
+
function exportedChown(stored) {
|
|
1838
|
+
if (stored === void 0 || stored === "" || stored === "false") return false;
|
|
1839
|
+
return /^\d+$/.test(stored) ? Number(stored) : stored;
|
|
1840
|
+
}
|
|
1841
|
+
function exportVolumes(server, mounted, onlyMounted = false) {
|
|
1842
|
+
const volumes = {};
|
|
1843
|
+
const names = /* @__PURE__ */ new Map();
|
|
1844
|
+
const dockerLocal = [...server.values()].filter((e) => e.scheduler === DOCKER_LOCAL);
|
|
1845
|
+
const describe = (name, e) => {
|
|
1846
|
+
const atDefault = (e.host_path ?? "") === defaultPath(name);
|
|
1847
|
+
if (!atDefault) return { path: e.host_path };
|
|
1848
|
+
return { chown: exportedChown(e.chown), ...e.mode ? { mode: e.mode } : {} };
|
|
1849
|
+
};
|
|
1850
|
+
for (const e of dockerLocal.filter((e2) => !isLegacyName(e2.name)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1851
|
+
if (onlyMounted && !mounted.has(e.name)) continue;
|
|
1852
|
+
volumes[e.name] = describe(e.name, e);
|
|
1853
|
+
names.set(e.name, e.name);
|
|
1854
|
+
}
|
|
1855
|
+
const used = dockerLocal.filter((e) => isLegacyName(e.name) && mounted.has(e.name));
|
|
1856
|
+
const given = legacyExportNames(used, new Set(server.keys()));
|
|
1857
|
+
for (const e of used) {
|
|
1858
|
+
const name = given.get(e.name);
|
|
1859
|
+
if (name == null || pathError(e.host_path) !== void 0) {
|
|
1860
|
+
volumes[e.name] = {};
|
|
1861
|
+
names.set(e.name, e.name);
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
volumes[name] = describe(name, e);
|
|
1865
|
+
names.set(e.name, name);
|
|
1866
|
+
}
|
|
1867
|
+
return { volumes, names };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/resources/storage.ts
|
|
1871
|
+
function formatSpec(s) {
|
|
1872
|
+
const tokens = [];
|
|
1873
|
+
if (s.readonly) tokens.push("ro");
|
|
1874
|
+
if (s.phases.length !== ALL_PHASES.length) for (const p of s.phases) tokens.push(`phase=${p}`);
|
|
1875
|
+
tokens.push(...s.options);
|
|
1876
|
+
if (s.subpath) tokens.push(`volume-subpath=${s.subpath}`);
|
|
1877
|
+
if (s.volumeChown) tokens.push(`volume-chown=${s.volumeChown}`);
|
|
1878
|
+
const prefix = s.scope === DEFAULT_SCOPE ? "" : `${s.scope}=`;
|
|
1879
|
+
return `${prefix}${s.entry}:${s.dir}${tokens.length ? `:${tokens.join(",")}` : ""}`;
|
|
1880
|
+
}
|
|
1881
|
+
function parseSpec(spec) {
|
|
1882
|
+
let scope = DEFAULT_SCOPE;
|
|
1883
|
+
let rest = spec;
|
|
1884
|
+
const scoped = spec.match(/^([A-Za-z0-9_-]+)=(.*)$/s);
|
|
1885
|
+
if (scoped) {
|
|
1886
|
+
scope = scoped[1];
|
|
1887
|
+
rest = scoped[2];
|
|
1888
|
+
}
|
|
1889
|
+
const first = rest.indexOf(":");
|
|
1890
|
+
const entry = rest.slice(0, first);
|
|
1891
|
+
const afterEntry = rest.slice(first + 1);
|
|
1892
|
+
const second = afterEntry.indexOf(":");
|
|
1893
|
+
const dir = second === -1 ? afterEntry : afterEntry.slice(0, second);
|
|
1894
|
+
const tokens = second === -1 ? [] : afterEntry.slice(second + 1).split(",").filter(Boolean);
|
|
1895
|
+
const parsed = { scope, entry, dir, readonly: false, phases: [...ALL_PHASES], options: [] };
|
|
1896
|
+
const phases = [];
|
|
1897
|
+
for (const t of tokens) {
|
|
1898
|
+
if (t === "ro") parsed.readonly = true;
|
|
1899
|
+
else if (t.startsWith("phase=")) phases.push(t.slice("phase=".length));
|
|
1900
|
+
else if (t.startsWith("volume-subpath=")) parsed.subpath = t.slice("volume-subpath=".length);
|
|
1901
|
+
else if (t.startsWith("volume-chown=")) parsed.volumeChown = t.slice("volume-chown=".length);
|
|
1902
|
+
else parsed.options.push(t);
|
|
1903
|
+
}
|
|
1904
|
+
if (phases.length > 0) parsed.phases = ALL_PHASES.filter((p) => phases.includes(p));
|
|
1905
|
+
return parsed;
|
|
1906
|
+
}
|
|
1907
|
+
function mountSpec(m) {
|
|
1908
|
+
return formatSpec({
|
|
1909
|
+
scope: m.process_type,
|
|
1910
|
+
entry: m.entry,
|
|
1911
|
+
dir: m.container_dir,
|
|
1912
|
+
readonly: m.readonly,
|
|
1913
|
+
phases: m.phases,
|
|
1914
|
+
options: m.options
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
function desiredSpecs(storage) {
|
|
1918
|
+
return storage === void 0 ? void 0 : declaredMounts(storage).map(mountSpec);
|
|
1919
|
+
}
|
|
1920
|
+
function parseAttachments(raw) {
|
|
1921
|
+
let report;
|
|
1922
|
+
try {
|
|
1923
|
+
report = JSON.parse(raw);
|
|
1924
|
+
} catch {
|
|
1925
|
+
throw new Error(`storage:report --format json did not return JSON: ${raw.slice(0, 80)}`);
|
|
1926
|
+
}
|
|
1927
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
1928
|
+
for (const [key, value] of Object.entries(report)) {
|
|
1929
|
+
const m = key.match(/^attachment\.(\d+)\.(.+)$/);
|
|
1930
|
+
if (!m) continue;
|
|
1931
|
+
const index = Number(m[1]);
|
|
1932
|
+
byIndex.set(index, { ...byIndex.get(index), [m[2]]: String(value ?? "") });
|
|
1933
|
+
}
|
|
1934
|
+
return [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, a]) => {
|
|
1935
|
+
const phases = (a["phases"] ?? "").split(",").filter((p) => p === "deploy" || p === "run");
|
|
1936
|
+
return formatSpec({
|
|
1937
|
+
scope: a["process-type"] || DEFAULT_SCOPE,
|
|
1938
|
+
entry: a["entry-name"] ?? "",
|
|
1939
|
+
dir: a["container-path"] ?? "",
|
|
1940
|
+
readonly: a["readonly"] === "true",
|
|
1941
|
+
phases: phases.length > 0 ? ALL_PHASES.filter((p) => phases.includes(p)) : [...ALL_PHASES],
|
|
1942
|
+
options: (a["volume-options"] ?? "").split(",").filter(Boolean),
|
|
1943
|
+
subpath: a["subpath"] || void 0,
|
|
1944
|
+
volumeChown: a["volume-chown"] || void 0
|
|
1945
|
+
});
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function replaceArg(spec) {
|
|
1949
|
+
const p = parseSpec(spec);
|
|
1950
|
+
return formatSpec({ ...p, scope: DEFAULT_SCOPE, subpath: void 0, volumeChown: void 0 });
|
|
1951
|
+
}
|
|
1952
|
+
function byScope(specs) {
|
|
1953
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
1954
|
+
for (const spec of specs) {
|
|
1955
|
+
const scope = parseSpec(spec).scope;
|
|
1956
|
+
scopes.set(scope, [...scopes.get(scope) ?? [], spec]);
|
|
1957
|
+
}
|
|
1958
|
+
return scopes;
|
|
1959
|
+
}
|
|
1960
|
+
var sameSet2 = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
|
|
1961
|
+
function orderActions(actions, before) {
|
|
1962
|
+
const held = /* @__PURE__ */ new Map();
|
|
1963
|
+
for (const [scope, specs] of before) held.set(scope, new Set(specs.map((s) => parseSpec(s).dir)));
|
|
1964
|
+
const pending = [...actions];
|
|
1965
|
+
const ordered = [];
|
|
1966
|
+
while (pending.length > 0) {
|
|
1967
|
+
const index = pending.findIndex((a) => a.specs.every((spec) => {
|
|
1968
|
+
const dir = parseSpec(spec).dir;
|
|
1969
|
+
return ![...held].some(([scope, dirs]) => scope !== a.scope && scopesOverlap(scope, a.scope) && dirs.has(dir));
|
|
1970
|
+
}));
|
|
1971
|
+
if (index === -1) {
|
|
1972
|
+
throw new Error(`cannot reorder the storage mounts: scopes ${pending.map((a) => a.scope).join(", ")} each claim a container dir another still holds. Apply the change in two runs`);
|
|
1973
|
+
}
|
|
1974
|
+
const [action] = pending.splice(index, 1);
|
|
1975
|
+
held.set(action.scope, new Set(action.specs.map((s) => parseSpec(s).dir)));
|
|
1976
|
+
ordered.push(action);
|
|
1977
|
+
}
|
|
1978
|
+
return ordered;
|
|
1979
|
+
}
|
|
1980
|
+
function containerView(specs, hosts) {
|
|
1981
|
+
const view = /* @__PURE__ */ new Set();
|
|
1982
|
+
for (const spec of specs) {
|
|
1983
|
+
const s = parseSpec(spec);
|
|
1984
|
+
const host = hosts?.get(s.entry)?.host_path ?? `?${s.entry}`;
|
|
1985
|
+
for (const phase of s.phases) view.add([phase, s.scope, host, s.dir, s.readonly, s.options.join(",")].join("\0"));
|
|
1986
|
+
}
|
|
1987
|
+
return view;
|
|
1988
|
+
}
|
|
1989
|
+
async function entriesOrUndefined(ctx) {
|
|
1990
|
+
try {
|
|
1991
|
+
const entries = await readEntries(ctx);
|
|
1992
|
+
for (const [name, host_path] of plannedHostPaths(ctx)) {
|
|
1993
|
+
if (!entries.has(name)) entries.set(name, { name, scheduler: "docker-local", host_path });
|
|
1994
|
+
}
|
|
1995
|
+
return entries;
|
|
1996
|
+
} catch {
|
|
1997
|
+
return void 0;
|
|
1998
|
+
}
|
|
870
1999
|
}
|
|
871
2000
|
var Storage = {
|
|
872
2001
|
key: "storage",
|
|
873
2002
|
read: async (ctx, target) => {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const result = /* @__PURE__ */ new Map();
|
|
881
|
-
for (const [app, report] of bulk) {
|
|
882
|
-
result.set(app, report["deploy-mounts"] ? parseDeployMounts(report["deploy-mounts"]) : []);
|
|
2003
|
+
let raw;
|
|
2004
|
+
try {
|
|
2005
|
+
raw = await ctx.queryStrict("storage:report", target, "--format", "json");
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
if (!await ctx.check("apps:exists", target)) return [];
|
|
2008
|
+
throw err;
|
|
883
2009
|
}
|
|
884
|
-
return
|
|
2010
|
+
return parseAttachments(raw);
|
|
885
2011
|
},
|
|
886
|
-
onChange: async (ctx, target,
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
2012
|
+
onChange: async (ctx, target, change) => {
|
|
2013
|
+
const hostsBefore = await entriesOrUndefined(ctx);
|
|
2014
|
+
const before = byScope(change.before);
|
|
2015
|
+
const after = byScope(change.after);
|
|
2016
|
+
const actions = [];
|
|
2017
|
+
for (const scope of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
|
|
2018
|
+
const was = before.get(scope) ?? [];
|
|
2019
|
+
const now = after.get(scope) ?? [];
|
|
2020
|
+
if (!sameSet2(was, now)) actions.push({ scope, specs: now });
|
|
890
2021
|
}
|
|
891
|
-
|
|
892
|
-
await ctx.run("storage:
|
|
893
|
-
|
|
2022
|
+
if (change.after.length === 0) {
|
|
2023
|
+
await ctx.run("storage:unmount", "--all", target);
|
|
2024
|
+
} else {
|
|
2025
|
+
for (const { scope, specs } of orderActions(actions, before)) {
|
|
2026
|
+
if (specs.length === 0) await ctx.run("storage:unmount", "--all", target, "--process-type", scope);
|
|
2027
|
+
else await ctx.run("storage:mount", "--replace", target, ...specs.map(replaceArg), ...scope === DEFAULT_SCOPE ? [] : ["--process-type", scope]);
|
|
2028
|
+
}
|
|
894
2029
|
}
|
|
2030
|
+
const hostsAfter = await entriesOrUndefined(ctx);
|
|
2031
|
+
const seenBefore = containerView(change.before, hostsBefore);
|
|
2032
|
+
const seenAfter = containerView(change.after, hostsAfter);
|
|
2033
|
+
const same2 = seenBefore.size === seenAfter.size && [...seenBefore].every((x) => seenAfter.has(x));
|
|
2034
|
+
if (!same2) ctx.needsRestart(target, "storage");
|
|
895
2035
|
}
|
|
896
2036
|
};
|
|
897
2037
|
|
|
@@ -1058,7 +2198,7 @@ var Certs = {
|
|
|
1058
2198
|
|
|
1059
2199
|
// src/core/shellwords.ts
|
|
1060
2200
|
function shellSplit(input) {
|
|
1061
|
-
const
|
|
2201
|
+
const words2 = [];
|
|
1062
2202
|
let cur = "";
|
|
1063
2203
|
let inWord = false;
|
|
1064
2204
|
let quote = null;
|
|
@@ -1079,7 +2219,7 @@ function shellSplit(input) {
|
|
|
1079
2219
|
inWord = true;
|
|
1080
2220
|
} else if (/\s/.test(c)) {
|
|
1081
2221
|
if (inWord) {
|
|
1082
|
-
|
|
2222
|
+
words2.push(cur);
|
|
1083
2223
|
cur = "";
|
|
1084
2224
|
inWord = false;
|
|
1085
2225
|
}
|
|
@@ -1089,8 +2229,8 @@ function shellSplit(input) {
|
|
|
1089
2229
|
}
|
|
1090
2230
|
}
|
|
1091
2231
|
if (quote) return void 0;
|
|
1092
|
-
if (inWord)
|
|
1093
|
-
return
|
|
2232
|
+
if (inWord) words2.push(cur);
|
|
2233
|
+
return words2;
|
|
1094
2234
|
}
|
|
1095
2235
|
function quoteShellArg(s) {
|
|
1096
2236
|
if (s === "") return "''";
|
|
@@ -1102,9 +2242,9 @@ function quoteShellArg(s) {
|
|
|
1102
2242
|
function parseBuildOptions(buildList) {
|
|
1103
2243
|
const out = [];
|
|
1104
2244
|
for (const raw of buildList) {
|
|
1105
|
-
const
|
|
1106
|
-
if (
|
|
1107
|
-
const m =
|
|
2245
|
+
const words2 = shellSplit(raw) ?? raw.trim().split(/\s+/);
|
|
2246
|
+
if (words2[0] !== "--build-arg" || words2.length < 2) continue;
|
|
2247
|
+
const m = words2.slice(1).join(" ").match(/^([^=\s]+)=?(.*)$/s);
|
|
1108
2248
|
if (m) out.push({ key: m[1], value: m[2], raw });
|
|
1109
2249
|
}
|
|
1110
2250
|
return out;
|
|
@@ -1249,8 +2389,8 @@ var DockerOptions = {
|
|
|
1249
2389
|
key: "docker_options",
|
|
1250
2390
|
// Pruned by `up` within a declared block only, so a missing block is not shown.
|
|
1251
2391
|
removals: (current, desired) => !desired ? [] : PHASES.flatMap((phase) => {
|
|
1252
|
-
const
|
|
1253
|
-
return (current?.[phase] ?? []).filter((o) => !
|
|
2392
|
+
const wanted2 = new Set(desired?.[phase] ?? []);
|
|
2393
|
+
return (current?.[phase] ?? []).filter((o) => !wanted2.has(o)).map((o) => `${phase}: ${o}`);
|
|
1254
2394
|
}),
|
|
1255
2395
|
async read(ctx, target) {
|
|
1256
2396
|
const raw = await ctx.query("docker-options:report", target);
|
|
@@ -1365,6 +2505,9 @@ var Ps = {
|
|
|
1365
2505
|
// src/resources/git.ts
|
|
1366
2506
|
var toDokku = (key) => key.replaceAll("_", "-");
|
|
1367
2507
|
var NOT_REMOVABLE = /* @__PURE__ */ new Set(["deploy_branch", "source_image", "rev_env_var"]);
|
|
2508
|
+
function isCopiedDeployBranch(current, inherited) {
|
|
2509
|
+
return inherited?.deploy_branch !== void 0 && current.deploy_branch === String(inherited.deploy_branch);
|
|
2510
|
+
}
|
|
1368
2511
|
function fromReport(report) {
|
|
1369
2512
|
const config = {};
|
|
1370
2513
|
if (report["deploy-branch"]) config.deploy_branch = report["deploy-branch"];
|
|
@@ -1376,7 +2519,12 @@ function fromReport(report) {
|
|
|
1376
2519
|
var Git = {
|
|
1377
2520
|
key: "git",
|
|
1378
2521
|
additive: true,
|
|
1379
|
-
|
|
2522
|
+
inheritsGlobal: true,
|
|
2523
|
+
removals: (current, desired, inherited) => Object.keys(current ?? {}).filter((k) => {
|
|
2524
|
+
if (desired && k in desired) return false;
|
|
2525
|
+
if (k === "deploy_branch") return isCopiedDeployBranch(current, inherited);
|
|
2526
|
+
return !NOT_REMOVABLE.has(k);
|
|
2527
|
+
}),
|
|
1380
2528
|
prune: async (ctx, target, removed) => {
|
|
1381
2529
|
for (const key of removed) await ctx.run("git:set", target, toDokku(key));
|
|
1382
2530
|
},
|
|
@@ -1515,29 +2663,38 @@ var NetworkProps = {
|
|
|
1515
2663
|
}
|
|
1516
2664
|
return result;
|
|
1517
2665
|
},
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
await ctx.run("network:set", target, "
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
await ctx.run("network:set", target, "tld", after.tld);
|
|
1537
|
-
ctx.needsRestart(target, "network");
|
|
2666
|
+
// `false` clears a property, so it is in sync when the server has no value; a
|
|
2667
|
+
// declared value must match what the server reports.
|
|
2668
|
+
inSync: (current, desired) => Object.keys(desired).every((k) => propInSync(current, desired, k)),
|
|
2669
|
+
onChange: async (ctx, target, { before, after }) => {
|
|
2670
|
+
const todo = (k) => after[k] !== void 0 && !propInSync(before, after, k);
|
|
2671
|
+
for (const [key, restart] of [
|
|
2672
|
+
["attach_post_create", false],
|
|
2673
|
+
["initial_network", false],
|
|
2674
|
+
["bind_all_interfaces", true],
|
|
2675
|
+
["static_web_listener", null],
|
|
2676
|
+
["tld", true]
|
|
2677
|
+
]) {
|
|
2678
|
+
if (!todo(key)) continue;
|
|
2679
|
+
const value = after[key];
|
|
2680
|
+
const args = value === false && key !== "bind_all_interfaces" ? [] : Array.isArray(value) ? value : [String(value)];
|
|
2681
|
+
await ctx.run("network:set", target, key.replaceAll("_", "-"), ...args);
|
|
2682
|
+
if (restart === false) ctx.needsRebuild(target, "network");
|
|
2683
|
+
else if (restart) ctx.needsRestart(target, "network");
|
|
1538
2684
|
}
|
|
1539
2685
|
}
|
|
1540
2686
|
};
|
|
2687
|
+
function propInSync(current, desired, key) {
|
|
2688
|
+
const want = desired[key];
|
|
2689
|
+
const have = current?.[key];
|
|
2690
|
+
if (want === void 0) return true;
|
|
2691
|
+
if (key === "bind_all_interfaces") return want === true === (have === true);
|
|
2692
|
+
if (want === false) return have === void 0;
|
|
2693
|
+
if (have === void 0 || have === false) return false;
|
|
2694
|
+
const w = Array.isArray(want) ? want : [want];
|
|
2695
|
+
const h = Array.isArray(have) ? have : [have];
|
|
2696
|
+
return w.length === h.length && w.every((v) => h.includes(v));
|
|
2697
|
+
}
|
|
1541
2698
|
|
|
1542
2699
|
// src/modules/plugins.ts
|
|
1543
2700
|
function parsePluginList(raw) {
|
|
@@ -1704,7 +2861,6 @@ async function exportNetworks(ctx) {
|
|
|
1704
2861
|
}
|
|
1705
2862
|
|
|
1706
2863
|
// src/modules/service-drift.ts
|
|
1707
|
-
import { createHash as createHash2 } from "crypto";
|
|
1708
2864
|
var infoReads = /* @__PURE__ */ new WeakMap();
|
|
1709
2865
|
function readServiceInfo(ctx, plugin, name) {
|
|
1710
2866
|
let reads = infoReads.get(ctx);
|
|
@@ -1721,6 +2877,9 @@ function readServiceInfo(ctx, plugin, name) {
|
|
|
1721
2877
|
}
|
|
1722
2878
|
return read;
|
|
1723
2879
|
}
|
|
2880
|
+
function forgetServiceInfo(ctx, plugin, name) {
|
|
2881
|
+
infoReads.get(ctx)?.delete(`${plugin}\0${name}`);
|
|
2882
|
+
}
|
|
1724
2883
|
async function fetchServiceInfo(ctx, plugin, name) {
|
|
1725
2884
|
const raw = await ctx.queryStrict(`${plugin}:info`, name, "--format", "json");
|
|
1726
2885
|
try {
|
|
@@ -1745,18 +2904,514 @@ async function readServiceMismatches(ctx, plugin, name, declared) {
|
|
|
1745
2904
|
if (declared.version === void 0 && declared.image === void 0) return [];
|
|
1746
2905
|
return versionMismatches(await readServiceInfo(ctx, plugin, name), declared);
|
|
1747
2906
|
}
|
|
2907
|
+
|
|
2908
|
+
// src/modules/service-properties.ts
|
|
2909
|
+
import { createHash as createHash2 } from "crypto";
|
|
2910
|
+
|
|
2911
|
+
// src/modules/service-mounts.ts
|
|
2912
|
+
async function entryHostPaths(ctx, volumes, names) {
|
|
2913
|
+
const paths = /* @__PURE__ */ new Map();
|
|
2914
|
+
let server;
|
|
2915
|
+
for (const name of names) {
|
|
2916
|
+
const declared = volumes?.[name];
|
|
2917
|
+
if (declared === void 0) throw new Error(`entry '${name}' is not declared under volumes:`);
|
|
2918
|
+
if (isLegacyName(name)) {
|
|
2919
|
+
server ??= await readEntries(ctx);
|
|
2920
|
+
const hostPath = server.get(name)?.host_path;
|
|
2921
|
+
if (!hostPath) throw new Error(`entry '${name}' is not on the server, so its host path is unknown`);
|
|
2922
|
+
paths.set(name, hostPath);
|
|
2923
|
+
} else {
|
|
2924
|
+
paths.set(name, declared.path ?? defaultPath(name));
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return paths;
|
|
2928
|
+
}
|
|
2929
|
+
async function resolveServiceMounts(ctx, volumes, mounts, failedEntries) {
|
|
2930
|
+
if (mounts === void 0) return void 0;
|
|
2931
|
+
const declared = declaredServiceMounts(mounts);
|
|
2932
|
+
for (const m of declared) {
|
|
2933
|
+
const reason = failedEntries?.get(m.entry);
|
|
2934
|
+
if (reason !== void 0) throw new Error(`entry '${m.entry}' failed: ${reason}`);
|
|
2935
|
+
}
|
|
2936
|
+
const paths = await entryHostPaths(ctx, volumes, [...new Set(declared.map((m) => m.entry))]);
|
|
2937
|
+
return declared.map((m) => {
|
|
2938
|
+
const tokens = [...m.readonly ? ["ro"] : [], ...m.options];
|
|
2939
|
+
return `${paths.get(m.entry)}:${m.container_dir}${tokens.length ? `:${tokens.join(",")}` : ""}`;
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// src/modules/links.ts
|
|
2944
|
+
function normalizeLink(entry) {
|
|
2945
|
+
return typeof entry === "string" ? { service: entry } : entry;
|
|
2946
|
+
}
|
|
2947
|
+
function resolveServicePlugin(name, config) {
|
|
2948
|
+
if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
|
|
2949
|
+
if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
|
|
2950
|
+
return void 0;
|
|
2951
|
+
}
|
|
2952
|
+
function allServices(config) {
|
|
2953
|
+
const entries = [];
|
|
2954
|
+
for (const name of Object.keys(config.postgres ?? {})) entries.push([name, "postgres"]);
|
|
2955
|
+
for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
|
|
2956
|
+
return entries;
|
|
2957
|
+
}
|
|
2958
|
+
var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
2959
|
+
function linkEnvVar(plugin, link) {
|
|
2960
|
+
if (link.env_var) return link.env_var;
|
|
2961
|
+
if (link.alias) return `${link.alias}_URL`;
|
|
2962
|
+
return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
|
|
2963
|
+
}
|
|
2964
|
+
function linkFlags(link) {
|
|
2965
|
+
const flags = [];
|
|
2966
|
+
if (link.alias) flags.push("--alias", link.alias);
|
|
2967
|
+
if (link.env_var) flags.push("--env-var", link.env_var);
|
|
2968
|
+
if (link.querystring) flags.push("--querystring", link.querystring);
|
|
2969
|
+
return flags;
|
|
2970
|
+
}
|
|
2971
|
+
function readEnv(ctx, app, cache) {
|
|
2972
|
+
let read = cache?.get(app);
|
|
2973
|
+
if (!read) {
|
|
2974
|
+
read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
|
|
2975
|
+
cache?.set(app, read);
|
|
2976
|
+
}
|
|
2977
|
+
return read;
|
|
2978
|
+
}
|
|
2979
|
+
function findLinkVariables(env, plugin, service) {
|
|
2980
|
+
const host = `dokku-${plugin}-${service}`;
|
|
2981
|
+
const found = [];
|
|
2982
|
+
for (const [name, value] of Object.entries(env)) {
|
|
2983
|
+
if (typeof value !== "string") continue;
|
|
2984
|
+
const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
|
|
2985
|
+
if (m && m[1] === host) found.push({ name, value });
|
|
2986
|
+
}
|
|
2987
|
+
return found;
|
|
2988
|
+
}
|
|
2989
|
+
function isFallbackName(plugin, name) {
|
|
2990
|
+
return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
|
|
2991
|
+
}
|
|
2992
|
+
function querystringOf(url) {
|
|
2993
|
+
const q = url.indexOf("?");
|
|
2994
|
+
return q === -1 ? "" : url.slice(q + 1);
|
|
2995
|
+
}
|
|
2996
|
+
function show(name, qs) {
|
|
2997
|
+
return qs ? `${name}?${maskQuerystring(qs)}` : name;
|
|
2998
|
+
}
|
|
2999
|
+
async function readLinkOptions(ctx, app, plugin, link, cache) {
|
|
3000
|
+
let env;
|
|
3001
|
+
try {
|
|
3002
|
+
env = await readEnv(ctx, app, cache);
|
|
3003
|
+
} catch (err) {
|
|
3004
|
+
return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
|
|
3005
|
+
}
|
|
3006
|
+
const found = findLinkVariables(env, plugin, link.service);
|
|
3007
|
+
if (found.length === 0) {
|
|
3008
|
+
return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
|
|
3009
|
+
}
|
|
3010
|
+
const name = linkEnvVar(plugin, link);
|
|
3011
|
+
const desired = link.querystring ?? "";
|
|
3012
|
+
const plain = !link.alias && !link.env_var;
|
|
3013
|
+
const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
|
|
3014
|
+
if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
|
|
3015
|
+
const current = querystringOf(hit.value);
|
|
3016
|
+
if (current === desired) return { state: "same" };
|
|
3017
|
+
return { state: "differs", current: show(name, current), desired: show(name, desired) };
|
|
3018
|
+
}
|
|
3019
|
+
async function readServiceLinks(ctx, app, desiredLinks, config) {
|
|
3020
|
+
const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
|
|
3021
|
+
const states = [];
|
|
3022
|
+
for (const [service, plugin] of allServices(config)) {
|
|
3023
|
+
states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
|
|
3024
|
+
}
|
|
3025
|
+
return states;
|
|
3026
|
+
}
|
|
3027
|
+
function servicesToUnlink(states) {
|
|
3028
|
+
return states.filter((s) => !s.link && s.isLinked);
|
|
3029
|
+
}
|
|
3030
|
+
async function ensureAppLinks(ctx, app, desiredLinks, config) {
|
|
3031
|
+
const states = await readServiceLinks(ctx, app, desiredLinks, config);
|
|
3032
|
+
const unlink = new Set(servicesToUnlink(states));
|
|
3033
|
+
for (const state of states) {
|
|
3034
|
+
const { service: serviceName, plugin, link, isLinked } = state;
|
|
3035
|
+
if (link && !isLinked) {
|
|
3036
|
+
logAction(app, `Linking ${serviceName}`);
|
|
3037
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
3038
|
+
ctx.needsRestart(app, "links");
|
|
3039
|
+
logDone();
|
|
3040
|
+
} else if (link && isLinked) {
|
|
3041
|
+
const opts = await readLinkOptions(ctx, app, plugin, link);
|
|
3042
|
+
if (opts.state === "differs") {
|
|
3043
|
+
logAction(app, `Relinking ${serviceName}`);
|
|
3044
|
+
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3045
|
+
try {
|
|
3046
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
3047
|
+
} catch (err) {
|
|
3048
|
+
logWarn(app, `${app} is now unlinked from ${serviceName}`);
|
|
3049
|
+
throw err;
|
|
3050
|
+
}
|
|
3051
|
+
ctx.needsRestart(app, "links");
|
|
3052
|
+
logDone();
|
|
3053
|
+
}
|
|
3054
|
+
} else if (unlink.has(state)) {
|
|
3055
|
+
logAction(app, `Unlinking ${serviceName}`);
|
|
3056
|
+
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3057
|
+
ctx.needsRestart(app, "links");
|
|
3058
|
+
logDone();
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
async function destroyAppLinks(ctx, app, links, config) {
|
|
3063
|
+
for (const serviceName of links.map((l) => normalizeLink(l).service)) {
|
|
3064
|
+
const resolved = resolveServicePlugin(serviceName, config);
|
|
3065
|
+
if (!resolved) continue;
|
|
3066
|
+
const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
|
|
3067
|
+
if (isLinked) {
|
|
3068
|
+
await ctx.run(`${resolved.plugin}:unlink`, serviceName, app, "--no-restart");
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
async function exportAppLinks(ctx, app, config) {
|
|
3073
|
+
const linked = [];
|
|
3074
|
+
for (const [serviceName, plugin] of allServices(config)) {
|
|
3075
|
+
if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
|
|
3076
|
+
let found = [];
|
|
3077
|
+
try {
|
|
3078
|
+
found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
|
|
3079
|
+
} catch {
|
|
3080
|
+
}
|
|
3081
|
+
const fallback = linkEnvVar(plugin, { service: serviceName });
|
|
3082
|
+
const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
|
|
3083
|
+
if (!v) {
|
|
3084
|
+
linked.push(serviceName);
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
const querystring = querystringOf(v.value);
|
|
3088
|
+
if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
|
|
3089
|
+
linked.push(serviceName);
|
|
3090
|
+
continue;
|
|
3091
|
+
}
|
|
3092
|
+
const entry = { service: serviceName };
|
|
3093
|
+
if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
|
|
3094
|
+
if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
|
|
3095
|
+
else entry.env_var = v.name;
|
|
3096
|
+
}
|
|
3097
|
+
if (querystring) entry.querystring = querystring;
|
|
3098
|
+
linked.push(entry);
|
|
3099
|
+
}
|
|
3100
|
+
return linked;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// src/modules/service-properties.ts
|
|
3104
|
+
function createArgs2(declared, mounts) {
|
|
3105
|
+
const args = [];
|
|
3106
|
+
for (const [key, value] of declaredEntries(declared.properties)) {
|
|
3107
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3108
|
+
if (!spec) continue;
|
|
3109
|
+
if (spec.repeat) for (const pair of value.split(" ")) args.push(`--${spec.createFlag}`, pair);
|
|
3110
|
+
else args.push(`--${spec.createFlag}`, value);
|
|
3111
|
+
}
|
|
3112
|
+
for (const mount of mounts ?? []) args.push("--volume", mount);
|
|
3113
|
+
return args;
|
|
3114
|
+
}
|
|
3115
|
+
var sha256 = (v) => createHash2("sha256").update(v).digest("hex");
|
|
3116
|
+
function declaredEntries(properties) {
|
|
3117
|
+
return Object.entries(properties ?? {}).map(([key, value]) => [key, propertyString(key, value)]);
|
|
3118
|
+
}
|
|
3119
|
+
function propertyChanges(plugin, info, properties, prune) {
|
|
3120
|
+
const changes = [];
|
|
3121
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3122
|
+
for (const [key, desired] of declaredEntries(properties)) {
|
|
3123
|
+
declared.add(key);
|
|
3124
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3125
|
+
if (spec?.kind === "secret") {
|
|
3126
|
+
if (!(`${key}-fingerprint` in info)) throw new Error(`dokku-${plugin} too old to report the ${key} fingerprint`);
|
|
3127
|
+
if (field(info, `${key}-fingerprint`) !== sha256(desired)) {
|
|
3128
|
+
changes.push({ key, value: desired, noRestart: true, restart: false, password: true });
|
|
3129
|
+
}
|
|
3130
|
+
continue;
|
|
3131
|
+
}
|
|
3132
|
+
if (!sameProperty(key, desired, field(info, key))) {
|
|
3133
|
+
changes.push({ key, value: desired, restart: spec?.restart ?? false, password: false });
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
if (prune) {
|
|
3137
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3138
|
+
if (declared.has(key) || isDefaultProperty(key, field(info, key))) continue;
|
|
3139
|
+
changes.push({ key, restart: MANAGED_PROPERTIES[key].restart, password: false });
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return changes;
|
|
3143
|
+
}
|
|
3144
|
+
function serverMounts(plugin, info) {
|
|
3145
|
+
if (!("mounts" in info)) throw new Error(`dokku-${plugin} too old to report mounts`);
|
|
3146
|
+
return parseInfoMounts(field(info, "mounts"));
|
|
3147
|
+
}
|
|
3148
|
+
function mountCommand(plugin, name, info, mounts) {
|
|
3149
|
+
if (mounts === void 0) return void 0;
|
|
3150
|
+
const desired = mountSet(mounts);
|
|
3151
|
+
const current = serverMounts(plugin, info);
|
|
3152
|
+
if (desired.join(" ") === current.join(" ")) return void 0;
|
|
3153
|
+
if (desired.length === 0) return [`${plugin}:unmount`, "--all", name];
|
|
3154
|
+
return [`${plugin}:mount`, "--replace", name, ...new Set(mounts)];
|
|
3155
|
+
}
|
|
3156
|
+
function mountMismatches(plugin, info, mounts) {
|
|
3157
|
+
if (mounts === void 0) return [];
|
|
3158
|
+
const desired = mountSet(mounts);
|
|
3159
|
+
const current = serverMounts(plugin, info);
|
|
3160
|
+
return desired.join(" ") === current.join(" ") ? [] : [{ field: "mounts", desired: desired.join(" ") || "(none)", current: current.join(" ") || "(none)" }];
|
|
3161
|
+
}
|
|
3162
|
+
function linkedApps(info, name, config, dryRun = false) {
|
|
3163
|
+
const apps = new Set(field(info, "links").split(/[\s,]+/).filter((a) => a !== ""));
|
|
3164
|
+
if (dryRun) {
|
|
3165
|
+
for (const [app, appConfig] of Object.entries(config.apps)) {
|
|
3166
|
+
if ((appConfig.links ?? []).some((l) => normalizeLink(l).service === name)) apps.add(app);
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
return [...apps];
|
|
3170
|
+
}
|
|
3171
|
+
function setArgs(plugin, name, change) {
|
|
3172
|
+
return [
|
|
3173
|
+
`${plugin}:set`,
|
|
3174
|
+
...change.noRestart ? ["--no-restart"] : [],
|
|
3175
|
+
name,
|
|
3176
|
+
change.key,
|
|
3177
|
+
...change.value === void 0 ? [] : ["--", change.value]
|
|
3178
|
+
];
|
|
3179
|
+
}
|
|
3180
|
+
async function ensureServiceProperties(ctx, plugin, services, config, opts = {}) {
|
|
3181
|
+
const failures = [];
|
|
3182
|
+
for (const [name, declared] of Object.entries(services)) {
|
|
3183
|
+
if (declared.properties === void 0 && declared.mounts === void 0 && !opts.prune) continue;
|
|
3184
|
+
if (opts.dryRunCreated?.has(name)) {
|
|
3185
|
+
for (const [key, value] of declaredEntries(declared.properties)) {
|
|
3186
|
+
if (!MANAGED_PROPERTIES[key]) await ctx.run(`${plugin}:set`, name, key, "--", value);
|
|
3187
|
+
}
|
|
3188
|
+
continue;
|
|
3189
|
+
}
|
|
3190
|
+
try {
|
|
3191
|
+
const info = await readServiceInfo(ctx, plugin, name);
|
|
3192
|
+
const mountSpecs = await resolveServiceMounts(ctx, config.volumes, declared.mounts, opts.failedVolumes);
|
|
3193
|
+
const changes = propertyChanges(plugin, info, declared.properties, opts.prune ?? false);
|
|
3194
|
+
const mount = mountCommand(plugin, name, info, mountSpecs);
|
|
3195
|
+
logAction("services", `Configuring properties for ${name}`);
|
|
3196
|
+
if (changes.length === 0 && !mount) {
|
|
3197
|
+
logSkip();
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
for (const change of changes) {
|
|
3201
|
+
await ctx.run(...setArgs(plugin, name, change));
|
|
3202
|
+
forgetServiceInfo(ctx, plugin, name);
|
|
3203
|
+
if (change.restart) ctx.needsServiceRestart(plugin, name, change.key);
|
|
3204
|
+
if (change.password) for (const app of linkedApps(info, name, config, opts.dryRun)) ctx.needsRestart(app, `${name} ${change.key} changed`);
|
|
3205
|
+
}
|
|
3206
|
+
if (mount) {
|
|
3207
|
+
await ctx.run(...mount);
|
|
3208
|
+
forgetServiceInfo(ctx, plugin, name);
|
|
3209
|
+
ctx.needsServiceRestart(plugin, name, "mounts");
|
|
3210
|
+
}
|
|
3211
|
+
logDone();
|
|
3212
|
+
} catch (error) {
|
|
3213
|
+
console.log("");
|
|
3214
|
+
logError(name, error instanceof Error ? error.message : String(error));
|
|
3215
|
+
failures.push({ service: name, error });
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return failures;
|
|
3219
|
+
}
|
|
3220
|
+
function propertyMismatches(plugin, info, properties) {
|
|
3221
|
+
const out = [];
|
|
3222
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3223
|
+
for (const [key, desired] of declaredEntries(properties)) {
|
|
3224
|
+
declared.add(key);
|
|
3225
|
+
const spec = MANAGED_PROPERTIES[key];
|
|
3226
|
+
if (spec?.kind === "secret") {
|
|
3227
|
+
if (!(`${key}-fingerprint` in info)) throw new Error(`dokku-${plugin} too old to report the ${key} fingerprint`);
|
|
3228
|
+
const stored = field(info, `${key}-fingerprint`);
|
|
3229
|
+
if (stored !== sha256(desired)) {
|
|
3230
|
+
out.push({ field: `properties.${key}`, desired: `the ${key} from the file`, current: stored === "" ? `no ${key}` : `another ${key}` });
|
|
3231
|
+
}
|
|
3232
|
+
continue;
|
|
3233
|
+
}
|
|
3234
|
+
const current = field(info, key);
|
|
3235
|
+
if (!sameProperty(key, desired, current)) out.push({ field: `properties.${key}`, desired, current });
|
|
3236
|
+
}
|
|
3237
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3238
|
+
const current = field(info, key);
|
|
3239
|
+
if (declared.has(key) || isDefaultProperty(key, current)) continue;
|
|
3240
|
+
out.push({ field: `properties.${key}`, desired: "(not in the file)", current, removed: true });
|
|
3241
|
+
}
|
|
3242
|
+
return out;
|
|
3243
|
+
}
|
|
3244
|
+
async function exportServiceProperties(ctx, plugin, name) {
|
|
3245
|
+
let info;
|
|
3246
|
+
try {
|
|
3247
|
+
info = await readServiceInfo(ctx, plugin, name);
|
|
3248
|
+
} catch {
|
|
3249
|
+
return {};
|
|
3250
|
+
}
|
|
3251
|
+
const properties = {};
|
|
3252
|
+
for (const key of PRUNABLE_PROPERTIES) {
|
|
3253
|
+
const current = field(info, key);
|
|
3254
|
+
if (isDefaultProperty(key, current)) continue;
|
|
3255
|
+
switch (MANAGED_PROPERTIES[key].kind) {
|
|
3256
|
+
case "list":
|
|
3257
|
+
properties[key] = current.split(",").filter((v) => v !== "");
|
|
3258
|
+
break;
|
|
3259
|
+
case "map":
|
|
3260
|
+
properties[key] = Object.fromEntries(current.split(";").filter((v) => v !== "").map((p) => [p.split("=")[0], EXPORT_PLACEHOLDER]));
|
|
3261
|
+
break;
|
|
3262
|
+
case "number":
|
|
3263
|
+
properties[key] = Number(current);
|
|
3264
|
+
break;
|
|
3265
|
+
case "targets":
|
|
3266
|
+
properties[key] = Object.fromEntries(targetPairs(current));
|
|
3267
|
+
break;
|
|
3268
|
+
default:
|
|
3269
|
+
properties[key] = current;
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const mounts = "mounts" in info ? parseInfoMounts(field(info, "mounts")) : [];
|
|
3273
|
+
return {
|
|
3274
|
+
...Object.keys(properties).length > 0 ? { properties } : {},
|
|
3275
|
+
...mounts.length > 0 ? { mountSpecs: mounts } : {}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// src/modules/service-backup.ts
|
|
3280
|
+
import { createHash as createHash3 } from "crypto";
|
|
1748
3281
|
function backupAuthFingerprint(accessKeyId, secretAccessKey) {
|
|
1749
3282
|
const firstLine = (v) => v.trim().split("\n")[0].trim();
|
|
1750
|
-
return
|
|
3283
|
+
return createHash3("sha256").update(`${firstLine(accessKeyId)}
|
|
1751
3284
|
${firstLine(secretAccessKey)}`).digest("hex");
|
|
1752
3285
|
}
|
|
3286
|
+
var sha2562 = (v) => createHash3("sha256").update(v).digest("hex");
|
|
3287
|
+
var SETTINGS = [
|
|
3288
|
+
{ key: "storage_class", property: "backup-storage-class" },
|
|
3289
|
+
{ key: "object_name", property: "backup-object-name" },
|
|
3290
|
+
{ key: "mailto", property: "backup-mailto" }
|
|
3291
|
+
];
|
|
3292
|
+
function otherScheduleIds(info) {
|
|
3293
|
+
const raw = info["backup-schedules"];
|
|
3294
|
+
const ids = Array.isArray(raw) ? raw.map((r) => String(r?.id ?? "")) : String(raw ?? "").split(";").map((r) => r.split("|")[0]);
|
|
3295
|
+
return ids.filter((id) => id !== "" && id !== "default");
|
|
3296
|
+
}
|
|
3297
|
+
var passphraseStored = (info) => field(info, "backup-encryption-fingerprint") !== "";
|
|
3298
|
+
var publicKeyStored = (info) => field(info, "backup-public-key-id") !== "";
|
|
3299
|
+
var encryptionStored = (info) => passphraseStored(info) || publicKeyStored(info) || field(info, "backup-keyserver") !== "";
|
|
3300
|
+
function settingsStored(info) {
|
|
3301
|
+
const out = SETTINGS.filter((s) => field(info, s.property) !== "").map((s) => s.key);
|
|
3302
|
+
if (field(info, "backup-timestamp") === "false") out.push("timestamp");
|
|
3303
|
+
return out;
|
|
3304
|
+
}
|
|
1753
3305
|
function hasBackup(info) {
|
|
1754
|
-
return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true";
|
|
3306
|
+
return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true" || encryptionStored(info) || settingsStored(info).length > 0;
|
|
3307
|
+
}
|
|
3308
|
+
function describeServerBackup(info) {
|
|
3309
|
+
const schedule = field(info, "backup-schedule");
|
|
3310
|
+
const parts = [];
|
|
3311
|
+
if (schedule) parts.push(`scheduled ${schedule} to ${field(info, "backup-bucket")}`);
|
|
3312
|
+
if (field(info, "backup-authenticated") === "true") parts.push("credentials stored");
|
|
3313
|
+
if (encryptionStored(info)) parts.push("encrypted");
|
|
3314
|
+
const settings = settingsStored(info);
|
|
3315
|
+
if (settings.length > 0) parts.push(`with ${settings.join(", ")} set`);
|
|
3316
|
+
return parts.join(", ") || "none";
|
|
3317
|
+
}
|
|
3318
|
+
function declaresNewerKeys(backup) {
|
|
3319
|
+
return backup.encryption !== void 0 || backup.timestamp !== void 0 || SETTINGS.some((s) => backup[s.key] !== void 0);
|
|
3320
|
+
}
|
|
3321
|
+
function assertReportsBackup(plugin, info, backup) {
|
|
3322
|
+
const needed = ["backup-auth-fingerprint"];
|
|
3323
|
+
if (backup !== false && declaresNewerKeys(backup)) needed.push("backup-timestamp", "backup-mailto");
|
|
3324
|
+
if (needed.some((key) => !(key in info))) throw new Error(`dokku-${plugin} too old to report backup state (needs 2.3.1+)`);
|
|
3325
|
+
}
|
|
3326
|
+
function encryptionMismatch(info, enc) {
|
|
3327
|
+
const fingerprint = field(info, "backup-encryption-fingerprint");
|
|
3328
|
+
const keyId = field(info, "backup-public-key-id");
|
|
3329
|
+
const keyserver = field(info, "backup-keyserver");
|
|
3330
|
+
const stored = [];
|
|
3331
|
+
if (enc === false) {
|
|
3332
|
+
if (passphraseStored(info)) stored.push("a passphrase");
|
|
3333
|
+
if (keyId !== "") stored.push(`public key ${keyId}`);
|
|
3334
|
+
if (keyserver !== "") stored.push(`keyserver ${keyserver}`);
|
|
3335
|
+
return stored.length ? { field: "backup.encryption", desired: "none", current: stored.join(", ") } : void 0;
|
|
3336
|
+
}
|
|
3337
|
+
if (enc.passphrase !== void 0) {
|
|
3338
|
+
if (fingerprint !== sha2562(enc.passphrase)) stored.push(fingerprint === "" ? "no passphrase" : "a different passphrase");
|
|
3339
|
+
if (keyId !== "") stored.push(`public key ${keyId}`);
|
|
3340
|
+
if (keyserver !== "") stored.push(`keyserver ${keyserver}`);
|
|
3341
|
+
return stored.length ? { field: "backup.encryption", desired: "passphrase", current: stored.join(", ") } : void 0;
|
|
3342
|
+
}
|
|
3343
|
+
if (keyId !== enc.public_key_id) stored.push(keyId === "" ? "no public key" : `public key ${keyId}`);
|
|
3344
|
+
if (keyserver !== (enc.keyserver ?? "")) stored.push(`keyserver ${keyserver || "none"}`);
|
|
3345
|
+
if (passphraseStored(info)) stored.push("a passphrase");
|
|
3346
|
+
return stored.length ? {
|
|
3347
|
+
field: "backup.encryption",
|
|
3348
|
+
desired: `public key ${enc.public_key_id}${enc.keyserver ? ` on ${enc.keyserver}` : ""}`,
|
|
3349
|
+
current: stored.join(", ")
|
|
3350
|
+
} : void 0;
|
|
3351
|
+
}
|
|
3352
|
+
function encryptionCommands(plugin, name, info, enc) {
|
|
3353
|
+
const set = (property, value) => [`${plugin}:set`, name, property, ...value === void 0 ? [] : [value]];
|
|
3354
|
+
const cmds = [];
|
|
3355
|
+
const clearPassphrase = () => {
|
|
3356
|
+
if (passphraseStored(info)) cmds.push(set("backup-encryption-passphrase"));
|
|
3357
|
+
};
|
|
3358
|
+
const clearKeyId = () => {
|
|
3359
|
+
if (publicKeyStored(info)) cmds.push(set("backup-public-key-id"));
|
|
3360
|
+
};
|
|
3361
|
+
const clearKeyserver = () => {
|
|
3362
|
+
if (field(info, "backup-keyserver") !== "") cmds.push(set("backup-keyserver"));
|
|
3363
|
+
};
|
|
3364
|
+
if (enc === false) {
|
|
3365
|
+
clearPassphrase();
|
|
3366
|
+
clearKeyId();
|
|
3367
|
+
clearKeyserver();
|
|
3368
|
+
} else if (enc.passphrase !== void 0) {
|
|
3369
|
+
if (field(info, "backup-encryption-fingerprint") !== sha2562(enc.passphrase)) cmds.push(set("backup-encryption-passphrase", enc.passphrase));
|
|
3370
|
+
clearKeyId();
|
|
3371
|
+
clearKeyserver();
|
|
3372
|
+
} else {
|
|
3373
|
+
if (field(info, "backup-keyserver") !== (enc.keyserver ?? "")) cmds.push(set("backup-keyserver", enc.keyserver));
|
|
3374
|
+
if (field(info, "backup-public-key-id") !== enc.public_key_id) cmds.push(set("backup-public-key-id", enc.public_key_id));
|
|
3375
|
+
clearPassphrase();
|
|
3376
|
+
}
|
|
3377
|
+
return cmds;
|
|
1755
3378
|
}
|
|
1756
|
-
function
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
|
3447
|
+
function backupNotes(info, backup) {
|
|
3448
|
+
const notes = [];
|
|
3449
|
+
if (backup === void 0) {
|
|
3450
|
+
if (hasBackup(info)) notes.push(`backup on the server is not in the file (${describeServerBackup(info)}); up leaves it, backup: false removes it`);
|
|
3451
|
+
} else if (backup !== false) {
|
|
3452
|
+
if (backup.encryption === void 0 && encryptionStored(info)) {
|
|
3453
|
+
notes.push("backup encryption on the server is not in the file; up leaves it, encryption: false removes it");
|
|
3454
|
+
}
|
|
3455
|
+
const extra = [
|
|
3456
|
+
...SETTINGS.filter((s) => backup[s.key] === void 0 && field(info, s.property) !== "").map((s) => s.key),
|
|
3457
|
+
...backup.timestamp === void 0 && field(info, "backup-timestamp") === "false" ? ["timestamp"] : []
|
|
3458
|
+
];
|
|
3459
|
+
if (extra.length > 0) notes.push(`backup ${extra.join(", ")} on the server ${extra.length === 1 ? "is" : "are"} not in the file; up leaves ${extra.length === 1 ? "it" : "them"}, up --prune clears ${extra.length === 1 ? "it" : "them"}`);
|
|
3460
|
+
}
|
|
3461
|
+
const others = otherScheduleIds(info);
|
|
3462
|
+
if (others.length > 0) notes.push(`other backup schedules (${others.join(", ")}) are not managed; up never touches them`);
|
|
3463
|
+
return notes;
|
|
3464
|
+
}
|
|
3465
|
+
async function ensureServiceBackups(ctx, plugin, services, opts = {}) {
|
|
3466
|
+
for (const [name, config] of Object.entries(services)) {
|
|
3467
|
+
const backup = config.backup;
|
|
3468
|
+
if (backup === void 0 && !opts.prune) continue;
|
|
3469
|
+
if (backup === void 0) {
|
|
3470
|
+
let info2;
|
|
3471
|
+
try {
|
|
3472
|
+
info2 = await readServiceInfo(ctx, plugin, name);
|
|
3473
|
+
} catch (err) {
|
|
3474
|
+
logWarn("services", `could not read backup state for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
if (hasBackup(info2)) {
|
|
3478
|
+
logWarn("services", `${name}: has a backup the file does not declare (${describeServerBackup(info2)}); --prune leaves it, set backup: false to remove it`);
|
|
3479
|
+
}
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
if (opts.dryRunCreated?.has(name)) {
|
|
3483
|
+
logAction("services", `Configuring backup for ${name}`);
|
|
3484
|
+
if (backup !== false) {
|
|
3485
|
+
const { schedule: schedule2, bucket: bucket2, auth: auth2 } = backup;
|
|
3486
|
+
await ctx.run(
|
|
3487
|
+
`${plugin}:backup-auth`,
|
|
3488
|
+
name,
|
|
3489
|
+
auth2.access_key_id,
|
|
3490
|
+
auth2.secret_access_key,
|
|
3491
|
+
auth2.region,
|
|
3492
|
+
auth2.signature_version,
|
|
3493
|
+
auth2.endpoint
|
|
3494
|
+
);
|
|
3495
|
+
await ctx.run(`${plugin}:backup-schedule`, name, schedule2, bucket2);
|
|
3496
|
+
const nothing = {};
|
|
3497
|
+
const cmds2 = [
|
|
3498
|
+
...backup.encryption !== void 0 ? encryptionCommands(plugin, name, nothing, backup.encryption) : [],
|
|
3499
|
+
...settingCommands(plugin, name, nothing, backup)
|
|
3500
|
+
];
|
|
3501
|
+
for (const cmd of cmds2) await ctx.run(...cmd);
|
|
3502
|
+
}
|
|
3503
|
+
logDone();
|
|
3504
|
+
continue;
|
|
3505
|
+
}
|
|
3506
|
+
const info = await readServiceInfo(ctx, plugin, name);
|
|
3507
|
+
logAction("services", `Configuring backup for ${name}`);
|
|
3508
|
+
const fields = new Set(backupMismatches(plugin, info, backup).map((m) => m.field));
|
|
3509
|
+
const prune = opts.prune && backup !== false ? pruneCommands(plugin, name, info, backup) : [];
|
|
3510
|
+
if (opts.prune && backup !== false && backup.encryption === void 0 && encryptionStored(info)) {
|
|
3511
|
+
logWarn("services", `${name}: has backup encryption the file does not declare; --prune leaves it, set encryption: false to remove it`);
|
|
3512
|
+
}
|
|
3513
|
+
if (fields.size === 0 && prune.length === 0) {
|
|
3514
|
+
logSkip();
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
if (backup === false) {
|
|
3518
|
+
if (field(info, "backup-schedule") !== "") await ctx.run(`${plugin}:backup-unschedule`, name, "--id", "default");
|
|
3519
|
+
if (field(info, "backup-authenticated") === "true") await ctx.run(`${plugin}:backup-deauth`, name);
|
|
3520
|
+
for (const cmd of clearAllCommands(plugin, name, info)) await ctx.run(...cmd);
|
|
3521
|
+
logDone();
|
|
3522
|
+
continue;
|
|
3523
|
+
}
|
|
3524
|
+
const { schedule, bucket, auth } = backup;
|
|
3525
|
+
if (fields.has("backup.auth")) {
|
|
3526
|
+
await ctx.run(
|
|
3527
|
+
`${plugin}:backup-auth`,
|
|
3528
|
+
name,
|
|
3529
|
+
auth.access_key_id,
|
|
3530
|
+
auth.secret_access_key,
|
|
3531
|
+
auth.region,
|
|
3532
|
+
auth.signature_version,
|
|
3533
|
+
auth.endpoint
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
if (fields.has("backup.schedule")) {
|
|
3537
|
+
await ctx.run(`${plugin}:backup-schedule`, name, schedule, bucket);
|
|
3538
|
+
if (!opts.dryRun) {
|
|
3539
|
+
const cronOutput = await ctx.query(`${plugin}:backup-schedule-cat`, name);
|
|
3540
|
+
if (!parseBackupSchedule(cronOutput)) {
|
|
3541
|
+
throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
} else if (fields.has("backup.bucket")) {
|
|
3545
|
+
if (otherScheduleIds(info).length > 0) await ctx.run(`${plugin}:backup-schedule`, name, schedule, bucket);
|
|
3546
|
+
else await ctx.run(`${plugin}:set`, name, "backup-bucket", bucket);
|
|
3547
|
+
}
|
|
3548
|
+
const cmds = [
|
|
3549
|
+
...backup.encryption !== void 0 ? encryptionCommands(plugin, name, info, backup.encryption) : [],
|
|
3550
|
+
...settingCommands(plugin, name, info, backup),
|
|
3551
|
+
...prune
|
|
3552
|
+
];
|
|
3553
|
+
for (const cmd of cmds) await ctx.run(...cmd);
|
|
3554
|
+
logDone();
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
function parseBackupSchedule(cronLine) {
|
|
3558
|
+
const parts = cronLine.trim().split(/\s+/);
|
|
3559
|
+
if (parts.length < 8) return null;
|
|
3560
|
+
const schedule = parts.slice(0, 5).join(" ");
|
|
3561
|
+
const bucket = parts[parts.length - 1];
|
|
3562
|
+
return { schedule, bucket };
|
|
3563
|
+
}
|
|
3564
|
+
async function exportServiceBackup(ctx, plugin, name) {
|
|
3565
|
+
let info;
|
|
3566
|
+
try {
|
|
3567
|
+
info = await readServiceInfo(ctx, plugin, name);
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
logWarn(name, `could not read the backup of ${plugin} service '${name}', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
3570
|
+
return void 0;
|
|
3571
|
+
}
|
|
1788
3572
|
const schedule = field(info, "backup-schedule");
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
3573
|
+
if (schedule === "") {
|
|
3574
|
+
if (hasBackup(info)) {
|
|
3575
|
+
logWarn(name, `${plugin} service '${name}' has backup ${describeServerBackup(info)} but no default schedule; its backup is left out of the export`);
|
|
3576
|
+
}
|
|
3577
|
+
return void 0;
|
|
3578
|
+
}
|
|
3579
|
+
const out = {
|
|
3580
|
+
schedule,
|
|
3581
|
+
bucket: field(info, "backup-bucket"),
|
|
3582
|
+
auth: {
|
|
3583
|
+
access_key_id: EXPORT_PLACEHOLDER,
|
|
3584
|
+
secret_access_key: EXPORT_PLACEHOLDER,
|
|
3585
|
+
region: EXPORT_PLACEHOLDER,
|
|
3586
|
+
signature_version: EXPORT_PLACEHOLDER,
|
|
3587
|
+
endpoint: EXPORT_PLACEHOLDER
|
|
3588
|
+
}
|
|
3589
|
+
};
|
|
3590
|
+
if (publicKeyStored(info)) {
|
|
3591
|
+
out.encryption = { public_key_id: field(info, "backup-public-key-id") };
|
|
3592
|
+
if (field(info, "backup-keyserver") !== "") out.encryption.keyserver = field(info, "backup-keyserver");
|
|
3593
|
+
} else if (passphraseStored(info)) {
|
|
3594
|
+
out.encryption = { passphrase: EXPORT_PLACEHOLDER };
|
|
3595
|
+
}
|
|
3596
|
+
for (const { key, property } of SETTINGS) {
|
|
3597
|
+
if (field(info, property) !== "") out[key] = field(info, property);
|
|
3598
|
+
}
|
|
3599
|
+
if (field(info, "backup-timestamp") === "false") out.timestamp = false;
|
|
3600
|
+
return out;
|
|
1793
3601
|
}
|
|
1794
3602
|
|
|
1795
3603
|
// src/modules/config.ts
|
|
@@ -1836,7 +3644,7 @@ async function readGlobalEnvDrift(ctx, env, current) {
|
|
|
1836
3644
|
}
|
|
1837
3645
|
|
|
1838
3646
|
// src/modules/postgres.ts
|
|
1839
|
-
async function ensurePostgres(ctx, services) {
|
|
3647
|
+
async function ensurePostgres(ctx, services, volumes, opts = {}) {
|
|
1840
3648
|
const created = /* @__PURE__ */ new Set();
|
|
1841
3649
|
for (const [name, config] of Object.entries(services)) {
|
|
1842
3650
|
logAction("services", `Ensuring ${name}`);
|
|
@@ -1857,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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
const backup = config.backup;
|
|
1869
|
-
if (backup === void 0 && !opts.prune) continue;
|
|
1870
|
-
if (backup === void 0) {
|
|
1871
|
-
let info2;
|
|
1872
|
-
try {
|
|
1873
|
-
info2 = await readServiceInfo(ctx, "postgres", name);
|
|
1874
|
-
} catch (err) {
|
|
1875
|
-
logWarn("services", `could not read backup state for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1876
|
-
continue;
|
|
1877
|
-
}
|
|
1878
|
-
if (hasBackup(info2)) {
|
|
1879
|
-
logWarn("services", `${name}: has a backup the file does not declare (${describeServerBackup(info2)}); --prune leaves it, set backup: false to remove it`);
|
|
1880
|
-
}
|
|
1881
|
-
continue;
|
|
1882
|
-
}
|
|
1883
|
-
if (opts.dryRunCreated?.has(name)) {
|
|
1884
|
-
logAction("services", `Configuring backup for ${name}`);
|
|
1885
|
-
if (backup !== false) {
|
|
1886
|
-
const { schedule: schedule2, bucket: bucket2, auth: auth2 } = backup;
|
|
1887
|
-
await ctx.run(
|
|
1888
|
-
"postgres:backup-auth",
|
|
1889
|
-
name,
|
|
1890
|
-
auth2.access_key_id,
|
|
1891
|
-
auth2.secret_access_key,
|
|
1892
|
-
auth2.region,
|
|
1893
|
-
auth2.signature_version,
|
|
1894
|
-
auth2.endpoint
|
|
1895
|
-
);
|
|
1896
|
-
await ctx.run("postgres:backup-schedule", name, schedule2, bucket2);
|
|
1897
|
-
}
|
|
1898
|
-
logDone();
|
|
1899
|
-
continue;
|
|
1900
|
-
}
|
|
1901
|
-
const info = await readServiceInfo(ctx, "postgres", name);
|
|
1902
|
-
logAction("services", `Configuring backup for ${name}`);
|
|
1903
|
-
const fields = new Set(backupMismatches(info, backup).map((m) => m.field));
|
|
1904
|
-
if (fields.size === 0) {
|
|
1905
|
-
logSkip();
|
|
1906
|
-
continue;
|
|
1907
|
-
}
|
|
1908
|
-
if (backup === false) {
|
|
1909
|
-
if (String(info["backup-schedule"] ?? "") !== "") await ctx.run("postgres:backup-unschedule", name);
|
|
1910
|
-
await ctx.run("postgres:backup-deauth", name);
|
|
1911
|
-
logDone();
|
|
3668
|
+
try {
|
|
3669
|
+
args.push(...createArgs2(config, await resolveServiceMounts(ctx, volumes, config.mounts, opts.failedVolumes)));
|
|
3670
|
+
await ctx.run(...args);
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
if (!opts.failures) throw error;
|
|
3673
|
+
console.log("");
|
|
3674
|
+
logError(name, error instanceof Error ? error.message : String(error));
|
|
3675
|
+
opts.failures.push({ service: name, error });
|
|
1912
3676
|
continue;
|
|
1913
3677
|
}
|
|
1914
|
-
|
|
1915
|
-
if (fields.has("backup.auth")) {
|
|
1916
|
-
await ctx.run(
|
|
1917
|
-
"postgres:backup-auth",
|
|
1918
|
-
name,
|
|
1919
|
-
auth.access_key_id,
|
|
1920
|
-
auth.secret_access_key,
|
|
1921
|
-
auth.region,
|
|
1922
|
-
auth.signature_version,
|
|
1923
|
-
auth.endpoint
|
|
1924
|
-
);
|
|
1925
|
-
}
|
|
1926
|
-
if (fields.has("backup.schedule")) {
|
|
1927
|
-
await ctx.run("postgres:backup-schedule", name, schedule, bucket);
|
|
1928
|
-
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
|
|
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
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
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
|
-
|
|
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}
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
2392
|
-
|
|
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
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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
|
|
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
|
|
2718
|
-
if (
|
|
2719
|
-
else if (
|
|
4423
|
+
const same2 = safeInSync(resource, current, desired, app, result.readWarnings);
|
|
4424
|
+
if (same2 === void 0) status = "unknown";
|
|
4425
|
+
else if (same2) status = removedHere.length > 0 ? "extra" : "in-sync";
|
|
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
|
|
2887
|
-
|
|
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
|
|
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
|
|
2901
|
-
if (
|
|
2902
|
-
const
|
|
2903
|
-
|
|
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 =
|
|
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 = [
|
|
2928
|
-
|
|
2929
|
-
|
|
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-
|
|
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(`
|