@0xmaxma/claude-gateway 1.6.4 → 1.6.5
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/config.template.json +11 -1
- package/dist/api/apps-router.d.ts.map +1 -1
- package/dist/api/apps-router.js +108 -0
- package/dist/api/apps-router.js.map +1 -1
- package/dist/apps/compose-generator.d.ts +15 -0
- package/dist/apps/compose-generator.d.ts.map +1 -1
- package/dist/apps/compose-generator.js +29 -0
- package/dist/apps/compose-generator.js.map +1 -1
- package/dist/apps/installer.d.ts +185 -1
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +657 -4
- package/dist/apps/installer.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -1
- package/mcp/tools/apps/client.ts +16 -0
- package/mcp/tools/apps/module.ts +78 -1
- package/mcp/tools/apps/skills/create-app-yaml/SKILL.md +3 -0
- package/mcp/tools/apps/skills/install-app/SKILL.md +11 -2
- package/package.json +1 -1
package/dist/apps/installer.js
CHANGED
|
@@ -44,6 +44,22 @@ const node_child_process_1 = require("node:child_process");
|
|
|
44
44
|
const compose_generator_1 = require("./compose-generator");
|
|
45
45
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
46
46
|
const DEFAULT_APPS_DIR = path.join(os.homedir(), '.claude-gateway', 'apps');
|
|
47
|
+
// Backups live under `<appsDir>/.backups/<app>/`. A dot-prefixed dir here is
|
|
48
|
+
// never mistaken for an installed app (the registry is driven by `apps.json`,
|
|
49
|
+
// not by enumerating the apps directory), and — being a sibling of each app's
|
|
50
|
+
// own dir rather than inside it — the archive survives that app's uninstall,
|
|
51
|
+
// which removes only `<appsDir>/<app>/`.
|
|
52
|
+
const APP_BACKUPS_DIRNAME = '.backups';
|
|
53
|
+
// Default per-app backup ceiling when config omits it. The N most recent are
|
|
54
|
+
// kept; older archives are pruned after each successful backup.
|
|
55
|
+
const DEFAULT_BACKUP_RETENTION = 10;
|
|
56
|
+
// Wall-clock ceiling for a single helper-container tar (backup or restore) of
|
|
57
|
+
// one volume. A few hundred MB tars in seconds; this only bounds a pathological
|
|
58
|
+
// hang so a stuck helper never wedges a backup job forever.
|
|
59
|
+
const VOLUME_TAR_TIMEOUT_MS = 300000;
|
|
60
|
+
// OCI image used for the throwaway tar helper. Small, ubiquitous, already a
|
|
61
|
+
// transitive dependency of most stacks, so it is almost always cache-warm.
|
|
62
|
+
const BACKUP_HELPER_IMAGE = 'alpine';
|
|
47
63
|
// Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
|
|
48
64
|
// the background (non-blocking), so this only bounds how long a hung container
|
|
49
65
|
// keeps its child process alive — not the gateway's responsiveness. Shorter than
|
|
@@ -59,17 +75,26 @@ const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
|
59
75
|
const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
|
|
60
76
|
// ─── Installer ────────────────────────────────────────────────────────────────
|
|
61
77
|
class AppInstaller {
|
|
62
|
-
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn) {
|
|
78
|
+
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
|
|
63
79
|
this.registry = registry;
|
|
64
80
|
this.registryClient = registryClient;
|
|
65
81
|
this.callbacks = callbacks;
|
|
66
82
|
this.spawn = spawn;
|
|
67
83
|
this.agentManager = agentManager;
|
|
68
84
|
this.spawnAsync = spawnAsync;
|
|
85
|
+
this.housekeepingConfig = housekeepingConfig;
|
|
69
86
|
this.jobs = new Map();
|
|
70
87
|
/** Tracks app names currently being installed to prevent concurrent installs of the same name. */
|
|
71
88
|
this.installingNames = new Set();
|
|
72
89
|
this.appsDir = appsDir ?? DEFAULT_APPS_DIR;
|
|
90
|
+
this.backupsDir = backupsDir ?? path.join(this.appsDir, APP_BACKUPS_DIRNAME);
|
|
91
|
+
this.appBackupConfig = {
|
|
92
|
+
retention: appBackupConfig?.retention !== undefined && appBackupConfig.retention >= 0
|
|
93
|
+
? Math.floor(appBackupConfig.retention)
|
|
94
|
+
: DEFAULT_BACKUP_RETENTION,
|
|
95
|
+
autoBackupBeforeUninstall: appBackupConfig?.autoBackupBeforeUninstall ?? true,
|
|
96
|
+
autoBackupBeforeUpdate: appBackupConfig?.autoBackupBeforeUpdate ?? true,
|
|
97
|
+
};
|
|
73
98
|
}
|
|
74
99
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
75
100
|
/** Start an async install job. Returns jobId immediately. */
|
|
@@ -161,6 +186,7 @@ class AppInstaller {
|
|
|
161
186
|
commit,
|
|
162
187
|
secretKeys: generated.secretKeys,
|
|
163
188
|
generatedKeys: generated.generatedKeys,
|
|
189
|
+
secretDefaults: generated.secretDefaults,
|
|
164
190
|
ports: generated.ports,
|
|
165
191
|
agentDeclaration: generated.agentDeclaration,
|
|
166
192
|
warnings: generated.warnings,
|
|
@@ -254,6 +280,17 @@ class AppInstaller {
|
|
|
254
280
|
return;
|
|
255
281
|
}
|
|
256
282
|
const appDir = entry.installPath;
|
|
283
|
+
// Safety hook: snapshot the app's data before tearing it down, so an
|
|
284
|
+
// accidental or regretted uninstall has a restore point. Best-effort — a
|
|
285
|
+
// backup failure must never block the uninstall the operator asked for.
|
|
286
|
+
if (this.appBackupConfig.autoBackupBeforeUninstall && fs.existsSync(appDir)) {
|
|
287
|
+
try {
|
|
288
|
+
await this.performBackup(entry);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
console.warn(`[apps] auto-backup before uninstall of "${appName}" failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
257
294
|
// docker compose down --rmi all (graceful fallback if dir is already gone)
|
|
258
295
|
if (fs.existsSync(appDir)) {
|
|
259
296
|
try {
|
|
@@ -291,6 +328,431 @@ class AppInstaller {
|
|
|
291
328
|
}
|
|
292
329
|
await this.registry.remove(appName);
|
|
293
330
|
}
|
|
331
|
+
// ─── Backup / Restore ───────────────────────────────────────────────────────
|
|
332
|
+
/**
|
|
333
|
+
* Start an async backup job. Returns jobId immediately; poll {@link getJob}.
|
|
334
|
+
* A backup is a permission-safe snapshot of the app's Docker named volumes +
|
|
335
|
+
* config (`.env`/`app.yaml`/compose) into a single archive under
|
|
336
|
+
* `<backupsDir>/<app>/`. The app is stopped for the snapshot and restarted
|
|
337
|
+
* afterwards (see {@link performBackup}).
|
|
338
|
+
*/
|
|
339
|
+
backup(appName) {
|
|
340
|
+
this.pruneOldJobs();
|
|
341
|
+
if (this.installingNames.has(appName)) {
|
|
342
|
+
throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
|
|
343
|
+
}
|
|
344
|
+
this.installingNames.add(appName);
|
|
345
|
+
const jobId = crypto.randomUUID();
|
|
346
|
+
const job = {
|
|
347
|
+
id: jobId,
|
|
348
|
+
status: 'pending',
|
|
349
|
+
logs: [],
|
|
350
|
+
startedAt: Date.now(),
|
|
351
|
+
updatedAt: Date.now(),
|
|
352
|
+
};
|
|
353
|
+
this.jobs.set(jobId, job);
|
|
354
|
+
void this.runBackup(job, appName)
|
|
355
|
+
.catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
|
|
356
|
+
.finally(() => this.installingNames.delete(appName));
|
|
357
|
+
return jobId;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Start an async restore job. Returns jobId immediately; poll {@link getJob}.
|
|
361
|
+
* Restores the app's volumes + config from a prior backup, then starts it.
|
|
362
|
+
*/
|
|
363
|
+
restore(appName, backupId) {
|
|
364
|
+
this.pruneOldJobs();
|
|
365
|
+
if (this.installingNames.has(appName)) {
|
|
366
|
+
throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
|
|
367
|
+
}
|
|
368
|
+
this.installingNames.add(appName);
|
|
369
|
+
const jobId = crypto.randomUUID();
|
|
370
|
+
const job = {
|
|
371
|
+
id: jobId,
|
|
372
|
+
status: 'pending',
|
|
373
|
+
logs: [],
|
|
374
|
+
startedAt: Date.now(),
|
|
375
|
+
updatedAt: Date.now(),
|
|
376
|
+
};
|
|
377
|
+
this.jobs.set(jobId, job);
|
|
378
|
+
void this.runRestore(job, appName, backupId)
|
|
379
|
+
.catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
|
|
380
|
+
.finally(() => this.installingNames.delete(appName));
|
|
381
|
+
return jobId;
|
|
382
|
+
}
|
|
383
|
+
/** List an app's backups, newest first. Reads the sidecar manifests. */
|
|
384
|
+
listBackups(appName) {
|
|
385
|
+
const dir = this.appBackupDir(appName);
|
|
386
|
+
if (!fs.existsSync(dir))
|
|
387
|
+
return [];
|
|
388
|
+
const infos = [];
|
|
389
|
+
for (const file of fs.readdirSync(dir)) {
|
|
390
|
+
if (!file.endsWith('.json'))
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
394
|
+
// Only surface a backup whose archive is actually present.
|
|
395
|
+
if (!fs.existsSync(path.join(dir, `${meta.id}.tar.gz`)))
|
|
396
|
+
continue;
|
|
397
|
+
infos.push({
|
|
398
|
+
id: meta.id,
|
|
399
|
+
createdAt: meta.createdAt,
|
|
400
|
+
sizeBytes: meta.sizeBytes,
|
|
401
|
+
appVersion: meta.appVersion,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
/* skip malformed manifest */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return infos.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
409
|
+
}
|
|
410
|
+
/** Delete one backup (archive + sidecar). Idempotent. */
|
|
411
|
+
deleteBackup(appName, backupId) {
|
|
412
|
+
if (!/^[\w.-]+$/.test(backupId))
|
|
413
|
+
throw new Error(`Invalid backup id "${backupId}"`);
|
|
414
|
+
const dir = this.appBackupDir(appName);
|
|
415
|
+
for (const ext of ['.tar.gz', '.json']) {
|
|
416
|
+
const p = path.join(dir, `${backupId}${ext}`);
|
|
417
|
+
try {
|
|
418
|
+
fs.rmSync(p, { force: true });
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
/* best-effort */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async runBackup(job, appName) {
|
|
426
|
+
job.status = 'running';
|
|
427
|
+
const entry = await this.registry.get(appName);
|
|
428
|
+
if (!entry)
|
|
429
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
430
|
+
const info = await this.performBackup(entry, (m) => this.log(job, m));
|
|
431
|
+
job.backup = info;
|
|
432
|
+
job.status = 'completed';
|
|
433
|
+
job.updatedAt = Date.now();
|
|
434
|
+
}
|
|
435
|
+
async runRestore(job, appName, backupId) {
|
|
436
|
+
job.status = 'running';
|
|
437
|
+
const entry = await this.registry.get(appName);
|
|
438
|
+
if (!entry)
|
|
439
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
440
|
+
const info = await this.performRestore(entry, backupId, (m) => this.log(job, m));
|
|
441
|
+
job.backup = info;
|
|
442
|
+
job.status = 'completed';
|
|
443
|
+
job.updatedAt = Date.now();
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Core backup routine (shared by the job path and the auto-backup hooks).
|
|
447
|
+
*
|
|
448
|
+
* Consistency: a running app is stopped for the snapshot so no writes land
|
|
449
|
+
* mid-tar, then ALWAYS restarted in a `finally` — a backup that throws never
|
|
450
|
+
* leaves the app stuck stopped.
|
|
451
|
+
*
|
|
452
|
+
* Permission safety: each named volume is tarred inside a throwaway root
|
|
453
|
+
* container (`docker run … tar czf`), so uid/gid/mode are preserved in the
|
|
454
|
+
* archive and no host `cp`/`chown`/sudo ever touches the volume data.
|
|
455
|
+
*/
|
|
456
|
+
async performBackup(entry, logSink) {
|
|
457
|
+
const log = (m) => logSink?.(m);
|
|
458
|
+
const appName = entry.name;
|
|
459
|
+
const appDir = entry.installPath;
|
|
460
|
+
if (!fs.existsSync(appDir)) {
|
|
461
|
+
throw new Error(`App "${appName}" directory is gone — cannot back up`);
|
|
462
|
+
}
|
|
463
|
+
const volumes = this.discoverVolumes(appName, appDir);
|
|
464
|
+
const bindMounts = this.discoverBindMounts(appName, appDir);
|
|
465
|
+
const wasRunning = (await this.queryRuntimeStatus(entry)) === 'running';
|
|
466
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), `bkp-${appName}-`));
|
|
467
|
+
try {
|
|
468
|
+
if (wasRunning) {
|
|
469
|
+
log(`Stopping "${appName}" for a consistent snapshot`);
|
|
470
|
+
this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
|
|
471
|
+
}
|
|
472
|
+
const volDir = path.join(staging, 'volumes');
|
|
473
|
+
fs.mkdirSync(volDir, { recursive: true });
|
|
474
|
+
for (const vol of volumes) {
|
|
475
|
+
log(`Archiving volume "${vol}"`);
|
|
476
|
+
this.run([
|
|
477
|
+
'docker', 'run', '--rm',
|
|
478
|
+
'-v', `${appName}_${vol}:/data:ro`,
|
|
479
|
+
'-v', `${volDir}:/backup`,
|
|
480
|
+
BACKUP_HELPER_IMAGE,
|
|
481
|
+
'tar', 'czf', `/backup/${vol}.tar.gz`, '-C', '/data', '.',
|
|
482
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
483
|
+
}
|
|
484
|
+
// Bind-mount data dirs under the app dir (not Docker named volumes).
|
|
485
|
+
// Archive each with the same root-helper tar so ownership (e.g. the
|
|
486
|
+
// postgres uid) survives without host-side cp/sudo. Indexed filenames
|
|
487
|
+
// avoid collisions between paths that flatten to the same name.
|
|
488
|
+
const bindDir = path.join(staging, 'binds');
|
|
489
|
+
fs.mkdirSync(bindDir, { recursive: true });
|
|
490
|
+
const capturedBinds = [];
|
|
491
|
+
for (const rel of bindMounts) {
|
|
492
|
+
const abs = path.join(appDir, rel);
|
|
493
|
+
if (!fs.existsSync(abs)) {
|
|
494
|
+
log(`Bind mount "${rel}" missing on disk — skipping`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
log(`Archiving bind mount "${rel}"`);
|
|
498
|
+
this.run([
|
|
499
|
+
'docker', 'run', '--rm',
|
|
500
|
+
'-v', `${abs}:/data:ro`,
|
|
501
|
+
'-v', `${bindDir}:/backup`,
|
|
502
|
+
BACKUP_HELPER_IMAGE,
|
|
503
|
+
'tar', 'czf', `/backup/bind-${capturedBinds.length}.tar.gz`, '-C', '/data', '.',
|
|
504
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
505
|
+
capturedBinds.push(rel);
|
|
506
|
+
}
|
|
507
|
+
// Config (owned by the gateway user, copied on the host).
|
|
508
|
+
const cfgDir = path.join(staging, 'config');
|
|
509
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
510
|
+
for (const f of ['.env', 'app.yaml', 'docker-compose.yml']) {
|
|
511
|
+
this.copyIfExists(path.join(appDir, f), path.join(cfgDir, f));
|
|
512
|
+
}
|
|
513
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto
|
|
514
|
+
.randomUUID()
|
|
515
|
+
.slice(0, 8)}`;
|
|
516
|
+
const meta = {
|
|
517
|
+
id,
|
|
518
|
+
appName,
|
|
519
|
+
appVersion: entry.version,
|
|
520
|
+
createdAt: new Date().toISOString(),
|
|
521
|
+
volumes,
|
|
522
|
+
bindMounts: capturedBinds,
|
|
523
|
+
sizeBytes: 0,
|
|
524
|
+
};
|
|
525
|
+
fs.writeFileSync(path.join(staging, 'metadata.json'), JSON.stringify(meta, null, 2));
|
|
526
|
+
const outDir = this.appBackupDir(appName);
|
|
527
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
528
|
+
const archivePath = path.join(outDir, `${id}.tar.gz`);
|
|
529
|
+
// The gateway host tars the staging tree. Volume tarballs written by the
|
|
530
|
+
// root helper are world-readable (0644), so this read succeeds without sudo.
|
|
531
|
+
this.run(['tar', 'czf', archivePath, '-C', staging, '.'], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
532
|
+
let sizeBytes = 0;
|
|
533
|
+
try {
|
|
534
|
+
sizeBytes = fs.statSync(archivePath).size;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
/* archive stat failed — leave size 0 */
|
|
538
|
+
}
|
|
539
|
+
meta.sizeBytes = sizeBytes;
|
|
540
|
+
fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(meta, null, 2));
|
|
541
|
+
this.pruneBackups(appName);
|
|
542
|
+
log(`Backup "${id}" complete (${sizeBytes} bytes, ${volumes.length} volume(s), ` +
|
|
543
|
+
`${capturedBinds.length} bind mount(s))`);
|
|
544
|
+
return { id, createdAt: meta.createdAt, sizeBytes, appVersion: meta.appVersion };
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
try {
|
|
548
|
+
this.rmrf(staging);
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
/* best-effort staging cleanup */
|
|
552
|
+
}
|
|
553
|
+
if (wasRunning) {
|
|
554
|
+
try {
|
|
555
|
+
log(`Restarting "${appName}"`);
|
|
556
|
+
this.composeUp(appName, appDir);
|
|
557
|
+
}
|
|
558
|
+
catch (restartErr) {
|
|
559
|
+
log(`WARNING: failed to restart "${appName}" after backup: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Core restore routine. Extracts the archive, wipes+repopulates each volume
|
|
566
|
+
* via the root helper (preserving inner ownership), restores `.env`, and
|
|
567
|
+
* starts the app on the restored data. Restoring across a differing
|
|
568
|
+
* appVersion is allowed but warns (possible schema/migration mismatch).
|
|
569
|
+
*/
|
|
570
|
+
async performRestore(entry, backupId, logSink) {
|
|
571
|
+
const log = (m) => logSink?.(m);
|
|
572
|
+
if (!/^[\w.-]+$/.test(backupId))
|
|
573
|
+
throw new Error(`Invalid backup id "${backupId}"`);
|
|
574
|
+
const appName = entry.name;
|
|
575
|
+
const appDir = entry.installPath;
|
|
576
|
+
const archivePath = path.join(this.appBackupDir(appName), `${backupId}.tar.gz`);
|
|
577
|
+
if (!fs.existsSync(archivePath)) {
|
|
578
|
+
throw new Error(`Backup "${backupId}" not found for app "${appName}"`);
|
|
579
|
+
}
|
|
580
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), `rst-${appName}-`));
|
|
581
|
+
try {
|
|
582
|
+
this.run(['tar', 'xzf', archivePath, '-C', staging], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
583
|
+
const meta = JSON.parse(fs.readFileSync(path.join(staging, 'metadata.json'), 'utf-8'));
|
|
584
|
+
if (meta.appVersion !== entry.version) {
|
|
585
|
+
log(`WARNING: restoring backup from version "${meta.appVersion}" onto installed "${entry.version}" — possible schema/migration mismatch`);
|
|
586
|
+
}
|
|
587
|
+
log(`Stopping "${appName}" before restore`);
|
|
588
|
+
try {
|
|
589
|
+
this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
/* may already be stopped */
|
|
593
|
+
}
|
|
594
|
+
const volDir = path.join(staging, 'volumes');
|
|
595
|
+
for (const vol of meta.volumes) {
|
|
596
|
+
const tarball = path.join(volDir, `${vol}.tar.gz`);
|
|
597
|
+
if (!fs.existsSync(tarball)) {
|
|
598
|
+
log(`WARNING: volume "${vol}" missing from backup — skipping`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
log(`Restoring volume "${vol}"`);
|
|
602
|
+
this.run([
|
|
603
|
+
'docker', 'run', '--rm',
|
|
604
|
+
'-v', `${appName}_${vol}:/data`,
|
|
605
|
+
'-v', `${volDir}:/backup`,
|
|
606
|
+
BACKUP_HELPER_IMAGE,
|
|
607
|
+
'sh', '-c',
|
|
608
|
+
// Wipe the live volume, then untar the snapshot (uid/gid preserved).
|
|
609
|
+
`rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/${vol}.tar.gz -C /data`,
|
|
610
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
611
|
+
}
|
|
612
|
+
// Restore bind-mount data dirs under the app dir. Indexed filenames match
|
|
613
|
+
// performBackup's capture order. Each restored path is re-checked to stay
|
|
614
|
+
// under the app dir (defense-in-depth against a tampered metadata path).
|
|
615
|
+
const bindDir = path.join(staging, 'binds');
|
|
616
|
+
const bindMounts = meta.bindMounts ?? [];
|
|
617
|
+
bindMounts.forEach((rel, i) => {
|
|
618
|
+
const abs = path.resolve(appDir, rel);
|
|
619
|
+
const base = appDir.endsWith(path.sep) ? appDir : appDir + path.sep;
|
|
620
|
+
if (abs !== appDir && !abs.startsWith(base)) {
|
|
621
|
+
log(`WARNING: bind mount "${rel}" escapes the app dir — skipping`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const tarball = path.join(bindDir, `bind-${i}.tar.gz`);
|
|
625
|
+
if (!fs.existsSync(tarball)) {
|
|
626
|
+
log(`WARNING: bind mount "${rel}" missing from backup — skipping`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
log(`Restoring bind mount "${rel}"`);
|
|
630
|
+
this.run([
|
|
631
|
+
'docker', 'run', '--rm',
|
|
632
|
+
'-v', `${abs}:/data`,
|
|
633
|
+
'-v', `${bindDir}:/backup`,
|
|
634
|
+
BACKUP_HELPER_IMAGE,
|
|
635
|
+
'sh', '-c',
|
|
636
|
+
// Wipe the live bind dir, then untar the snapshot (uid/gid preserved).
|
|
637
|
+
`rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/bind-${i}.tar.gz -C /data`,
|
|
638
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
639
|
+
});
|
|
640
|
+
// Restore config that carries generated secrets, so the app boots with the
|
|
641
|
+
// same credentials the volume data was created under.
|
|
642
|
+
this.copyIfExists(path.join(staging, 'config', '.env'), path.join(appDir, '.env'));
|
|
643
|
+
log(`Starting "${appName}" on restored data`);
|
|
644
|
+
this.composeUp(appName, appDir);
|
|
645
|
+
await this.registry.updateStatus(appName, 'running').catch(() => { });
|
|
646
|
+
log(`Restore of "${backupId}" complete`);
|
|
647
|
+
return {
|
|
648
|
+
id: meta.id,
|
|
649
|
+
createdAt: meta.createdAt,
|
|
650
|
+
sizeBytes: meta.sizeBytes,
|
|
651
|
+
appVersion: meta.appVersion,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
try {
|
|
656
|
+
this.rmrf(staging);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
/* best-effort */
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* List an app's compose-level named volumes (the top-level `volumes:` keys).
|
|
665
|
+
* Returns [] when the app declares none or the query fails — a config-only
|
|
666
|
+
* backup is still useful (it captures `.env`).
|
|
667
|
+
*/
|
|
668
|
+
discoverVolumes(appName, appDir) {
|
|
669
|
+
try {
|
|
670
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--volumes'], appDir, 30000);
|
|
671
|
+
return stdout
|
|
672
|
+
.split('\n')
|
|
673
|
+
.map((s) => s.trim())
|
|
674
|
+
.filter((s) => s.length > 0);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Discover bind-mount source directories that live **under the app dir**
|
|
682
|
+
* (e.g. `./data/photos` → `data/photos`). These hold app-owned data that is
|
|
683
|
+
* not a Docker named volume, so {@link discoverVolumes} never reports them —
|
|
684
|
+
* yet they are deleted on uninstall and must be captured in a backup.
|
|
685
|
+
*
|
|
686
|
+
* Returns app-dir-relative POSIX paths, deduped and sorted. Bind mounts whose
|
|
687
|
+
* source resolves outside the app dir (shared host resources such as a
|
|
688
|
+
* read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
|
|
689
|
+
* returns `[]` on any failure, mirroring {@link discoverVolumes}.
|
|
690
|
+
*/
|
|
691
|
+
discoverBindMounts(appName, appDir) {
|
|
692
|
+
try {
|
|
693
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
|
|
694
|
+
const parsed = JSON.parse(stdout);
|
|
695
|
+
// Local-dev installs symlink the app dir into appsDir, and the generated
|
|
696
|
+
// compose resolves bind sources against the symlink's *realpath*. Match a
|
|
697
|
+
// source that sits under either the symlink path or its target, so those
|
|
698
|
+
// bind mounts are not wrongly excluded.
|
|
699
|
+
const bases = [appDir];
|
|
700
|
+
try {
|
|
701
|
+
const real = fs.realpathSync(appDir);
|
|
702
|
+
if (real !== appDir)
|
|
703
|
+
bases.push(real);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
/* app dir unreadable — fall back to the literal path */
|
|
707
|
+
}
|
|
708
|
+
const rels = new Set();
|
|
709
|
+
for (const svc of Object.values(parsed.services ?? {})) {
|
|
710
|
+
for (const vol of svc.volumes ?? []) {
|
|
711
|
+
if (vol.type !== 'bind' || typeof vol.source !== 'string')
|
|
712
|
+
continue;
|
|
713
|
+
const abs = path.resolve(appDir, vol.source);
|
|
714
|
+
const matched = bases.find((b) => abs === b || abs.startsWith(b + path.sep));
|
|
715
|
+
if (!matched)
|
|
716
|
+
continue; // outside the app dir
|
|
717
|
+
const rel = path.relative(matched, abs);
|
|
718
|
+
if (rel.length > 0)
|
|
719
|
+
rels.add(rel.split(path.sep).join('/'));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return Array.from(rels).sort();
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return [];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/** Keep the N most recent backups per app (by createdAt); prune older. */
|
|
729
|
+
pruneBackups(appName) {
|
|
730
|
+
const retention = this.appBackupConfig.retention;
|
|
731
|
+
if (retention <= 0)
|
|
732
|
+
return; // 0 = unbounded
|
|
733
|
+
const all = this.listBackups(appName); // newest first
|
|
734
|
+
for (const stale of all.slice(retention)) {
|
|
735
|
+
this.deleteBackup(appName, stale.id);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
appBackupDir(appName) {
|
|
739
|
+
// Never trust the name reaching the filesystem: an unvalidated value (e.g.
|
|
740
|
+
// a `%2F`-smuggled `../../x` from a route param) would let path.join escape
|
|
741
|
+
// the backups tree. Every backup op funnels through here, so this one guard
|
|
742
|
+
// covers backup/restore/list/delete.
|
|
743
|
+
if (!APP_NAME_RE.test(appName))
|
|
744
|
+
throw new Error(`Invalid app name "${appName}"`);
|
|
745
|
+
return path.join(this.backupsDir, appName);
|
|
746
|
+
}
|
|
747
|
+
copyIfExists(src, dest) {
|
|
748
|
+
try {
|
|
749
|
+
if (fs.existsSync(src))
|
|
750
|
+
fs.copyFileSync(src, dest);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
/* best-effort — a missing/unreadable optional file is not fatal */
|
|
754
|
+
}
|
|
755
|
+
}
|
|
294
756
|
async startStopRestart(appName, action) {
|
|
295
757
|
const entry = await this.registry.get(appName);
|
|
296
758
|
if (!entry)
|
|
@@ -570,8 +1032,21 @@ class AppInstaller {
|
|
|
570
1032
|
this.log(job, `Warning: ${w}`);
|
|
571
1033
|
}
|
|
572
1034
|
// ── Write .env ────────────────────────────────────────────────────────
|
|
1035
|
+
// Carry over an existing .env before writing, but ONLY for a local
|
|
1036
|
+
// (symlinked) install. A local install can re-point at a source tree that
|
|
1037
|
+
// still holds a prior .env alongside persisted data (e.g. a postgres pgdata
|
|
1038
|
+
// bind mount). writeEnvFile treats an already-present generated secret as
|
|
1039
|
+
// pinned, so reusing the existing .env keeps DB_PASSWORD/etc. stable and
|
|
1040
|
+
// lets the app reconnect to that data instead of failing auth (mirrors the
|
|
1041
|
+
// reconfigure path). This is deliberately scoped to `source === 'local'`:
|
|
1042
|
+
// a registry/GitHub install checks out into appDir, and a repo that
|
|
1043
|
+
// committed a `.env` would otherwise get its secrets pinned to committed
|
|
1044
|
+
// values — so for those sources we ignore any checked-out .env and generate
|
|
1045
|
+
// fresh, unchanged from before. Operator-supplied envVars still win.
|
|
573
1046
|
this.log(job, 'Writing .env');
|
|
574
|
-
const
|
|
1047
|
+
const existingEnv = source === 'local' ? this.readEnvFile(appDir) : {};
|
|
1048
|
+
const mergedEnv = { ...existingEnv, ...(options.envVars ?? {}) };
|
|
1049
|
+
const generatedNames = this.writeEnvFile(appDir, appName, generated, mergedEnv);
|
|
575
1050
|
if (generatedNames.length > 0) {
|
|
576
1051
|
this.log(job, `Generated secrets: ${generatedNames.join(', ')}`);
|
|
577
1052
|
}
|
|
@@ -665,6 +1140,9 @@ class AppInstaller {
|
|
|
665
1140
|
// ── Update status to running ──────────────────────────────────────────
|
|
666
1141
|
await this.registry.updateStatus(appName, 'running');
|
|
667
1142
|
this.log(job, 'Containers healthy');
|
|
1143
|
+
// ── Housekeeping: reclaim leaked build cache + dangling images ────────
|
|
1144
|
+
// Best-effort, config-gated, after the new stack is up (issue #302).
|
|
1145
|
+
this.pruneAfterBuild(job);
|
|
668
1146
|
// ── Register proxy routes ─────────────────────────────────────────────
|
|
669
1147
|
this.callbacks.registerRoutes(appName, generated.ports);
|
|
670
1148
|
// ── Build result ──────────────────────────────────────────────────────
|
|
@@ -743,6 +1221,17 @@ class AppInstaller {
|
|
|
743
1221
|
return;
|
|
744
1222
|
}
|
|
745
1223
|
this.log(job, `Updating ${appName} ${entry.commit.slice(0, 8)} → ${target.newCommit.slice(0, 8)}`);
|
|
1224
|
+
// Safety hook: snapshot before the update so a bad new image can be rolled
|
|
1225
|
+
// back. Best-effort — a backup failure must not block the update.
|
|
1226
|
+
if (this.appBackupConfig.autoBackupBeforeUpdate) {
|
|
1227
|
+
try {
|
|
1228
|
+
const info = await this.performBackup(entry, (m) => this.log(job, m));
|
|
1229
|
+
this.log(job, `Pre-update backup "${info.id}" created`);
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
746
1235
|
const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
|
|
747
1236
|
try {
|
|
748
1237
|
// ── Shallow fetch of specific commit into tmp dir ─────────────────────
|
|
@@ -802,6 +1291,9 @@ class AppInstaller {
|
|
|
802
1291
|
// we can reclaim exactly those images after the update — without a
|
|
803
1292
|
// `compose down` that would collide with the new stack (issue #283).
|
|
804
1293
|
const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
|
|
1294
|
+
// Also capture the app's declared image refs (repo:tag) so a superseded
|
|
1295
|
+
// *pulled* tag can be reclaimed after the update (issue #302).
|
|
1296
|
+
const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
|
|
805
1297
|
// ── Bring old containers down (keeps images for rollback) ─────────────
|
|
806
1298
|
this.log(job, 'Stopping old containers');
|
|
807
1299
|
this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
|
|
@@ -840,6 +1332,7 @@ class AppInstaller {
|
|
|
840
1332
|
// below never removes an image the new containers depend on (e.g. when the
|
|
841
1333
|
// old and new versions happen to share a base/image).
|
|
842
1334
|
const newImageIds = this.captureComposeImageIds(appName, tmpDir);
|
|
1335
|
+
const newImageRefs = this.captureComposeImageRefs(appName, tmpDir);
|
|
843
1336
|
// ── Swap dirs ─────────────────────────────────────────────────────────
|
|
844
1337
|
// Swap in place at the recorded install path — NOT path.join(appsDir, appName).
|
|
845
1338
|
// For legacy installs the on-disk dir is named after the source repo/URL
|
|
@@ -892,7 +1385,14 @@ class AppInstaller {
|
|
|
892
1385
|
}
|
|
893
1386
|
catch { /* still in use or already gone — non-fatal */ }
|
|
894
1387
|
}
|
|
1388
|
+
// Reclaim a superseded *pulled* tag the image-ID reclaim above can't
|
|
1389
|
+
// (a pulled image with >1 repo tag isn't removable by ID) — only this
|
|
1390
|
+
// app's own prior refs the new stack no longer uses (issue #302).
|
|
1391
|
+
this.reclaimSupersededTags(job, oldImageRefs, newImageRefs);
|
|
895
1392
|
this.safeRmrf(oldBackupDir, job, 'old backup dir');
|
|
1393
|
+
// ── Housekeeping: reclaim leaked build cache + dangling images ────────
|
|
1394
|
+
// Best-effort, config-gated, after the image reclaim (issue #302).
|
|
1395
|
+
this.pruneAfterBuild(job);
|
|
896
1396
|
// ── Build result ──────────────────────────────────────────────────────
|
|
897
1397
|
const proxyUrls = {};
|
|
898
1398
|
for (const p of generated.ports) {
|
|
@@ -1143,9 +1643,16 @@ class AppInstaller {
|
|
|
1143
1643
|
}
|
|
1144
1644
|
}
|
|
1145
1645
|
const envLines = [];
|
|
1646
|
+
const secretDefaults = generated.secretDefaults ?? {};
|
|
1146
1647
|
for (const key of generated.secretKeys) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1648
|
+
// Precedence: operator-supplied value → declared default → empty. An
|
|
1649
|
+
// empty operator value (UI always sends the field, possibly blank) falls
|
|
1650
|
+
// through to the default, matching how generated keys treat '' as unset.
|
|
1651
|
+
const provided = merged[key];
|
|
1652
|
+
const raw = provided !== undefined && provided !== ''
|
|
1653
|
+
? provided
|
|
1654
|
+
: secretDefaults[key] ?? '';
|
|
1655
|
+
envLines.push(`${key}=${raw.replace(/[\r\n]/g, '')}`);
|
|
1149
1656
|
}
|
|
1150
1657
|
const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
|
|
1151
1658
|
const generatedNames = [];
|
|
@@ -1453,6 +1960,152 @@ class AppInstaller {
|
|
|
1453
1960
|
return [];
|
|
1454
1961
|
}
|
|
1455
1962
|
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Image references (repo:tag) an app's compose file declares, e.g.
|
|
1965
|
+
* "ghcr.io/x/monitor:1.1". Used to reclaim a superseded *pulled* tag on
|
|
1966
|
+
* update — a tag bump the image-ID reclaim misses, because a pulled image
|
|
1967
|
+
* carrying more than one repo tag cannot be removed by ID (issue #302).
|
|
1968
|
+
* Best-effort — returns [] on any error.
|
|
1969
|
+
*/
|
|
1970
|
+
captureComposeImageRefs(appName, dir) {
|
|
1971
|
+
try {
|
|
1972
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--images'], dir, 15000);
|
|
1973
|
+
return [...new Set(stdout.trim().split('\n').map((s) => s.trim()).filter(Boolean))];
|
|
1974
|
+
}
|
|
1975
|
+
catch {
|
|
1976
|
+
return [];
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
// ─── Docker housekeeping (issue #302) ───────────────────────────────────────
|
|
1980
|
+
/** Resolve the housekeeping toggles against their conservative defaults. */
|
|
1981
|
+
resolveHousekeeping() {
|
|
1982
|
+
const hk = this.housekeepingConfig ?? {};
|
|
1983
|
+
return {
|
|
1984
|
+
buildCachePrune: hk.buildCachePrune ?? true,
|
|
1985
|
+
buildCacheMaxAgeHours: hk.buildCacheMaxAgeHours ?? 168,
|
|
1986
|
+
danglingImagePrune: hk.danglingImagePrune ?? true,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Best-effort Docker housekeeping after a successful build (install + update).
|
|
1991
|
+
* Reclaims ONLY provably-unreferenced junk:
|
|
1992
|
+
* - build cache older than the configured window — time-filtered on purpose
|
|
1993
|
+
* so a concurrent build's fresh layers are never evicted;
|
|
1994
|
+
* - dangling `<none>` images with no container (safe by definition).
|
|
1995
|
+
* Gated by config (all toggles off ⇒ no prune calls). Every prune is wrapped
|
|
1996
|
+
* so a failure NEVER fails the parent install/update. Enforces the safety
|
|
1997
|
+
* floor: no `-a`, no `system prune`, no volume prune (issue #302).
|
|
1998
|
+
*/
|
|
1999
|
+
pruneAfterBuild(job) {
|
|
2000
|
+
const hk = this.resolveHousekeeping();
|
|
2001
|
+
if (hk.buildCachePrune) {
|
|
2002
|
+
try {
|
|
2003
|
+
this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
|
|
2004
|
+
this.log(job, `Build cache pruned (older than ${hk.buildCacheMaxAgeHours}h)`);
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
this.log(job, `Build-cache prune skipped (non-fatal): ${err.message}`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (hk.danglingImagePrune) {
|
|
2011
|
+
try {
|
|
2012
|
+
// `image prune -f` (NEVER `-a`) — removes only untagged <none> layers
|
|
2013
|
+
// with no container, so tagged images of other stopped apps survive.
|
|
2014
|
+
this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
|
|
2015
|
+
this.log(job, 'Dangling images pruned');
|
|
2016
|
+
}
|
|
2017
|
+
catch (err) {
|
|
2018
|
+
this.log(job, `Dangling-image prune skipped (non-fatal): ${err.message}`);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Reclaim this app's own superseded pulled tags after an update (issue #302).
|
|
2024
|
+
* The image-ID reclaim misses a pulled-tag bump (e.g. monitor:1.1 → :1.2.0):
|
|
2025
|
+
* a pulled image with more than one repo tag can't be removed by ID. Remove
|
|
2026
|
+
* exactly the app's prior refs that the NEW stack no longer references.
|
|
2027
|
+
* `image rm <ref>` fails safely (and is caught) when a container still uses
|
|
2028
|
+
* the image, so an in-use image is never yanked. Only this app's own tags are
|
|
2029
|
+
* ever touched — never a blanket prune.
|
|
2030
|
+
*/
|
|
2031
|
+
reclaimSupersededTags(job, oldImageRefs, newImageRefs) {
|
|
2032
|
+
const newRefSet = new Set(newImageRefs);
|
|
2033
|
+
for (const ref of oldImageRefs) {
|
|
2034
|
+
if (newRefSet.has(ref))
|
|
2035
|
+
continue; // still used by new stack — keep
|
|
2036
|
+
try {
|
|
2037
|
+
this.run(['docker', 'image', 'rm', ref], os.tmpdir(), 60000);
|
|
2038
|
+
this.log(job, `Reclaimed superseded image tag ${ref}`);
|
|
2039
|
+
}
|
|
2040
|
+
catch {
|
|
2041
|
+
/* in use / already gone — non-fatal */
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/** Read-only reclaim report (issue #302). Best-effort; never mutates state. */
|
|
2046
|
+
housekeepingReport() {
|
|
2047
|
+
return this.buildHousekeepingReport();
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Execute the SAFE reclaim (build cache + dangling images only) and return a
|
|
2051
|
+
* fresh report. This is an explicit operator action, so it runs regardless of
|
|
2052
|
+
* the auto-path config toggles — but it still honors the fixed safety floor:
|
|
2053
|
+
* never `-a`, never `system prune`, never a volume/auto delete. The build-cache
|
|
2054
|
+
* window uses the configured value (default 168h).
|
|
2055
|
+
*/
|
|
2056
|
+
housekeepingPrune() {
|
|
2057
|
+
const hk = this.resolveHousekeeping();
|
|
2058
|
+
const pruned = { buildCache: false, danglingImages: false };
|
|
2059
|
+
try {
|
|
2060
|
+
this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
|
|
2061
|
+
pruned.buildCache = true;
|
|
2062
|
+
}
|
|
2063
|
+
catch {
|
|
2064
|
+
/* best-effort */
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
|
|
2068
|
+
pruned.danglingImages = true;
|
|
2069
|
+
}
|
|
2070
|
+
catch {
|
|
2071
|
+
/* best-effort */
|
|
2072
|
+
}
|
|
2073
|
+
return { mode: 'prune', pruned, report: this.buildHousekeepingReport() };
|
|
2074
|
+
}
|
|
2075
|
+
buildHousekeepingReport() {
|
|
2076
|
+
return {
|
|
2077
|
+
buildCacheReclaimable: this.readBuildCacheReclaimable(),
|
|
2078
|
+
danglingImageCount: this.splitLines(this.safeRunStdout(['docker', 'image', 'ls', '--filter', 'dangling=true', '--quiet'])).length,
|
|
2079
|
+
orphanVolumes: this.splitLines(this.safeRunStdout(['docker', 'volume', 'ls', '--filter', 'dangling=true', '--quiet'])),
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
/** `docker` stdout, or '' on any error (report helpers must never throw). */
|
|
2083
|
+
safeRunStdout(args, timeoutMs = 30000) {
|
|
2084
|
+
try {
|
|
2085
|
+
return this.run(args, os.tmpdir(), timeoutMs).stdout;
|
|
2086
|
+
}
|
|
2087
|
+
catch {
|
|
2088
|
+
return '';
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
splitLines(s) {
|
|
2092
|
+
return s.trim().split('\n').map((x) => x.trim()).filter(Boolean);
|
|
2093
|
+
}
|
|
2094
|
+
/** Reclaimable build-cache size from the `docker system df` "Build Cache" row. */
|
|
2095
|
+
readBuildCacheReclaimable() {
|
|
2096
|
+
const out = this.safeRunStdout([
|
|
2097
|
+
'docker', 'system', 'df', '--format', '{{.Type}}\t{{.Reclaimable}}',
|
|
2098
|
+
]);
|
|
2099
|
+
for (const line of this.splitLines(out)) {
|
|
2100
|
+
const tab = line.indexOf('\t');
|
|
2101
|
+
if (tab < 0)
|
|
2102
|
+
continue;
|
|
2103
|
+
const type = line.slice(0, tab).trim().toLowerCase();
|
|
2104
|
+
if (type.includes('build cache'))
|
|
2105
|
+
return line.slice(tab + 1).trim();
|
|
2106
|
+
}
|
|
2107
|
+
return '';
|
|
2108
|
+
}
|
|
1456
2109
|
run(args, cwd, timeoutMs = 30000) {
|
|
1457
2110
|
const opts = {
|
|
1458
2111
|
encoding: 'utf-8',
|