dokku-compose 0.13.1 → 0.14.1
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 +38 -10
- package/dist/{chunk-3W53KPE4.js → chunk-WM244P42.js} +1 -1
- package/dist/index.js +1499 -400
- package/dist/{ps-IWBYZRDO.js → ps-QQAVQTEV.js} +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -8,11 +8,11 @@ import {
|
|
|
8
8
|
logError,
|
|
9
9
|
logSkip,
|
|
10
10
|
logWarn
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-WM244P42.js";
|
|
12
12
|
|
|
13
13
|
// src/index.ts
|
|
14
14
|
import { Command } from "commander";
|
|
15
|
-
import * as
|
|
15
|
+
import * as fs4 from "fs";
|
|
16
16
|
import * as yaml3 from "js-yaml";
|
|
17
17
|
import { createRequire } from "module";
|
|
18
18
|
|
|
@@ -22,6 +22,241 @@ import * as yaml from "js-yaml";
|
|
|
22
22
|
|
|
23
23
|
// src/core/schema.ts
|
|
24
24
|
import { z } from "zod";
|
|
25
|
+
|
|
26
|
+
// src/resources/parsers.ts
|
|
27
|
+
function parseReport(raw, namespace) {
|
|
28
|
+
const result = {};
|
|
29
|
+
const prefixPattern = namespace.replace(/-/g, "[\\s-]");
|
|
30
|
+
const prefix = new RegExp(`^${prefixPattern}\\s+`, "i");
|
|
31
|
+
for (const line of raw.split("\n")) {
|
|
32
|
+
if (line.trimStart().startsWith("=====>")) continue;
|
|
33
|
+
const colonIdx = line.indexOf(":");
|
|
34
|
+
if (colonIdx === -1) continue;
|
|
35
|
+
const rawKey = line.slice(0, colonIdx).trim();
|
|
36
|
+
if (!rawKey) continue;
|
|
37
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
38
|
+
const stripped = rawKey.replace(prefix, "");
|
|
39
|
+
const key = stripped.toLowerCase().replace(/\s+/g, "-");
|
|
40
|
+
if (key.startsWith("computed-") || key.startsWith("global-") || key === "last-visited-at") continue;
|
|
41
|
+
if (!value) continue;
|
|
42
|
+
result[key] = value;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function parseBulkReport(raw, namespace, headerNamespace) {
|
|
47
|
+
const result = /* @__PURE__ */ new Map();
|
|
48
|
+
const sections = raw.split(/(?=^=====> )/m).filter((s) => s.trim());
|
|
49
|
+
const nsPattern = (headerNamespace ?? namespace).replace(/-/g, "[\\s-]");
|
|
50
|
+
const headerRe = new RegExp(`^=====> (.+?)\\s+${nsPattern}\\s+information`, "i");
|
|
51
|
+
for (const section of sections) {
|
|
52
|
+
const headerEnd = section.indexOf("\n");
|
|
53
|
+
if (headerEnd === -1) continue;
|
|
54
|
+
const header = section.slice(0, headerEnd);
|
|
55
|
+
const match = header.match(headerRe);
|
|
56
|
+
if (!match) continue;
|
|
57
|
+
const app = match[1];
|
|
58
|
+
result.set(app, parseReport(section, namespace));
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function parseJsonReport(raw, scope) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`report --format json did not return JSON: ${raw.slice(0, 200)}`);
|
|
68
|
+
}
|
|
69
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
70
|
+
throw new Error(`report --format json did not return a JSON object: ${raw.slice(0, 200)}`);
|
|
71
|
+
}
|
|
72
|
+
const result = {};
|
|
73
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
74
|
+
if (value === null || value === void 0 || String(value) === "") continue;
|
|
75
|
+
if (scope === "global") {
|
|
76
|
+
if (key.startsWith("global-")) result[key.slice("global-".length)] = String(value);
|
|
77
|
+
} else if (scope.includes(key)) {
|
|
78
|
+
result[key] = String(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/resources/properties.ts
|
|
85
|
+
async function isAppDeployed(ctx, target) {
|
|
86
|
+
const raw = await ctx.query("ps:report", target, "--deployed");
|
|
87
|
+
return raw.trim() === "true";
|
|
88
|
+
}
|
|
89
|
+
var NGINX_KEYS = [
|
|
90
|
+
"access-log-format",
|
|
91
|
+
"access-log-path",
|
|
92
|
+
"bind-address-ipv4",
|
|
93
|
+
"bind-address-ipv6",
|
|
94
|
+
"client-body-timeout",
|
|
95
|
+
"client-header-timeout",
|
|
96
|
+
"client-max-body-size",
|
|
97
|
+
"disable-custom-config",
|
|
98
|
+
"error-log-path",
|
|
99
|
+
"hsts",
|
|
100
|
+
"hsts-include-subdomains",
|
|
101
|
+
"hsts-max-age",
|
|
102
|
+
"hsts-preload",
|
|
103
|
+
"keepalive-timeout",
|
|
104
|
+
"lingering-timeout",
|
|
105
|
+
"nginx-conf-sigil-path",
|
|
106
|
+
"nginx-service-command",
|
|
107
|
+
"proxy-buffer-size",
|
|
108
|
+
"proxy-buffering",
|
|
109
|
+
"proxy-buffers",
|
|
110
|
+
"proxy-busy-buffers-size",
|
|
111
|
+
"proxy-connect-timeout",
|
|
112
|
+
"proxy-keepalive",
|
|
113
|
+
"proxy-read-timeout",
|
|
114
|
+
"proxy-send-timeout",
|
|
115
|
+
"send-timeout",
|
|
116
|
+
"underscore-in-headers",
|
|
117
|
+
"x-forwarded-for-value",
|
|
118
|
+
"x-forwarded-port-value",
|
|
119
|
+
"x-forwarded-proto-value",
|
|
120
|
+
"x-forwarded-ssl"
|
|
121
|
+
];
|
|
122
|
+
var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
|
|
123
|
+
var LOGS_GLOBAL_KEYS = [...LOGS_KEYS, "vector-image", "vector-networks"];
|
|
124
|
+
var REGISTRY_KEYS = [
|
|
125
|
+
"image-repo",
|
|
126
|
+
"image-repo-template",
|
|
127
|
+
"push-extra-tags",
|
|
128
|
+
"push-on-release",
|
|
129
|
+
"server"
|
|
130
|
+
];
|
|
131
|
+
function propertyRemovals(allowed) {
|
|
132
|
+
return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
|
|
133
|
+
}
|
|
134
|
+
function propertyPrune(setCmd, afterPrune = []) {
|
|
135
|
+
return async (ctx, target, removed) => {
|
|
136
|
+
for (const key of removed) await ctx.run(setCmd, target, key);
|
|
137
|
+
for (const cmd of afterPrune) {
|
|
138
|
+
try {
|
|
139
|
+
await ctx.run(cmd, target);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (await isAppDeployed(ctx, target)) throw err;
|
|
142
|
+
logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function propertyResource(opts) {
|
|
148
|
+
return {
|
|
149
|
+
key: opts.key,
|
|
150
|
+
// Keys only on the server never make the block "changed"; diff shows them as removals.
|
|
151
|
+
additive: true,
|
|
152
|
+
removals: propertyRemovals(opts.settable),
|
|
153
|
+
prune: propertyPrune(opts.setCmd, opts.afterChange),
|
|
154
|
+
async read(ctx, target) {
|
|
155
|
+
if (opts.json) return parseJsonReport(await ctx.queryStrict(`${opts.namespace}:report`, target, "--format", "json"), opts.settable);
|
|
156
|
+
const raw = await ctx.query(`${opts.namespace}:report`, target);
|
|
157
|
+
return parseReport(raw, opts.namespace);
|
|
158
|
+
},
|
|
159
|
+
...opts.json ? {} : {
|
|
160
|
+
async readAll(ctx) {
|
|
161
|
+
const raw = await ctx.query(`${opts.namespace}:report`);
|
|
162
|
+
return parseBulkReport(raw, opts.namespace);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
async onChange(ctx, target, change, deferFollowUp = false) {
|
|
166
|
+
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
167
|
+
await ctx.run(opts.setCmd, target, key, String(value));
|
|
168
|
+
}
|
|
169
|
+
if (opts.afterChange && !deferFollowUp) {
|
|
170
|
+
for (const cmd of opts.afterChange) {
|
|
171
|
+
try {
|
|
172
|
+
await ctx.run(cmd, target);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (await isAppDeployed(ctx, target)) throw err;
|
|
175
|
+
logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
var Nginx = propertyResource({
|
|
183
|
+
key: "nginx",
|
|
184
|
+
namespace: "nginx",
|
|
185
|
+
setCmd: "nginx:set",
|
|
186
|
+
settable: NGINX_KEYS,
|
|
187
|
+
afterChange: ["proxy:build-config"]
|
|
188
|
+
});
|
|
189
|
+
var Logs = propertyResource({
|
|
190
|
+
key: "logs",
|
|
191
|
+
namespace: "logs",
|
|
192
|
+
setCmd: "logs:set",
|
|
193
|
+
settable: LOGS_KEYS,
|
|
194
|
+
json: true
|
|
195
|
+
});
|
|
196
|
+
var Registry = propertyResource({
|
|
197
|
+
key: "registry",
|
|
198
|
+
namespace: "registry",
|
|
199
|
+
setCmd: "registry:set",
|
|
200
|
+
settable: REGISTRY_KEYS
|
|
201
|
+
});
|
|
202
|
+
function schedulerParts(cfg) {
|
|
203
|
+
if (typeof cfg === "string") return { selected: cfg, shell: "" };
|
|
204
|
+
return { selected: cfg?.selected ?? "", shell: cfg?.shell ?? "" };
|
|
205
|
+
}
|
|
206
|
+
function schedulerFromReport(report) {
|
|
207
|
+
const selected = report["selected"] ?? "";
|
|
208
|
+
const shell = report["shell"];
|
|
209
|
+
return shell ? { ...selected ? { selected } : {}, shell } : selected;
|
|
210
|
+
}
|
|
211
|
+
var Scheduler = {
|
|
212
|
+
key: "scheduler",
|
|
213
|
+
// A scheduler setting on the app that the file does not declare.
|
|
214
|
+
removals: (current, desired) => {
|
|
215
|
+
const have = schedulerParts(current);
|
|
216
|
+
const want = schedulerParts(desired);
|
|
217
|
+
const removed = [];
|
|
218
|
+
if (have.selected && !want.selected) removed.push("scheduler");
|
|
219
|
+
if (have.shell && !want.shell) removed.push("shell");
|
|
220
|
+
return removed;
|
|
221
|
+
},
|
|
222
|
+
prune: async (ctx, target, removed) => {
|
|
223
|
+
for (const label of removed) await ctx.run("scheduler:set", target, label === "shell" ? "shell" : "selected");
|
|
224
|
+
},
|
|
225
|
+
inSync: (current, desired) => {
|
|
226
|
+
const have = schedulerParts(current);
|
|
227
|
+
const want = schedulerParts(desired);
|
|
228
|
+
return (!want.selected || want.selected === have.selected) && (!want.shell || want.shell === have.shell);
|
|
229
|
+
},
|
|
230
|
+
async read(ctx, target) {
|
|
231
|
+
const raw = await ctx.query("scheduler:report", target);
|
|
232
|
+
return schedulerFromReport(parseReport(raw, "scheduler"));
|
|
233
|
+
},
|
|
234
|
+
async readAll(ctx) {
|
|
235
|
+
const raw = await ctx.query("scheduler:report");
|
|
236
|
+
const bulk = parseBulkReport(raw, "scheduler");
|
|
237
|
+
const result = /* @__PURE__ */ new Map();
|
|
238
|
+
for (const [app, report] of bulk) result.set(app, schedulerFromReport(report));
|
|
239
|
+
return result;
|
|
240
|
+
},
|
|
241
|
+
async onChange(ctx, target, change) {
|
|
242
|
+
const have = schedulerParts(change.before);
|
|
243
|
+
const want = schedulerParts(change.after);
|
|
244
|
+
if (want.selected && want.selected !== have.selected) await ctx.run("scheduler:set", target, "selected", want.selected);
|
|
245
|
+
if (want.shell && want.shell !== have.shell) await ctx.run("scheduler:set", target, "shell", want.shell);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// src/core/schema.ts
|
|
250
|
+
function propertyMap(namespace, keys, value) {
|
|
251
|
+
return z.record(z.string(), value).superRefine((map, ctx) => {
|
|
252
|
+
for (const key of Object.keys(map)) {
|
|
253
|
+
if (!keys.includes(key)) {
|
|
254
|
+
ctx.addIssue({ code: "custom", path: [key], message: `'${key}' is not a ${namespace}:set property (valid: ${keys.join(", ")})` });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
var StringOrNumber = z.union([z.string(), z.number()]);
|
|
25
260
|
var PortSchema = z.string().regex(
|
|
26
261
|
/^(http|https|tcp|udp):\d+:\d+$/,
|
|
27
262
|
"Port must be scheme:host:container (e.g. http:80:3000)"
|
|
@@ -34,46 +269,79 @@ var ChecksSchema = z.union([
|
|
|
34
269
|
z.literal(false),
|
|
35
270
|
z.object({
|
|
36
271
|
disabled: z.array(z.string()).optional(),
|
|
37
|
-
skipped: z.array(z.string()).optional()
|
|
38
|
-
|
|
272
|
+
skipped: z.array(z.string()).optional(),
|
|
273
|
+
// The only `checks:set` key (Dokku 0.38.31); anything else is refused.
|
|
274
|
+
"wait-to-retire": z.union([z.string(), z.number(), z.boolean()]).optional()
|
|
275
|
+
}).strict()
|
|
39
276
|
]);
|
|
40
277
|
var GitSchema = z.object({
|
|
41
|
-
deploy_branch: z.string().optional()
|
|
42
|
-
|
|
278
|
+
deploy_branch: z.string().optional(),
|
|
279
|
+
keep_git_dir: z.union([z.string(), z.boolean()]).optional(),
|
|
280
|
+
rev_env_var: z.string().optional(),
|
|
281
|
+
source_image: z.string().optional(),
|
|
282
|
+
// Dokku reads `archive-max-files` and `archive-max-size` only from the global setting
|
|
283
|
+
// (`git:set --global`), which dokku-compose does not manage yet; a per-app value never applies.
|
|
284
|
+
archive_max_files: z.any().optional().refine((v) => v === void 0, { message: "archive_max_files is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-files <n>`" }),
|
|
285
|
+
archive_max_size: z.any().optional().refine((v) => v === void 0, { message: "archive_max_size is read only from the global git setting, which dokku-compose does not manage; run `dokku git:set --global archive-max-size <n>`" })
|
|
286
|
+
}).strict();
|
|
287
|
+
var LinkSchema = z.union([
|
|
288
|
+
z.string(),
|
|
289
|
+
z.object({
|
|
290
|
+
service: z.string(),
|
|
291
|
+
alias: z.string().min(1).optional(),
|
|
292
|
+
env_var: z.string().min(1).optional(),
|
|
293
|
+
querystring: z.string().min(1).optional()
|
|
294
|
+
}).strict().refine((l) => !(l.alias && l.env_var), { message: "`alias` and `env_var` cannot be combined" })
|
|
295
|
+
]);
|
|
43
296
|
var AppSchema = z.object({
|
|
44
297
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
45
|
-
links: z.array(
|
|
298
|
+
links: z.array(LinkSchema).optional(),
|
|
46
299
|
ports: z.array(PortSchema).optional(),
|
|
47
300
|
env: EnvMapSchema.optional(),
|
|
301
|
+
/** App env keys something else sets: names only; `diff` stops noting them. */
|
|
302
|
+
env_external: z.array(z.string().min(1)).optional(),
|
|
48
303
|
ssl: z.union([
|
|
49
304
|
z.literal(false),
|
|
50
305
|
z.literal(true),
|
|
51
306
|
z.object({ certfile: z.string(), keyfile: z.string() })
|
|
52
307
|
]).optional(),
|
|
53
308
|
storage: z.array(z.string()).optional(),
|
|
54
|
-
proxy: z.object({
|
|
309
|
+
proxy: z.object({
|
|
310
|
+
enabled: z.boolean().optional(),
|
|
311
|
+
type: z.string().optional(),
|
|
312
|
+
proxy_port: StringOrNumber.optional(),
|
|
313
|
+
proxy_ssl_port: StringOrNumber.optional()
|
|
314
|
+
}).strict().optional(),
|
|
55
315
|
networks: z.array(z.string()).optional(),
|
|
56
316
|
network: z.object({
|
|
57
317
|
attach_post_create: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
58
318
|
initial_network: z.union([z.string(), z.literal(false)]).optional(),
|
|
59
319
|
bind_all_interfaces: z.boolean().optional(),
|
|
320
|
+
static_web_listener: z.union([z.string(), z.literal(false)]).optional(),
|
|
60
321
|
tld: z.union([z.string(), z.literal(false)]).optional()
|
|
61
322
|
}).optional(),
|
|
62
|
-
nginx:
|
|
63
|
-
logs:
|
|
64
|
-
|
|
65
|
-
|
|
323
|
+
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
|
|
324
|
+
logs: propertyMap("logs", LOGS_KEYS, StringOrNumber).optional(),
|
|
325
|
+
// `auth-servers` is report-only: it is a registry login, not a `registry:set` key.
|
|
326
|
+
registry: propertyMap("registry", REGISTRY_KEYS, z.union([z.string(), z.boolean()])).optional(),
|
|
327
|
+
scheduler: z.union([z.string(), z.object({ selected: z.string().optional(), shell: z.string().optional() }).strict()]).optional(),
|
|
66
328
|
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(),
|
|
67
329
|
ps: z.object({
|
|
68
330
|
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
69
331
|
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
70
|
-
"stop-timeout-seconds": 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()
|
|
71
337
|
}).strict().optional(),
|
|
72
338
|
checks: ChecksSchema.optional(),
|
|
73
339
|
build: z.object({
|
|
74
340
|
dockerfile: z.string().optional(),
|
|
75
341
|
app_json: z.string().optional(),
|
|
76
342
|
context: z.string().optional(),
|
|
343
|
+
selected: z.string().optional(),
|
|
344
|
+
skip_cleanup: z.union([z.string(), z.boolean()]).optional(),
|
|
77
345
|
args: z.record(z.string(), z.string()).optional()
|
|
78
346
|
}).optional(),
|
|
79
347
|
docker_options: z.object({
|
|
@@ -98,7 +366,8 @@ var ServiceBackupSchema = z.object({
|
|
|
98
366
|
var PostgresSchema = z.object({
|
|
99
367
|
version: z.string().optional(),
|
|
100
368
|
image: z.string().optional(),
|
|
101
|
-
backup
|
|
369
|
+
// false removes the service's backup schedule and credentials; absent leaves them alone
|
|
370
|
+
backup: z.union([ServiceBackupSchema, z.literal(false)]).optional()
|
|
102
371
|
});
|
|
103
372
|
var RedisSchema = z.object({
|
|
104
373
|
version: z.string().optional(),
|
|
@@ -124,8 +393,9 @@ var ConfigSchema = z.object({
|
|
|
124
393
|
apps: z.record(z.string(), AppSchema),
|
|
125
394
|
domains: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
126
395
|
env: EnvMapSchema.optional(),
|
|
127
|
-
nginx:
|
|
128
|
-
|
|
396
|
+
nginx: propertyMap("nginx", NGINX_KEYS, StringOrNumber).optional(),
|
|
397
|
+
// The top-level block is also applied with `--global`, which takes two more keys.
|
|
398
|
+
logs: propertyMap("logs", LOGS_GLOBAL_KEYS, StringOrNumber).optional(),
|
|
129
399
|
git: GitSchema.optional()
|
|
130
400
|
});
|
|
131
401
|
function parseConfig(raw) {
|
|
@@ -190,7 +460,7 @@ function maskSensitiveArgs(cmd) {
|
|
|
190
460
|
return `${key}=${maskValue(value)}`;
|
|
191
461
|
});
|
|
192
462
|
masked = masked.replace(
|
|
193
|
-
/^(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
|
|
463
|
+
/^(?!.*--password-stdin)(registry:login\s+(?:--global\s+)?\S+\s+\S+\s+)(\S+)/,
|
|
194
464
|
(_, prefix, password) => `${prefix}${maskValue(password)}`
|
|
195
465
|
);
|
|
196
466
|
masked = masked.replace(
|
|
@@ -215,7 +485,7 @@ function maskSensitiveArgsList(args) {
|
|
|
215
485
|
masked[i] = `${m[1]}=${maskValue(m[2])}`;
|
|
216
486
|
}
|
|
217
487
|
}
|
|
218
|
-
if (masked[0] === "registry:login") {
|
|
488
|
+
if (masked[0] === "registry:login" && !masked.includes("--password-stdin")) {
|
|
219
489
|
mark(masked.length - 1);
|
|
220
490
|
}
|
|
221
491
|
if (/^\S+:backup-auth$/.test(masked[0] ?? "")) {
|
|
@@ -239,7 +509,7 @@ function maskSensitiveData(data) {
|
|
|
239
509
|
if (typeof data === "object") {
|
|
240
510
|
const result = {};
|
|
241
511
|
for (const [key, value] of Object.entries(data)) {
|
|
242
|
-
if (typeof value === "string" && SENSITIVE_RE.test(key)) {
|
|
512
|
+
if (typeof value === "string" && (SENSITIVE_RE.test(key) || /sink/i.test(key))) {
|
|
243
513
|
result[key] = maskValue(value);
|
|
244
514
|
} else if (typeof value === "object" && value !== null) {
|
|
245
515
|
result[key] = maskSensitiveData(value);
|
|
@@ -251,6 +521,13 @@ function maskSensitiveData(data) {
|
|
|
251
521
|
}
|
|
252
522
|
return data;
|
|
253
523
|
}
|
|
524
|
+
function maskQuerystring(qs) {
|
|
525
|
+
return qs.split("&").map((pair) => {
|
|
526
|
+
const i = pair.indexOf("=");
|
|
527
|
+
if (i === -1 || !SENSITIVE_RE.test(pair.slice(0, i))) return pair;
|
|
528
|
+
return `${pair.slice(0, i)}=${maskValue(pair.slice(i + 1))}`;
|
|
529
|
+
}).join("&");
|
|
530
|
+
}
|
|
254
531
|
|
|
255
532
|
// src/core/dokku.ts
|
|
256
533
|
var DokkuCommandError = class extends Error {
|
|
@@ -275,17 +552,18 @@ function createRunner(opts = {}) {
|
|
|
275
552
|
if (/^[A-Za-z0-9_\-:.,/=@]+$/.test(arg)) return arg;
|
|
276
553
|
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
277
554
|
}
|
|
278
|
-
async function execDokku(args) {
|
|
555
|
+
async function execDokku(args, input) {
|
|
556
|
+
const stdio = input === void 0 ? { stdin: "ignore" } : { input };
|
|
279
557
|
if (opts.host) {
|
|
280
558
|
try {
|
|
281
|
-
const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)],
|
|
559
|
+
const result = await execa("ssh", [...sshControlFlags, `dokku@${opts.host}`, "--", ...args.map(shellQuote)], stdio);
|
|
282
560
|
return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
|
|
283
561
|
} catch (e) {
|
|
284
562
|
return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
|
|
285
563
|
}
|
|
286
564
|
} else {
|
|
287
565
|
try {
|
|
288
|
-
const result = await execa("dokku", args,
|
|
566
|
+
const result = await execa("dokku", args, stdio);
|
|
289
567
|
return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: result.exitCode ?? 0, ok: true };
|
|
290
568
|
} catch (e) {
|
|
291
569
|
return { stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), exitCode: e.exitCode ?? 1, ok: false };
|
|
@@ -317,6 +595,18 @@ function createRunner(opts = {}) {
|
|
|
317
595
|
const { ok } = await execDokku(args);
|
|
318
596
|
return ok;
|
|
319
597
|
},
|
|
598
|
+
async status(args, input) {
|
|
599
|
+
const { exitCode, ok } = await execDokku(args, input);
|
|
600
|
+
return ok ? 0 : exitCode;
|
|
601
|
+
},
|
|
602
|
+
async runWithInput(input, ...args) {
|
|
603
|
+
if (opts.dryRun) {
|
|
604
|
+
log.push(args.map((a) => a.includes(" ") ? shellQuote(a) : a).join(" "));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const { ok, exitCode, stderr } = await execDokku(args, input);
|
|
608
|
+
if (!ok) throw new DokkuCommandError(args, exitCode, input ? stderr.split(input).join("****") : stderr);
|
|
609
|
+
},
|
|
320
610
|
async close() {
|
|
321
611
|
if (!opts.host || !controlPath) return;
|
|
322
612
|
try {
|
|
@@ -331,9 +621,23 @@ function createRunner(opts = {}) {
|
|
|
331
621
|
function createContext(runner) {
|
|
332
622
|
const cache = /* @__PURE__ */ new Map();
|
|
333
623
|
const commands = [];
|
|
624
|
+
const restarts = /* @__PURE__ */ new Map();
|
|
625
|
+
const rebuilds = /* @__PURE__ */ new Map();
|
|
334
626
|
return {
|
|
335
627
|
commands,
|
|
336
628
|
runner,
|
|
629
|
+
restarts,
|
|
630
|
+
rebuilds,
|
|
631
|
+
needsRebuild(app, reason) {
|
|
632
|
+
const reasons = rebuilds.get(app) ?? [];
|
|
633
|
+
if (!reasons.includes(reason)) reasons.push(reason);
|
|
634
|
+
rebuilds.set(app, reasons);
|
|
635
|
+
},
|
|
636
|
+
needsRestart(app, reason) {
|
|
637
|
+
const reasons = restarts.get(app) ?? [];
|
|
638
|
+
if (!reasons.includes(reason)) reasons.push(reason);
|
|
639
|
+
restarts.set(app, reasons);
|
|
640
|
+
},
|
|
337
641
|
query(...args) {
|
|
338
642
|
const key = args.join("\0");
|
|
339
643
|
if (!cache.has(key)) {
|
|
@@ -347,6 +651,13 @@ function createContext(runner) {
|
|
|
347
651
|
check(...args) {
|
|
348
652
|
return runner.check(...args);
|
|
349
653
|
},
|
|
654
|
+
status(args, input) {
|
|
655
|
+
return runner.status(args, input);
|
|
656
|
+
},
|
|
657
|
+
async runWithInput(input, ...args) {
|
|
658
|
+
commands.push(args);
|
|
659
|
+
await runner.runWithInput(input, ...args);
|
|
660
|
+
},
|
|
350
661
|
async run(...args) {
|
|
351
662
|
commands.push(args);
|
|
352
663
|
await runner.run(...args);
|
|
@@ -358,6 +669,17 @@ function createContext(runner) {
|
|
|
358
669
|
}
|
|
359
670
|
|
|
360
671
|
// src/core/change.ts
|
|
672
|
+
function sameValue(x, y, subset = false) {
|
|
673
|
+
if (x !== null && y !== null && typeof x === "object" && typeof y === "object") {
|
|
674
|
+
if (Array.isArray(x) || Array.isArray(y)) {
|
|
675
|
+
if (!Array.isArray(x) || !Array.isArray(y)) return false;
|
|
676
|
+
return x.length === y.length && x.every((v) => y.some((w) => sameValue(v, w))) && y.every((w) => x.some((v) => sameValue(v, w)));
|
|
677
|
+
}
|
|
678
|
+
const kx = Object.keys(x), ky = Object.keys(y);
|
|
679
|
+
return (subset ? ky : kx).every((k) => k in x && k in y && sameValue(x[k], y[k], subset)) && (subset || kx.length === ky.length);
|
|
680
|
+
}
|
|
681
|
+
return String(x) === String(y);
|
|
682
|
+
}
|
|
361
683
|
function computeChange(before, after) {
|
|
362
684
|
if (before === null || before === void 0 || after === null || after === void 0) {
|
|
363
685
|
return { before, after, changed: before !== after };
|
|
@@ -385,7 +707,7 @@ function computeChange(before, after) {
|
|
|
385
707
|
for (const key of allKeys) {
|
|
386
708
|
if (!(key in b)) added[key] = a[key];
|
|
387
709
|
else if (!(key in a)) removed.push(key);
|
|
388
|
-
else if (
|
|
710
|
+
else if (!sameValue(b[key], a[key])) modified[key] = a[key];
|
|
389
711
|
}
|
|
390
712
|
const changed = Object.keys(added).length > 0 || removed.length > 0 || Object.keys(modified).length > 0;
|
|
391
713
|
return { before, after, changed, added, removed, modified };
|
|
@@ -394,6 +716,9 @@ function computeChange(before, after) {
|
|
|
394
716
|
}
|
|
395
717
|
function computeAdditiveChange(before, after) {
|
|
396
718
|
const change = computeChange(before ?? {}, after);
|
|
719
|
+
const b = before ?? {};
|
|
720
|
+
const a = after;
|
|
721
|
+
for (const k of Object.keys(change.modified)) if (sameValue(b[k], a[k], true)) delete change.modified[k];
|
|
397
722
|
change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
|
|
398
723
|
return change;
|
|
399
724
|
}
|
|
@@ -420,6 +745,17 @@ async function reconcile(resource, ctx, target, desired, onRemovals, prune = fal
|
|
|
420
745
|
return;
|
|
421
746
|
}
|
|
422
747
|
const before = await resource.read(ctx, target);
|
|
748
|
+
if (resource.inSync) {
|
|
749
|
+
if (before !== void 0 && resource.inSync(before, desired)) {
|
|
750
|
+
logSkip();
|
|
751
|
+
} else {
|
|
752
|
+
await resource.onChange(ctx, target, { before, after: desired, changed: true });
|
|
753
|
+
logDone();
|
|
754
|
+
}
|
|
755
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
756
|
+
if (canPrune) await pruneFrom(before);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
423
759
|
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
424
760
|
if (canPrune) {
|
|
425
761
|
const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
|
|
@@ -457,50 +793,10 @@ var Apps = {
|
|
|
457
793
|
}
|
|
458
794
|
};
|
|
459
795
|
|
|
460
|
-
// src/resources/parsers.ts
|
|
461
|
-
function parseReport(raw, namespace) {
|
|
462
|
-
const result = {};
|
|
463
|
-
const prefixPattern = namespace.replace(/-/g, "[\\s-]");
|
|
464
|
-
const prefix = new RegExp(`^${prefixPattern}\\s+`, "i");
|
|
465
|
-
for (const line of raw.split("\n")) {
|
|
466
|
-
if (line.trimStart().startsWith("=====>")) continue;
|
|
467
|
-
const colonIdx = line.indexOf(":");
|
|
468
|
-
if (colonIdx === -1) continue;
|
|
469
|
-
const rawKey = line.slice(0, colonIdx).trim();
|
|
470
|
-
if (!rawKey) continue;
|
|
471
|
-
const value = line.slice(colonIdx + 1).trim();
|
|
472
|
-
const stripped = rawKey.replace(prefix, "");
|
|
473
|
-
const key = stripped.toLowerCase().replace(/\s+/g, "-");
|
|
474
|
-
if (key.startsWith("computed-") || key.startsWith("global-") || key === "last-visited-at") continue;
|
|
475
|
-
if (!value) continue;
|
|
476
|
-
result[key] = value;
|
|
477
|
-
}
|
|
478
|
-
return result;
|
|
479
|
-
}
|
|
480
|
-
function parseBulkReport(raw, namespace, headerNamespace) {
|
|
481
|
-
const result = /* @__PURE__ */ new Map();
|
|
482
|
-
const sections = raw.split(/(?=^=====> )/m).filter((s) => s.trim());
|
|
483
|
-
const nsPattern = (headerNamespace ?? namespace).replace(/-/g, "[\\s-]");
|
|
484
|
-
const headerRe = new RegExp(`^=====> (.+?)\\s+${nsPattern}\\s+information`, "i");
|
|
485
|
-
for (const section of sections) {
|
|
486
|
-
const headerEnd = section.indexOf("\n");
|
|
487
|
-
if (headerEnd === -1) continue;
|
|
488
|
-
const header = section.slice(0, headerEnd);
|
|
489
|
-
const match = header.match(headerRe);
|
|
490
|
-
if (!match) continue;
|
|
491
|
-
const app = match[1];
|
|
492
|
-
result.set(app, parseReport(section, namespace));
|
|
493
|
-
}
|
|
494
|
-
return result;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
796
|
// src/resources/lists.ts
|
|
498
797
|
function splitWords(raw) {
|
|
499
798
|
return raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
|
|
500
799
|
}
|
|
501
|
-
function splitLines(raw) {
|
|
502
|
-
return raw.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
503
|
-
}
|
|
504
800
|
var Ports = {
|
|
505
801
|
key: "ports",
|
|
506
802
|
read: async (ctx, target) => {
|
|
@@ -520,24 +816,53 @@ var Ports = {
|
|
|
520
816
|
await ctx.run("ports:set", target, ...change.after);
|
|
521
817
|
}
|
|
522
818
|
};
|
|
819
|
+
function parseEnabled(raw) {
|
|
820
|
+
const v = raw.trim();
|
|
821
|
+
if (v === "true") return true;
|
|
822
|
+
if (v === "false") return false;
|
|
823
|
+
throw new Error(`unexpected domains:report --domains-app-enabled output: '${v}'`);
|
|
824
|
+
}
|
|
523
825
|
var Domains = {
|
|
524
826
|
key: "domains",
|
|
525
827
|
read: async (ctx, target) => {
|
|
828
|
+
let enabledRaw;
|
|
829
|
+
try {
|
|
830
|
+
enabledRaw = await ctx.queryStrict("domains:report", target, "--domains-app-enabled");
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (!await ctx.check("apps:exists", target)) return [];
|
|
833
|
+
throw err;
|
|
834
|
+
}
|
|
835
|
+
const enabled = parseEnabled(enabledRaw);
|
|
836
|
+
if (!enabled) return false;
|
|
526
837
|
const raw = await ctx.query("domains:report", target, "--domains-app-vhosts");
|
|
527
|
-
return
|
|
838
|
+
return splitWords(raw);
|
|
528
839
|
},
|
|
529
840
|
readAll: async (ctx) => {
|
|
530
841
|
const raw = await ctx.query("domains:report");
|
|
531
842
|
const bulk = parseBulkReport(raw, "domains");
|
|
532
843
|
const result = /* @__PURE__ */ new Map();
|
|
533
844
|
for (const [app, report] of bulk) {
|
|
534
|
-
|
|
845
|
+
if (report["app-enabled"] === void 0) {
|
|
846
|
+
result.set(app, splitWords(report["app-vhosts"] ?? ""));
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
result.set(app, parseEnabled(report["app-enabled"]) ? splitWords(report["app-vhosts"] ?? "") : false);
|
|
535
850
|
}
|
|
536
851
|
return result;
|
|
537
852
|
},
|
|
538
|
-
onChange: async (ctx, target, {
|
|
539
|
-
|
|
540
|
-
|
|
853
|
+
onChange: async (ctx, target, { before, after }) => {
|
|
854
|
+
if (after === false) {
|
|
855
|
+
await ctx.run("domains:disable", target);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (Array.isArray(before)) {
|
|
859
|
+
const { added, removed } = computeChange(before, after);
|
|
860
|
+
for (const d of removed) await ctx.run("domains:remove", target, d);
|
|
861
|
+
for (const d of added) await ctx.run("domains:add", target, d);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
await ctx.run("domains:enable", target);
|
|
865
|
+
await ctx.run("domains:set", target, ...after);
|
|
541
866
|
}
|
|
542
867
|
};
|
|
543
868
|
function parseDeployMounts(raw) {
|
|
@@ -559,146 +884,14 @@ var Storage = {
|
|
|
559
884
|
return result;
|
|
560
885
|
},
|
|
561
886
|
onChange: async (ctx, target, { added, removed }) => {
|
|
562
|
-
for (const m of removed)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
// src/resources/properties.ts
|
|
568
|
-
async function isAppDeployed(ctx, target) {
|
|
569
|
-
const raw = await ctx.query("ps:report", target, "--deployed");
|
|
570
|
-
return raw.trim() === "true";
|
|
571
|
-
}
|
|
572
|
-
var NGINX_KEYS = [
|
|
573
|
-
"access-log-format",
|
|
574
|
-
"access-log-path",
|
|
575
|
-
"bind-address-ipv4",
|
|
576
|
-
"bind-address-ipv6",
|
|
577
|
-
"client-body-timeout",
|
|
578
|
-
"client-header-timeout",
|
|
579
|
-
"client-max-body-size",
|
|
580
|
-
"disable-custom-config",
|
|
581
|
-
"error-log-path",
|
|
582
|
-
"hsts",
|
|
583
|
-
"hsts-include-subdomains",
|
|
584
|
-
"hsts-max-age",
|
|
585
|
-
"hsts-preload",
|
|
586
|
-
"keepalive-timeout",
|
|
587
|
-
"lingering-timeout",
|
|
588
|
-
"proxy-buffer-size",
|
|
589
|
-
"proxy-buffering",
|
|
590
|
-
"proxy-buffers",
|
|
591
|
-
"proxy-busy-buffers-size",
|
|
592
|
-
"proxy-connect-timeout",
|
|
593
|
-
"proxy-keepalive",
|
|
594
|
-
"proxy-read-timeout",
|
|
595
|
-
"proxy-send-timeout",
|
|
596
|
-
"send-timeout",
|
|
597
|
-
"underscore-in-headers",
|
|
598
|
-
"x-forwarded-for-value",
|
|
599
|
-
"x-forwarded-port-value",
|
|
600
|
-
"x-forwarded-proto-value",
|
|
601
|
-
"x-forwarded-ssl"
|
|
602
|
-
];
|
|
603
|
-
var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
|
|
604
|
-
var REGISTRY_KEYS = [
|
|
605
|
-
"auth-servers",
|
|
606
|
-
"image-repo",
|
|
607
|
-
"image-repo-template",
|
|
608
|
-
"push-extra-tags",
|
|
609
|
-
"push-on-release",
|
|
610
|
-
"server"
|
|
611
|
-
];
|
|
612
|
-
function propertyRemovals(allowed) {
|
|
613
|
-
return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
|
|
614
|
-
}
|
|
615
|
-
function propertyPrune(setCmd, afterPrune = []) {
|
|
616
|
-
return async (ctx, target, removed) => {
|
|
617
|
-
for (const key of removed) await ctx.run(setCmd, target, key);
|
|
618
|
-
for (const cmd of afterPrune) {
|
|
619
|
-
try {
|
|
620
|
-
await ctx.run(cmd, target);
|
|
621
|
-
} catch (err) {
|
|
622
|
-
if (await isAppDeployed(ctx, target)) throw err;
|
|
623
|
-
logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
|
|
624
|
-
}
|
|
887
|
+
for (const m of removed) {
|
|
888
|
+
await ctx.run("storage:unmount", target, m);
|
|
889
|
+
ctx.needsRestart(target, "storage");
|
|
625
890
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
return {
|
|
630
|
-
key: opts.key,
|
|
631
|
-
// Keys only on the server never make the block "changed"; diff shows them as removals.
|
|
632
|
-
additive: true,
|
|
633
|
-
removals: propertyRemovals(opts.settable),
|
|
634
|
-
prune: propertyPrune(opts.setCmd, opts.afterChange),
|
|
635
|
-
async read(ctx, target) {
|
|
636
|
-
const raw = await ctx.query(`${opts.namespace}:report`, target);
|
|
637
|
-
return parseReport(raw, opts.namespace);
|
|
638
|
-
},
|
|
639
|
-
async readAll(ctx) {
|
|
640
|
-
const raw = await ctx.query(`${opts.namespace}:report`);
|
|
641
|
-
return parseBulkReport(raw, opts.namespace);
|
|
642
|
-
},
|
|
643
|
-
async onChange(ctx, target, change, deferFollowUp = false) {
|
|
644
|
-
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
645
|
-
await ctx.run(opts.setCmd, target, key, String(value));
|
|
646
|
-
}
|
|
647
|
-
if (opts.afterChange && !deferFollowUp) {
|
|
648
|
-
for (const cmd of opts.afterChange) {
|
|
649
|
-
try {
|
|
650
|
-
await ctx.run(cmd, target);
|
|
651
|
-
} catch (err) {
|
|
652
|
-
if (await isAppDeployed(ctx, target)) throw err;
|
|
653
|
-
logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
var Nginx = propertyResource({
|
|
661
|
-
key: "nginx",
|
|
662
|
-
namespace: "nginx",
|
|
663
|
-
setCmd: "nginx:set",
|
|
664
|
-
settable: NGINX_KEYS,
|
|
665
|
-
afterChange: ["proxy:build-config"]
|
|
666
|
-
});
|
|
667
|
-
var Logs = propertyResource({
|
|
668
|
-
key: "logs",
|
|
669
|
-
namespace: "logs",
|
|
670
|
-
setCmd: "logs:set",
|
|
671
|
-
settable: LOGS_KEYS
|
|
672
|
-
});
|
|
673
|
-
var Registry = propertyResource({
|
|
674
|
-
key: "registry",
|
|
675
|
-
namespace: "registry",
|
|
676
|
-
setCmd: "registry:set",
|
|
677
|
-
settable: REGISTRY_KEYS
|
|
678
|
-
});
|
|
679
|
-
var Scheduler = {
|
|
680
|
-
key: "scheduler",
|
|
681
|
-
// A scheduler selected on the app but not declared in the file.
|
|
682
|
-
removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
|
|
683
|
-
prune: async (ctx, target) => {
|
|
684
|
-
await ctx.run("scheduler:set", target, "selected");
|
|
685
|
-
},
|
|
686
|
-
async read(ctx, target) {
|
|
687
|
-
const raw = await ctx.query("scheduler:report", target);
|
|
688
|
-
const report = parseReport(raw, "scheduler");
|
|
689
|
-
return report["selected"] ?? "";
|
|
690
|
-
},
|
|
691
|
-
async readAll(ctx) {
|
|
692
|
-
const raw = await ctx.query("scheduler:report");
|
|
693
|
-
const bulk = parseBulkReport(raw, "scheduler");
|
|
694
|
-
const result = /* @__PURE__ */ new Map();
|
|
695
|
-
for (const [app, report] of bulk) {
|
|
696
|
-
result.set(app, report["selected"] ?? "");
|
|
891
|
+
for (const m of added) {
|
|
892
|
+
await ctx.run("storage:mount", target, m);
|
|
893
|
+
ctx.needsRestart(target, "storage");
|
|
697
894
|
}
|
|
698
|
-
return result;
|
|
699
|
-
},
|
|
700
|
-
async onChange(ctx, target, change) {
|
|
701
|
-
await ctx.run("scheduler:set", target, "selected", change.after);
|
|
702
895
|
}
|
|
703
896
|
};
|
|
704
897
|
|
|
@@ -722,6 +915,40 @@ var Proxy = {
|
|
|
722
915
|
await ctx.run(after ? "proxy:enable" : "proxy:disable", target);
|
|
723
916
|
}
|
|
724
917
|
};
|
|
918
|
+
var PROXY_PROP_KEYS = ["type", "proxy_port", "proxy_ssl_port"];
|
|
919
|
+
function proxyPropsFromReport(report) {
|
|
920
|
+
const config = {};
|
|
921
|
+
if (report["type"]) config.type = report["type"];
|
|
922
|
+
if (report["proxy-port"]) config.proxy_port = report["proxy-port"];
|
|
923
|
+
if (report["proxy-ssl-port"]) config.proxy_ssl_port = report["proxy-ssl-port"];
|
|
924
|
+
return config;
|
|
925
|
+
}
|
|
926
|
+
var ProxyProps = {
|
|
927
|
+
key: "proxy_props",
|
|
928
|
+
additive: true,
|
|
929
|
+
// Dokku writes `proxy-port` and `proxy-ssl-port` itself on every nginx build, so they are
|
|
930
|
+
// compared only when declared and are never removals; only `type` is the user's to remove.
|
|
931
|
+
removals: (current, desired) => ["type"].filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
|
|
932
|
+
prune: async (ctx, target, removed) => {
|
|
933
|
+
for (const key of removed) await ctx.run("proxy:set", target, key.replaceAll("_", "-"));
|
|
934
|
+
},
|
|
935
|
+
read: async (ctx, target) => proxyPropsFromReport(parseReport(await ctx.query("proxy:report", target), "proxy")),
|
|
936
|
+
readAll: async (ctx) => {
|
|
937
|
+
const bulk = parseBulkReport(await ctx.query("proxy:report"), "proxy");
|
|
938
|
+
return new Map([...bulk].map(([app, report]) => [app, proxyPropsFromReport(report)]));
|
|
939
|
+
},
|
|
940
|
+
onChange: async (ctx, target, change) => {
|
|
941
|
+
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
942
|
+
await ctx.run("proxy:set", target, key.replaceAll("_", "-"), String(value));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
function proxyProps(proxy) {
|
|
947
|
+
if (!proxy) return void 0;
|
|
948
|
+
const props = {};
|
|
949
|
+
for (const k of PROXY_PROP_KEYS) if (proxy[k] !== void 0) props[k] = proxy[k];
|
|
950
|
+
return Object.keys(props).length > 0 ? props : void 0;
|
|
951
|
+
}
|
|
725
952
|
|
|
726
953
|
// src/core/config-json.ts
|
|
727
954
|
function parseConfigJson(raw, what) {
|
|
@@ -763,6 +990,7 @@ var Config = {
|
|
|
763
990
|
const { added, removed, modified } = change;
|
|
764
991
|
if (removed.length > 0) {
|
|
765
992
|
await ctx.run("config:unset", "--no-restart", target, ...removed);
|
|
993
|
+
ctx.needsRestart(target, "env");
|
|
766
994
|
}
|
|
767
995
|
const allDesiredKeys = Object.keys(change.after);
|
|
768
996
|
if (allDesiredKeys.length === 0) {
|
|
@@ -779,53 +1007,127 @@ var Config = {
|
|
|
779
1007
|
...pairs,
|
|
780
1008
|
`${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
|
|
781
1009
|
);
|
|
1010
|
+
ctx.needsRestart(target, "env");
|
|
782
1011
|
}
|
|
783
1012
|
}
|
|
784
1013
|
};
|
|
785
1014
|
|
|
786
1015
|
// src/resources/certs.ts
|
|
1016
|
+
import * as fs2 from "fs";
|
|
1017
|
+
var NO_ENDPOINT = "doesn't have an SSL endpoint";
|
|
1018
|
+
function readCertFile(path2) {
|
|
1019
|
+
try {
|
|
1020
|
+
return fs2.readFileSync(path2, "utf8");
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
throw new Error(`ssl: cannot read certfile '${path2}': ${err instanceof Error ? err.message : String(err)}`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
787
1025
|
var Certs = {
|
|
788
1026
|
key: "ssl",
|
|
789
|
-
read: async (ctx, target) => {
|
|
790
|
-
const
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
for (const [app, report] of bulk) {
|
|
799
|
-
const enabled = report["enabled"];
|
|
800
|
-
result.set(app, enabled === void 0 ? void 0 : enabled === "true");
|
|
1027
|
+
read: async (ctx, target) => {
|
|
1028
|
+
const enabled = (await ctx.query("certs:report", target, "--ssl-enabled")).trim();
|
|
1029
|
+
if (enabled === "false") return false;
|
|
1030
|
+
if (enabled !== "true") return void 0;
|
|
1031
|
+
try {
|
|
1032
|
+
return { crt: await ctx.queryStrict("certs:show", target, "crt") };
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
if (err instanceof DokkuCommandError && err.message.includes(NO_ENDPOINT)) return false;
|
|
1035
|
+
return void 0;
|
|
801
1036
|
}
|
|
802
|
-
|
|
1037
|
+
},
|
|
1038
|
+
inSync: (current, desired) => {
|
|
1039
|
+
const installed = current;
|
|
1040
|
+
if (installed === void 0) return false;
|
|
1041
|
+
if (desired === false) return installed === false;
|
|
1042
|
+
if (desired === true) return installed !== false;
|
|
1043
|
+
if (installed === false || typeof desired !== "object" || !("certfile" in desired)) return false;
|
|
1044
|
+
return installed.crt.trimEnd() === readCertFile(desired.certfile).trimEnd();
|
|
803
1045
|
},
|
|
804
1046
|
onChange: async (ctx, target, { before, after }) => {
|
|
1047
|
+
if (before === void 0) {
|
|
1048
|
+
throw new Error("ssl: could not read the installed certificate; left as is");
|
|
1049
|
+
}
|
|
805
1050
|
if (after === false && before) {
|
|
806
1051
|
await ctx.run("certs:remove", target);
|
|
807
1052
|
}
|
|
808
|
-
if (after && typeof after === "object") {
|
|
809
|
-
await ctx.run("certs:add", target, after.certfile, after.keyfile);
|
|
1053
|
+
if (after && typeof after === "object" && "certfile" in after) {
|
|
1054
|
+
await ctx.run(before ? "certs:update" : "certs:add", target, after.certfile, after.keyfile);
|
|
810
1055
|
}
|
|
811
1056
|
}
|
|
812
1057
|
};
|
|
813
1058
|
|
|
1059
|
+
// src/core/shellwords.ts
|
|
1060
|
+
function shellSplit(input) {
|
|
1061
|
+
const words = [];
|
|
1062
|
+
let cur = "";
|
|
1063
|
+
let inWord = false;
|
|
1064
|
+
let quote = null;
|
|
1065
|
+
for (let i = 0; i < input.length; i++) {
|
|
1066
|
+
const c = input[i];
|
|
1067
|
+
if (quote === "'") {
|
|
1068
|
+
if (c === "'") quote = null;
|
|
1069
|
+
else cur += c;
|
|
1070
|
+
} else if (c === "\\") {
|
|
1071
|
+
if (i + 1 >= input.length) return void 0;
|
|
1072
|
+
cur += input[++i];
|
|
1073
|
+
inWord = true;
|
|
1074
|
+
} else if (quote === '"') {
|
|
1075
|
+
if (c === '"') quote = null;
|
|
1076
|
+
else cur += c;
|
|
1077
|
+
} else if (c === '"' || c === "'") {
|
|
1078
|
+
quote = c;
|
|
1079
|
+
inWord = true;
|
|
1080
|
+
} else if (/\s/.test(c)) {
|
|
1081
|
+
if (inWord) {
|
|
1082
|
+
words.push(cur);
|
|
1083
|
+
cur = "";
|
|
1084
|
+
inWord = false;
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
cur += c;
|
|
1088
|
+
inWord = true;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (quote) return void 0;
|
|
1092
|
+
if (inWord) words.push(cur);
|
|
1093
|
+
return words;
|
|
1094
|
+
}
|
|
1095
|
+
function quoteShellArg(s) {
|
|
1096
|
+
if (s === "") return "''";
|
|
1097
|
+
if (!/[ \t\n"'$`\\*?\[\]<>|&;(){}!#~]/.test(s)) return s;
|
|
1098
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
814
1101
|
// src/resources/builder.ts
|
|
815
|
-
function parseBuildOptions(
|
|
1102
|
+
function parseBuildOptions(buildList) {
|
|
816
1103
|
const out = [];
|
|
817
|
-
for (const raw of
|
|
818
|
-
const
|
|
1104
|
+
for (const raw of buildList) {
|
|
1105
|
+
const words = shellSplit(raw) ?? raw.trim().split(/\s+/);
|
|
1106
|
+
if (words[0] !== "--build-arg" || words.length < 2) continue;
|
|
1107
|
+
const m = words.slice(1).join(" ").match(/^([^=\s]+)=?(.*)$/s);
|
|
819
1108
|
if (m) out.push({ key: m[1], value: m[2], raw });
|
|
820
1109
|
}
|
|
821
1110
|
return out;
|
|
822
1111
|
}
|
|
823
|
-
function parseBuildArgs(
|
|
1112
|
+
function parseBuildArgs(buildList) {
|
|
824
1113
|
const args = {};
|
|
825
|
-
for (const o of parseBuildOptions(
|
|
1114
|
+
for (const o of parseBuildOptions(buildList)) args[o.key] = o.value;
|
|
826
1115
|
return args;
|
|
827
1116
|
}
|
|
828
|
-
function
|
|
1117
|
+
async function readBuildList(ctx, target) {
|
|
1118
|
+
const raw = await ctx.query("docker-options:report", target, "--format", "json");
|
|
1119
|
+
if (!raw.trim()) return [];
|
|
1120
|
+
let report;
|
|
1121
|
+
try {
|
|
1122
|
+
report = JSON.parse(raw);
|
|
1123
|
+
} catch {
|
|
1124
|
+
report = void 0;
|
|
1125
|
+
}
|
|
1126
|
+
const list = report?.["build-list"];
|
|
1127
|
+
if (!Array.isArray(list)) throw new Error(`docker-options:report ${target} --format json did not return a build-list (needs Dokku >= 0.38.22)`);
|
|
1128
|
+
return list.map(String);
|
|
1129
|
+
}
|
|
1130
|
+
function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, buildList) {
|
|
829
1131
|
const config = {};
|
|
830
1132
|
if (dockerfileReport["dockerfile-path"])
|
|
831
1133
|
config.dockerfile = dockerfileReport["dockerfile-path"];
|
|
@@ -833,71 +1135,93 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
|
|
|
833
1135
|
config.app_json = appJsonReport["selected"];
|
|
834
1136
|
if (builderReport["build-dir"])
|
|
835
1137
|
config.context = builderReport["build-dir"];
|
|
836
|
-
|
|
1138
|
+
if (builderReport["selected"])
|
|
1139
|
+
config.selected = builderReport["selected"];
|
|
1140
|
+
if (builderReport["skip-cleanup"])
|
|
1141
|
+
config.skip_cleanup = builderReport["skip-cleanup"];
|
|
1142
|
+
const args = parseBuildArgs(buildList);
|
|
837
1143
|
if (Object.keys(args).length > 0) config.args = args;
|
|
1144
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1145
|
+
const duplicated = /* @__PURE__ */ new Set();
|
|
1146
|
+
for (const o of parseBuildOptions(buildList)) (seen.has(o.key) ? duplicated : seen).add(o.key);
|
|
1147
|
+
Object.defineProperty(config, "duplicateArgs", { value: duplicated, enumerable: false });
|
|
838
1148
|
return config;
|
|
839
1149
|
}
|
|
840
1150
|
var Builder = {
|
|
841
1151
|
key: "build",
|
|
842
1152
|
additive: true,
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
//
|
|
1153
|
+
// Additive compare, plus: a declared arg stored more than once is drift until one option is left.
|
|
1154
|
+
inSync: (current, desired) => !computeAdditiveChange(current, desired).changed && !Object.keys(desired.args ?? {}).some((k) => current?.duplicateArgs?.has(k)),
|
|
1155
|
+
// `builder:set` keys and build args (`--build-arg KEY=...` build docker options) not in the file.
|
|
1156
|
+
removals: (current, desired) => [
|
|
1157
|
+
...["selected", "skip_cleanup"].filter((k) => current?.[k] !== void 0 && desired?.[k] === void 0),
|
|
1158
|
+
...Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`)
|
|
1159
|
+
],
|
|
1160
|
+
// Reset the stale `builder:set` keys; remove exactly the stale `--build-arg` options, other build options stay.
|
|
846
1161
|
prune: async (ctx, target, removed) => {
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1162
|
+
for (const key of removed.filter((l) => !l.startsWith("args."))) {
|
|
1163
|
+
await ctx.run("builder:set", target, key.replaceAll("_", "-"));
|
|
1164
|
+
ctx.needsRebuild(target, "build");
|
|
1165
|
+
}
|
|
1166
|
+
const stale = new Set(removed.filter((l) => l.startsWith("args.")).map((l) => l.slice("args.".length)));
|
|
1167
|
+
if (stale.size === 0) return;
|
|
1168
|
+
const options = parseBuildOptions(await readBuildList(ctx, target)).filter((o) => stale.has(o.key));
|
|
1169
|
+
for (const o of options) {
|
|
1170
|
+
await ctx.run("docker-options:remove", target, "build", o.raw);
|
|
1171
|
+
ctx.needsRebuild(target, "build args");
|
|
1172
|
+
}
|
|
851
1173
|
for (const k of stale) {
|
|
852
1174
|
if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
|
|
853
1175
|
}
|
|
854
1176
|
},
|
|
855
1177
|
async read(ctx, target) {
|
|
856
|
-
const [builderRaw, dockerfileRaw, appJsonRaw,
|
|
1178
|
+
const [builderRaw, dockerfileRaw, appJsonRaw, buildList] = await Promise.all([
|
|
857
1179
|
ctx.query("builder:report", target),
|
|
858
1180
|
ctx.query("builder-dockerfile:report", target),
|
|
859
1181
|
ctx.query("app-json:report", target),
|
|
860
|
-
ctx
|
|
1182
|
+
readBuildList(ctx, target)
|
|
861
1183
|
]);
|
|
862
1184
|
return buildConfigFromReports(
|
|
863
1185
|
parseReport(builderRaw, "builder"),
|
|
864
1186
|
parseReport(dockerfileRaw, "builder-dockerfile"),
|
|
865
1187
|
parseReport(appJsonRaw, "app-json"),
|
|
866
|
-
|
|
1188
|
+
buildList
|
|
867
1189
|
);
|
|
868
1190
|
},
|
|
869
|
-
async
|
|
870
|
-
|
|
871
|
-
ctx.query("builder:report"),
|
|
872
|
-
ctx.query("builder-dockerfile:report"),
|
|
873
|
-
ctx.query("app-json:report"),
|
|
874
|
-
ctx.query("docker-options:report")
|
|
875
|
-
]);
|
|
876
|
-
const builderBulk = parseBulkReport(builderRaw, "builder");
|
|
877
|
-
const dockerfileBulk = parseBulkReport(dockerfileRaw, "builder-dockerfile");
|
|
878
|
-
const appJsonBulk = parseBulkReport(appJsonRaw, "app-json");
|
|
879
|
-
const dockerOptsBulk = parseBulkReport(dockerOptsRaw, "docker-options");
|
|
880
|
-
const result = /* @__PURE__ */ new Map();
|
|
881
|
-
for (const app of builderBulk.keys()) {
|
|
882
|
-
result.set(app, buildConfigFromReports(
|
|
883
|
-
builderBulk.get(app) ?? {},
|
|
884
|
-
dockerfileBulk.get(app) ?? {},
|
|
885
|
-
appJsonBulk.get(app) ?? {},
|
|
886
|
-
dockerOptsBulk.get(app)?.["build"] ?? ""
|
|
887
|
-
));
|
|
888
|
-
}
|
|
889
|
-
return result;
|
|
890
|
-
},
|
|
891
|
-
async onChange(ctx, target, { after }) {
|
|
892
|
-
if (after.dockerfile)
|
|
1191
|
+
async onChange(ctx, target, { before, after }) {
|
|
1192
|
+
if (after.dockerfile && after.dockerfile !== before?.dockerfile) {
|
|
893
1193
|
await ctx.run("builder-dockerfile:set", target, "dockerfile-path", after.dockerfile);
|
|
894
|
-
|
|
1194
|
+
ctx.needsRebuild(target, "build");
|
|
1195
|
+
}
|
|
1196
|
+
if (after.app_json && after.app_json !== before?.app_json) {
|
|
895
1197
|
await ctx.run("app-json:set", target, "appjson-path", after.app_json);
|
|
896
|
-
|
|
1198
|
+
ctx.needsRebuild(target, "build");
|
|
1199
|
+
}
|
|
1200
|
+
if (after.context && after.context !== before?.context) {
|
|
897
1201
|
await ctx.run("builder:set", target, "build-dir", after.context);
|
|
1202
|
+
ctx.needsRebuild(target, "build");
|
|
1203
|
+
}
|
|
1204
|
+
if (after.selected && after.selected !== before?.selected) {
|
|
1205
|
+
await ctx.run("builder:set", target, "selected", after.selected);
|
|
1206
|
+
ctx.needsRebuild(target, "build");
|
|
1207
|
+
}
|
|
1208
|
+
if (after.skip_cleanup !== void 0 && String(after.skip_cleanup) !== String(before?.skip_cleanup))
|
|
1209
|
+
await ctx.run("builder:set", target, "skip-cleanup", String(after.skip_cleanup));
|
|
898
1210
|
if (after.args) {
|
|
1211
|
+
const stored = parseBuildOptions(await readBuildList(ctx, target));
|
|
1212
|
+
const current = parseBuildArgs(stored.map((o) => o.raw));
|
|
1213
|
+
const dup = before?.duplicateArgs;
|
|
1214
|
+
const changedKeys = new Set(Object.keys(after.args).filter((k) => k in current && (current[k] !== after.args[k] || dup?.has(k))));
|
|
1215
|
+
if (changedKeys.size > 0) {
|
|
1216
|
+
for (const o of stored.filter((o2) => changedKeys.has(o2.key))) {
|
|
1217
|
+
await ctx.run("docker-options:remove", target, "build", o.raw);
|
|
1218
|
+
ctx.needsRebuild(target, "build args");
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
899
1221
|
for (const [key, value] of Object.entries(after.args)) {
|
|
900
|
-
|
|
1222
|
+
if (key in current && !changedKeys.has(key)) continue;
|
|
1223
|
+
await ctx.run("docker-options:add", target, "build", `--build-arg ${quoteShellArg(`${key}=${value}`)}`);
|
|
1224
|
+
ctx.needsRebuild(target, "build args");
|
|
901
1225
|
}
|
|
902
1226
|
}
|
|
903
1227
|
}
|
|
@@ -941,22 +1265,43 @@ var DockerOptions = {
|
|
|
941
1265
|
}
|
|
942
1266
|
return result;
|
|
943
1267
|
},
|
|
1268
|
+
// Dokku: options "only apply to containers created after the options have been
|
|
1269
|
+
// modified" (docs/advanced-usage/docker-options.md, v0.38.31), so deploy and
|
|
1270
|
+
// run options need a restart. Build options are read only by a build, which
|
|
1271
|
+
// a restart does not run, so they need `ps:rebuild`.
|
|
944
1272
|
async onChange(ctx, target, { before, after }) {
|
|
945
1273
|
for (const phase of PHASES) {
|
|
946
1274
|
const prev = new Set(before[phase] ?? []);
|
|
947
1275
|
const desired = new Set(after[phase] ?? []);
|
|
948
1276
|
for (const opt of desired) {
|
|
949
|
-
if (!prev.has(opt))
|
|
1277
|
+
if (!prev.has(opt)) {
|
|
1278
|
+
await ctx.run("docker-options:add", target, phase, opt);
|
|
1279
|
+
if (phase === "build") ctx.needsRebuild(target, "build options");
|
|
1280
|
+
else ctx.needsRestart(target, "docker_options");
|
|
1281
|
+
}
|
|
950
1282
|
}
|
|
951
1283
|
for (const opt of prev) {
|
|
952
|
-
if (!desired.has(opt))
|
|
1284
|
+
if (!desired.has(opt)) {
|
|
1285
|
+
await ctx.run("docker-options:remove", target, phase, opt);
|
|
1286
|
+
if (phase === "build") ctx.needsRebuild(target, "build options");
|
|
1287
|
+
else ctx.needsRestart(target, "docker_options");
|
|
1288
|
+
}
|
|
953
1289
|
}
|
|
954
1290
|
}
|
|
955
1291
|
}
|
|
956
1292
|
};
|
|
957
1293
|
|
|
958
1294
|
// src/resources/ps.ts
|
|
959
|
-
var PS_KEYS = [
|
|
1295
|
+
var PS_KEYS = [
|
|
1296
|
+
"dockerfile-start-cmd",
|
|
1297
|
+
"procfile-path",
|
|
1298
|
+
"restart-policy",
|
|
1299
|
+
"restore",
|
|
1300
|
+
"skip-deploy",
|
|
1301
|
+
"start-cmd",
|
|
1302
|
+
"stop-timeout-seconds"
|
|
1303
|
+
];
|
|
1304
|
+
var DOKKU_MANAGED = /* @__PURE__ */ new Set(["restore"]);
|
|
960
1305
|
function parseScale(raw) {
|
|
961
1306
|
const result = {};
|
|
962
1307
|
let parsed;
|
|
@@ -1003,7 +1348,7 @@ var Scale = {
|
|
|
1003
1348
|
var Ps = {
|
|
1004
1349
|
key: "ps",
|
|
1005
1350
|
additive: true,
|
|
1006
|
-
removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
|
|
1351
|
+
removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !DOKKU_MANAGED.has(k) && !(desired && k in desired)),
|
|
1007
1352
|
prune: async (ctx, target, removed) => {
|
|
1008
1353
|
for (const key of removed) await ctx.run("ps:set", target, key);
|
|
1009
1354
|
},
|
|
@@ -1018,24 +1363,35 @@ var Ps = {
|
|
|
1018
1363
|
};
|
|
1019
1364
|
|
|
1020
1365
|
// src/resources/git.ts
|
|
1366
|
+
var toDokku = (key) => key.replaceAll("_", "-");
|
|
1367
|
+
var NOT_REMOVABLE = /* @__PURE__ */ new Set(["deploy_branch", "source_image", "rev_env_var"]);
|
|
1368
|
+
function fromReport(report) {
|
|
1369
|
+
const config = {};
|
|
1370
|
+
if (report["deploy-branch"]) config.deploy_branch = report["deploy-branch"];
|
|
1371
|
+
if (report["keep-git-dir"]) config.keep_git_dir = report["keep-git-dir"];
|
|
1372
|
+
if (report["rev-env-var"]) config.rev_env_var = report["rev-env-var"];
|
|
1373
|
+
if (report["source-image"]) config.source_image = report["source-image"];
|
|
1374
|
+
return config;
|
|
1375
|
+
}
|
|
1021
1376
|
var Git = {
|
|
1022
1377
|
key: "git",
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1378
|
+
additive: true,
|
|
1379
|
+
removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !NOT_REMOVABLE.has(k) && !(desired && k in desired)),
|
|
1380
|
+
prune: async (ctx, target, removed) => {
|
|
1381
|
+
for (const key of removed) await ctx.run("git:set", target, toDokku(key));
|
|
1026
1382
|
},
|
|
1383
|
+
read: async (ctx, target) => fromReport(parseReport(await ctx.query("git:report", target), "git")),
|
|
1027
1384
|
readAll: async (ctx) => {
|
|
1028
|
-
const raw = await ctx.query("git:report");
|
|
1029
|
-
const bulk = parseBulkReport(raw, "git");
|
|
1030
1385
|
const result = /* @__PURE__ */ new Map();
|
|
1031
|
-
for (const
|
|
1032
|
-
|
|
1386
|
+
for (const section of (await ctx.query("git:report")).split(/(?=^=====> )/m)) {
|
|
1387
|
+
const app = section.match(/^=====> (.+?)\s+git\s+information/i)?.[1];
|
|
1388
|
+
if (app) result.set(app, fromReport(parseReport(section, "git")));
|
|
1033
1389
|
}
|
|
1034
1390
|
return result;
|
|
1035
1391
|
},
|
|
1036
|
-
onChange: async (ctx, target,
|
|
1037
|
-
|
|
1038
|
-
await ctx.run("git:set", target,
|
|
1392
|
+
onChange: async (ctx, target, change) => {
|
|
1393
|
+
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
1394
|
+
await ctx.run("git:set", target, toDokku(key), String(value));
|
|
1039
1395
|
}
|
|
1040
1396
|
}
|
|
1041
1397
|
};
|
|
@@ -1043,11 +1399,12 @@ var Git = {
|
|
|
1043
1399
|
// src/resources/checks.ts
|
|
1044
1400
|
var Checks = {
|
|
1045
1401
|
key: "checks",
|
|
1046
|
-
|
|
1047
|
-
//
|
|
1048
|
-
// Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty.
|
|
1402
|
+
// Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty;
|
|
1403
|
+
// a disabled list of `_all_` (`checks:disable <app>` with no types) reads as `false`.
|
|
1049
1404
|
read: async (ctx, target) => {
|
|
1050
1405
|
const report = parseReport(await ctx.query("checks:report", target), "checks");
|
|
1406
|
+
if (report["disabled-list"] === void 0) return void 0;
|
|
1407
|
+
if (report["disabled-list"] === "_all_") return false;
|
|
1051
1408
|
const current = {};
|
|
1052
1409
|
for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
|
|
1053
1410
|
const v = report[reportKey];
|
|
@@ -1060,7 +1417,7 @@ var Checks = {
|
|
|
1060
1417
|
prune: async (ctx, target, removed, current, desired) => {
|
|
1061
1418
|
const cur = current;
|
|
1062
1419
|
const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
1063
|
-
const declared = new Set(desired
|
|
1420
|
+
const declared = new Set(desired ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
|
|
1064
1421
|
const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
|
|
1065
1422
|
const enable = [...stale].filter((t) => !declared.has(t));
|
|
1066
1423
|
for (const t of stale) {
|
|
@@ -1068,26 +1425,32 @@ var Checks = {
|
|
|
1068
1425
|
logWarn(target, `checks: '${t}' is declared in the file but also set in a list the file does not declare; left as is. Run 'dokku checks:enable ${target} ${t}' and then 'up' to clear it`);
|
|
1069
1426
|
}
|
|
1070
1427
|
}
|
|
1071
|
-
if (enable.length > 0) await ctx.run("checks:enable", target,
|
|
1428
|
+
if (enable.length > 0) await ctx.run("checks:enable", target, enable.join(","));
|
|
1072
1429
|
if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
|
|
1073
1430
|
},
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
if (after.disabled && after.disabled.length > 0) {
|
|
1080
|
-
await ctx.run("checks:disable", target, ...after.disabled);
|
|
1081
|
-
}
|
|
1082
|
-
if (after.skipped && after.skipped.length > 0) {
|
|
1083
|
-
await ctx.run("checks:skip", target, ...after.skipped);
|
|
1084
|
-
}
|
|
1085
|
-
for (const [key, value] of Object.entries(after)) {
|
|
1086
|
-
if (key === "disabled" || key === "skipped") continue;
|
|
1087
|
-
await ctx.run("checks:set", target, key, String(value));
|
|
1088
|
-
}
|
|
1431
|
+
inSync: (current, desired) => checksCalls(current, desired).length === 0,
|
|
1432
|
+
onChange: async (ctx, target, { before, after }) => {
|
|
1433
|
+
if (before === void 0) throw new Error("checks: could not read checks:report; left as is");
|
|
1434
|
+
for (const call of checksCalls(before, after)) await ctx.run(call[0], target, ...call.slice(1));
|
|
1089
1435
|
}
|
|
1090
1436
|
};
|
|
1437
|
+
function checksCalls(current, desired) {
|
|
1438
|
+
if (desired === false) return current === false ? [] : [["checks:disable"]];
|
|
1439
|
+
const calls = [];
|
|
1440
|
+
const cur = current || {};
|
|
1441
|
+
if (current === false) calls.push(["checks:enable"]);
|
|
1442
|
+
const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
1443
|
+
for (const [key, cmd] of [["disabled", "checks:disable"], ["skipped", "checks:skip"]]) {
|
|
1444
|
+
const have = new Set(split(cur[key]));
|
|
1445
|
+
const missing = (desired[key] ?? []).filter((t) => !have.has(t));
|
|
1446
|
+
if (missing.length > 0) calls.push([cmd, missing.join(",")]);
|
|
1447
|
+
}
|
|
1448
|
+
for (const [key, value] of Object.entries(desired)) {
|
|
1449
|
+
if (key === "disabled" || key === "skipped") continue;
|
|
1450
|
+
if (cur[key] !== String(value)) calls.push(["checks:set", key, String(value)]);
|
|
1451
|
+
}
|
|
1452
|
+
return calls;
|
|
1453
|
+
}
|
|
1091
1454
|
|
|
1092
1455
|
// src/resources/network.ts
|
|
1093
1456
|
var Networks = {
|
|
@@ -1107,6 +1470,7 @@ var Networks = {
|
|
|
1107
1470
|
},
|
|
1108
1471
|
onChange: async (ctx, target, { after }) => {
|
|
1109
1472
|
await ctx.run("network:set", target, "attach-post-deploy", ...after);
|
|
1473
|
+
ctx.needsRebuild(target, "networks");
|
|
1110
1474
|
}
|
|
1111
1475
|
};
|
|
1112
1476
|
var NetworkProps = {
|
|
@@ -1115,7 +1479,11 @@ var NetworkProps = {
|
|
|
1115
1479
|
removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
|
|
1116
1480
|
// Dokku's property names are hyphenated; the schema's are underscored.
|
|
1117
1481
|
prune: async (ctx, target, removed) => {
|
|
1118
|
-
for (const key of removed)
|
|
1482
|
+
for (const key of removed) {
|
|
1483
|
+
await ctx.run("network:set", target, key.replaceAll("_", "-"));
|
|
1484
|
+
if (key === "attach_post_create" || key === "initial_network") ctx.needsRebuild(target, "network");
|
|
1485
|
+
else ctx.needsRestart(target, "network");
|
|
1486
|
+
}
|
|
1119
1487
|
},
|
|
1120
1488
|
read: async (ctx, target) => {
|
|
1121
1489
|
const raw = await ctx.query("network:report", target);
|
|
@@ -1126,6 +1494,7 @@ var NetworkProps = {
|
|
|
1126
1494
|
}
|
|
1127
1495
|
if (report["initial-network"]) config.initial_network = report["initial-network"];
|
|
1128
1496
|
if (report["bind-all-interfaces"] === "true") config.bind_all_interfaces = true;
|
|
1497
|
+
if (report["static-web-listener"]) config.static_web_listener = report["static-web-listener"];
|
|
1129
1498
|
if (report["tld"]) config.tld = report["tld"];
|
|
1130
1499
|
return config;
|
|
1131
1500
|
},
|
|
@@ -1140,6 +1509,7 @@ var NetworkProps = {
|
|
|
1140
1509
|
}
|
|
1141
1510
|
if (report["initial-network"]) config.initial_network = report["initial-network"];
|
|
1142
1511
|
if (report["bind-all-interfaces"] === "true") config.bind_all_interfaces = true;
|
|
1512
|
+
if (report["static-web-listener"]) config.static_web_listener = report["static-web-listener"];
|
|
1143
1513
|
if (report["tld"]) config.tld = report["tld"];
|
|
1144
1514
|
result.set(app, config);
|
|
1145
1515
|
}
|
|
@@ -1149,15 +1519,22 @@ var NetworkProps = {
|
|
|
1149
1519
|
if (after.attach_post_create !== void 0 && after.attach_post_create !== false) {
|
|
1150
1520
|
const nets = Array.isArray(after.attach_post_create) ? after.attach_post_create : [after.attach_post_create];
|
|
1151
1521
|
await ctx.run("network:set", target, "attach-post-create", ...nets);
|
|
1522
|
+
ctx.needsRebuild(target, "network");
|
|
1152
1523
|
}
|
|
1153
1524
|
if (after.initial_network !== void 0 && after.initial_network !== false) {
|
|
1154
1525
|
await ctx.run("network:set", target, "initial-network", after.initial_network);
|
|
1526
|
+
ctx.needsRebuild(target, "network");
|
|
1155
1527
|
}
|
|
1156
1528
|
if (after.bind_all_interfaces !== void 0) {
|
|
1157
1529
|
await ctx.run("network:set", target, "bind-all-interfaces", String(after.bind_all_interfaces));
|
|
1530
|
+
ctx.needsRestart(target, "network");
|
|
1531
|
+
}
|
|
1532
|
+
if (after.static_web_listener !== void 0 && after.static_web_listener !== false) {
|
|
1533
|
+
await ctx.run("network:set", target, "static-web-listener", after.static_web_listener);
|
|
1158
1534
|
}
|
|
1159
1535
|
if (after.tld !== void 0 && after.tld !== false) {
|
|
1160
1536
|
await ctx.run("network:set", target, "tld", after.tld);
|
|
1537
|
+
ctx.needsRestart(target, "network");
|
|
1161
1538
|
}
|
|
1162
1539
|
}
|
|
1163
1540
|
};
|
|
@@ -1251,13 +1628,61 @@ async function ensurePlugins(ctx, plugins) {
|
|
|
1251
1628
|
}
|
|
1252
1629
|
|
|
1253
1630
|
// src/modules/docker-auth.ts
|
|
1631
|
+
var AUTH_MATCH = 0;
|
|
1632
|
+
var AUTH_MISSING = 1;
|
|
1633
|
+
var AUTH_DIFFERS = 2;
|
|
1634
|
+
var AUTH_INVALID_ARGUMENTS = 3;
|
|
1635
|
+
var AUTH_UNKNOWN = 4;
|
|
1636
|
+
function registryHostname(server) {
|
|
1637
|
+
const normalized = server === "hub.docker.com" || server === "docker.com" ? "docker.io" : server;
|
|
1638
|
+
const host = normalized.replace(/^https?:\/\//, "").split("/")[0];
|
|
1639
|
+
return ["", "docker.io", "index.docker.io"].includes(host) ? "docker.io" : host;
|
|
1640
|
+
}
|
|
1641
|
+
async function loginMatches(ctx, server, creds) {
|
|
1642
|
+
const code = await ctx.status(
|
|
1643
|
+
["registry:auth-status", "--global", "--password-stdin", server, creds.username],
|
|
1644
|
+
creds.password
|
|
1645
|
+
);
|
|
1646
|
+
if (code === AUTH_MATCH) return true;
|
|
1647
|
+
if (code === AUTH_MISSING || code === AUTH_DIFFERS || code === AUTH_UNKNOWN) return false;
|
|
1648
|
+
if (code === AUTH_INVALID_ARGUMENTS) {
|
|
1649
|
+
throw new Error(`registry:auth-status rejected the arguments for ${server} (exit 3)`);
|
|
1650
|
+
}
|
|
1651
|
+
throw new Error(`registry:auth-status failed for ${server} (exit ${code})`);
|
|
1652
|
+
}
|
|
1653
|
+
async function globalAuthServers(ctx) {
|
|
1654
|
+
const raw = await ctx.query("registry:report", "--global", "--registry-global-auth-servers");
|
|
1655
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1656
|
+
}
|
|
1254
1657
|
async function ensureDockerAuth(ctx, config) {
|
|
1255
1658
|
for (const [server, creds] of Object.entries(config)) {
|
|
1256
1659
|
logAction("docker-auth", `Logging in to ${server} as ${creds.username}`);
|
|
1257
|
-
await ctx
|
|
1660
|
+
if (await loginMatches(ctx, server, creds)) {
|
|
1661
|
+
logSkip();
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
await ctx.runWithInput(creds.password, "registry:login", "--global", "--password-stdin", server, creds.username);
|
|
1665
|
+
logDone();
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async function pruneDockerAuth(ctx, config) {
|
|
1669
|
+
const declared = new Set(Object.keys(config).map(registryHostname));
|
|
1670
|
+
for (const server of await globalAuthServers(ctx)) {
|
|
1671
|
+
if (declared.has(server)) continue;
|
|
1672
|
+
logAction("docker-auth", `Logging out of ${server}`);
|
|
1673
|
+
await ctx.run("registry:logout", "--global", server);
|
|
1258
1674
|
logDone();
|
|
1259
1675
|
}
|
|
1260
1676
|
}
|
|
1677
|
+
async function readDockerAuthDrift(ctx, config) {
|
|
1678
|
+
const stale = [];
|
|
1679
|
+
for (const [server, creds] of Object.entries(config)) {
|
|
1680
|
+
if (!await loginMatches(ctx, server, creds)) stale.push(server);
|
|
1681
|
+
}
|
|
1682
|
+
const declared = new Set(Object.keys(config).map(registryHostname));
|
|
1683
|
+
const extra = (await globalAuthServers(ctx)).filter((s) => !declared.has(s));
|
|
1684
|
+
return { stale, extra };
|
|
1685
|
+
}
|
|
1261
1686
|
|
|
1262
1687
|
// src/modules/network.ts
|
|
1263
1688
|
async function ensureNetworks(ctx, networks) {
|
|
@@ -1278,61 +1703,255 @@ async function exportNetworks(ctx) {
|
|
|
1278
1703
|
return output.split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("=====>") && !DOCKER_BUILTIN_NETWORKS.has(s));
|
|
1279
1704
|
}
|
|
1280
1705
|
|
|
1281
|
-
// src/modules/
|
|
1706
|
+
// src/modules/service-drift.ts
|
|
1282
1707
|
import { createHash as createHash2 } from "crypto";
|
|
1283
|
-
|
|
1284
|
-
|
|
1708
|
+
var infoReads = /* @__PURE__ */ new WeakMap();
|
|
1709
|
+
function readServiceInfo(ctx, plugin, name) {
|
|
1710
|
+
let reads = infoReads.get(ctx);
|
|
1711
|
+
if (!reads) {
|
|
1712
|
+
reads = /* @__PURE__ */ new Map();
|
|
1713
|
+
infoReads.set(ctx, reads);
|
|
1714
|
+
}
|
|
1715
|
+
const key = `${plugin}\0${name}`;
|
|
1716
|
+
let read = reads.get(key);
|
|
1717
|
+
if (!read) {
|
|
1718
|
+
read = fetchServiceInfo(ctx, plugin, name);
|
|
1719
|
+
read.catch(() => reads.delete(key));
|
|
1720
|
+
reads.set(key, read);
|
|
1721
|
+
}
|
|
1722
|
+
return read;
|
|
1723
|
+
}
|
|
1724
|
+
async function fetchServiceInfo(ctx, plugin, name) {
|
|
1725
|
+
const raw = await ctx.queryStrict(`${plugin}:info`, name, "--format", "json");
|
|
1726
|
+
try {
|
|
1727
|
+
const info = JSON.parse(raw);
|
|
1728
|
+
if (info && typeof info === "object" && !Array.isArray(info)) return info;
|
|
1729
|
+
} catch {
|
|
1730
|
+
}
|
|
1731
|
+
throw new Error(`${plugin}:info ${name} --format json did not return JSON`);
|
|
1732
|
+
}
|
|
1733
|
+
var field = (info, key) => String(info[key] ?? "");
|
|
1734
|
+
function versionMismatches(info, declared) {
|
|
1735
|
+
const out = [];
|
|
1736
|
+
if (declared.version !== void 0 && declared.version !== field(info, "image-version")) {
|
|
1737
|
+
out.push({ field: "version", desired: declared.version, current: field(info, "image-version") });
|
|
1738
|
+
}
|
|
1739
|
+
if (declared.image !== void 0 && declared.image !== field(info, "image")) {
|
|
1740
|
+
out.push({ field: "image", desired: declared.image, current: field(info, "image") });
|
|
1741
|
+
}
|
|
1742
|
+
return out;
|
|
1743
|
+
}
|
|
1744
|
+
async function readServiceMismatches(ctx, plugin, name, declared) {
|
|
1745
|
+
if (declared.version === void 0 && declared.image === void 0) return [];
|
|
1746
|
+
return versionMismatches(await readServiceInfo(ctx, plugin, name), declared);
|
|
1747
|
+
}
|
|
1748
|
+
function backupAuthFingerprint(accessKeyId, secretAccessKey) {
|
|
1749
|
+
const firstLine = (v) => v.trim().split("\n")[0].trim();
|
|
1750
|
+
return createHash2("sha256").update(`${firstLine(accessKeyId)}
|
|
1751
|
+
${firstLine(secretAccessKey)}`).digest("hex");
|
|
1752
|
+
}
|
|
1753
|
+
function hasBackup(info) {
|
|
1754
|
+
return field(info, "backup-schedule") !== "" || field(info, "backup-authenticated") === "true";
|
|
1285
1755
|
}
|
|
1286
|
-
function
|
|
1287
|
-
|
|
1756
|
+
function backupMismatches(info, backup) {
|
|
1757
|
+
for (const key of ["backup-authenticated", "backup-auth-fingerprint", "backup-schedule"]) {
|
|
1758
|
+
if (!(key in info)) throw new Error("dokku-postgres too old to report backup state (needs 2.3.1+)");
|
|
1759
|
+
}
|
|
1760
|
+
if (backup === false) {
|
|
1761
|
+
return hasBackup(info) ? [{ field: "backup", desired: "none", current: describeServerBackup(info) }] : [];
|
|
1762
|
+
}
|
|
1763
|
+
const out = [];
|
|
1764
|
+
const { auth } = backup;
|
|
1765
|
+
const authDiffers = [];
|
|
1766
|
+
if (field(info, "backup-auth-fingerprint") !== backupAuthFingerprint(auth.access_key_id, auth.secret_access_key)) {
|
|
1767
|
+
authDiffers.push("credentials");
|
|
1768
|
+
}
|
|
1769
|
+
if (field(info, "backup-default-region") !== auth.region) authDiffers.push("region");
|
|
1770
|
+
if (field(info, "backup-signature-version") !== auth.signature_version) authDiffers.push("signature version");
|
|
1771
|
+
if (field(info, "backup-endpoint-url") !== auth.endpoint) authDiffers.push("endpoint");
|
|
1772
|
+
if (authDiffers.length > 0) {
|
|
1773
|
+
out.push({
|
|
1774
|
+
field: "backup.auth",
|
|
1775
|
+
desired: `${authDiffers.join(", ")} from the file`,
|
|
1776
|
+
current: field(info, "backup-authenticated") === "true" ? "other values" : "not authenticated"
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
if (field(info, "backup-schedule") !== backup.schedule) {
|
|
1780
|
+
out.push({ field: "backup.schedule", desired: backup.schedule, current: field(info, "backup-schedule") });
|
|
1781
|
+
}
|
|
1782
|
+
if (field(info, "backup-bucket") !== backup.bucket) {
|
|
1783
|
+
out.push({ field: "backup.bucket", desired: backup.bucket, current: field(info, "backup-bucket") });
|
|
1784
|
+
}
|
|
1785
|
+
return out;
|
|
1288
1786
|
}
|
|
1787
|
+
function describeServerBackup(info) {
|
|
1788
|
+
const schedule = field(info, "backup-schedule");
|
|
1789
|
+
const parts = [];
|
|
1790
|
+
if (schedule) parts.push(`scheduled ${schedule} to ${field(info, "backup-bucket")}`);
|
|
1791
|
+
if (field(info, "backup-authenticated") === "true") parts.push("credentials stored");
|
|
1792
|
+
return parts.join(", ") || "none";
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// src/modules/config.ts
|
|
1796
|
+
var MANAGED_KEYS_VAR2 = "DOKKU_COMPOSE_MANAGED_KEYS";
|
|
1797
|
+
var GLOBAL_MANAGED_KEYS_VAR = "DOKKU_COMPOSE_GLOBAL_MANAGED_KEYS";
|
|
1798
|
+
function isReserved(key) {
|
|
1799
|
+
return key === GLOBAL_MANAGED_KEYS_VAR || key === MANAGED_KEYS_VAR2 || key.startsWith(BACKUP_HASH_PREFIX);
|
|
1800
|
+
}
|
|
1801
|
+
var globalEnvReads = /* @__PURE__ */ new WeakMap();
|
|
1802
|
+
function readGlobalEnv(ctx) {
|
|
1803
|
+
let read = globalEnvReads.get(ctx);
|
|
1804
|
+
if (!read) {
|
|
1805
|
+
read = ctx.queryStrict("config:export", "--global", "--format", "json").then((raw) => parseConfigJson(raw, "--global"));
|
|
1806
|
+
read.catch(() => globalEnvReads.delete(ctx));
|
|
1807
|
+
globalEnvReads.set(ctx, read);
|
|
1808
|
+
}
|
|
1809
|
+
return read;
|
|
1810
|
+
}
|
|
1811
|
+
var BACKUP_HASH_PREFIX = "DOKKU_COMPOSE_BACKUP_HASH_";
|
|
1812
|
+
async function planGlobalEnv(ctx, env, read) {
|
|
1813
|
+
const current = read ?? await readGlobalEnv(ctx);
|
|
1814
|
+
const desired = Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)]));
|
|
1815
|
+
const managed = (current[GLOBAL_MANAGED_KEYS_VAR] ?? "").split(",").filter(Boolean);
|
|
1816
|
+
const removed = managed.filter((k) => !(k in desired) && !isReserved(k) && k in current);
|
|
1817
|
+
const toSet = Object.fromEntries(Object.entries(desired).filter(([k, v]) => current[k] !== v));
|
|
1818
|
+
const marker = Object.keys(desired).join(",");
|
|
1819
|
+
const markerOutdated = [...managed].sort().join(",") !== Object.keys(desired).sort().join(",");
|
|
1820
|
+
return { toSet, removed, marker, markerOutdated };
|
|
1821
|
+
}
|
|
1822
|
+
async function ensureGlobalConfig(ctx, env) {
|
|
1823
|
+
if (env === false) return false;
|
|
1824
|
+
const { toSet, removed, marker, markerOutdated } = await planGlobalEnv(ctx, env ?? {});
|
|
1825
|
+
const unset = [...removed];
|
|
1826
|
+
if (markerOutdated && marker === "") unset.push(GLOBAL_MANAGED_KEYS_VAR);
|
|
1827
|
+
if (unset.length > 0) await ctx.run("config:unset", "--global", ...unset);
|
|
1828
|
+
const pairs = Object.entries(toSet).map(([k, v]) => `${k}=${v}`);
|
|
1829
|
+
if (markerOutdated && marker !== "") pairs.push(`${GLOBAL_MANAGED_KEYS_VAR}=${marker}`);
|
|
1830
|
+
if (pairs.length > 0) await ctx.run("config:set", "--global", ...pairs);
|
|
1831
|
+
return Object.keys(toSet).length > 0 || removed.length > 0;
|
|
1832
|
+
}
|
|
1833
|
+
async function readGlobalEnvDrift(ctx, env, current) {
|
|
1834
|
+
const { toSet, removed, markerOutdated } = await planGlobalEnv(ctx, env ?? {}, current);
|
|
1835
|
+
return { changed: Object.keys(toSet), removed, markerOutdated };
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/modules/postgres.ts
|
|
1289
1839
|
async function ensurePostgres(ctx, services) {
|
|
1840
|
+
const created = /* @__PURE__ */ new Set();
|
|
1290
1841
|
for (const [name, config] of Object.entries(services)) {
|
|
1291
1842
|
logAction("services", `Ensuring ${name}`);
|
|
1292
1843
|
const exists = await ctx.check("postgres:exists", name);
|
|
1293
1844
|
if (exists) {
|
|
1294
1845
|
logSkip();
|
|
1846
|
+
try {
|
|
1847
|
+
const mismatches = await readServiceMismatches(ctx, "postgres", name, config);
|
|
1848
|
+
if (mismatches.length > 0) {
|
|
1849
|
+
const parts = mismatches.map((m) => `${m.field} is ${m.current || "(unknown)"} on the server but ${m.desired} in the file`);
|
|
1850
|
+
logWarn("services", `${name}: ${parts.join("; ")}; up does not change it`);
|
|
1851
|
+
}
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
logWarn("services", `${name}: could not compare version and image: ${err instanceof Error ? err.message : String(err)}`);
|
|
1854
|
+
}
|
|
1295
1855
|
continue;
|
|
1296
1856
|
}
|
|
1297
1857
|
const args = ["postgres:create", name];
|
|
1298
1858
|
if (config.image) args.push("--image", config.image);
|
|
1299
1859
|
if (config.version) args.push("--image-version", config.version);
|
|
1300
1860
|
await ctx.run(...args);
|
|
1861
|
+
created.add(name);
|
|
1301
1862
|
logDone();
|
|
1302
1863
|
}
|
|
1864
|
+
return created;
|
|
1303
1865
|
}
|
|
1304
|
-
async function ensurePostgresBackups(ctx, services) {
|
|
1866
|
+
async function ensurePostgresBackups(ctx, services, opts = {}) {
|
|
1305
1867
|
for (const [name, config] of Object.entries(services)) {
|
|
1306
|
-
|
|
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);
|
|
1307
1902
|
logAction("services", `Configuring backup for ${name}`);
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
const storedHash = await ctx.query("config:get", "--global", hashKey);
|
|
1311
|
-
if (storedHash === desiredHash) {
|
|
1903
|
+
const fields = new Set(backupMismatches(info, backup).map((m) => m.field));
|
|
1904
|
+
if (fields.size === 0) {
|
|
1312
1905
|
logSkip();
|
|
1313
1906
|
continue;
|
|
1314
1907
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
"postgres:backup-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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();
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
const { schedule, bucket, auth } = backup;
|
|
1915
|
+
if (fields.has("backup.auth")) {
|
|
1916
|
+
await ctx.run(
|
|
1917
|
+
"postgres:backup-auth",
|
|
1918
|
+
name,
|
|
1919
|
+
auth.access_key_id,
|
|
1920
|
+
auth.secret_access_key,
|
|
1921
|
+
auth.region,
|
|
1922
|
+
auth.signature_version,
|
|
1923
|
+
auth.endpoint
|
|
1330
1924
|
);
|
|
1331
1925
|
}
|
|
1332
|
-
|
|
1926
|
+
if (fields.has("backup.schedule")) {
|
|
1927
|
+
await ctx.run("postgres:backup-schedule", name, schedule, bucket);
|
|
1928
|
+
const cronOutput = await ctx.query("postgres:backup-schedule-cat", name);
|
|
1929
|
+
if (!parseBackupSchedule(cronOutput)) {
|
|
1930
|
+
throw new Error(`Backup schedule for ${name} was not installed after backup-schedule ran`);
|
|
1931
|
+
}
|
|
1932
|
+
} else if (fields.has("backup.bucket")) {
|
|
1933
|
+
await ctx.run("postgres:set", name, "backup-bucket", bucket);
|
|
1934
|
+
}
|
|
1333
1935
|
logDone();
|
|
1334
1936
|
}
|
|
1335
1937
|
}
|
|
1938
|
+
async function removeBackupHashKeys(ctx) {
|
|
1939
|
+
let env;
|
|
1940
|
+
try {
|
|
1941
|
+
env = await readGlobalEnv(ctx);
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
logWarn("global", `could not read global env to remove old backup hash keys: ${err instanceof Error ? err.message : String(err)}`);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
const stale = staleBackupHashKeys(env);
|
|
1947
|
+
if (stale.length === 0) return;
|
|
1948
|
+
logAction("global", `Removing ${stale.length} old backup hash key(s)`);
|
|
1949
|
+
await ctx.run("config:unset", "--global", "--no-restart", ...stale);
|
|
1950
|
+
logDone();
|
|
1951
|
+
}
|
|
1952
|
+
function staleBackupHashKeys(env) {
|
|
1953
|
+
return Object.keys(env).filter((k) => k.startsWith(BACKUP_HASH_PREFIX)).sort();
|
|
1954
|
+
}
|
|
1336
1955
|
async function destroyPostgres(ctx, services) {
|
|
1337
1956
|
for (const [name] of Object.entries(services)) {
|
|
1338
1957
|
logAction("services", `Destroying ${name}`);
|
|
@@ -1405,6 +2024,15 @@ async function ensureRedis(ctx, services) {
|
|
|
1405
2024
|
const exists = await ctx.check("redis:exists", name);
|
|
1406
2025
|
if (exists) {
|
|
1407
2026
|
logSkip();
|
|
2027
|
+
try {
|
|
2028
|
+
const mismatches = await readServiceMismatches(ctx, "redis", name, config);
|
|
2029
|
+
if (mismatches.length > 0) {
|
|
2030
|
+
const parts = mismatches.map((m) => `${m.field} is ${m.current || "(unknown)"} on the server but ${m.desired} in the file`);
|
|
2031
|
+
logWarn("services", `${name}: ${parts.join("; ")}; up does not change it`);
|
|
2032
|
+
}
|
|
2033
|
+
} catch (err) {
|
|
2034
|
+
logWarn("services", `${name}: could not compare version and image: ${err instanceof Error ? err.message : String(err)}`);
|
|
2035
|
+
}
|
|
1408
2036
|
continue;
|
|
1409
2037
|
}
|
|
1410
2038
|
const args = ["redis:create", name];
|
|
@@ -1453,6 +2081,9 @@ async function exportRedis(ctx) {
|
|
|
1453
2081
|
}
|
|
1454
2082
|
|
|
1455
2083
|
// src/modules/links.ts
|
|
2084
|
+
function normalizeLink(entry) {
|
|
2085
|
+
return typeof entry === "string" ? { service: entry } : entry;
|
|
2086
|
+
}
|
|
1456
2087
|
function resolveServicePlugin(name, config) {
|
|
1457
2088
|
if (config.postgres?.[name]) return { plugin: "postgres", config: config.postgres[name] };
|
|
1458
2089
|
if (config.redis?.[name]) return { plugin: "redis", config: config.redis[name] };
|
|
@@ -1464,24 +2095,112 @@ function allServices(config) {
|
|
|
1464
2095
|
for (const name of Object.keys(config.redis ?? {})) entries.push([name, "redis"]);
|
|
1465
2096
|
return entries;
|
|
1466
2097
|
}
|
|
2098
|
+
var DEFAULT_ENV_VAR = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
2099
|
+
function linkEnvVar(plugin, link) {
|
|
2100
|
+
if (link.env_var) return link.env_var;
|
|
2101
|
+
if (link.alias) return `${link.alias}_URL`;
|
|
2102
|
+
return DEFAULT_ENV_VAR[plugin] ?? `${plugin.toUpperCase()}_URL`;
|
|
2103
|
+
}
|
|
2104
|
+
function linkFlags(link) {
|
|
2105
|
+
const flags = [];
|
|
2106
|
+
if (link.alias) flags.push("--alias", link.alias);
|
|
2107
|
+
if (link.env_var) flags.push("--env-var", link.env_var);
|
|
2108
|
+
if (link.querystring) flags.push("--querystring", link.querystring);
|
|
2109
|
+
return flags;
|
|
2110
|
+
}
|
|
2111
|
+
function readEnv(ctx, app, cache) {
|
|
2112
|
+
let read = cache?.get(app);
|
|
2113
|
+
if (!read) {
|
|
2114
|
+
read = ctx.queryStrict("config:export", "--format", "json", app).then((out) => JSON.parse(out));
|
|
2115
|
+
cache?.set(app, read);
|
|
2116
|
+
}
|
|
2117
|
+
return read;
|
|
2118
|
+
}
|
|
2119
|
+
function findLinkVariables(env, plugin, service) {
|
|
2120
|
+
const host = `dokku-${plugin}-${service}`;
|
|
2121
|
+
const found = [];
|
|
2122
|
+
for (const [name, value] of Object.entries(env)) {
|
|
2123
|
+
if (typeof value !== "string") continue;
|
|
2124
|
+
const m = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?([^:/?]+)/i);
|
|
2125
|
+
if (m && m[1] === host) found.push({ name, value });
|
|
2126
|
+
}
|
|
2127
|
+
return found;
|
|
2128
|
+
}
|
|
2129
|
+
function isFallbackName(plugin, name) {
|
|
2130
|
+
return new RegExp(`^DOKKU_${plugin.toUpperCase()}_[A-Z]+_URL$`).test(name);
|
|
2131
|
+
}
|
|
2132
|
+
function querystringOf(url) {
|
|
2133
|
+
const q = url.indexOf("?");
|
|
2134
|
+
return q === -1 ? "" : url.slice(q + 1);
|
|
2135
|
+
}
|
|
2136
|
+
function show(name, qs) {
|
|
2137
|
+
return qs ? `${name}?${maskQuerystring(qs)}` : name;
|
|
2138
|
+
}
|
|
2139
|
+
async function readLinkOptions(ctx, app, plugin, link, cache) {
|
|
2140
|
+
let env;
|
|
2141
|
+
try {
|
|
2142
|
+
env = await readEnv(ctx, app, cache);
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
return { state: "unreadable", reason: err instanceof Error ? err.message : String(err) };
|
|
2145
|
+
}
|
|
2146
|
+
const found = findLinkVariables(env, plugin, link.service);
|
|
2147
|
+
if (found.length === 0) {
|
|
2148
|
+
return { state: "unreadable", reason: `no variable of '${app}' points at ${link.service}` };
|
|
2149
|
+
}
|
|
2150
|
+
const name = linkEnvVar(plugin, link);
|
|
2151
|
+
const desired = link.querystring ?? "";
|
|
2152
|
+
const plain = !link.alias && !link.env_var;
|
|
2153
|
+
const hit = found.find((v) => v.name === name) ?? (plain ? found.find((v) => isFallbackName(plugin, v.name)) : void 0);
|
|
2154
|
+
if (!hit) return { state: "differs", current: show(found[0].name, querystringOf(found[0].value)), desired: show(name, desired) };
|
|
2155
|
+
const current = querystringOf(hit.value);
|
|
2156
|
+
if (current === desired) return { state: "same" };
|
|
2157
|
+
return { state: "differs", current: show(name, current), desired: show(name, desired) };
|
|
2158
|
+
}
|
|
2159
|
+
async function readServiceLinks(ctx, app, desiredLinks, config) {
|
|
2160
|
+
const desired = new Map(desiredLinks.map(normalizeLink).map((l) => [l.service, l]));
|
|
2161
|
+
const states = [];
|
|
2162
|
+
for (const [service, plugin] of allServices(config)) {
|
|
2163
|
+
states.push({ service, plugin, link: desired.get(service), isLinked: await ctx.check(`${plugin}:linked`, service, app) });
|
|
2164
|
+
}
|
|
2165
|
+
return states;
|
|
2166
|
+
}
|
|
2167
|
+
function servicesToUnlink(states) {
|
|
2168
|
+
return states.filter((s) => !s.link && s.isLinked);
|
|
2169
|
+
}
|
|
1467
2170
|
async function ensureAppLinks(ctx, app, desiredLinks, config) {
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const
|
|
1472
|
-
if (
|
|
2171
|
+
const states = await readServiceLinks(ctx, app, desiredLinks, config);
|
|
2172
|
+
const unlink = new Set(servicesToUnlink(states));
|
|
2173
|
+
for (const state of states) {
|
|
2174
|
+
const { service: serviceName, plugin, link, isLinked } = state;
|
|
2175
|
+
if (link && !isLinked) {
|
|
1473
2176
|
logAction(app, `Linking ${serviceName}`);
|
|
1474
|
-
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart");
|
|
2177
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
2178
|
+
ctx.needsRestart(app, "links");
|
|
1475
2179
|
logDone();
|
|
1476
|
-
} else if (
|
|
2180
|
+
} else if (link && isLinked) {
|
|
2181
|
+
const opts = await readLinkOptions(ctx, app, plugin, link);
|
|
2182
|
+
if (opts.state === "differs") {
|
|
2183
|
+
logAction(app, `Relinking ${serviceName}`);
|
|
2184
|
+
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
2185
|
+
try {
|
|
2186
|
+
await ctx.run(`${plugin}:link`, serviceName, app, "--no-restart", ...linkFlags(link));
|
|
2187
|
+
} catch (err) {
|
|
2188
|
+
logWarn(app, `${app} is now unlinked from ${serviceName}`);
|
|
2189
|
+
throw err;
|
|
2190
|
+
}
|
|
2191
|
+
ctx.needsRestart(app, "links");
|
|
2192
|
+
logDone();
|
|
2193
|
+
}
|
|
2194
|
+
} else if (unlink.has(state)) {
|
|
1477
2195
|
logAction(app, `Unlinking ${serviceName}`);
|
|
1478
2196
|
await ctx.run(`${plugin}:unlink`, serviceName, app, "--no-restart");
|
|
2197
|
+
ctx.needsRestart(app, "links");
|
|
1479
2198
|
logDone();
|
|
1480
2199
|
}
|
|
1481
2200
|
}
|
|
1482
2201
|
}
|
|
1483
2202
|
async function destroyAppLinks(ctx, app, links, config) {
|
|
1484
|
-
for (const serviceName of links) {
|
|
2203
|
+
for (const serviceName of links.map((l) => normalizeLink(l).service)) {
|
|
1485
2204
|
const resolved = resolveServicePlugin(serviceName, config);
|
|
1486
2205
|
if (!resolved) continue;
|
|
1487
2206
|
const isLinked = await ctx.check(`${resolved.plugin}:linked`, serviceName, app);
|
|
@@ -1493,45 +2212,103 @@ async function destroyAppLinks(ctx, app, links, config) {
|
|
|
1493
2212
|
async function exportAppLinks(ctx, app, config) {
|
|
1494
2213
|
const linked = [];
|
|
1495
2214
|
for (const [serviceName, plugin] of allServices(config)) {
|
|
1496
|
-
|
|
1497
|
-
|
|
2215
|
+
if (!await ctx.check(`${plugin}:linked`, serviceName, app)) continue;
|
|
2216
|
+
let found = [];
|
|
2217
|
+
try {
|
|
2218
|
+
found = findLinkVariables(await readEnv(ctx, app), plugin, serviceName);
|
|
2219
|
+
} catch {
|
|
2220
|
+
}
|
|
2221
|
+
const fallback = linkEnvVar(plugin, { service: serviceName });
|
|
2222
|
+
const v = found.find((f) => f.name === fallback) ?? found.find((f) => isFallbackName(plugin, f.name)) ?? found[0];
|
|
2223
|
+
if (!v) {
|
|
2224
|
+
linked.push(serviceName);
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
const querystring = querystringOf(v.value);
|
|
2228
|
+
if ((v.name === fallback || isFallbackName(plugin, v.name)) && !querystring) {
|
|
2229
|
+
linked.push(serviceName);
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
const entry = { service: serviceName };
|
|
2233
|
+
if (v.name !== fallback && !isFallbackName(plugin, v.name)) {
|
|
2234
|
+
if (v.name.endsWith("_URL") && v.name.length > 4) entry.alias = v.name.slice(0, -4);
|
|
2235
|
+
else entry.env_var = v.name;
|
|
2236
|
+
}
|
|
2237
|
+
if (querystring) entry.querystring = querystring;
|
|
2238
|
+
linked.push(entry);
|
|
1498
2239
|
}
|
|
1499
2240
|
return linked;
|
|
1500
2241
|
}
|
|
1501
2242
|
|
|
1502
2243
|
// src/modules/domains.ts
|
|
2244
|
+
async function readGlobalDomains(ctx) {
|
|
2245
|
+
const raw = await ctx.queryStrict("domains:report", "--global", "--domains-global-vhosts");
|
|
2246
|
+
return raw.split(/\s+/).filter(Boolean);
|
|
2247
|
+
}
|
|
2248
|
+
function globalDomainsInSync(current, desired) {
|
|
2249
|
+
if (desired === false) return current.length === 0;
|
|
2250
|
+
const sa = new Set(current);
|
|
2251
|
+
const sb = new Set(desired);
|
|
2252
|
+
return sa.size === sb.size && [...sa].every((x) => sb.has(x));
|
|
2253
|
+
}
|
|
1503
2254
|
async function ensureGlobalDomains(ctx, domains) {
|
|
1504
2255
|
logAction("global", "Configuring domains");
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
2256
|
+
const current = await readGlobalDomains(ctx);
|
|
2257
|
+
if (globalDomainsInSync(current, domains)) {
|
|
2258
|
+
logSkip();
|
|
2259
|
+
return;
|
|
1509
2260
|
}
|
|
2261
|
+
if (domains === false) await ctx.run("domains:clear-global");
|
|
2262
|
+
else await ctx.run("domains:set-global", ...domains);
|
|
1510
2263
|
logDone();
|
|
1511
2264
|
}
|
|
1512
2265
|
|
|
1513
|
-
// src/modules/config.ts
|
|
1514
|
-
async function ensureGlobalConfig(ctx, env) {
|
|
1515
|
-
if (env === false) return;
|
|
1516
|
-
const pairs = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
1517
|
-
await ctx.run("config:set", "--global", ...pairs);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
2266
|
// src/modules/logs.ts
|
|
2267
|
+
async function readGlobalLogs(ctx) {
|
|
2268
|
+
return parseJsonReport(await ctx.queryStrict("logs:report", "--global", "--format", "json"), "global");
|
|
2269
|
+
}
|
|
1521
2270
|
async function ensureGlobalLogs(ctx, logs) {
|
|
2271
|
+
logAction("global", "Configuring logs");
|
|
2272
|
+
const current = await readGlobalLogs(ctx);
|
|
2273
|
+
let changed = false;
|
|
1522
2274
|
for (const [key, value] of Object.entries(logs)) {
|
|
2275
|
+
if (current[key] === String(value)) continue;
|
|
1523
2276
|
await ctx.run("logs:set", "--global", key, String(value));
|
|
2277
|
+
changed = true;
|
|
1524
2278
|
}
|
|
2279
|
+
if (changed) logDone();
|
|
2280
|
+
else logSkip();
|
|
1525
2281
|
}
|
|
1526
2282
|
|
|
1527
2283
|
// src/modules/nginx.ts
|
|
2284
|
+
async function readGlobalNginx(ctx) {
|
|
2285
|
+
return parseJsonReport(await ctx.queryStrict("nginx:report", "--global", "--format", "json"), "global");
|
|
2286
|
+
}
|
|
1528
2287
|
async function ensureGlobalNginx(ctx, nginx) {
|
|
2288
|
+
logAction("global", "Configuring nginx");
|
|
2289
|
+
const current = await readGlobalNginx(ctx);
|
|
2290
|
+
let changed = false;
|
|
1529
2291
|
for (const [key, value] of Object.entries(nginx)) {
|
|
2292
|
+
if (current[key] === String(value)) continue;
|
|
1530
2293
|
await ctx.run("nginx:set", "--global", key, String(value));
|
|
2294
|
+
changed = true;
|
|
1531
2295
|
}
|
|
2296
|
+
if (changed) logDone();
|
|
2297
|
+
else logSkip();
|
|
1532
2298
|
}
|
|
1533
2299
|
|
|
1534
2300
|
// src/commands/up.ts
|
|
2301
|
+
function markGlobalEnvRestart(ctx, apps, reason = "global env") {
|
|
2302
|
+
for (const app of apps) ctx.needsRestart(app, reason);
|
|
2303
|
+
}
|
|
2304
|
+
function printRestartSummary(ctx, dryRun = false) {
|
|
2305
|
+
const restarts = [...ctx.restarts].filter(([app]) => !ctx.rebuilds.has(app));
|
|
2306
|
+
if (restarts.length === 0 && ctx.rebuilds.size === 0) return;
|
|
2307
|
+
console.log(`
|
|
2308
|
+
# Apps that ${dryRun ? "would need" : "need"} a restart or rebuild:`);
|
|
2309
|
+
for (const [app, reasons] of restarts) console.log(`dokku ps:restart ${app} # ${reasons.join(", ")}`);
|
|
2310
|
+
for (const [app, reasons] of ctx.rebuilds) console.log(`dokku ps:rebuild ${app} # ${reasons.join(", ")}`);
|
|
2311
|
+
}
|
|
1535
2312
|
var UpFailedError = class extends Error {
|
|
1536
2313
|
constructor(failures) {
|
|
1537
2314
|
super(
|
|
@@ -1567,6 +2344,7 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
|
|
|
1567
2344
|
await step(() => reconcile(Networks, ctx, app, appConfig.networks));
|
|
1568
2345
|
await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
|
|
1569
2346
|
await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
|
|
2347
|
+
await step(() => reconcile(ProxyProps, ctx, app, proxyProps(appConfig.proxy), count, prune));
|
|
1570
2348
|
await step(() => reconcile(Ports, ctx, app, appConfig.ports));
|
|
1571
2349
|
if (config.postgres || config.redis) {
|
|
1572
2350
|
await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
|
|
@@ -1590,18 +2368,36 @@ async function processApp(ctx, config, app, appConfig, failFast = false, prune =
|
|
|
1590
2368
|
if (errors.length > 0) throw new AppStepsError(errors);
|
|
1591
2369
|
}
|
|
1592
2370
|
async function runUp(ctx, config, appFilter, opts = {}) {
|
|
2371
|
+
try {
|
|
2372
|
+
await runUpPhases(ctx, config, appFilter, opts);
|
|
2373
|
+
} finally {
|
|
2374
|
+
printRestartSummary(ctx, opts.dryRun);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
async function runUpPhases(ctx, config, appFilter, opts) {
|
|
1593
2378
|
const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
|
|
1594
2379
|
enforceFileVersion(await assertDokkuSupported(ctx), config.dokku?.version);
|
|
1595
2380
|
if (config.plugins) await ensurePlugins(ctx, config.plugins);
|
|
1596
|
-
if (config.docker_auth)
|
|
2381
|
+
if (config.docker_auth) {
|
|
2382
|
+
await ensureDockerAuth(ctx, config.docker_auth);
|
|
2383
|
+
if (opts.prune) await pruneDockerAuth(ctx, config.docker_auth);
|
|
2384
|
+
}
|
|
1597
2385
|
if (config.domains !== void 0) await ensureGlobalDomains(ctx, config.domains);
|
|
1598
|
-
if (
|
|
2386
|
+
if (await ensureGlobalConfig(ctx, config.env)) {
|
|
2387
|
+
markGlobalEnvRestart(ctx, apps.filter((app) => config.apps[app]));
|
|
2388
|
+
}
|
|
1599
2389
|
if (config.logs !== void 0) await ensureGlobalLogs(ctx, config.logs);
|
|
1600
2390
|
if (config.nginx !== void 0) await ensureGlobalNginx(ctx, config.nginx);
|
|
1601
2391
|
if (config.networks) await ensureNetworks(ctx, config.networks);
|
|
1602
|
-
|
|
2392
|
+
const createdPostgres = config.postgres ? await ensurePostgres(ctx, config.postgres) : /* @__PURE__ */ new Set();
|
|
1603
2393
|
if (config.redis) await ensureRedis(ctx, config.redis);
|
|
1604
|
-
if (config.postgres)
|
|
2394
|
+
if (config.postgres) {
|
|
2395
|
+
await ensurePostgresBackups(ctx, config.postgres, {
|
|
2396
|
+
prune: opts.prune,
|
|
2397
|
+
dryRunCreated: opts.dryRun ? createdPostgres : void 0
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
await removeBackupHashKeys(ctx);
|
|
1605
2401
|
const failures = [];
|
|
1606
2402
|
for (const app of apps) {
|
|
1607
2403
|
const appConfig = config.apps[app];
|
|
@@ -1693,6 +2489,7 @@ var NETWORKING_RESOURCES = [
|
|
|
1693
2489
|
Networks,
|
|
1694
2490
|
NetworkProps,
|
|
1695
2491
|
Proxy,
|
|
2492
|
+
ProxyProps,
|
|
1696
2493
|
Ports
|
|
1697
2494
|
];
|
|
1698
2495
|
var CONFIG_RESOURCES = [
|
|
@@ -1732,7 +2529,10 @@ async function runExport(ctx, opts) {
|
|
|
1732
2529
|
const prefetched = /* @__PURE__ */ new Map();
|
|
1733
2530
|
await Promise.all(
|
|
1734
2531
|
ALL_APP_RESOURCES.filter((r) => !r.forceApply && !r.key.startsWith("_") && r.readAll).map(async (r) => {
|
|
1735
|
-
|
|
2532
|
+
try {
|
|
2533
|
+
prefetched.set(r.key, await r.readAll(ctx));
|
|
2534
|
+
} catch {
|
|
2535
|
+
}
|
|
1736
2536
|
})
|
|
1737
2537
|
);
|
|
1738
2538
|
for (const app of apps) {
|
|
@@ -1740,13 +2540,24 @@ async function runExport(ctx, opts) {
|
|
|
1740
2540
|
for (const resource of ALL_APP_RESOURCES) {
|
|
1741
2541
|
if (resource.key.startsWith("_")) continue;
|
|
1742
2542
|
if (resource.forceApply) continue;
|
|
2543
|
+
if (resource.key === "checks") continue;
|
|
1743
2544
|
const bulk = prefetched.get(resource.key);
|
|
1744
|
-
|
|
2545
|
+
let value;
|
|
2546
|
+
try {
|
|
2547
|
+
value = bulk ? bulk.get(app) : await resource.read(ctx, app);
|
|
2548
|
+
} catch (err) {
|
|
2549
|
+
logWarn(app, `could not read '${resource.key}', left out of the export: ${err instanceof Error ? err.message : String(err)}`);
|
|
2550
|
+
continue;
|
|
2551
|
+
}
|
|
1745
2552
|
if (value === void 0 || value === null || value === "") continue;
|
|
1746
2553
|
if (Array.isArray(value) && value.length === 0) continue;
|
|
1747
2554
|
if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) continue;
|
|
1748
|
-
if (resource.key === "
|
|
1749
|
-
appConfig.
|
|
2555
|
+
if (resource.key === "ssl") {
|
|
2556
|
+
appConfig.ssl = value !== false;
|
|
2557
|
+
} else if (resource.key === "proxy") {
|
|
2558
|
+
appConfig.proxy = { ...appConfig.proxy, enabled: value };
|
|
2559
|
+
} else if (resource.key === "proxy_props") {
|
|
2560
|
+
appConfig.proxy = { ...appConfig.proxy, ...value };
|
|
1750
2561
|
} else {
|
|
1751
2562
|
appConfig[resource.key] = value;
|
|
1752
2563
|
}
|
|
@@ -1760,14 +2571,41 @@ async function runExport(ctx, opts) {
|
|
|
1760
2571
|
return config;
|
|
1761
2572
|
}
|
|
1762
2573
|
|
|
2574
|
+
// src/modules/env-notes.ts
|
|
2575
|
+
var MANAGED_KEYS_VAR3 = "DOKKU_COMPOSE_MANAGED_KEYS";
|
|
2576
|
+
var DOKKU_WRITTEN = /* @__PURE__ */ new Set(["GIT_REV", "NO_VHOST"]);
|
|
2577
|
+
var SERVICE_URL_HOST = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?]*@)?dokku-[a-z]+-[^:/?]+/i;
|
|
2578
|
+
function undeclaredEnvKeys(input) {
|
|
2579
|
+
const { env } = input;
|
|
2580
|
+
const skip = /* @__PURE__ */ new Set([MANAGED_KEYS_VAR3, ...input.declared, ...input.external]);
|
|
2581
|
+
for (const k of String(env[MANAGED_KEYS_VAR3] ?? "").split(",")) if (k) skip.add(k);
|
|
2582
|
+
for (const link of input.links) {
|
|
2583
|
+
for (const v of findLinkVariables(env, link.plugin, link.service)) skip.add(v.name);
|
|
2584
|
+
}
|
|
2585
|
+
const notes = [];
|
|
2586
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2587
|
+
if (skip.has(key) || DOKKU_WRITTEN.has(key)) continue;
|
|
2588
|
+
if (key.startsWith("DOKKU_") && key !== "DOKKU_START_CMD") continue;
|
|
2589
|
+
if (typeof value === "string" && SERVICE_URL_HOST.test(value)) continue;
|
|
2590
|
+
const duplicate = key in input.globalFile && key in input.globalServer && input.globalServer[key] === String(value);
|
|
2591
|
+
notes.push(duplicate ? { key, label: "duplicate of global" } : { key });
|
|
2592
|
+
}
|
|
2593
|
+
return notes;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
1763
2596
|
// src/commands/diff.ts
|
|
1764
2597
|
import chalk from "chalk";
|
|
1765
|
-
function
|
|
1766
|
-
if (
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2598
|
+
function displayCurrent(resourceKey, current) {
|
|
2599
|
+
if (resourceKey === "ssl" && current && typeof current === "object") return "(certificate installed)";
|
|
2600
|
+
return current;
|
|
2601
|
+
}
|
|
2602
|
+
function safeInSync(resource, current, desired, app, warnings) {
|
|
2603
|
+
try {
|
|
2604
|
+
return resource.inSync(current, desired);
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
warnings.push(`could not compare '${resource.key}' for app '${app}': ${err instanceof Error ? err.message : String(err)}`);
|
|
2607
|
+
return void 0;
|
|
2608
|
+
}
|
|
1771
2609
|
}
|
|
1772
2610
|
function hasAdditionsOrChanges(change) {
|
|
1773
2611
|
const c = change;
|
|
@@ -1779,7 +2617,7 @@ function isEmptyValue(value) {
|
|
|
1779
2617
|
return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
|
|
1780
2618
|
}
|
|
1781
2619
|
async function computeDiff(ctx, config) {
|
|
1782
|
-
const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
|
|
2620
|
+
const result = { apps: {}, services: {}, plugins: {}, global: {}, networks: {}, inSync: true, envNotes: {}, readWarnings: [] };
|
|
1783
2621
|
const appNames = Object.keys(config.apps);
|
|
1784
2622
|
const serverVersion = await assertDokkuSupported(ctx);
|
|
1785
2623
|
const fileVersion = config.dokku?.version;
|
|
@@ -1806,13 +2644,29 @@ async function computeDiff(ctx, config) {
|
|
|
1806
2644
|
}
|
|
1807
2645
|
})
|
|
1808
2646
|
);
|
|
2647
|
+
let globalEnv;
|
|
2648
|
+
let globalEnvValues = {};
|
|
2649
|
+
let globalEnvError;
|
|
2650
|
+
if (config.env !== false) {
|
|
2651
|
+
try {
|
|
2652
|
+
globalEnvValues = await readGlobalEnv(ctx);
|
|
2653
|
+
globalEnv = await readGlobalEnvDrift(ctx, config.env, globalEnvValues);
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
globalEnvError = err;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
1809
2658
|
for (const [app, appConfig] of Object.entries(config.apps)) {
|
|
1810
2659
|
const appDiff = {};
|
|
2660
|
+
const envCache = /* @__PURE__ */ new Map();
|
|
1811
2661
|
for (const resource of ALL_APP_RESOURCES) {
|
|
1812
2662
|
if (resource.key.startsWith("_")) continue;
|
|
1813
2663
|
let desired;
|
|
1814
2664
|
if (resource.key === "proxy") {
|
|
1815
2665
|
desired = appConfig.proxy?.enabled;
|
|
2666
|
+
} else if (resource.key === "git") {
|
|
2667
|
+
desired = appConfig.git ?? config.git;
|
|
2668
|
+
} else if (resource.key === "proxy_props") {
|
|
2669
|
+
desired = proxyProps(appConfig.proxy);
|
|
1816
2670
|
} else {
|
|
1817
2671
|
desired = appConfig[resource.key];
|
|
1818
2672
|
}
|
|
@@ -1847,17 +2701,28 @@ async function computeDiff(ctx, config) {
|
|
|
1847
2701
|
current = await resource.read(ctx, app);
|
|
1848
2702
|
} else if (bulk) {
|
|
1849
2703
|
current = bulk.get(app);
|
|
1850
|
-
if (resource.key === "ssl" && current === void 0) {
|
|
1851
|
-
current = await resource.read(ctx, app);
|
|
1852
|
-
if (current === void 0) bulkUnreadable = true;
|
|
1853
|
-
}
|
|
1854
2704
|
} else {
|
|
1855
2705
|
current = await resource.read(ctx, app);
|
|
1856
2706
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
if (
|
|
2707
|
+
if (resource.inSync) {
|
|
2708
|
+
const removedHere = resource.removals ? resource.removals(current, desired) : [];
|
|
2709
|
+
let status;
|
|
2710
|
+
if (current === void 0) {
|
|
2711
|
+
status = "unknown";
|
|
2712
|
+
result.readWarnings.push(`could not read '${resource.key}' for app '${app}'`);
|
|
2713
|
+
} else {
|
|
2714
|
+
const same = safeInSync(resource, current, desired, app, result.readWarnings);
|
|
2715
|
+
if (same === void 0) status = "unknown";
|
|
2716
|
+
else if (same) status = removedHere.length > 0 ? "extra" : "in-sync";
|
|
2717
|
+
else status = current === false || isEmptyValue(current) ? "missing" : "changed";
|
|
2718
|
+
}
|
|
2719
|
+
appDiff[resource.key] = {
|
|
2720
|
+
status,
|
|
2721
|
+
desired,
|
|
2722
|
+
current: displayCurrent(resource.key, current),
|
|
2723
|
+
...removedHere.length > 0 ? { removed: removedHere } : {}
|
|
2724
|
+
};
|
|
2725
|
+
if (status !== "in-sync") result.inSync = false;
|
|
1861
2726
|
continue;
|
|
1862
2727
|
}
|
|
1863
2728
|
let removed = resource.removals ? resource.removals(current, desired) : [];
|
|
@@ -1893,16 +2758,57 @@ async function computeDiff(ctx, config) {
|
|
|
1893
2758
|
appDiff[resource.key].removed = removed;
|
|
1894
2759
|
}
|
|
1895
2760
|
}
|
|
1896
|
-
if (
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2761
|
+
if (appExists.get(app) && (config.postgres || config.redis)) {
|
|
2762
|
+
const unlink = servicesToUnlink(await readServiceLinks(ctx, app, appConfig.links ?? [], config)).map((s) => s.service);
|
|
2763
|
+
if (appConfig.links === void 0) {
|
|
2764
|
+
if (unlink.length > 0) {
|
|
2765
|
+
appDiff["links"] = { status: "extra", current: unlink, removed: unlink, note: "no links: in the file; up will unlink these" };
|
|
2766
|
+
result.inSync = false;
|
|
2767
|
+
}
|
|
2768
|
+
} else {
|
|
2769
|
+
for (const service of unlink) {
|
|
2770
|
+
appDiff[`links:${service}`] = { status: "extra", current: service, removed: [service], note: "up unlinks" };
|
|
2771
|
+
result.inSync = false;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
if (appConfig.links && appExists.get(app)) {
|
|
2776
|
+
for (const entry of appConfig.links) {
|
|
2777
|
+
const link = normalizeLink(entry);
|
|
2778
|
+
const resolved = resolveServicePlugin(link.service, config);
|
|
2779
|
+
if (!resolved) continue;
|
|
2780
|
+
if (!await ctx.check(`${resolved.plugin}:linked`, link.service, app)) {
|
|
2781
|
+
appDiff[`links:${link.service}`] = { status: "missing", desired: linkEnvVar(resolved.plugin, link), current: void 0 };
|
|
2782
|
+
result.inSync = false;
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
const opts = await readLinkOptions(ctx, app, resolved.plugin, link, envCache);
|
|
2786
|
+
if (opts.state === "differs") {
|
|
2787
|
+
appDiff[`links:${link.service}`] = { status: "changed", desired: opts.desired, current: opts.current, note: "up unlinks and relinks it" };
|
|
2788
|
+
result.inSync = false;
|
|
2789
|
+
} else if (opts.state === "unreadable") {
|
|
2790
|
+
result.readWarnings.push(`could not read the link options of '${link.service}' for app '${app}' (not counted as drift): ${opts.reason}`);
|
|
1901
2791
|
}
|
|
1902
2792
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2793
|
+
}
|
|
2794
|
+
if (appExists.get(app)) {
|
|
2795
|
+
try {
|
|
2796
|
+
const links = (appConfig.links ?? []).flatMap((entry) => {
|
|
2797
|
+
const link = normalizeLink(entry);
|
|
2798
|
+
const resolved = resolveServicePlugin(link.service, config);
|
|
2799
|
+
return resolved ? [{ plugin: resolved.plugin, service: link.service }] : [];
|
|
2800
|
+
});
|
|
2801
|
+
const notes = undeclaredEnvKeys({
|
|
2802
|
+
env: await readEnv(ctx, app, envCache),
|
|
2803
|
+
declared: appConfig.env ? Object.keys(appConfig.env) : [],
|
|
2804
|
+
links,
|
|
2805
|
+
external: appConfig.env_external ?? [],
|
|
2806
|
+
globalFile: config.env || {},
|
|
2807
|
+
globalServer: globalEnvValues
|
|
2808
|
+
});
|
|
2809
|
+
if (notes.length > 0) result.envNotes[app] = notes;
|
|
2810
|
+
} catch (err) {
|
|
2811
|
+
result.readWarnings.push(`could not read the env of app '${app}' to note keys the file does not declare: ${err instanceof Error ? err.message : String(err)}`);
|
|
1906
2812
|
}
|
|
1907
2813
|
}
|
|
1908
2814
|
result.apps[app] = appDiff;
|
|
@@ -1917,20 +2823,137 @@ async function computeDiff(ctx, config) {
|
|
|
1917
2823
|
if (plan.action !== "unknown") result.inSync = false;
|
|
1918
2824
|
}
|
|
1919
2825
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2826
|
+
if (config.domains !== void 0) {
|
|
2827
|
+
try {
|
|
2828
|
+
const current = await readGlobalDomains(ctx);
|
|
2829
|
+
if (!globalDomainsInSync(current, config.domains)) {
|
|
2830
|
+
result.global.domains = { status: current.length === 0 ? "missing" : "changed", desired: config.domains, current };
|
|
2831
|
+
result.inSync = false;
|
|
2832
|
+
}
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
result.global.domains = { status: "unknown", desired: config.domains };
|
|
2835
|
+
result.readWarnings.push(`could not read global 'domains': ${err instanceof Error ? err.message : String(err)}`);
|
|
2836
|
+
result.inSync = false;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
if (globalEnv) {
|
|
2840
|
+
const { changed, removed, markerOutdated } = globalEnv;
|
|
2841
|
+
if (changed.length > 0 || removed.length > 0 || markerOutdated) {
|
|
2842
|
+
result.global.env = {
|
|
2843
|
+
status: changed.length === 0 && removed.length > 0 ? "extra" : "changed",
|
|
2844
|
+
desired: Object.fromEntries(changed.map((k) => [k, "****"])),
|
|
2845
|
+
removed,
|
|
2846
|
+
note: changed.length === 0 && removed.length === 0 ? `${GLOBAL_MANAGED_KEYS_VAR} not written yet` : void 0
|
|
2847
|
+
};
|
|
2848
|
+
result.inSync = false;
|
|
2849
|
+
}
|
|
2850
|
+
} else if (config.env !== false) {
|
|
2851
|
+
const err = globalEnvError;
|
|
2852
|
+
result.global.env = { status: "unknown", desired: Object.keys(config.env ?? {}) };
|
|
2853
|
+
result.readWarnings.push(`could not read global 'env': ${err instanceof Error ? err.message : String(err)}`);
|
|
2854
|
+
result.inSync = false;
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const stale = staleBackupHashKeys(await readGlobalEnv(ctx));
|
|
2858
|
+
if (stale.length > 0) {
|
|
2859
|
+
result.global.backup_hash = { status: "extra", removed: stale, note: "old backup hash keys; up unsets them" };
|
|
2860
|
+
result.inSync = false;
|
|
2861
|
+
}
|
|
2862
|
+
} catch (err) {
|
|
2863
|
+
if (config.env === false) result.readWarnings.push(`could not read global env: ${err instanceof Error ? err.message : String(err)}`);
|
|
2864
|
+
}
|
|
2865
|
+
if (config.docker_auth) {
|
|
2866
|
+
try {
|
|
2867
|
+
const { stale, extra } = await readDockerAuthDrift(ctx, config.docker_auth);
|
|
2868
|
+
if (stale.length > 0 || extra.length > 0) {
|
|
2869
|
+
result.global.docker_auth = {
|
|
2870
|
+
status: stale.length > 0 ? "changed" : "extra",
|
|
2871
|
+
desired: Object.fromEntries(stale.map((server) => [server, "login missing or differs"])),
|
|
2872
|
+
removed: extra,
|
|
2873
|
+
note: extra.length > 0 ? "up --prune logs out of these" : void 0
|
|
2874
|
+
};
|
|
2875
|
+
result.inSync = false;
|
|
2876
|
+
}
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
result.global.docker_auth = { status: "unknown", desired: Object.keys(config.docker_auth) };
|
|
2879
|
+
result.readWarnings.push(`could not read global 'docker_auth': ${err instanceof Error ? err.message : String(err)}`);
|
|
2880
|
+
result.inSync = false;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
for (const [name, declared, read] of [
|
|
2884
|
+
["logs", config.logs, readGlobalLogs],
|
|
2885
|
+
["nginx", config.nginx, readGlobalNginx]
|
|
2886
|
+
]) {
|
|
2887
|
+
if (declared === void 0) continue;
|
|
2888
|
+
let current;
|
|
2889
|
+
try {
|
|
2890
|
+
current = await read(ctx);
|
|
2891
|
+
} catch (err) {
|
|
2892
|
+
result.global[name] = { status: "unknown", desired: declared };
|
|
2893
|
+
result.readWarnings.push(`could not read global '${name}': ${err instanceof Error ? err.message : String(err)}`);
|
|
2894
|
+
result.inSync = false;
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
const differs = Object.entries(declared).filter(([k, v]) => current[k] !== String(v));
|
|
2898
|
+
if (differs.length === 0) continue;
|
|
2899
|
+
const shown = Object.fromEntries(Object.keys(declared).filter((k) => k in current).map((k) => [k, current[k]]));
|
|
2900
|
+
result.global[name] = { status: differs.every(([k]) => !(k in current)) ? "missing" : "changed", desired: declared, current: shown };
|
|
2901
|
+
result.inSync = false;
|
|
2902
|
+
}
|
|
2903
|
+
for (const net of config.networks ?? []) {
|
|
2904
|
+
if (await ctx.check("network:exists", net)) continue;
|
|
2905
|
+
result.networks[net] = { status: "missing" };
|
|
2906
|
+
result.inSync = false;
|
|
1924
2907
|
}
|
|
1925
|
-
for (const
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
2908
|
+
for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
|
|
2909
|
+
for (const [svc, declared] of Object.entries(services ?? {})) {
|
|
2910
|
+
const exists = await ctx.check(`${plugin}:exists`, svc);
|
|
2911
|
+
if (!exists) {
|
|
2912
|
+
result.services[svc] = { status: "missing" };
|
|
2913
|
+
result.inSync = false;
|
|
2914
|
+
continue;
|
|
2915
|
+
}
|
|
2916
|
+
const backup = plugin === "postgres" ? declared.backup : void 0;
|
|
2917
|
+
const compares = declared?.version !== void 0 || declared?.image !== void 0 || backup !== void 0;
|
|
2918
|
+
if (!compares && plugin === "redis") {
|
|
2919
|
+
result.services[svc] = { status: "in-sync", mismatches: [] };
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
try {
|
|
2923
|
+
const info = await readServiceInfo(ctx, plugin, svc);
|
|
2924
|
+
const mismatches = [...versionMismatches(info, declared), ...backup !== void 0 ? backupMismatches(info, backup) : []];
|
|
2925
|
+
const note = plugin === "postgres" && backup === void 0 && hasBackup(info) ? `backup on the server is not in the file (${describeServerBackup(info)}); up leaves it, backup: false removes it` : void 0;
|
|
2926
|
+
result.services[svc] = { status: mismatches.length ? "changed" : "in-sync", mismatches, note };
|
|
2927
|
+
if (mismatches.length) result.inSync = false;
|
|
2928
|
+
} catch (err) {
|
|
2929
|
+
result.readWarnings.push(`could not read ${plugin} service '${svc}': ${err instanceof Error ? err.message : String(err)}`);
|
|
2930
|
+
if (!compares) {
|
|
2931
|
+
result.services[svc] = { status: "in-sync", mismatches: [] };
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
result.services[svc] = { status: "unknown" };
|
|
2935
|
+
result.inSync = false;
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
1929
2938
|
}
|
|
1930
2939
|
return result;
|
|
1931
2940
|
}
|
|
1932
2941
|
function maskDiffResult(diff) {
|
|
1933
|
-
const masked = {
|
|
2942
|
+
const masked = {
|
|
2943
|
+
apps: {},
|
|
2944
|
+
services: diff.services,
|
|
2945
|
+
plugins: diff.plugins,
|
|
2946
|
+
dokku: diff.dokku,
|
|
2947
|
+
global: Object.fromEntries(Object.entries(diff.global).map(([k, d]) => [k, {
|
|
2948
|
+
...d,
|
|
2949
|
+
desired: maskSensitiveData(d.desired),
|
|
2950
|
+
current: maskSensitiveData(d.current)
|
|
2951
|
+
}])),
|
|
2952
|
+
networks: diff.networks,
|
|
2953
|
+
inSync: diff.inSync,
|
|
2954
|
+
envNotes: diff.envNotes,
|
|
2955
|
+
readWarnings: diff.readWarnings
|
|
2956
|
+
};
|
|
1934
2957
|
for (const [app, appDiff] of Object.entries(diff.apps)) {
|
|
1935
2958
|
const maskedAppDiff = {};
|
|
1936
2959
|
for (const [feature, d] of Object.entries(appDiff)) {
|
|
@@ -1946,6 +2969,9 @@ function maskDiffResult(diff) {
|
|
|
1946
2969
|
}
|
|
1947
2970
|
return masked;
|
|
1948
2971
|
}
|
|
2972
|
+
function driftCount(diff) {
|
|
2973
|
+
return Object.values(diff.apps).flatMap((a) => Object.values(a)).filter((d) => d.status !== "in-sync").length + Object.values(diff.services).filter((d) => d.status !== "in-sync").length + Object.values(diff.plugins).filter((d) => d.status !== "unknown").length + Object.keys(diff.global).length + Object.keys(diff.networks).length + (diff.dokku ? 1 : 0);
|
|
2974
|
+
}
|
|
1949
2975
|
function formatSummary(diff) {
|
|
1950
2976
|
const lines = [""];
|
|
1951
2977
|
for (const [app, appDiff] of Object.entries(diff.apps)) {
|
|
@@ -1960,11 +2986,22 @@ function formatSummary(diff) {
|
|
|
1960
2986
|
lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
|
|
1961
2987
|
}
|
|
1962
2988
|
}
|
|
2989
|
+
const notes = diff.envNotes[app];
|
|
2990
|
+
if (notes) lines.push(` note: env not in the file (up leaves these): ${notes.map(showNote).join(", ")}`);
|
|
1963
2991
|
}
|
|
1964
2992
|
if (diff.dokku) {
|
|
1965
2993
|
lines.push(` dokku`);
|
|
1966
2994
|
lines.push(` ${chalk.yellow("~")} version: ${diff.dokku.file} (file) \u2192 ${diff.dokku.server} (server)`);
|
|
1967
2995
|
}
|
|
2996
|
+
for (const [name, d] of Object.entries(diff.global)) {
|
|
2997
|
+
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
|
|
2998
|
+
lines.push(` global`);
|
|
2999
|
+
lines.push(` ${sym} ${name}: ${formatFeatureSummary(d)}`);
|
|
3000
|
+
}
|
|
3001
|
+
for (const net of Object.keys(diff.networks)) {
|
|
3002
|
+
lines.push(` networks:`);
|
|
3003
|
+
lines.push(` ${chalk.green("+")} ${net}: not created`);
|
|
3004
|
+
}
|
|
1968
3005
|
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
1969
3006
|
const sym = d.status === "missing" ? chalk.green("+") : d.status === "unknown" ? chalk.red("?") : chalk.yellow("~");
|
|
1970
3007
|
const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
|
|
@@ -1976,8 +3013,13 @@ function formatSummary(diff) {
|
|
|
1976
3013
|
lines.push(` services:`);
|
|
1977
3014
|
lines.push(` ${chalk.green("+")} ${svc}: not provisioned`);
|
|
1978
3015
|
}
|
|
3016
|
+
if (d.mismatches?.length || d.note) lines.push(` services: ${svc}`);
|
|
3017
|
+
for (const m of d.mismatches ?? []) {
|
|
3018
|
+
lines.push(` ${chalk.yellow("~")} ${m.field}: ${m.current || "(unknown)"} \u2192 ${m.desired}`);
|
|
3019
|
+
}
|
|
3020
|
+
if (d.note) lines.push(` note: ${d.note}`);
|
|
1979
3021
|
}
|
|
1980
|
-
const total =
|
|
3022
|
+
const total = driftCount(diff);
|
|
1981
3023
|
if (diff.readWarnings.length > 0) {
|
|
1982
3024
|
lines.push("");
|
|
1983
3025
|
lines.push(chalk.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
|
|
@@ -1992,6 +3034,9 @@ function formatSummary(diff) {
|
|
|
1992
3034
|
lines.push("");
|
|
1993
3035
|
return lines.join("\n");
|
|
1994
3036
|
}
|
|
3037
|
+
function showNote(n) {
|
|
3038
|
+
return n.label ? `${n.key} (${n.label})` : n.key;
|
|
3039
|
+
}
|
|
1995
3040
|
function formatFeatureSummary(d) {
|
|
1996
3041
|
const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
|
|
1997
3042
|
if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
|
|
@@ -2004,7 +3049,8 @@ function formatChange(d) {
|
|
|
2004
3049
|
if (Array.isArray(d.desired) && Array.isArray(d.current)) {
|
|
2005
3050
|
return `${d.current.length} \u2192 ${d.desired.length} items`;
|
|
2006
3051
|
}
|
|
2007
|
-
|
|
3052
|
+
const desired = JSON.stringify(d.desired, showUnset);
|
|
3053
|
+
return d.current === void 0 ? desired : `${JSON.stringify(d.current, showUnset)} \u2192 ${desired}`;
|
|
2008
3054
|
}
|
|
2009
3055
|
function showUnset(_key, value) {
|
|
2010
3056
|
return value === "\0missing" ? "(unset)" : value;
|
|
@@ -2032,11 +3078,30 @@ function formatVerbose(diff) {
|
|
|
2032
3078
|
for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
|
|
2033
3079
|
}
|
|
2034
3080
|
}
|
|
3081
|
+
for (const [app, notes] of Object.entries(diff.envNotes)) {
|
|
3082
|
+
lines.push(`@@ app: ${app} / env not in the file (note; up leaves these) @@`);
|
|
3083
|
+
for (const n of notes) lines.push(` note: ${showNote(n)}`);
|
|
3084
|
+
}
|
|
2035
3085
|
if (diff.dokku) {
|
|
2036
3086
|
lines.push(`@@ dokku version @@`);
|
|
2037
3087
|
lines.push(chalk.red(`- ${diff.dokku.server} (server)`));
|
|
2038
3088
|
lines.push(chalk.green(`+ ${diff.dokku.file} (file)`));
|
|
2039
3089
|
}
|
|
3090
|
+
for (const [name, d] of Object.entries(diff.global)) {
|
|
3091
|
+
lines.push(`@@ global / ${name} @@`);
|
|
3092
|
+
if (d.status === "unknown") {
|
|
3093
|
+
lines.push(chalk.red(` ? could not read server state \u2014 not reported as missing`));
|
|
3094
|
+
continue;
|
|
3095
|
+
}
|
|
3096
|
+
if (d.current !== void 0) for (const line of JSON.stringify(d.current, null, 2).split("\n")) lines.push(chalk.red(`- ${line}`));
|
|
3097
|
+
if (d.status !== "extra") for (const line of JSON.stringify(d.desired, null, 2).split("\n")) lines.push(chalk.green(`+ ${line}`));
|
|
3098
|
+
for (const r of d.removed ?? []) lines.push(chalk.red(`- ${r} (not in the file)`));
|
|
3099
|
+
if (d.note) lines.push(` (${d.note})`);
|
|
3100
|
+
}
|
|
3101
|
+
for (const net of Object.keys(diff.networks)) {
|
|
3102
|
+
lines.push(`@@ networks @@`);
|
|
3103
|
+
lines.push(chalk.green(`+ ${net}`));
|
|
3104
|
+
}
|
|
2040
3105
|
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
2041
3106
|
lines.push(`@@ plugin: ${name} @@`);
|
|
2042
3107
|
if (d.current !== void 0) lines.push(chalk.red(`- ${d.current}`));
|
|
@@ -2047,28 +3112,38 @@ function formatVerbose(diff) {
|
|
|
2047
3112
|
lines.push(`@@ services @@`);
|
|
2048
3113
|
lines.push(chalk.green(`+ ${svc}`));
|
|
2049
3114
|
}
|
|
3115
|
+
for (const m of d.mismatches ?? []) {
|
|
3116
|
+
lines.push(`@@ services: ${svc} ${m.field} @@`);
|
|
3117
|
+
lines.push(chalk.red(`- ${m.current}`));
|
|
3118
|
+
lines.push(chalk.green(`+ ${m.desired}`));
|
|
3119
|
+
}
|
|
3120
|
+
if (d.note) lines.push(`@@ services: ${svc} @@
|
|
3121
|
+
(${d.note})`);
|
|
2050
3122
|
}
|
|
2051
3123
|
if (diff.readWarnings.length > 0) {
|
|
2052
3124
|
lines.push("");
|
|
2053
3125
|
lines.push(chalk.red("Could not read some server state \u2014 treated as unknown, not missing:"));
|
|
2054
3126
|
for (const w of diff.readWarnings) lines.push(chalk.red(` - ${w}`));
|
|
2055
3127
|
}
|
|
3128
|
+
if (driftCount(diff) === 0) {
|
|
3129
|
+
lines.push(chalk.green("Everything in sync."));
|
|
3130
|
+
}
|
|
2056
3131
|
lines.push("");
|
|
2057
3132
|
return lines.join("\n");
|
|
2058
3133
|
}
|
|
2059
3134
|
|
|
2060
3135
|
// src/commands/validate.ts
|
|
2061
|
-
import * as
|
|
3136
|
+
import * as fs3 from "fs";
|
|
2062
3137
|
import * as yaml2 from "js-yaml";
|
|
2063
3138
|
function validate(filePath) {
|
|
2064
3139
|
const errors = [];
|
|
2065
3140
|
const warnings = [];
|
|
2066
|
-
if (!
|
|
3141
|
+
if (!fs3.existsSync(filePath)) {
|
|
2067
3142
|
return { errors: [`File not found: ${filePath}`], warnings };
|
|
2068
3143
|
}
|
|
2069
3144
|
let raw;
|
|
2070
3145
|
try {
|
|
2071
|
-
raw = yaml2.load(
|
|
3146
|
+
raw = yaml2.load(fs3.readFileSync(filePath, "utf8"));
|
|
2072
3147
|
} catch (e) {
|
|
2073
3148
|
return { errors: [`YAML parse error: ${e.message}`], warnings };
|
|
2074
3149
|
}
|
|
@@ -2076,7 +3151,13 @@ function validate(filePath) {
|
|
|
2076
3151
|
if (!result.success) {
|
|
2077
3152
|
for (const issue of result.error.issues) {
|
|
2078
3153
|
const issuePath = issue.path.join(".");
|
|
2079
|
-
|
|
3154
|
+
const branches = issue.code === "invalid_union" ? issue.errors : void 0;
|
|
3155
|
+
const reasons = branches?.filter((b) => !b.every((i) => i.code === "invalid_type" && i.path.length === 0)).flat();
|
|
3156
|
+
if (reasons && reasons.length > 0) {
|
|
3157
|
+
for (const r of reasons) errors.push(`${[issuePath, ...r.path].filter((x) => x !== "").join(".")}: ${r.message}`);
|
|
3158
|
+
} else {
|
|
3159
|
+
errors.push(`${issuePath}: ${issue.message}`);
|
|
3160
|
+
}
|
|
2080
3161
|
}
|
|
2081
3162
|
}
|
|
2082
3163
|
const data = raw;
|
|
@@ -2089,9 +3170,11 @@ function validate(filePath) {
|
|
|
2089
3170
|
for (const name of Object.keys(data.redis)) serviceNames.add(name);
|
|
2090
3171
|
}
|
|
2091
3172
|
for (const [appName, appCfg] of Object.entries(data.apps)) {
|
|
3173
|
+
errors.push(...envExternalErrors(appName, appCfg));
|
|
2092
3174
|
if (!appCfg?.links) continue;
|
|
2093
|
-
for (const
|
|
2094
|
-
|
|
3175
|
+
for (const entry of appCfg.links) {
|
|
3176
|
+
const link = typeof entry === "string" ? entry : entry?.service;
|
|
3177
|
+
if (typeof link === "string" && !serviceNames.has(link)) {
|
|
2095
3178
|
errors.push(`apps.${appName}.links: service "${link}" not defined in postgres or redis`);
|
|
2096
3179
|
}
|
|
2097
3180
|
}
|
|
@@ -2106,6 +3189,22 @@ function validate(filePath) {
|
|
|
2106
3189
|
}
|
|
2107
3190
|
return { errors, warnings };
|
|
2108
3191
|
}
|
|
3192
|
+
function envExternalErrors(appName, appCfg) {
|
|
3193
|
+
const list = appCfg?.env_external;
|
|
3194
|
+
if (!Array.isArray(list)) return [];
|
|
3195
|
+
const declared = appCfg.env && typeof appCfg.env === "object" ? appCfg.env : {};
|
|
3196
|
+
const errors = [];
|
|
3197
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3198
|
+
for (const key of list) {
|
|
3199
|
+
if (typeof key !== "string") continue;
|
|
3200
|
+
const where = `apps.${appName}.env_external`;
|
|
3201
|
+
if (key === "DOKKU_COMPOSE_MANAGED_KEYS") errors.push(`${where}: "${key}" is dokku-compose's own marker`);
|
|
3202
|
+
else if (seen.has(key)) errors.push(`${where}: "${key}" is listed twice`);
|
|
3203
|
+
else if (key in declared) errors.push(`${where}: "${key}" is also in env:`);
|
|
3204
|
+
seen.add(key);
|
|
3205
|
+
}
|
|
3206
|
+
return errors;
|
|
3207
|
+
}
|
|
2109
3208
|
|
|
2110
3209
|
// src/index.ts
|
|
2111
3210
|
import chalk2 from "chalk";
|
|
@@ -2123,7 +3222,7 @@ program.command("up [apps...]").description("Create/update apps and services to
|
|
|
2123
3222
|
const runner = makeRunner(opts);
|
|
2124
3223
|
const ctx = createContext(runner);
|
|
2125
3224
|
try {
|
|
2126
|
-
await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
|
|
3225
|
+
await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false, dryRun: opts.dryRun ?? false });
|
|
2127
3226
|
if (opts.dryRun) {
|
|
2128
3227
|
console.log("\n# Commands that would run:");
|
|
2129
3228
|
for (const cmd of runner.dryRunLog) {
|
|
@@ -2183,7 +3282,7 @@ program.command("export").description("Export server state to dokku-compose.yml
|
|
|
2183
3282
|
const result = opts.sensitive ? rawResult : maskSensitiveData(rawResult);
|
|
2184
3283
|
const out = yaml3.dump(result, { lineWidth: 120 });
|
|
2185
3284
|
if (opts.output) {
|
|
2186
|
-
|
|
3285
|
+
fs4.writeFileSync(opts.output, out);
|
|
2187
3286
|
console.error(`Written to ${opts.output}`);
|
|
2188
3287
|
} else {
|
|
2189
3288
|
process.stdout.write(out);
|
|
@@ -2218,7 +3317,7 @@ program.command("ps [apps...]").description("Show status of configured apps").op
|
|
|
2218
3317
|
const config = loadConfig(opts.file);
|
|
2219
3318
|
const runner = makeRunner({});
|
|
2220
3319
|
try {
|
|
2221
|
-
const { runPs } = await import("./ps-
|
|
3320
|
+
const { runPs } = await import("./ps-QQAVQTEV.js");
|
|
2222
3321
|
await runPs(runner, config, apps);
|
|
2223
3322
|
} catch (error) {
|
|
2224
3323
|
console.error(chalk2.red(`
|