dokku-compose 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -6
- package/dist/index.js +529 -76
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,9 +101,32 @@ Running `up` again produces no changes — every step checks current state befor
|
|
|
101
101
|
| `--dry-run` | Print commands without executing |
|
|
102
102
|
| `--sensitive` | Show sensitive values in output (masked by default) |
|
|
103
103
|
| `--fail-fast` | Stop on first error (default: continue to next app) |
|
|
104
|
+
| `--prune` | `up` only: also reset settings the file no longer declares (read `diff` first) |
|
|
104
105
|
| `--remove-orphans` | Destroy services and networks not in config |
|
|
105
106
|
| `--verbose` | Show git-style +/- diff (diff command only) |
|
|
106
107
|
|
|
108
|
+
### Settings removed from the file
|
|
109
|
+
|
|
110
|
+
`diff` marks with a red `-` every setting the server still has that the file no longer declares
|
|
111
|
+
(managed env keys, nginx, logs, registry, scheduler, checks, `ps` and network properties, build
|
|
112
|
+
args, and entries missing from a declared list), including when the whole block is gone. Only
|
|
113
|
+
settable keys count, never read-only report lines, and a `-` counts as drift. It also warns when
|
|
114
|
+
an app has no `links:` but has services linked on the server, because `up` unlinks them.
|
|
115
|
+
|
|
116
|
+
`up` always unsets env keys removed from the file. For every other removal it leaves the setting in place and prints a one-line count per app of the leftovers it noticed in the blocks you declare; it does not look at removed whole blocks or checks. Run `dokku-compose diff` for the full list.
|
|
117
|
+
|
|
118
|
+
**`up --prune`** resets those leftovers. Read `diff` first: every `-` it shows for the resources below is what `--prune` will reset, and there is no prompt (`diff` is the preview, the flag is the consent). Add `--dry-run` to print the reset commands without running them.
|
|
119
|
+
|
|
120
|
+
| Resource | Reset command |
|
|
121
|
+
|----------|---------------|
|
|
122
|
+
| `nginx`, `logs`, `registry`, `ps` | `<plugin>:set <app> <key>` with no value (Dokku's default); nginx then rebuilds the proxy config |
|
|
123
|
+
| `scheduler` | `scheduler:set <app> selected` |
|
|
124
|
+
| `network` properties | `network:set <app> <key>` with no value (`attach_post_create` becomes `attach-post-create`, and so on) |
|
|
125
|
+
| `checks` | `checks:enable <app> <types>` for the process types of a removed `disabled`/`skipped` list that the file declares in no list (a type the file does declare is left, with a warning naming the command to run by hand), `checks:set <app> wait-to-retire` |
|
|
126
|
+
| `build.args` | `docker-options:remove <app> build "--build-arg KEY=VALUE"` for each stale key, by the exact option text (every occurrence); other build options stay |
|
|
127
|
+
|
|
128
|
+
It covers a block that is gone from the file as well as keys removed from a declared block, and it reads the app once more for a block the file no longer has. `--prune` never destroys an app, service or network, never uninstalls a plugin, never changes `scale`, and does not clear whole `domains`, `ports`, `storage` or `networks` lists because their key is missing (those already prune inside a block the file declares). `proxy` is not a removal. `networks:` owns `attach-post-deploy`: `--prune` does not touch it, and a missing `networks:` key does not clear it (that could detach a running app from its network on the next deploy); `networks: []` does. After `up --prune`, `diff` shows no `-` for the resources above, except for checks that were skipped with a warning (their `-` lines stay).
|
|
129
|
+
|
|
107
130
|
### Errors
|
|
108
131
|
|
|
109
132
|
A failing Dokku command (mutation) is never reported as a success. On `up` and `down`, a
|
|
@@ -123,7 +146,7 @@ All features are idempotent — running `up` twice produces no changes.
|
|
|
123
146
|
|---------|-------------|-----------|
|
|
124
147
|
| Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) |
|
|
125
148
|
| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) |
|
|
126
|
-
| Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) |
|
|
149
|
+
| Environment Variables | Set config vars per app or globally, with full convergence (keys removed from the YAML are unset) | [config](docs/reference/config.md) |
|
|
127
150
|
| Build | Dockerfile path, build context, app.json, build args | [builder](docs/reference/builder.md) |
|
|
128
151
|
| Docker Options | Custom Docker options per phase (build/deploy/run) | [docker_options](docs/reference/docker_options.md) |
|
|
129
152
|
| Networks | Create shared Docker networks, attach to apps | [network](docs/reference/network.md) |
|
|
@@ -131,16 +154,29 @@ All features are idempotent — running `up` twice produces no changes.
|
|
|
131
154
|
| Port Mappings | Map external ports to container ports | [ports](docs/reference/ports.md) |
|
|
132
155
|
| SSL Certificates | Add or remove SSL certs | [certs](docs/reference/certs.md) |
|
|
133
156
|
| Proxy | Enable/disable proxy, select implementation | [proxy](docs/reference/proxy.md) |
|
|
134
|
-
| Storage | Persistent bind mounts with full convergence | [storage](docs/reference/storage.md) |
|
|
157
|
+
| Storage | Persistent bind mounts with full convergence (keys removed from the YAML are unset) | [storage](docs/reference/storage.md) |
|
|
135
158
|
| Nginx | Set any nginx property per app or globally | [nginx](docs/reference/nginx.md) |
|
|
136
159
|
| Zero-Downtime Checks | Configure deploy checks, disable per process type | [checks](docs/reference/checks.md) |
|
|
137
160
|
| Log Management | Log retention and vector sink configuration | [logs](docs/reference/logs.md) |
|
|
138
|
-
| Plugins | Install Dokku plugins
|
|
161
|
+
| Plugins | Install Dokku plugins at pinned versions, updating forward only | [plugins](docs/reference/plugins.md) |
|
|
162
|
+
| Process Scale & ps | Declare process counts (`ps:scale`) and ps properties (`ps:set`) | [scale](docs/reference/scale.md) |
|
|
139
163
|
| Docker Auth | Authenticate the host docker daemon to private registries (e.g. ghcr.io) for `git:from-image` flows | [docker_auth](docs/reference/docker_auth.md) |
|
|
140
164
|
| Postgres | Postgres services with optional S3 backups | [postgres](docs/reference/postgres.md) |
|
|
141
165
|
| Redis | Redis service instances | [redis](docs/reference/redis.md) |
|
|
142
166
|
| Service Links | Link postgres/redis services to apps | [plugins](docs/reference/plugins.md#linking-services-to-apps-appsapplinks) |
|
|
143
167
|
|
|
168
|
+
## Variable interpolation
|
|
169
|
+
|
|
170
|
+
`${VAR}` in a string value is replaced with the environment variable `VAR`. The file is parsed as YAML first and substituted afterwards, value by value, so a secret can contain any character (`"`, `'`, `#`, `\`, `:`, newlines) and arrives in the value unchanged; it can never change the file's structure. Keys and comments are never substituted. Every unset or empty variable is collected and reported together:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Unset environment variable(s) referenced in config: R2_HOST, GITHUB_TOKEN
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
To write a literal `${`, use `$${`: `"$${NOT_A_VAR}"` loads as `${NOT_A_VAR}`.
|
|
177
|
+
|
|
178
|
+
Because substitution happens after parsing, a value that is a bare `${VAR}` is always a string, and a variable cannot inject YAML (extra keys, lists, quoting). Earlier versions substituted into the raw text, so anything that relied on that must now be written in the file.
|
|
179
|
+
|
|
144
180
|
## Execution Modes
|
|
145
181
|
|
|
146
182
|
```bash
|
|
@@ -197,7 +233,7 @@ dokku-compose/
|
|
|
197
233
|
│ │ └── validate.ts # validate command (offline)
|
|
198
234
|
│ └── tests/
|
|
199
235
|
│ ├── fixtures/ # Test YAML configs
|
|
200
|
-
│ └── *.test.ts # Unit tests
|
|
236
|
+
│ └── *.test.ts # Unit tests, beside src/modules and src/resources
|
|
201
237
|
└── dokku-compose.yml.example
|
|
202
238
|
```
|
|
203
239
|
|
|
@@ -215,8 +251,8 @@ bun install
|
|
|
215
251
|
# Run all tests
|
|
216
252
|
bun test
|
|
217
253
|
|
|
218
|
-
# Run a specific
|
|
219
|
-
bun test src/
|
|
254
|
+
# Run a specific file's tests
|
|
255
|
+
bun test src/resources/config.test.ts
|
|
220
256
|
```
|
|
221
257
|
|
|
222
258
|
Tests use [Bun's test runner](https://bun.sh/docs/cli/test) with a mocked `Runner` — no real Dokku server needed.
|
package/dist/index.js
CHANGED
|
@@ -53,6 +53,12 @@ var AppSchema = z.object({
|
|
|
53
53
|
logs: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
|
54
54
|
registry: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
|
|
55
55
|
scheduler: z.string().optional(),
|
|
56
|
+
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(),
|
|
57
|
+
ps: z.object({
|
|
58
|
+
"procfile-path": z.union([z.string(), z.number()]).optional(),
|
|
59
|
+
"restart-policy": z.union([z.string(), z.number()]).optional(),
|
|
60
|
+
"stop-timeout-seconds": z.union([z.string(), z.number()]).optional()
|
|
61
|
+
}).strict().optional(),
|
|
56
62
|
checks: ChecksSchema.optional(),
|
|
57
63
|
build: z.object({
|
|
58
64
|
dockerfile: z.string().optional(),
|
|
@@ -117,16 +123,29 @@ function parseConfig(raw) {
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
// src/core/config.ts
|
|
120
|
-
function
|
|
126
|
+
function interpolateValue(value, missing) {
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
return value.replace(/\$\$\{|\$\{([^}]+)\}/g, (match, name) => {
|
|
129
|
+
if (name === void 0) return "${";
|
|
130
|
+
const v = process.env[name];
|
|
131
|
+
if (v === void 0 || v === "") {
|
|
132
|
+
missing.push(name);
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
return v;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(value)) return value.map((v) => interpolateValue(v, missing));
|
|
139
|
+
if (value !== null && typeof value === "object") {
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [k, v] of Object.entries(value)) out[k] = interpolateValue(v, missing);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function interpolateEnvVars(raw) {
|
|
121
147
|
const missing = [];
|
|
122
|
-
const result =
|
|
123
|
-
const value = process.env[name];
|
|
124
|
-
if (value === void 0 || value === "") {
|
|
125
|
-
missing.push(name);
|
|
126
|
-
return "";
|
|
127
|
-
}
|
|
128
|
-
return value;
|
|
129
|
-
});
|
|
148
|
+
const result = interpolateValue(raw, missing);
|
|
130
149
|
if (missing.length > 0) {
|
|
131
150
|
const unique = Array.from(new Set(missing));
|
|
132
151
|
throw new Error(
|
|
@@ -140,7 +159,7 @@ function loadConfig(filePath) {
|
|
|
140
159
|
throw new Error(`Config file not found: ${filePath}`);
|
|
141
160
|
}
|
|
142
161
|
const content = fs.readFileSync(filePath, "utf8");
|
|
143
|
-
const raw = yaml.load(
|
|
162
|
+
const raw = interpolateEnvVars(yaml.load(content));
|
|
144
163
|
return parseConfig(raw);
|
|
145
164
|
}
|
|
146
165
|
|
|
@@ -279,6 +298,11 @@ function createRunner(opts = {}) {
|
|
|
279
298
|
const { stdout } = await execDokku(args);
|
|
280
299
|
return stdout.trim();
|
|
281
300
|
},
|
|
301
|
+
async queryStrict(...args) {
|
|
302
|
+
const { stdout, ok, exitCode, stderr } = await execDokku(args);
|
|
303
|
+
if (!ok) throw new DokkuCommandError(args, exitCode, stderr);
|
|
304
|
+
return stdout.trim();
|
|
305
|
+
},
|
|
282
306
|
async check(...args) {
|
|
283
307
|
const { ok } = await execDokku(args);
|
|
284
308
|
return ok;
|
|
@@ -307,6 +331,9 @@ function createContext(runner) {
|
|
|
307
331
|
}
|
|
308
332
|
return cache.get(key);
|
|
309
333
|
},
|
|
334
|
+
queryStrict(...args) {
|
|
335
|
+
return runner.queryStrict(...args);
|
|
336
|
+
},
|
|
310
337
|
check(...args) {
|
|
311
338
|
return runner.check(...args);
|
|
312
339
|
},
|
|
@@ -355,6 +382,11 @@ function computeChange(before, after) {
|
|
|
355
382
|
}
|
|
356
383
|
return { before, after, changed: before !== after };
|
|
357
384
|
}
|
|
385
|
+
function computeAdditiveChange(before, after) {
|
|
386
|
+
const change = computeChange(before ?? {}, after);
|
|
387
|
+
change.changed = Object.keys(change.added).length > 0 || Object.keys(change.modified).length > 0;
|
|
388
|
+
return change;
|
|
389
|
+
}
|
|
358
390
|
|
|
359
391
|
// src/core/logger.ts
|
|
360
392
|
import chalk from "chalk";
|
|
@@ -375,16 +407,41 @@ function logWarn(context, message) {
|
|
|
375
407
|
}
|
|
376
408
|
|
|
377
409
|
// src/core/reconcile.ts
|
|
378
|
-
async function reconcile(resource, ctx, target, desired) {
|
|
379
|
-
|
|
410
|
+
async function reconcile(resource, ctx, target, desired, onRemovals, prune = false) {
|
|
411
|
+
const canPrune = prune && !!resource.prune && !!resource.removals;
|
|
412
|
+
const pruneFrom = async (current) => {
|
|
413
|
+
const removed = resource.removals(current, desired);
|
|
414
|
+
if (removed.length === 0) return;
|
|
415
|
+
logAction(target, `${resource.key} (prune)`);
|
|
416
|
+
await resource.prune(ctx, target, removed, current, desired);
|
|
417
|
+
logDone();
|
|
418
|
+
};
|
|
419
|
+
if (desired === void 0) {
|
|
420
|
+
if (canPrune) await pruneFrom(await resource.read(ctx, target));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
380
423
|
logAction(target, `${resource.key}`);
|
|
381
424
|
if (resource.forceApply) {
|
|
382
425
|
await resource.onChange(ctx, target, { before: void 0, after: desired, changed: true });
|
|
383
426
|
logDone();
|
|
427
|
+
if (canPrune) await pruneFrom(await resource.read(ctx, target));
|
|
384
428
|
return;
|
|
385
429
|
}
|
|
386
430
|
const before = await resource.read(ctx, target);
|
|
387
|
-
|
|
431
|
+
if (onRemovals && resource.removals) onRemovals(resource.removals(before, desired));
|
|
432
|
+
if (canPrune) {
|
|
433
|
+
const change2 = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
|
|
434
|
+
const pending = resource.removals(before, desired).length > 0;
|
|
435
|
+
if (change2.changed) {
|
|
436
|
+
await resource.onChange(ctx, target, change2, pending);
|
|
437
|
+
logDone();
|
|
438
|
+
} else {
|
|
439
|
+
logSkip();
|
|
440
|
+
}
|
|
441
|
+
await pruneFrom(before);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const change = resource.additive ? computeAdditiveChange(before, desired) : computeChange(before, desired);
|
|
388
445
|
if (!change.changed) {
|
|
389
446
|
logSkip();
|
|
390
447
|
return;
|
|
@@ -520,9 +577,69 @@ async function isAppDeployed(ctx, target) {
|
|
|
520
577
|
const raw = await ctx.query("ps:report", target, "--deployed");
|
|
521
578
|
return raw.trim() === "true";
|
|
522
579
|
}
|
|
580
|
+
var NGINX_KEYS = [
|
|
581
|
+
"access-log-format",
|
|
582
|
+
"access-log-path",
|
|
583
|
+
"bind-address-ipv4",
|
|
584
|
+
"bind-address-ipv6",
|
|
585
|
+
"client-body-timeout",
|
|
586
|
+
"client-header-timeout",
|
|
587
|
+
"client-max-body-size",
|
|
588
|
+
"disable-custom-config",
|
|
589
|
+
"error-log-path",
|
|
590
|
+
"hsts",
|
|
591
|
+
"hsts-include-subdomains",
|
|
592
|
+
"hsts-max-age",
|
|
593
|
+
"hsts-preload",
|
|
594
|
+
"keepalive-timeout",
|
|
595
|
+
"lingering-timeout",
|
|
596
|
+
"proxy-buffer-size",
|
|
597
|
+
"proxy-buffering",
|
|
598
|
+
"proxy-buffers",
|
|
599
|
+
"proxy-busy-buffers-size",
|
|
600
|
+
"proxy-connect-timeout",
|
|
601
|
+
"proxy-keepalive",
|
|
602
|
+
"proxy-read-timeout",
|
|
603
|
+
"proxy-send-timeout",
|
|
604
|
+
"send-timeout",
|
|
605
|
+
"underscore-in-headers",
|
|
606
|
+
"x-forwarded-for-value",
|
|
607
|
+
"x-forwarded-port-value",
|
|
608
|
+
"x-forwarded-proto-value",
|
|
609
|
+
"x-forwarded-ssl"
|
|
610
|
+
];
|
|
611
|
+
var LOGS_KEYS = ["app-label-alias", "max-size", "vector-cron-sink", "vector-sink"];
|
|
612
|
+
var REGISTRY_KEYS = [
|
|
613
|
+
"auth-servers",
|
|
614
|
+
"image-repo",
|
|
615
|
+
"image-repo-template",
|
|
616
|
+
"push-extra-tags",
|
|
617
|
+
"push-on-release",
|
|
618
|
+
"server"
|
|
619
|
+
];
|
|
620
|
+
function propertyRemovals(allowed) {
|
|
621
|
+
return (current, desired) => Object.keys(current ?? {}).filter((k) => allowed.includes(k) && !(desired && k in desired));
|
|
622
|
+
}
|
|
623
|
+
function propertyPrune(setCmd, afterPrune = []) {
|
|
624
|
+
return async (ctx, target, removed) => {
|
|
625
|
+
for (const key of removed) await ctx.run(setCmd, target, key);
|
|
626
|
+
for (const cmd of afterPrune) {
|
|
627
|
+
try {
|
|
628
|
+
await ctx.run(cmd, target);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (await isAppDeployed(ctx, target)) throw err;
|
|
631
|
+
logWarn(target, `${cmd} failed on an undeployed app \u2014 its proxy config will be built on first deploy`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
523
636
|
function propertyResource(opts) {
|
|
524
637
|
return {
|
|
525
638
|
key: opts.key,
|
|
639
|
+
// Keys only on the server never make the block "changed"; diff shows them as removals.
|
|
640
|
+
additive: true,
|
|
641
|
+
removals: propertyRemovals(opts.settable),
|
|
642
|
+
prune: propertyPrune(opts.setCmd, opts.afterChange),
|
|
526
643
|
async read(ctx, target) {
|
|
527
644
|
const raw = await ctx.query(`${opts.namespace}:report`, target);
|
|
528
645
|
return parseReport(raw, opts.namespace);
|
|
@@ -531,11 +648,11 @@ function propertyResource(opts) {
|
|
|
531
648
|
const raw = await ctx.query(`${opts.namespace}:report`);
|
|
532
649
|
return parseBulkReport(raw, opts.namespace);
|
|
533
650
|
},
|
|
534
|
-
async onChange(ctx, target, change) {
|
|
651
|
+
async onChange(ctx, target, change, deferFollowUp = false) {
|
|
535
652
|
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
536
653
|
await ctx.run(opts.setCmd, target, key, String(value));
|
|
537
654
|
}
|
|
538
|
-
if (opts.afterChange) {
|
|
655
|
+
if (opts.afterChange && !deferFollowUp) {
|
|
539
656
|
for (const cmd of opts.afterChange) {
|
|
540
657
|
try {
|
|
541
658
|
await ctx.run(cmd, target);
|
|
@@ -552,20 +669,28 @@ var Nginx = propertyResource({
|
|
|
552
669
|
key: "nginx",
|
|
553
670
|
namespace: "nginx",
|
|
554
671
|
setCmd: "nginx:set",
|
|
672
|
+
settable: NGINX_KEYS,
|
|
555
673
|
afterChange: ["proxy:build-config"]
|
|
556
674
|
});
|
|
557
675
|
var Logs = propertyResource({
|
|
558
676
|
key: "logs",
|
|
559
677
|
namespace: "logs",
|
|
560
|
-
setCmd: "logs:set"
|
|
678
|
+
setCmd: "logs:set",
|
|
679
|
+
settable: LOGS_KEYS
|
|
561
680
|
});
|
|
562
681
|
var Registry = propertyResource({
|
|
563
682
|
key: "registry",
|
|
564
683
|
namespace: "registry",
|
|
565
|
-
setCmd: "registry:set"
|
|
684
|
+
setCmd: "registry:set",
|
|
685
|
+
settable: REGISTRY_KEYS
|
|
566
686
|
});
|
|
567
687
|
var Scheduler = {
|
|
568
688
|
key: "scheduler",
|
|
689
|
+
// A scheduler selected on the app but not declared in the file.
|
|
690
|
+
removals: (current, desired) => desired === void 0 && current ? ["scheduler"] : [],
|
|
691
|
+
prune: async (ctx, target) => {
|
|
692
|
+
await ctx.run("scheduler:set", target, "selected");
|
|
693
|
+
},
|
|
569
694
|
async read(ctx, target) {
|
|
570
695
|
const raw = await ctx.query("scheduler:report", target);
|
|
571
696
|
const report = parseReport(raw, "scheduler");
|
|
@@ -606,21 +731,38 @@ var Proxy = {
|
|
|
606
731
|
}
|
|
607
732
|
};
|
|
608
733
|
|
|
734
|
+
// src/core/config-json.ts
|
|
735
|
+
function parseConfigJson(raw, what) {
|
|
736
|
+
let parsed;
|
|
737
|
+
try {
|
|
738
|
+
parsed = JSON.parse(raw);
|
|
739
|
+
} catch {
|
|
740
|
+
throw new Error(`${what}: config:export did not return JSON`);
|
|
741
|
+
}
|
|
742
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
743
|
+
throw new Error(`${what}: config:export did not return a JSON object`);
|
|
744
|
+
}
|
|
745
|
+
const result = {};
|
|
746
|
+
for (const [k, v] of Object.entries(parsed)) result[k] = String(v);
|
|
747
|
+
return result;
|
|
748
|
+
}
|
|
749
|
+
|
|
609
750
|
// src/resources/config.ts
|
|
610
751
|
var MANAGED_KEYS_VAR = "DOKKU_COMPOSE_MANAGED_KEYS";
|
|
752
|
+
var MISSING = "\0missing";
|
|
611
753
|
var Config = {
|
|
612
754
|
key: "env",
|
|
755
|
+
removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && typeof desired === "object" && k in desired)),
|
|
613
756
|
read: async (ctx, target) => {
|
|
614
757
|
const managedRaw = await ctx.query("config:get", target, MANAGED_KEYS_VAR);
|
|
615
758
|
const managedKeys = managedRaw.trim() ? managedRaw.trim().split(",").filter(Boolean) : [];
|
|
616
759
|
const result = {};
|
|
760
|
+
for (const k of managedKeys) result[k] = MISSING;
|
|
617
761
|
if (managedKeys.length > 0) {
|
|
618
|
-
const raw = await ctx.
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (
|
|
622
|
-
result[match[1]] = match[2];
|
|
623
|
-
}
|
|
762
|
+
const raw = await ctx.queryStrict("config:export", "--format", "json", target);
|
|
763
|
+
const current = parseConfigJson(raw, target);
|
|
764
|
+
for (const k of managedKeys) {
|
|
765
|
+
if (k in current) result[k] = current[k];
|
|
624
766
|
}
|
|
625
767
|
}
|
|
626
768
|
return result;
|
|
@@ -630,9 +772,12 @@ var Config = {
|
|
|
630
772
|
if (removed.length > 0) {
|
|
631
773
|
await ctx.run("config:unset", "--no-restart", target, ...removed);
|
|
632
774
|
}
|
|
633
|
-
const toSet = { ...added, ...modified };
|
|
634
775
|
const allDesiredKeys = Object.keys(change.after);
|
|
635
|
-
|
|
776
|
+
if (allDesiredKeys.length === 0) {
|
|
777
|
+
await ctx.run("config:unset", "--no-restart", target, MANAGED_KEYS_VAR);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const toSet = { ...added, ...modified };
|
|
636
781
|
if (Object.keys(toSet).length > 0 || removed.length > 0) {
|
|
637
782
|
const pairs = Object.entries(change.after).map(([k, v]) => `${k}=${v}`);
|
|
638
783
|
await ctx.run(
|
|
@@ -640,7 +785,7 @@ var Config = {
|
|
|
640
785
|
"--no-restart",
|
|
641
786
|
target,
|
|
642
787
|
...pairs,
|
|
643
|
-
`${MANAGED_KEYS_VAR}=${
|
|
788
|
+
`${MANAGED_KEYS_VAR}=${allDesiredKeys.join(",")}`
|
|
644
789
|
);
|
|
645
790
|
}
|
|
646
791
|
}
|
|
@@ -675,10 +820,17 @@ var Certs = {
|
|
|
675
820
|
};
|
|
676
821
|
|
|
677
822
|
// src/resources/builder.ts
|
|
823
|
+
function parseBuildOptions(dockerOptsBuild) {
|
|
824
|
+
const out = [];
|
|
825
|
+
for (const raw of dockerOptsBuild.trim().split(/\s+(?=--\S)/)) {
|
|
826
|
+
const m = raw.match(/^--build-arg\s+([^=\s]+)=?(.*)$/s);
|
|
827
|
+
if (m) out.push({ key: m[1], value: m[2], raw });
|
|
828
|
+
}
|
|
829
|
+
return out;
|
|
830
|
+
}
|
|
678
831
|
function parseBuildArgs(dockerOptsBuild) {
|
|
679
832
|
const args = {};
|
|
680
|
-
const
|
|
681
|
-
for (const m of matches) args[m[1]] = m[2];
|
|
833
|
+
for (const o of parseBuildOptions(dockerOptsBuild)) args[o.key] = o.value;
|
|
682
834
|
return args;
|
|
683
835
|
}
|
|
684
836
|
function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport, dockerOptsBuild) {
|
|
@@ -695,6 +847,19 @@ function buildConfigFromReports(builderReport, dockerfileReport, appJsonReport,
|
|
|
695
847
|
}
|
|
696
848
|
var Builder = {
|
|
697
849
|
key: "build",
|
|
850
|
+
additive: true,
|
|
851
|
+
// Build args (`--build-arg KEY=...` build docker options) whose KEY is not in `build.args`.
|
|
852
|
+
removals: (current, desired) => Object.keys(current?.args ?? {}).filter((k) => !(desired?.args && k in desired.args)).map((k) => `args.${k}`),
|
|
853
|
+
// Remove exactly the stale `--build-arg` options; other build options stay.
|
|
854
|
+
prune: async (ctx, target, removed) => {
|
|
855
|
+
const stale = new Set(removed.map((l) => l.slice("args.".length)));
|
|
856
|
+
const report = parseReport(await ctx.query("docker-options:report", target), "docker-options");
|
|
857
|
+
const options = parseBuildOptions(report["build"] ?? "").filter((o) => stale.has(o.key));
|
|
858
|
+
for (const o of options) await ctx.run("docker-options:remove", target, "build", o.raw);
|
|
859
|
+
for (const k of stale) {
|
|
860
|
+
if (!options.some((o) => o.key === k)) logWarn(target, `build arg '${k}' not found as an exact docker option; left as is`);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
698
863
|
async read(ctx, target) {
|
|
699
864
|
const [builderRaw, dockerfileRaw, appJsonRaw, dockerOptsRaw] = await Promise.all([
|
|
700
865
|
ctx.query("builder:report", target),
|
|
@@ -766,6 +931,11 @@ function dockerOptsFromReport(report) {
|
|
|
766
931
|
}
|
|
767
932
|
var DockerOptions = {
|
|
768
933
|
key: "docker_options",
|
|
934
|
+
// Pruned by `up` within a declared block only, so a missing block is not shown.
|
|
935
|
+
removals: (current, desired) => !desired ? [] : PHASES.flatMap((phase) => {
|
|
936
|
+
const wanted = new Set(desired?.[phase] ?? []);
|
|
937
|
+
return (current?.[phase] ?? []).filter((o) => !wanted.has(o)).map((o) => `${phase}: ${o}`);
|
|
938
|
+
}),
|
|
769
939
|
async read(ctx, target) {
|
|
770
940
|
const raw = await ctx.query("docker-options:report", target);
|
|
771
941
|
return dockerOptsFromReport(parseReport(raw, "docker-options"));
|
|
@@ -793,6 +963,68 @@ var DockerOptions = {
|
|
|
793
963
|
}
|
|
794
964
|
};
|
|
795
965
|
|
|
966
|
+
// src/resources/ps.ts
|
|
967
|
+
var PS_KEYS = ["procfile-path", "restart-policy", "stop-timeout-seconds"];
|
|
968
|
+
function parseScale(raw) {
|
|
969
|
+
const result = {};
|
|
970
|
+
let parsed;
|
|
971
|
+
try {
|
|
972
|
+
parsed = JSON.parse(raw);
|
|
973
|
+
} catch {
|
|
974
|
+
return result;
|
|
975
|
+
}
|
|
976
|
+
if (!Array.isArray(parsed)) return result;
|
|
977
|
+
for (const e of parsed) {
|
|
978
|
+
if (e && typeof e.process_type === "string" && e.quantity !== void 0) {
|
|
979
|
+
result[e.process_type] = String(e.quantity);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
984
|
+
function parsePsReport(raw) {
|
|
985
|
+
const result = {};
|
|
986
|
+
let parsed;
|
|
987
|
+
try {
|
|
988
|
+
parsed = JSON.parse(raw);
|
|
989
|
+
} catch {
|
|
990
|
+
return result;
|
|
991
|
+
}
|
|
992
|
+
if (!parsed || typeof parsed !== "object") return result;
|
|
993
|
+
for (const key of PS_KEYS) {
|
|
994
|
+
const v = parsed[key];
|
|
995
|
+
if (v !== void 0 && v !== null && String(v) !== "") result[key] = String(v);
|
|
996
|
+
}
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
var Scale = {
|
|
1000
|
+
key: "scale",
|
|
1001
|
+
additive: true,
|
|
1002
|
+
async read(ctx, target) {
|
|
1003
|
+
return parseScale(await ctx.query("ps:scale", "--format", "json", target));
|
|
1004
|
+
},
|
|
1005
|
+
async onChange(ctx, target, change) {
|
|
1006
|
+
const pairs = Object.entries({ ...change.added, ...change.modified }).map(([proc, count]) => `${proc}=${count}`);
|
|
1007
|
+
if (pairs.length === 0) return;
|
|
1008
|
+
await ctx.run("ps:scale", "--skip-deploy", target, ...pairs);
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
var Ps = {
|
|
1012
|
+
key: "ps",
|
|
1013
|
+
additive: true,
|
|
1014
|
+
removals: (current, desired) => PS_KEYS.filter((k) => current?.[k] !== void 0 && !(desired && k in desired)),
|
|
1015
|
+
prune: async (ctx, target, removed) => {
|
|
1016
|
+
for (const key of removed) await ctx.run("ps:set", target, key);
|
|
1017
|
+
},
|
|
1018
|
+
async read(ctx, target) {
|
|
1019
|
+
return parsePsReport(await ctx.queryStrict("ps:report", target, "--format", "json"));
|
|
1020
|
+
},
|
|
1021
|
+
async onChange(ctx, target, change) {
|
|
1022
|
+
for (const [key, value] of Object.entries({ ...change.added, ...change.modified })) {
|
|
1023
|
+
await ctx.run("ps:set", target, key, String(value));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
|
|
796
1028
|
// src/resources/git.ts
|
|
797
1029
|
var Git = {
|
|
798
1030
|
key: "git",
|
|
@@ -820,7 +1052,33 @@ var Git = {
|
|
|
820
1052
|
var Checks = {
|
|
821
1053
|
key: "checks",
|
|
822
1054
|
forceApply: true,
|
|
823
|
-
|
|
1055
|
+
// Applying never reads (forceApply); the read only finds settings to report as removed.
|
|
1056
|
+
// Settable state: the per-process lists and `wait-to-retire`. A list of "none" is empty.
|
|
1057
|
+
read: async (ctx, target) => {
|
|
1058
|
+
const report = parseReport(await ctx.query("checks:report", target), "checks");
|
|
1059
|
+
const current = {};
|
|
1060
|
+
for (const [reportKey, key] of [["disabled-list", "disabled"], ["skipped-list", "skipped"]]) {
|
|
1061
|
+
const v = report[reportKey];
|
|
1062
|
+
if (v && v !== "none" && v !== "_all_") current[key] = v;
|
|
1063
|
+
}
|
|
1064
|
+
if (report["wait-to-retire"]) current["wait-to-retire"] = report["wait-to-retire"];
|
|
1065
|
+
return current;
|
|
1066
|
+
},
|
|
1067
|
+
removals: (current, desired) => desired === false ? [] : Object.keys(current || {}).filter((k) => !(desired && k in desired)),
|
|
1068
|
+
prune: async (ctx, target, removed, current, desired) => {
|
|
1069
|
+
const cur = current;
|
|
1070
|
+
const split = (v) => (v ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
1071
|
+
const declared = new Set(desired && desired !== false ? [...desired.disabled ?? [], ...desired.skipped ?? []] : []);
|
|
1072
|
+
const stale = new Set(removed.filter((k) => k === "disabled" || k === "skipped").flatMap((k) => split(cur[k])));
|
|
1073
|
+
const enable = [...stale].filter((t) => !declared.has(t));
|
|
1074
|
+
for (const t of stale) {
|
|
1075
|
+
if (declared.has(t)) {
|
|
1076
|
+
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`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (enable.length > 0) await ctx.run("checks:enable", target, ...enable);
|
|
1080
|
+
if (removed.includes("wait-to-retire")) await ctx.run("checks:set", target, "wait-to-retire");
|
|
1081
|
+
},
|
|
824
1082
|
onChange: async (ctx, target, { after }) => {
|
|
825
1083
|
if (after === false) {
|
|
826
1084
|
await ctx.run("checks:disable", target);
|
|
@@ -861,6 +1119,12 @@ var Networks = {
|
|
|
861
1119
|
};
|
|
862
1120
|
var NetworkProps = {
|
|
863
1121
|
key: "network",
|
|
1122
|
+
additive: true,
|
|
1123
|
+
removals: (current, desired) => Object.keys(current ?? {}).filter((k) => !(desired && k in desired)),
|
|
1124
|
+
// Dokku's property names are hyphenated; the schema's are underscored.
|
|
1125
|
+
prune: async (ctx, target, removed) => {
|
|
1126
|
+
for (const key of removed) await ctx.run("network:set", target, key.replaceAll("_", "-"));
|
|
1127
|
+
},
|
|
864
1128
|
read: async (ctx, target) => {
|
|
865
1129
|
const raw = await ctx.query("network:report", target);
|
|
866
1130
|
const report = parseReport(raw, "network");
|
|
@@ -907,19 +1171,90 @@ var NetworkProps = {
|
|
|
907
1171
|
};
|
|
908
1172
|
|
|
909
1173
|
// src/modules/plugins.ts
|
|
1174
|
+
function parsePluginList(raw) {
|
|
1175
|
+
const result = /* @__PURE__ */ new Map();
|
|
1176
|
+
let parsed;
|
|
1177
|
+
try {
|
|
1178
|
+
parsed = JSON.parse(raw);
|
|
1179
|
+
} catch {
|
|
1180
|
+
throw new Error(`plugin:list --format json did not return JSON (does this Dokku support --format json?): ${raw.slice(0, 200)}`);
|
|
1181
|
+
}
|
|
1182
|
+
if (!Array.isArray(parsed)) {
|
|
1183
|
+
throw new Error(`plugin:list --format json did not return a JSON array: ${raw.slice(0, 200)}`);
|
|
1184
|
+
}
|
|
1185
|
+
for (const p of parsed) {
|
|
1186
|
+
if (p && typeof p.name === "string") {
|
|
1187
|
+
result.set(p.name, {
|
|
1188
|
+
version: typeof p.version === "string" ? p.version : "",
|
|
1189
|
+
committish: typeof p.committish === "string" ? p.committish : ""
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return result;
|
|
1194
|
+
}
|
|
1195
|
+
var SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
1196
|
+
function compareVersions(a, b) {
|
|
1197
|
+
const na = a.replace(/^v/, "");
|
|
1198
|
+
const nb = b.replace(/^v/, "");
|
|
1199
|
+
const ma = na.match(SEMVER);
|
|
1200
|
+
const mb = nb.match(SEMVER);
|
|
1201
|
+
if (!ma || !mb) return na === nb ? 0 : void 0;
|
|
1202
|
+
for (let i = 1; i <= 3; i++) {
|
|
1203
|
+
const d = Number(ma[i]) - Number(mb[i]);
|
|
1204
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
1205
|
+
}
|
|
1206
|
+
return 0;
|
|
1207
|
+
}
|
|
1208
|
+
var SHA = /^[0-9a-f]{7,40}$/i;
|
|
1209
|
+
function planPlugins(listOutput, plugins) {
|
|
1210
|
+
const installed = parsePluginList(listOutput);
|
|
1211
|
+
return Object.entries(plugins).map(([name, config]) => {
|
|
1212
|
+
const current = installed.get(name);
|
|
1213
|
+
const desired = config.version;
|
|
1214
|
+
if (current === void 0) return { name, action: "install", desired };
|
|
1215
|
+
const installedVersion = current.version || current.committish.slice(0, 7) || void 0;
|
|
1216
|
+
if (desired === void 0) return { name, action: "ok", installed: installedVersion };
|
|
1217
|
+
if (SHA.test(desired) && current.committish) {
|
|
1218
|
+
if (current.committish.toLowerCase().startsWith(desired.toLowerCase())) {
|
|
1219
|
+
return { name, action: "ok", installed: installedVersion, desired };
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (!current.version) return { name, action: "unknown", installed: installedVersion, desired };
|
|
1223
|
+
const cmp = compareVersions(current.version, desired);
|
|
1224
|
+
const action = cmp === void 0 ? "unknown" : cmp < 0 ? "update" : cmp > 0 ? "newer" : "ok";
|
|
1225
|
+
return { name, action, installed: installedVersion, desired };
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
910
1228
|
async function ensurePlugins(ctx, plugins) {
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1229
|
+
const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), plugins);
|
|
1230
|
+
for (const plan of plans) {
|
|
1231
|
+
const config = plugins[plan.name];
|
|
1232
|
+
switch (plan.action) {
|
|
1233
|
+
case "install":
|
|
1234
|
+
logAction("plugins", `Installing ${plan.name}`);
|
|
1235
|
+
await ctx.run(
|
|
1236
|
+
"plugin:install",
|
|
1237
|
+
config.url,
|
|
1238
|
+
...config.version ? ["--committish", config.version] : [],
|
|
1239
|
+
"--name",
|
|
1240
|
+
plan.name
|
|
1241
|
+
);
|
|
1242
|
+
logDone();
|
|
1243
|
+
break;
|
|
1244
|
+
case "update":
|
|
1245
|
+
logAction("plugins", `Updating ${plan.name}`);
|
|
1246
|
+
await ctx.run("plugin:update", plan.name, config.version);
|
|
1247
|
+
logDone();
|
|
1248
|
+
break;
|
|
1249
|
+
case "newer":
|
|
1250
|
+
logWarn("plugins", `${plan.name} is ${plan.installed}, newer than the declared ${plan.desired}; leaving it (never downgrades)`);
|
|
1251
|
+
break;
|
|
1252
|
+
case "unknown":
|
|
1253
|
+
logWarn("plugins", `${plan.name} is ${plan.installed} and the declared ${plan.desired} cannot be compared; leaving it`);
|
|
1254
|
+
break;
|
|
1255
|
+
default:
|
|
1256
|
+
break;
|
|
920
1257
|
}
|
|
921
|
-
await ctx.run("plugin:install", config.url, "--name", name);
|
|
922
|
-
logDone();
|
|
923
1258
|
}
|
|
924
1259
|
}
|
|
925
1260
|
|
|
@@ -1248,27 +1583,53 @@ var UpFailedError = class extends Error {
|
|
|
1248
1583
|
this.name = "UpFailedError";
|
|
1249
1584
|
}
|
|
1250
1585
|
};
|
|
1251
|
-
|
|
1586
|
+
var AppStepsError = class extends AggregateError {
|
|
1587
|
+
constructor(errors) {
|
|
1588
|
+
super(errors, errors.map((e) => e instanceof Error ? e.message : String(e)).join("; "));
|
|
1589
|
+
this.name = "AppStepsError";
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
async function processApp(ctx, config, app, appConfig, failFast = false, prune = false) {
|
|
1593
|
+
const errors = [];
|
|
1594
|
+
let leftOnServer = 0;
|
|
1595
|
+
const count = prune ? void 0 : (removed) => {
|
|
1596
|
+
leftOnServer += removed.length;
|
|
1597
|
+
};
|
|
1598
|
+
const step = async (fn) => {
|
|
1599
|
+
try {
|
|
1600
|
+
await fn();
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
if (failFast) throw error;
|
|
1603
|
+
logError(app, error instanceof Error ? error.message : String(error));
|
|
1604
|
+
errors.push(error);
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1252
1607
|
await reconcile(Apps, ctx, app, true);
|
|
1253
|
-
await reconcile(Domains, ctx, app, appConfig.domains);
|
|
1254
|
-
await reconcile(Networks, ctx, app, appConfig.networks);
|
|
1255
|
-
await reconcile(NetworkProps, ctx, app, appConfig.network);
|
|
1256
|
-
await reconcile(Proxy, ctx, app, appConfig.proxy?.enabled);
|
|
1257
|
-
await reconcile(Ports, ctx, app, appConfig.ports);
|
|
1608
|
+
await step(() => reconcile(Domains, ctx, app, appConfig.domains));
|
|
1609
|
+
await step(() => reconcile(Networks, ctx, app, appConfig.networks));
|
|
1610
|
+
await step(() => reconcile(NetworkProps, ctx, app, appConfig.network, count, prune));
|
|
1611
|
+
await step(() => reconcile(Proxy, ctx, app, appConfig.proxy?.enabled));
|
|
1612
|
+
await step(() => reconcile(Ports, ctx, app, appConfig.ports));
|
|
1258
1613
|
if (config.postgres || config.redis) {
|
|
1259
|
-
await ensureAppLinks(ctx, app, appConfig.links ?? [], config);
|
|
1260
|
-
}
|
|
1261
|
-
await reconcile(Certs, ctx, app, appConfig.ssl);
|
|
1262
|
-
await reconcile(Storage, ctx, app, appConfig.storage);
|
|
1263
|
-
await reconcile(Nginx, ctx, app, appConfig.nginx);
|
|
1264
|
-
await reconcile(Checks, ctx, app, appConfig.checks);
|
|
1265
|
-
await reconcile(Logs, ctx, app, appConfig.logs);
|
|
1266
|
-
await reconcile(Registry, ctx, app, appConfig.registry);
|
|
1267
|
-
await reconcile(Scheduler, ctx, app, appConfig.scheduler);
|
|
1268
|
-
await reconcile(
|
|
1269
|
-
await reconcile(
|
|
1270
|
-
await reconcile(
|
|
1271
|
-
await reconcile(
|
|
1614
|
+
await step(() => ensureAppLinks(ctx, app, appConfig.links ?? [], config));
|
|
1615
|
+
}
|
|
1616
|
+
await step(() => reconcile(Certs, ctx, app, appConfig.ssl));
|
|
1617
|
+
await step(() => reconcile(Storage, ctx, app, appConfig.storage));
|
|
1618
|
+
await step(() => reconcile(Nginx, ctx, app, appConfig.nginx, count, prune));
|
|
1619
|
+
await step(() => reconcile(Checks, ctx, app, appConfig.checks, void 0, prune));
|
|
1620
|
+
await step(() => reconcile(Logs, ctx, app, appConfig.logs, count, prune));
|
|
1621
|
+
await step(() => reconcile(Registry, ctx, app, appConfig.registry, count, prune));
|
|
1622
|
+
await step(() => reconcile(Scheduler, ctx, app, appConfig.scheduler, count, prune));
|
|
1623
|
+
await step(() => reconcile(Ps, ctx, app, appConfig.ps, count, prune));
|
|
1624
|
+
await step(() => reconcile(Scale, ctx, app, appConfig.scale));
|
|
1625
|
+
await step(() => reconcile(Config, ctx, app, appConfig.env || {}));
|
|
1626
|
+
await step(() => reconcile(Builder, ctx, app, appConfig.build, count, prune));
|
|
1627
|
+
await step(() => reconcile(Git, ctx, app, appConfig.git ?? config.git));
|
|
1628
|
+
await step(() => reconcile(DockerOptions, ctx, app, appConfig.docker_options));
|
|
1629
|
+
if (leftOnServer > 0) {
|
|
1630
|
+
logWarn(app, `${leftOnServer} setting(s) no longer in the compose file were left on the server; run 'dokku-compose diff' to see them`);
|
|
1631
|
+
}
|
|
1632
|
+
if (errors.length > 0) throw new AppStepsError(errors);
|
|
1272
1633
|
}
|
|
1273
1634
|
async function runUp(ctx, config, appFilter, opts = {}) {
|
|
1274
1635
|
const apps = appFilter.length > 0 ? appFilter : Object.keys(config.apps);
|
|
@@ -1288,11 +1649,12 @@ async function runUp(ctx, config, appFilter, opts = {}) {
|
|
|
1288
1649
|
const appConfig = config.apps[app];
|
|
1289
1650
|
if (!appConfig) continue;
|
|
1290
1651
|
try {
|
|
1291
|
-
await processApp(ctx, config, app, appConfig);
|
|
1652
|
+
await processApp(ctx, config, app, appConfig, opts.failFast, opts.prune);
|
|
1292
1653
|
} catch (error) {
|
|
1293
1654
|
if (opts.failFast) throw error;
|
|
1294
|
-
|
|
1295
|
-
|
|
1655
|
+
if (!(error instanceof AppStepsError)) {
|
|
1656
|
+
logError(app, error instanceof Error ? error.message : String(error));
|
|
1657
|
+
}
|
|
1296
1658
|
failures.push({ app, error });
|
|
1297
1659
|
}
|
|
1298
1660
|
}
|
|
@@ -1382,6 +1744,8 @@ var CONFIG_RESOURCES = [
|
|
|
1382
1744
|
Logs,
|
|
1383
1745
|
Registry,
|
|
1384
1746
|
Scheduler,
|
|
1747
|
+
Ps,
|
|
1748
|
+
Scale,
|
|
1385
1749
|
Config
|
|
1386
1750
|
];
|
|
1387
1751
|
var BUILD_RESOURCES = [
|
|
@@ -1447,11 +1811,17 @@ function diffSslStatus(desired, current) {
|
|
|
1447
1811
|
if (current === false || current === null) return "missing";
|
|
1448
1812
|
return void 0;
|
|
1449
1813
|
}
|
|
1814
|
+
function hasAdditionsOrChanges(change) {
|
|
1815
|
+
const c = change;
|
|
1816
|
+
if (c.added === void 0) return c.changed;
|
|
1817
|
+
const added = Array.isArray(c.added) ? c.added.length : Object.keys(c.added).length;
|
|
1818
|
+
return added > 0 || Object.keys(c.modified ?? {}).length > 0;
|
|
1819
|
+
}
|
|
1450
1820
|
function isEmptyValue(value) {
|
|
1451
1821
|
return value === null || value === void 0 || Array.isArray(value) && value.length === 0 || typeof value === "object" && Object.keys(value).length === 0;
|
|
1452
1822
|
}
|
|
1453
1823
|
async function computeDiff(ctx, config) {
|
|
1454
|
-
const result = { apps: {}, services: {}, inSync: true, readWarnings: [] };
|
|
1824
|
+
const result = { apps: {}, services: {}, plugins: {}, inSync: true, readWarnings: [] };
|
|
1455
1825
|
const appNames = Object.keys(config.apps);
|
|
1456
1826
|
await ensureDokkuVersion(ctx, config.dokku?.version);
|
|
1457
1827
|
const appExists = /* @__PURE__ */ new Map();
|
|
@@ -1477,14 +1847,15 @@ async function computeDiff(ctx, config) {
|
|
|
1477
1847
|
const appDiff = {};
|
|
1478
1848
|
for (const resource of ALL_APP_RESOURCES) {
|
|
1479
1849
|
if (resource.key.startsWith("_")) continue;
|
|
1480
|
-
if (resource.forceApply) continue;
|
|
1481
1850
|
let desired;
|
|
1482
1851
|
if (resource.key === "proxy") {
|
|
1483
1852
|
desired = appConfig.proxy?.enabled;
|
|
1484
1853
|
} else {
|
|
1485
1854
|
desired = appConfig[resource.key];
|
|
1486
1855
|
}
|
|
1487
|
-
|
|
1856
|
+
const checkRemovalsOnly = desired === void 0;
|
|
1857
|
+
if (checkRemovalsOnly && (!resource.removals || !appExists.get(app))) continue;
|
|
1858
|
+
if (resource.forceApply && !resource.removals) continue;
|
|
1488
1859
|
if (!appExists.get(app)) {
|
|
1489
1860
|
appDiff[resource.key] = { status: "missing", desired, current: void 0 };
|
|
1490
1861
|
result.inSync = false;
|
|
@@ -1493,6 +1864,21 @@ async function computeDiff(ctx, config) {
|
|
|
1493
1864
|
const bulk = prefetched.get(resource.key);
|
|
1494
1865
|
let current;
|
|
1495
1866
|
let bulkUnreadable = false;
|
|
1867
|
+
if (checkRemovalsOnly) {
|
|
1868
|
+
try {
|
|
1869
|
+
current = bulk ? bulk.get(app) : await resource.read(ctx, app);
|
|
1870
|
+
if (bulk === null || current === void 0) current = await resource.read(ctx, app);
|
|
1871
|
+
} catch (err) {
|
|
1872
|
+
result.readWarnings.push(`could not read '${resource.key}' for app '${app}' to look for removed settings: ${err instanceof Error ? err.message : String(err)}`);
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const removed2 = resource.removals(current, void 0);
|
|
1876
|
+
if (removed2.length > 0) {
|
|
1877
|
+
appDiff[resource.key] = { status: "extra", current, removed: removed2 };
|
|
1878
|
+
result.inSync = false;
|
|
1879
|
+
}
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1496
1882
|
if (bulk === null) {
|
|
1497
1883
|
bulkUnreadable = true;
|
|
1498
1884
|
current = await resource.read(ctx, app);
|
|
@@ -1511,9 +1897,23 @@ async function computeDiff(ctx, config) {
|
|
|
1511
1897
|
if (sslStatus !== "in-sync") result.inSync = false;
|
|
1512
1898
|
continue;
|
|
1513
1899
|
}
|
|
1514
|
-
|
|
1515
|
-
if (
|
|
1900
|
+
let removed = resource.removals ? resource.removals(current, desired) : [];
|
|
1901
|
+
if (resource.forceApply) {
|
|
1902
|
+
if (removed.length > 0) {
|
|
1903
|
+
appDiff[resource.key] = { status: "extra", desired, current, removed };
|
|
1904
|
+
result.inSync = false;
|
|
1905
|
+
}
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1908
|
+
const change = resource.additive ? computeAdditiveChange(current, desired) : computeChange(current, desired);
|
|
1909
|
+
if (!resource.removals && Array.isArray(current) && "removed" in change) {
|
|
1910
|
+
removed = change.removed;
|
|
1911
|
+
}
|
|
1912
|
+
if (!change.changed && removed.length === 0) {
|
|
1516
1913
|
appDiff[resource.key] = { status: "in-sync", desired, current };
|
|
1914
|
+
} else if (removed.length > 0 && !hasAdditionsOrChanges(change)) {
|
|
1915
|
+
appDiff[resource.key] = { status: "extra", desired, current, removed };
|
|
1916
|
+
result.inSync = false;
|
|
1517
1917
|
} else if (bulkUnreadable && isEmptyValue(current)) {
|
|
1518
1918
|
appDiff[resource.key] = { status: "unknown", desired, current };
|
|
1519
1919
|
result.inSync = false;
|
|
@@ -1526,9 +1926,34 @@ async function computeDiff(ctx, config) {
|
|
|
1526
1926
|
appDiff[resource.key] = { status: "changed", desired, current };
|
|
1527
1927
|
result.inSync = false;
|
|
1528
1928
|
}
|
|
1929
|
+
if (removed.length > 0 && appDiff[resource.key].status !== "extra") {
|
|
1930
|
+
appDiff[resource.key].removed = removed;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
if (appConfig.links === void 0 && appExists.get(app)) {
|
|
1934
|
+
const linked = [];
|
|
1935
|
+
for (const [plugin, services] of [["postgres", config.postgres], ["redis", config.redis]]) {
|
|
1936
|
+
for (const svc of Object.keys(services ?? {})) {
|
|
1937
|
+
if (await ctx.check(`${plugin}:linked`, svc, app)) linked.push(svc);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (linked.length > 0) {
|
|
1941
|
+
appDiff["links"] = { status: "extra", current: linked, removed: linked, note: "no links: in the file; up will unlink these" };
|
|
1942
|
+
result.inSync = false;
|
|
1943
|
+
}
|
|
1529
1944
|
}
|
|
1530
1945
|
result.apps[app] = appDiff;
|
|
1531
1946
|
}
|
|
1947
|
+
if (config.plugins) {
|
|
1948
|
+
const plans = planPlugins(await ctx.queryStrict("plugin:list", "--format", "json"), config.plugins);
|
|
1949
|
+
for (const plan of plans) {
|
|
1950
|
+
if (plan.action === "ok") continue;
|
|
1951
|
+
const status = plan.action === "install" ? "missing" : plan.action === "unknown" ? "unknown" : "changed";
|
|
1952
|
+
const note = plan.action === "newer" ? "installed is newer; up warns and leaves it" : plan.action === "unknown" ? "versions cannot be compared; up leaves it" : void 0;
|
|
1953
|
+
result.plugins[plan.name] = { status, desired: plan.desired, current: plan.installed, note };
|
|
1954
|
+
if (plan.action !== "unknown") result.inSync = false;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1532
1957
|
for (const svc of Object.keys(config.postgres ?? {})) {
|
|
1533
1958
|
const exists = await ctx.check("postgres:exists", svc);
|
|
1534
1959
|
result.services[svc] = { status: exists ? "in-sync" : "missing" };
|
|
@@ -1542,14 +1967,16 @@ async function computeDiff(ctx, config) {
|
|
|
1542
1967
|
return result;
|
|
1543
1968
|
}
|
|
1544
1969
|
function maskDiffResult(diff) {
|
|
1545
|
-
const masked = { apps: {}, services: diff.services, inSync: diff.inSync, readWarnings: diff.readWarnings };
|
|
1970
|
+
const masked = { apps: {}, services: diff.services, plugins: diff.plugins, inSync: diff.inSync, readWarnings: diff.readWarnings };
|
|
1546
1971
|
for (const [app, appDiff] of Object.entries(diff.apps)) {
|
|
1547
1972
|
const maskedAppDiff = {};
|
|
1548
1973
|
for (const [feature, d] of Object.entries(appDiff)) {
|
|
1549
1974
|
maskedAppDiff[feature] = {
|
|
1550
1975
|
status: d.status,
|
|
1551
1976
|
desired: maskSensitiveData(d.desired),
|
|
1552
|
-
current: maskSensitiveData(d.current)
|
|
1977
|
+
current: maskSensitiveData(d.current),
|
|
1978
|
+
...d.removed ? { removed: d.removed } : {},
|
|
1979
|
+
...d.note ? { note: d.note } : {}
|
|
1553
1980
|
};
|
|
1554
1981
|
}
|
|
1555
1982
|
masked.apps[app] = maskedAppDiff;
|
|
@@ -1566,18 +1993,24 @@ function formatSummary(diff) {
|
|
|
1566
1993
|
} else {
|
|
1567
1994
|
lines.push(` app: ${chalk2.bold(app)}`);
|
|
1568
1995
|
for (const [feature, d] of changes) {
|
|
1569
|
-
const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
|
|
1996
|
+
const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : d.status === "extra" ? chalk2.red("-") : chalk2.yellow("~");
|
|
1570
1997
|
lines.push(` ${sym} ${feature}: ${formatFeatureSummary(d)}`);
|
|
1571
1998
|
}
|
|
1572
1999
|
}
|
|
1573
2000
|
}
|
|
2001
|
+
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
2002
|
+
const sym = d.status === "missing" ? chalk2.green("+") : d.status === "unknown" ? chalk2.red("?") : chalk2.yellow("~");
|
|
2003
|
+
const detail = d.status === "missing" ? `not installed (want ${d.desired ?? "any"})` : `${d.current} \u2192 ${d.desired}`;
|
|
2004
|
+
lines.push(` plugin: ${name}`);
|
|
2005
|
+
lines.push(` ${sym} version: ${detail}${d.note ? ` (${d.note})` : ""}`);
|
|
2006
|
+
}
|
|
1574
2007
|
for (const [svc, d] of Object.entries(diff.services)) {
|
|
1575
2008
|
if (d.status === "missing") {
|
|
1576
2009
|
lines.push(` services:`);
|
|
1577
2010
|
lines.push(` ${chalk2.green("+")} ${svc}: not provisioned`);
|
|
1578
2011
|
}
|
|
1579
2012
|
}
|
|
1580
|
-
const total = 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;
|
|
2013
|
+
const total = 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;
|
|
1581
2014
|
if (diff.readWarnings.length > 0) {
|
|
1582
2015
|
lines.push("");
|
|
1583
2016
|
lines.push(chalk2.red(" Could not read some server state \u2014 treated as unknown, not missing:"));
|
|
@@ -1593,12 +2026,21 @@ function formatSummary(diff) {
|
|
|
1593
2026
|
return lines.join("\n");
|
|
1594
2027
|
}
|
|
1595
2028
|
function formatFeatureSummary(d) {
|
|
2029
|
+
const gone = d.removed?.length ? `not in the file: ${d.removed.join(", ")}` : "";
|
|
2030
|
+
if (d.status === "extra") return `${gone}${d.note ? ` (${d.note})` : ""}`;
|
|
2031
|
+
const base = formatChange(d);
|
|
2032
|
+
return gone ? `${base}; ${gone}` : base;
|
|
2033
|
+
}
|
|
2034
|
+
function formatChange(d) {
|
|
1596
2035
|
if (d.status === "unknown") return "(could not read server state \u2014 not reported as missing)";
|
|
1597
2036
|
if (d.status === "missing") return "(not set on server)";
|
|
1598
2037
|
if (Array.isArray(d.desired) && Array.isArray(d.current)) {
|
|
1599
2038
|
return `${d.current.length} \u2192 ${d.desired.length} items`;
|
|
1600
2039
|
}
|
|
1601
|
-
return `${JSON.stringify(d.current)} \u2192 ${JSON.stringify(d.desired)}`;
|
|
2040
|
+
return `${JSON.stringify(d.current, showUnset)} \u2192 ${JSON.stringify(d.desired, showUnset)}`;
|
|
2041
|
+
}
|
|
2042
|
+
function showUnset(_key, value) {
|
|
2043
|
+
return value === "\0missing" ? "(unset)" : value;
|
|
1602
2044
|
}
|
|
1603
2045
|
function formatVerbose(diff) {
|
|
1604
2046
|
const lines = [""];
|
|
@@ -1611,12 +2053,23 @@ function formatVerbose(diff) {
|
|
|
1611
2053
|
lines.push(chalk2.red(` ? could not read server state \u2014 not reported as missing`));
|
|
1612
2054
|
continue;
|
|
1613
2055
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
2056
|
+
if (d.status === "extra") {
|
|
2057
|
+
for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r}`));
|
|
2058
|
+
if (d.note) lines.push(` (${d.note})`);
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
const currentLines = d.current !== void 0 ? JSON.stringify(d.current, showUnset, 2).split("\n") : [];
|
|
2062
|
+
const desiredLines = JSON.stringify(d.desired, showUnset, 2).split("\n");
|
|
1616
2063
|
for (const line of currentLines) lines.push(chalk2.red(`- ${line}`));
|
|
1617
2064
|
for (const line of desiredLines) lines.push(chalk2.green(`+ ${line}`));
|
|
2065
|
+
for (const r of d.removed ?? []) lines.push(chalk2.red(`- ${r} (not in the file)`));
|
|
1618
2066
|
}
|
|
1619
2067
|
}
|
|
2068
|
+
for (const [name, d] of Object.entries(diff.plugins)) {
|
|
2069
|
+
lines.push(`@@ plugin: ${name} @@`);
|
|
2070
|
+
if (d.current !== void 0) lines.push(chalk2.red(`- ${d.current}`));
|
|
2071
|
+
lines.push(chalk2.green(`+ ${d.desired ?? "(any version)"}`) + (d.note ? ` (${d.note})` : ""));
|
|
2072
|
+
}
|
|
1620
2073
|
for (const [svc, d] of Object.entries(diff.services)) {
|
|
1621
2074
|
if (d.status === "missing") {
|
|
1622
2075
|
lines.push(`@@ services @@`);
|
|
@@ -1693,12 +2146,12 @@ function makeRunner(opts) {
|
|
|
1693
2146
|
dryRun: opts.dryRun ?? false
|
|
1694
2147
|
});
|
|
1695
2148
|
}
|
|
1696
|
-
program.command("up [apps...]").description("Create/update apps and services to match config").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--dry-run", "Print commands without executing").option("--sensitive", "Show sensitive values in dry-run output (masked by default)").option("--fail-fast", "Stop on first error").action(async (apps, opts) => {
|
|
2149
|
+
program.command("up [apps...]").description("Create/update apps and services to match config").option("-f, --file <path>", "Config file", "dokku-compose.yml").option("--dry-run", "Print commands without executing").option("--sensitive", "Show sensitive values in dry-run output (masked by default)").option("--fail-fast", "Stop on first error").option("--prune", "Also reset settings the file no longer declares (read `diff` first)").action(async (apps, opts) => {
|
|
1697
2150
|
const config = loadConfig(opts.file);
|
|
1698
2151
|
const runner = makeRunner(opts);
|
|
1699
2152
|
const ctx = createContext(runner);
|
|
1700
2153
|
try {
|
|
1701
|
-
await runUp(ctx, config, apps, { failFast: opts.failFast ?? false });
|
|
2154
|
+
await runUp(ctx, config, apps, { failFast: opts.failFast ?? false, prune: opts.prune ?? false });
|
|
1702
2155
|
if (opts.dryRun) {
|
|
1703
2156
|
console.log("\n# Commands that would run:");
|
|
1704
2157
|
for (const cmd of runner.dryRunLog) {
|