@0xmaxma/claude-gateway 1.6.4 → 1.6.6
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 +14 -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 +211 -1
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +768 -4
- package/dist/apps/installer.js.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +35 -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
|
@@ -42,8 +42,29 @@ const os = __importStar(require("node:os"));
|
|
|
42
42
|
const crypto = __importStar(require("node:crypto"));
|
|
43
43
|
const node_child_process_1 = require("node:child_process");
|
|
44
44
|
const compose_generator_1 = require("./compose-generator");
|
|
45
|
+
const cleanup_1 = require("../history/cleanup");
|
|
45
46
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
46
47
|
const DEFAULT_APPS_DIR = path.join(os.homedir(), '.claude-gateway', 'apps');
|
|
48
|
+
// Backups live under `<appsDir>/.backups/<app>/`. A dot-prefixed dir here is
|
|
49
|
+
// never mistaken for an installed app (the registry is driven by `apps.json`,
|
|
50
|
+
// not by enumerating the apps directory), and — being a sibling of each app's
|
|
51
|
+
// own dir rather than inside it — the archive survives that app's uninstall,
|
|
52
|
+
// which removes only `<appsDir>/<app>/`.
|
|
53
|
+
const APP_BACKUPS_DIRNAME = '.backups';
|
|
54
|
+
// Default per-app backup ceiling when config omits it. The N most recent are
|
|
55
|
+
// kept; older archives are pruned after each successful backup and by the daily
|
|
56
|
+
// scheduler.
|
|
57
|
+
const DEFAULT_BACKUP_RETENTION = 3;
|
|
58
|
+
// Default age ceiling (days) when config omits it. Backups older than this are
|
|
59
|
+
// pruned regardless of count. 0 disables age pruning.
|
|
60
|
+
const DEFAULT_BACKUP_MAX_AGE_DAYS = 30;
|
|
61
|
+
// Wall-clock ceiling for a single helper-container tar (backup or restore) of
|
|
62
|
+
// one volume. A few hundred MB tars in seconds; this only bounds a pathological
|
|
63
|
+
// hang so a stuck helper never wedges a backup job forever.
|
|
64
|
+
const VOLUME_TAR_TIMEOUT_MS = 300000;
|
|
65
|
+
// OCI image used for the throwaway tar helper. Small, ubiquitous, already a
|
|
66
|
+
// transitive dependency of most stacks, so it is almost always cache-warm.
|
|
67
|
+
const BACKUP_HELPER_IMAGE = 'alpine';
|
|
47
68
|
// Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
|
|
48
69
|
// the background (non-blocking), so this only bounds how long a hung container
|
|
49
70
|
// keeps its child process alive — not the gateway's responsiveness. Shorter than
|
|
@@ -55,21 +76,57 @@ const RESTORE_COMPOSE_TIMEOUT_MS = 180000;
|
|
|
55
76
|
const RESTORE_MAX_CONCURRENCY = 4;
|
|
56
77
|
const COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
57
78
|
const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
79
|
+
/**
|
|
80
|
+
* Validate an IANA timezone from config before it reaches `Intl.DateTimeFormat`.
|
|
81
|
+
* An invalid string would otherwise throw a RangeError inside the daily-cleanup
|
|
82
|
+
* scheduler at boot — config is untrusted input, so a typo must degrade to UTC,
|
|
83
|
+
* never crash the gateway.
|
|
84
|
+
*/
|
|
85
|
+
function isValidTimezone(tz) {
|
|
86
|
+
if (typeof tz !== 'string' || tz.length === 0)
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
58
96
|
// Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
|
|
59
97
|
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
98
|
// ─── Installer ────────────────────────────────────────────────────────────────
|
|
61
99
|
class AppInstaller {
|
|
62
|
-
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn) {
|
|
100
|
+
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
|
|
63
101
|
this.registry = registry;
|
|
64
102
|
this.registryClient = registryClient;
|
|
65
103
|
this.callbacks = callbacks;
|
|
66
104
|
this.spawn = spawn;
|
|
67
105
|
this.agentManager = agentManager;
|
|
68
106
|
this.spawnAsync = spawnAsync;
|
|
107
|
+
this.housekeepingConfig = housekeepingConfig;
|
|
69
108
|
this.jobs = new Map();
|
|
70
109
|
/** Tracks app names currently being installed to prevent concurrent installs of the same name. */
|
|
71
110
|
this.installingNames = new Set();
|
|
72
111
|
this.appsDir = appsDir ?? DEFAULT_APPS_DIR;
|
|
112
|
+
this.backupsDir = backupsDir ?? path.join(this.appsDir, APP_BACKUPS_DIRNAME);
|
|
113
|
+
this.appBackupConfig = {
|
|
114
|
+
retention: appBackupConfig?.retention !== undefined && appBackupConfig.retention >= 0
|
|
115
|
+
? Math.floor(appBackupConfig.retention)
|
|
116
|
+
: DEFAULT_BACKUP_RETENTION,
|
|
117
|
+
maxAgeDays: appBackupConfig?.maxAgeDays !== undefined && appBackupConfig.maxAgeDays >= 0
|
|
118
|
+
? Math.floor(appBackupConfig.maxAgeDays)
|
|
119
|
+
: DEFAULT_BACKUP_MAX_AGE_DAYS,
|
|
120
|
+
cleanupHour: appBackupConfig?.cleanupHour !== undefined &&
|
|
121
|
+
Number.isInteger(appBackupConfig.cleanupHour) &&
|
|
122
|
+
appBackupConfig.cleanupHour >= 0 &&
|
|
123
|
+
appBackupConfig.cleanupHour <= 23
|
|
124
|
+
? appBackupConfig.cleanupHour
|
|
125
|
+
: 0,
|
|
126
|
+
cleanupTimezone: isValidTimezone(appBackupConfig?.cleanupTimezone) ? appBackupConfig.cleanupTimezone : 'UTC',
|
|
127
|
+
autoBackupBeforeUninstall: appBackupConfig?.autoBackupBeforeUninstall ?? true,
|
|
128
|
+
autoBackupBeforeUpdate: appBackupConfig?.autoBackupBeforeUpdate ?? true,
|
|
129
|
+
};
|
|
73
130
|
}
|
|
74
131
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
75
132
|
/** Start an async install job. Returns jobId immediately. */
|
|
@@ -161,6 +218,7 @@ class AppInstaller {
|
|
|
161
218
|
commit,
|
|
162
219
|
secretKeys: generated.secretKeys,
|
|
163
220
|
generatedKeys: generated.generatedKeys,
|
|
221
|
+
secretDefaults: generated.secretDefaults,
|
|
164
222
|
ports: generated.ports,
|
|
165
223
|
agentDeclaration: generated.agentDeclaration,
|
|
166
224
|
warnings: generated.warnings,
|
|
@@ -254,6 +312,17 @@ class AppInstaller {
|
|
|
254
312
|
return;
|
|
255
313
|
}
|
|
256
314
|
const appDir = entry.installPath;
|
|
315
|
+
// Safety hook: snapshot the app's data before tearing it down, so an
|
|
316
|
+
// accidental or regretted uninstall has a restore point. Best-effort — a
|
|
317
|
+
// backup failure must never block the uninstall the operator asked for.
|
|
318
|
+
if (this.appBackupConfig.autoBackupBeforeUninstall && fs.existsSync(appDir)) {
|
|
319
|
+
try {
|
|
320
|
+
await this.performBackup(entry);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
console.warn(`[apps] auto-backup before uninstall of "${appName}" failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
257
326
|
// docker compose down --rmi all (graceful fallback if dir is already gone)
|
|
258
327
|
if (fs.existsSync(appDir)) {
|
|
259
328
|
try {
|
|
@@ -291,6 +360,510 @@ class AppInstaller {
|
|
|
291
360
|
}
|
|
292
361
|
await this.registry.remove(appName);
|
|
293
362
|
}
|
|
363
|
+
// ─── Backup / Restore ───────────────────────────────────────────────────────
|
|
364
|
+
/**
|
|
365
|
+
* Start an async backup job. Returns jobId immediately; poll {@link getJob}.
|
|
366
|
+
* A backup is a permission-safe snapshot of the app's Docker named volumes +
|
|
367
|
+
* config (`.env`/`app.yaml`/compose) into a single archive under
|
|
368
|
+
* `<backupsDir>/<app>/`. The app is stopped for the snapshot and restarted
|
|
369
|
+
* afterwards (see {@link performBackup}).
|
|
370
|
+
*/
|
|
371
|
+
backup(appName) {
|
|
372
|
+
this.pruneOldJobs();
|
|
373
|
+
if (this.installingNames.has(appName)) {
|
|
374
|
+
throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
|
|
375
|
+
}
|
|
376
|
+
this.installingNames.add(appName);
|
|
377
|
+
const jobId = crypto.randomUUID();
|
|
378
|
+
const job = {
|
|
379
|
+
id: jobId,
|
|
380
|
+
status: 'pending',
|
|
381
|
+
logs: [],
|
|
382
|
+
startedAt: Date.now(),
|
|
383
|
+
updatedAt: Date.now(),
|
|
384
|
+
};
|
|
385
|
+
this.jobs.set(jobId, job);
|
|
386
|
+
void this.runBackup(job, appName)
|
|
387
|
+
.catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
|
|
388
|
+
.finally(() => this.installingNames.delete(appName));
|
|
389
|
+
return jobId;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Start an async restore job. Returns jobId immediately; poll {@link getJob}.
|
|
393
|
+
* Restores the app's volumes + config from a prior backup, then starts it.
|
|
394
|
+
*/
|
|
395
|
+
restore(appName, backupId) {
|
|
396
|
+
this.pruneOldJobs();
|
|
397
|
+
if (this.installingNames.has(appName)) {
|
|
398
|
+
throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
|
|
399
|
+
}
|
|
400
|
+
this.installingNames.add(appName);
|
|
401
|
+
const jobId = crypto.randomUUID();
|
|
402
|
+
const job = {
|
|
403
|
+
id: jobId,
|
|
404
|
+
status: 'pending',
|
|
405
|
+
logs: [],
|
|
406
|
+
startedAt: Date.now(),
|
|
407
|
+
updatedAt: Date.now(),
|
|
408
|
+
};
|
|
409
|
+
this.jobs.set(jobId, job);
|
|
410
|
+
void this.runRestore(job, appName, backupId)
|
|
411
|
+
.catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
|
|
412
|
+
.finally(() => this.installingNames.delete(appName));
|
|
413
|
+
return jobId;
|
|
414
|
+
}
|
|
415
|
+
/** List an app's backups, newest first. Reads the sidecar manifests. */
|
|
416
|
+
listBackups(appName) {
|
|
417
|
+
const dir = this.appBackupDir(appName);
|
|
418
|
+
if (!fs.existsSync(dir))
|
|
419
|
+
return [];
|
|
420
|
+
const infos = [];
|
|
421
|
+
for (const file of fs.readdirSync(dir)) {
|
|
422
|
+
if (!file.endsWith('.json'))
|
|
423
|
+
continue;
|
|
424
|
+
try {
|
|
425
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
426
|
+
// Only surface a backup whose archive is actually present.
|
|
427
|
+
if (!fs.existsSync(path.join(dir, `${meta.id}.tar.gz`)))
|
|
428
|
+
continue;
|
|
429
|
+
infos.push({
|
|
430
|
+
id: meta.id,
|
|
431
|
+
createdAt: meta.createdAt,
|
|
432
|
+
sizeBytes: meta.sizeBytes,
|
|
433
|
+
appVersion: meta.appVersion,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
/* skip malformed manifest */
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return infos.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
441
|
+
}
|
|
442
|
+
/** Delete one backup (archive + sidecar). Idempotent. */
|
|
443
|
+
deleteBackup(appName, backupId) {
|
|
444
|
+
if (!/^[\w.-]+$/.test(backupId))
|
|
445
|
+
throw new Error(`Invalid backup id "${backupId}"`);
|
|
446
|
+
const dir = this.appBackupDir(appName);
|
|
447
|
+
for (const ext of ['.tar.gz', '.json']) {
|
|
448
|
+
const p = path.join(dir, `${backupId}${ext}`);
|
|
449
|
+
try {
|
|
450
|
+
fs.rmSync(p, { force: true });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
/* best-effort */
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async runBackup(job, appName) {
|
|
458
|
+
job.status = 'running';
|
|
459
|
+
const entry = await this.registry.get(appName);
|
|
460
|
+
if (!entry)
|
|
461
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
462
|
+
const info = await this.performBackup(entry, (m) => this.log(job, m));
|
|
463
|
+
job.backup = info;
|
|
464
|
+
job.status = 'completed';
|
|
465
|
+
job.updatedAt = Date.now();
|
|
466
|
+
}
|
|
467
|
+
async runRestore(job, appName, backupId) {
|
|
468
|
+
job.status = 'running';
|
|
469
|
+
const entry = await this.registry.get(appName);
|
|
470
|
+
if (!entry)
|
|
471
|
+
throw new Error(`App "${appName}" is not installed`);
|
|
472
|
+
const info = await this.performRestore(entry, backupId, (m) => this.log(job, m));
|
|
473
|
+
job.backup = info;
|
|
474
|
+
job.status = 'completed';
|
|
475
|
+
job.updatedAt = Date.now();
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Core backup routine (shared by the job path and the auto-backup hooks).
|
|
479
|
+
*
|
|
480
|
+
* Consistency: a running app is stopped for the snapshot so no writes land
|
|
481
|
+
* mid-tar, then ALWAYS restarted in a `finally` — a backup that throws never
|
|
482
|
+
* leaves the app stuck stopped.
|
|
483
|
+
*
|
|
484
|
+
* Permission safety: each named volume is tarred inside a throwaway root
|
|
485
|
+
* container (`docker run … tar czf`), so uid/gid/mode are preserved in the
|
|
486
|
+
* archive and no host `cp`/`chown`/sudo ever touches the volume data.
|
|
487
|
+
*/
|
|
488
|
+
async performBackup(entry, logSink) {
|
|
489
|
+
const log = (m) => logSink?.(m);
|
|
490
|
+
const appName = entry.name;
|
|
491
|
+
const appDir = entry.installPath;
|
|
492
|
+
if (!fs.existsSync(appDir)) {
|
|
493
|
+
throw new Error(`App "${appName}" directory is gone — cannot back up`);
|
|
494
|
+
}
|
|
495
|
+
const volumes = this.discoverVolumes(appName, appDir);
|
|
496
|
+
const bindMounts = this.discoverBindMounts(appName, appDir);
|
|
497
|
+
const wasRunning = (await this.queryRuntimeStatus(entry)) === 'running';
|
|
498
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), `bkp-${appName}-`));
|
|
499
|
+
try {
|
|
500
|
+
if (wasRunning) {
|
|
501
|
+
log(`Stopping "${appName}" for a consistent snapshot`);
|
|
502
|
+
this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
|
|
503
|
+
}
|
|
504
|
+
const volDir = path.join(staging, 'volumes');
|
|
505
|
+
fs.mkdirSync(volDir, { recursive: true });
|
|
506
|
+
for (const vol of volumes) {
|
|
507
|
+
log(`Archiving volume "${vol}"`);
|
|
508
|
+
this.run([
|
|
509
|
+
'docker', 'run', '--rm',
|
|
510
|
+
'-v', `${appName}_${vol}:/data:ro`,
|
|
511
|
+
'-v', `${volDir}:/backup`,
|
|
512
|
+
BACKUP_HELPER_IMAGE,
|
|
513
|
+
'tar', 'czf', `/backup/${vol}.tar.gz`, '-C', '/data', '.',
|
|
514
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
515
|
+
}
|
|
516
|
+
// Bind-mount data dirs under the app dir (not Docker named volumes).
|
|
517
|
+
// Archive each with the same root-helper tar so ownership (e.g. the
|
|
518
|
+
// postgres uid) survives without host-side cp/sudo. Indexed filenames
|
|
519
|
+
// avoid collisions between paths that flatten to the same name.
|
|
520
|
+
const bindDir = path.join(staging, 'binds');
|
|
521
|
+
fs.mkdirSync(bindDir, { recursive: true });
|
|
522
|
+
const capturedBinds = [];
|
|
523
|
+
for (const rel of bindMounts) {
|
|
524
|
+
const abs = path.join(appDir, rel);
|
|
525
|
+
if (!fs.existsSync(abs)) {
|
|
526
|
+
log(`Bind mount "${rel}" missing on disk — skipping`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
log(`Archiving bind mount "${rel}"`);
|
|
530
|
+
this.run([
|
|
531
|
+
'docker', 'run', '--rm',
|
|
532
|
+
'-v', `${abs}:/data:ro`,
|
|
533
|
+
'-v', `${bindDir}:/backup`,
|
|
534
|
+
BACKUP_HELPER_IMAGE,
|
|
535
|
+
'tar', 'czf', `/backup/bind-${capturedBinds.length}.tar.gz`, '-C', '/data', '.',
|
|
536
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
537
|
+
capturedBinds.push(rel);
|
|
538
|
+
}
|
|
539
|
+
// Config (owned by the gateway user, copied on the host).
|
|
540
|
+
const cfgDir = path.join(staging, 'config');
|
|
541
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
542
|
+
for (const f of ['.env', 'app.yaml', 'docker-compose.yml']) {
|
|
543
|
+
this.copyIfExists(path.join(appDir, f), path.join(cfgDir, f));
|
|
544
|
+
}
|
|
545
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto
|
|
546
|
+
.randomUUID()
|
|
547
|
+
.slice(0, 8)}`;
|
|
548
|
+
const meta = {
|
|
549
|
+
id,
|
|
550
|
+
appName,
|
|
551
|
+
appVersion: entry.version,
|
|
552
|
+
createdAt: new Date().toISOString(),
|
|
553
|
+
volumes,
|
|
554
|
+
bindMounts: capturedBinds,
|
|
555
|
+
sizeBytes: 0,
|
|
556
|
+
};
|
|
557
|
+
fs.writeFileSync(path.join(staging, 'metadata.json'), JSON.stringify(meta, null, 2));
|
|
558
|
+
const outDir = this.appBackupDir(appName);
|
|
559
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
560
|
+
const archivePath = path.join(outDir, `${id}.tar.gz`);
|
|
561
|
+
// The gateway host tars the staging tree. Volume tarballs written by the
|
|
562
|
+
// root helper are world-readable (0644), so this read succeeds without sudo.
|
|
563
|
+
this.run(['tar', 'czf', archivePath, '-C', staging, '.'], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
564
|
+
let sizeBytes = 0;
|
|
565
|
+
try {
|
|
566
|
+
sizeBytes = fs.statSync(archivePath).size;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
/* archive stat failed — leave size 0 */
|
|
570
|
+
}
|
|
571
|
+
meta.sizeBytes = sizeBytes;
|
|
572
|
+
fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(meta, null, 2));
|
|
573
|
+
this.pruneBackups(appName);
|
|
574
|
+
log(`Backup "${id}" complete (${sizeBytes} bytes, ${volumes.length} volume(s), ` +
|
|
575
|
+
`${capturedBinds.length} bind mount(s))`);
|
|
576
|
+
return { id, createdAt: meta.createdAt, sizeBytes, appVersion: meta.appVersion };
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
try {
|
|
580
|
+
this.rmrf(staging);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* best-effort staging cleanup */
|
|
584
|
+
}
|
|
585
|
+
if (wasRunning) {
|
|
586
|
+
try {
|
|
587
|
+
log(`Restarting "${appName}"`);
|
|
588
|
+
this.composeUp(appName, appDir);
|
|
589
|
+
}
|
|
590
|
+
catch (restartErr) {
|
|
591
|
+
log(`WARNING: failed to restart "${appName}" after backup: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Core restore routine. Extracts the archive, wipes+repopulates each volume
|
|
598
|
+
* via the root helper (preserving inner ownership), restores `.env`, and
|
|
599
|
+
* starts the app on the restored data. Restoring across a differing
|
|
600
|
+
* appVersion is allowed but warns (possible schema/migration mismatch).
|
|
601
|
+
*/
|
|
602
|
+
async performRestore(entry, backupId, logSink) {
|
|
603
|
+
const log = (m) => logSink?.(m);
|
|
604
|
+
if (!/^[\w.-]+$/.test(backupId))
|
|
605
|
+
throw new Error(`Invalid backup id "${backupId}"`);
|
|
606
|
+
const appName = entry.name;
|
|
607
|
+
const appDir = entry.installPath;
|
|
608
|
+
const archivePath = path.join(this.appBackupDir(appName), `${backupId}.tar.gz`);
|
|
609
|
+
if (!fs.existsSync(archivePath)) {
|
|
610
|
+
throw new Error(`Backup "${backupId}" not found for app "${appName}"`);
|
|
611
|
+
}
|
|
612
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), `rst-${appName}-`));
|
|
613
|
+
try {
|
|
614
|
+
this.run(['tar', 'xzf', archivePath, '-C', staging], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
615
|
+
const meta = JSON.parse(fs.readFileSync(path.join(staging, 'metadata.json'), 'utf-8'));
|
|
616
|
+
if (meta.appVersion !== entry.version) {
|
|
617
|
+
log(`WARNING: restoring backup from version "${meta.appVersion}" onto installed "${entry.version}" — possible schema/migration mismatch`);
|
|
618
|
+
}
|
|
619
|
+
log(`Stopping "${appName}" before restore`);
|
|
620
|
+
try {
|
|
621
|
+
this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
/* may already be stopped */
|
|
625
|
+
}
|
|
626
|
+
const volDir = path.join(staging, 'volumes');
|
|
627
|
+
for (const vol of meta.volumes) {
|
|
628
|
+
const tarball = path.join(volDir, `${vol}.tar.gz`);
|
|
629
|
+
if (!fs.existsSync(tarball)) {
|
|
630
|
+
log(`WARNING: volume "${vol}" missing from backup — skipping`);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
log(`Restoring volume "${vol}"`);
|
|
634
|
+
this.run([
|
|
635
|
+
'docker', 'run', '--rm',
|
|
636
|
+
'-v', `${appName}_${vol}:/data`,
|
|
637
|
+
'-v', `${volDir}:/backup`,
|
|
638
|
+
BACKUP_HELPER_IMAGE,
|
|
639
|
+
'sh', '-c',
|
|
640
|
+
// Wipe the live volume, then untar the snapshot (uid/gid preserved).
|
|
641
|
+
`rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/${vol}.tar.gz -C /data`,
|
|
642
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
643
|
+
}
|
|
644
|
+
// Restore bind-mount data dirs under the app dir. Indexed filenames match
|
|
645
|
+
// performBackup's capture order. Each restored path is re-checked to stay
|
|
646
|
+
// under the app dir (defense-in-depth against a tampered metadata path).
|
|
647
|
+
const bindDir = path.join(staging, 'binds');
|
|
648
|
+
const bindMounts = meta.bindMounts ?? [];
|
|
649
|
+
bindMounts.forEach((rel, i) => {
|
|
650
|
+
const abs = path.resolve(appDir, rel);
|
|
651
|
+
const base = appDir.endsWith(path.sep) ? appDir : appDir + path.sep;
|
|
652
|
+
if (abs !== appDir && !abs.startsWith(base)) {
|
|
653
|
+
log(`WARNING: bind mount "${rel}" escapes the app dir — skipping`);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const tarball = path.join(bindDir, `bind-${i}.tar.gz`);
|
|
657
|
+
if (!fs.existsSync(tarball)) {
|
|
658
|
+
log(`WARNING: bind mount "${rel}" missing from backup — skipping`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
log(`Restoring bind mount "${rel}"`);
|
|
662
|
+
this.run([
|
|
663
|
+
'docker', 'run', '--rm',
|
|
664
|
+
'-v', `${abs}:/data`,
|
|
665
|
+
'-v', `${bindDir}:/backup`,
|
|
666
|
+
BACKUP_HELPER_IMAGE,
|
|
667
|
+
'sh', '-c',
|
|
668
|
+
// Wipe the live bind dir, then untar the snapshot (uid/gid preserved).
|
|
669
|
+
`rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/bind-${i}.tar.gz -C /data`,
|
|
670
|
+
], undefined, VOLUME_TAR_TIMEOUT_MS);
|
|
671
|
+
});
|
|
672
|
+
// Restore config that carries generated secrets, so the app boots with the
|
|
673
|
+
// same credentials the volume data was created under.
|
|
674
|
+
this.copyIfExists(path.join(staging, 'config', '.env'), path.join(appDir, '.env'));
|
|
675
|
+
log(`Starting "${appName}" on restored data`);
|
|
676
|
+
this.composeUp(appName, appDir);
|
|
677
|
+
await this.registry.updateStatus(appName, 'running').catch(() => { });
|
|
678
|
+
log(`Restore of "${backupId}" complete`);
|
|
679
|
+
return {
|
|
680
|
+
id: meta.id,
|
|
681
|
+
createdAt: meta.createdAt,
|
|
682
|
+
sizeBytes: meta.sizeBytes,
|
|
683
|
+
appVersion: meta.appVersion,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
try {
|
|
688
|
+
this.rmrf(staging);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
/* best-effort */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* List an app's compose-level named volumes (the top-level `volumes:` keys).
|
|
697
|
+
* Returns [] when the app declares none or the query fails — a config-only
|
|
698
|
+
* backup is still useful (it captures `.env`).
|
|
699
|
+
*/
|
|
700
|
+
discoverVolumes(appName, appDir) {
|
|
701
|
+
try {
|
|
702
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--volumes'], appDir, 30000);
|
|
703
|
+
return stdout
|
|
704
|
+
.split('\n')
|
|
705
|
+
.map((s) => s.trim())
|
|
706
|
+
.filter((s) => s.length > 0);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Discover bind-mount source directories that live **under the app dir**
|
|
714
|
+
* (e.g. `./data/photos` → `data/photos`). These hold app-owned data that is
|
|
715
|
+
* not a Docker named volume, so {@link discoverVolumes} never reports them —
|
|
716
|
+
* yet they are deleted on uninstall and must be captured in a backup.
|
|
717
|
+
*
|
|
718
|
+
* Returns app-dir-relative POSIX paths, deduped and sorted. Bind mounts whose
|
|
719
|
+
* source resolves outside the app dir (shared host resources such as a
|
|
720
|
+
* read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
|
|
721
|
+
* returns `[]` on any failure, mirroring {@link discoverVolumes}.
|
|
722
|
+
*/
|
|
723
|
+
discoverBindMounts(appName, appDir) {
|
|
724
|
+
try {
|
|
725
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
|
|
726
|
+
const parsed = JSON.parse(stdout);
|
|
727
|
+
// Local-dev installs symlink the app dir into appsDir, and the generated
|
|
728
|
+
// compose resolves bind sources against the symlink's *realpath*. Match a
|
|
729
|
+
// source that sits under either the symlink path or its target, so those
|
|
730
|
+
// bind mounts are not wrongly excluded.
|
|
731
|
+
const bases = [appDir];
|
|
732
|
+
try {
|
|
733
|
+
const real = fs.realpathSync(appDir);
|
|
734
|
+
if (real !== appDir)
|
|
735
|
+
bases.push(real);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
/* app dir unreadable — fall back to the literal path */
|
|
739
|
+
}
|
|
740
|
+
const rels = new Set();
|
|
741
|
+
for (const svc of Object.values(parsed.services ?? {})) {
|
|
742
|
+
for (const vol of svc.volumes ?? []) {
|
|
743
|
+
if (vol.type !== 'bind' || typeof vol.source !== 'string')
|
|
744
|
+
continue;
|
|
745
|
+
const abs = path.resolve(appDir, vol.source);
|
|
746
|
+
const matched = bases.find((b) => abs === b || abs.startsWith(b + path.sep));
|
|
747
|
+
if (!matched)
|
|
748
|
+
continue; // outside the app dir
|
|
749
|
+
const rel = path.relative(matched, abs);
|
|
750
|
+
if (rel.length > 0)
|
|
751
|
+
rels.add(rel.split(path.sep).join('/'));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return Array.from(rels).sort();
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return [];
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Prune an app's backups by the union policy (issue #310): a backup is
|
|
762
|
+
* deleted when it is beyond the retention count **OR** older than
|
|
763
|
+
* `maxAgeDays` — whichever matches. `retention === 0` disables the count cap;
|
|
764
|
+
* `maxAgeDays === 0` disables the age cap. Runs after each successful backup
|
|
765
|
+
* and from the daily scheduler.
|
|
766
|
+
*/
|
|
767
|
+
pruneBackups(appName, now = Date.now()) {
|
|
768
|
+
const { retention, maxAgeDays } = this.appBackupConfig;
|
|
769
|
+
if (retention <= 0 && maxAgeDays <= 0)
|
|
770
|
+
return; // both caps disabled
|
|
771
|
+
const all = this.listBackups(appName); // newest first
|
|
772
|
+
const doomed = new Set();
|
|
773
|
+
// Count cap: everything past the N newest.
|
|
774
|
+
if (retention > 0) {
|
|
775
|
+
for (const stale of all.slice(retention))
|
|
776
|
+
doomed.add(stale.id);
|
|
777
|
+
}
|
|
778
|
+
// Age cap: anything older than the cutoff (union — dedupe via the set).
|
|
779
|
+
if (maxAgeDays > 0) {
|
|
780
|
+
const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
781
|
+
for (const b of all) {
|
|
782
|
+
const created = Date.parse(b.createdAt);
|
|
783
|
+
if (!Number.isNaN(created) && created < cutoff)
|
|
784
|
+
doomed.add(b.id);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
for (const id of doomed)
|
|
788
|
+
this.deleteBackup(appName, id);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Prune **every** app's backups by the union policy. Enumerates the backup
|
|
792
|
+
* subdirs under `.backups/` (skipping anything that is not a valid app name),
|
|
793
|
+
* so it also reaches apps that are no longer being backed up. Best-effort:
|
|
794
|
+
* a failure on one app never aborts the sweep.
|
|
795
|
+
*/
|
|
796
|
+
cleanupAllBackups(now = Date.now()) {
|
|
797
|
+
const { retention, maxAgeDays } = this.appBackupConfig;
|
|
798
|
+
if (retention <= 0 && maxAgeDays <= 0)
|
|
799
|
+
return; // both caps disabled
|
|
800
|
+
let entries;
|
|
801
|
+
try {
|
|
802
|
+
entries = fs.readdirSync(this.backupsDir, { withFileTypes: true });
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return; // no backups dir yet — nothing to prune
|
|
806
|
+
}
|
|
807
|
+
for (const entry of entries) {
|
|
808
|
+
if (!entry.isDirectory())
|
|
809
|
+
continue;
|
|
810
|
+
if (!APP_NAME_RE.test(entry.name))
|
|
811
|
+
continue; // skip stray dirs
|
|
812
|
+
try {
|
|
813
|
+
this.pruneBackups(entry.name, now);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
/* best-effort — one bad app must not abort the sweep */
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Start the daily backup-cleanup scheduler (issue #310). Fires once per day at
|
|
822
|
+
* `cleanupHour` in `cleanupTimezone`, pruning all apps by the union policy.
|
|
823
|
+
* Returns a cancel function. No-op (returns a noop canceller) when both caps
|
|
824
|
+
* are disabled. The timer is `unref`'d so it never holds the event loop open.
|
|
825
|
+
*/
|
|
826
|
+
startBackupCleanup() {
|
|
827
|
+
const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
|
|
828
|
+
if (retention <= 0 && maxAgeDays <= 0)
|
|
829
|
+
return () => { };
|
|
830
|
+
let timer;
|
|
831
|
+
const schedule = () => {
|
|
832
|
+
const delay = (0, cleanup_1.msUntilNextHour)(cleanupHour, cleanupTimezone);
|
|
833
|
+
timer = setTimeout(() => {
|
|
834
|
+
try {
|
|
835
|
+
this.cleanupAllBackups();
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
/* best-effort */
|
|
839
|
+
}
|
|
840
|
+
schedule(); // reschedule for the next day
|
|
841
|
+
}, delay);
|
|
842
|
+
if (typeof timer.unref === 'function') {
|
|
843
|
+
timer.unref();
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
schedule();
|
|
847
|
+
return () => clearTimeout(timer);
|
|
848
|
+
}
|
|
849
|
+
appBackupDir(appName) {
|
|
850
|
+
// Never trust the name reaching the filesystem: an unvalidated value (e.g.
|
|
851
|
+
// a `%2F`-smuggled `../../x` from a route param) would let path.join escape
|
|
852
|
+
// the backups tree. Every backup op funnels through here, so this one guard
|
|
853
|
+
// covers backup/restore/list/delete.
|
|
854
|
+
if (!APP_NAME_RE.test(appName))
|
|
855
|
+
throw new Error(`Invalid app name "${appName}"`);
|
|
856
|
+
return path.join(this.backupsDir, appName);
|
|
857
|
+
}
|
|
858
|
+
copyIfExists(src, dest) {
|
|
859
|
+
try {
|
|
860
|
+
if (fs.existsSync(src))
|
|
861
|
+
fs.copyFileSync(src, dest);
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
/* best-effort — a missing/unreadable optional file is not fatal */
|
|
865
|
+
}
|
|
866
|
+
}
|
|
294
867
|
async startStopRestart(appName, action) {
|
|
295
868
|
const entry = await this.registry.get(appName);
|
|
296
869
|
if (!entry)
|
|
@@ -570,8 +1143,21 @@ class AppInstaller {
|
|
|
570
1143
|
this.log(job, `Warning: ${w}`);
|
|
571
1144
|
}
|
|
572
1145
|
// ── Write .env ────────────────────────────────────────────────────────
|
|
1146
|
+
// Carry over an existing .env before writing, but ONLY for a local
|
|
1147
|
+
// (symlinked) install. A local install can re-point at a source tree that
|
|
1148
|
+
// still holds a prior .env alongside persisted data (e.g. a postgres pgdata
|
|
1149
|
+
// bind mount). writeEnvFile treats an already-present generated secret as
|
|
1150
|
+
// pinned, so reusing the existing .env keeps DB_PASSWORD/etc. stable and
|
|
1151
|
+
// lets the app reconnect to that data instead of failing auth (mirrors the
|
|
1152
|
+
// reconfigure path). This is deliberately scoped to `source === 'local'`:
|
|
1153
|
+
// a registry/GitHub install checks out into appDir, and a repo that
|
|
1154
|
+
// committed a `.env` would otherwise get its secrets pinned to committed
|
|
1155
|
+
// values — so for those sources we ignore any checked-out .env and generate
|
|
1156
|
+
// fresh, unchanged from before. Operator-supplied envVars still win.
|
|
573
1157
|
this.log(job, 'Writing .env');
|
|
574
|
-
const
|
|
1158
|
+
const existingEnv = source === 'local' ? this.readEnvFile(appDir) : {};
|
|
1159
|
+
const mergedEnv = { ...existingEnv, ...(options.envVars ?? {}) };
|
|
1160
|
+
const generatedNames = this.writeEnvFile(appDir, appName, generated, mergedEnv);
|
|
575
1161
|
if (generatedNames.length > 0) {
|
|
576
1162
|
this.log(job, `Generated secrets: ${generatedNames.join(', ')}`);
|
|
577
1163
|
}
|
|
@@ -665,6 +1251,9 @@ class AppInstaller {
|
|
|
665
1251
|
// ── Update status to running ──────────────────────────────────────────
|
|
666
1252
|
await this.registry.updateStatus(appName, 'running');
|
|
667
1253
|
this.log(job, 'Containers healthy');
|
|
1254
|
+
// ── Housekeeping: reclaim leaked build cache + dangling images ────────
|
|
1255
|
+
// Best-effort, config-gated, after the new stack is up (issue #302).
|
|
1256
|
+
this.pruneAfterBuild(job);
|
|
668
1257
|
// ── Register proxy routes ─────────────────────────────────────────────
|
|
669
1258
|
this.callbacks.registerRoutes(appName, generated.ports);
|
|
670
1259
|
// ── Build result ──────────────────────────────────────────────────────
|
|
@@ -743,6 +1332,17 @@ class AppInstaller {
|
|
|
743
1332
|
return;
|
|
744
1333
|
}
|
|
745
1334
|
this.log(job, `Updating ${appName} ${entry.commit.slice(0, 8)} → ${target.newCommit.slice(0, 8)}`);
|
|
1335
|
+
// Safety hook: snapshot before the update so a bad new image can be rolled
|
|
1336
|
+
// back. Best-effort — a backup failure must not block the update.
|
|
1337
|
+
if (this.appBackupConfig.autoBackupBeforeUpdate) {
|
|
1338
|
+
try {
|
|
1339
|
+
const info = await this.performBackup(entry, (m) => this.log(job, m));
|
|
1340
|
+
this.log(job, `Pre-update backup "${info.id}" created`);
|
|
1341
|
+
}
|
|
1342
|
+
catch (err) {
|
|
1343
|
+
this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
746
1346
|
const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
|
|
747
1347
|
try {
|
|
748
1348
|
// ── Shallow fetch of specific commit into tmp dir ─────────────────────
|
|
@@ -802,6 +1402,9 @@ class AppInstaller {
|
|
|
802
1402
|
// we can reclaim exactly those images after the update — without a
|
|
803
1403
|
// `compose down` that would collide with the new stack (issue #283).
|
|
804
1404
|
const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
|
|
1405
|
+
// Also capture the app's declared image refs (repo:tag) so a superseded
|
|
1406
|
+
// *pulled* tag can be reclaimed after the update (issue #302).
|
|
1407
|
+
const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
|
|
805
1408
|
// ── Bring old containers down (keeps images for rollback) ─────────────
|
|
806
1409
|
this.log(job, 'Stopping old containers');
|
|
807
1410
|
this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
|
|
@@ -840,6 +1443,7 @@ class AppInstaller {
|
|
|
840
1443
|
// below never removes an image the new containers depend on (e.g. when the
|
|
841
1444
|
// old and new versions happen to share a base/image).
|
|
842
1445
|
const newImageIds = this.captureComposeImageIds(appName, tmpDir);
|
|
1446
|
+
const newImageRefs = this.captureComposeImageRefs(appName, tmpDir);
|
|
843
1447
|
// ── Swap dirs ─────────────────────────────────────────────────────────
|
|
844
1448
|
// Swap in place at the recorded install path — NOT path.join(appsDir, appName).
|
|
845
1449
|
// For legacy installs the on-disk dir is named after the source repo/URL
|
|
@@ -892,7 +1496,14 @@ class AppInstaller {
|
|
|
892
1496
|
}
|
|
893
1497
|
catch { /* still in use or already gone — non-fatal */ }
|
|
894
1498
|
}
|
|
1499
|
+
// Reclaim a superseded *pulled* tag the image-ID reclaim above can't
|
|
1500
|
+
// (a pulled image with >1 repo tag isn't removable by ID) — only this
|
|
1501
|
+
// app's own prior refs the new stack no longer uses (issue #302).
|
|
1502
|
+
this.reclaimSupersededTags(job, oldImageRefs, newImageRefs);
|
|
895
1503
|
this.safeRmrf(oldBackupDir, job, 'old backup dir');
|
|
1504
|
+
// ── Housekeeping: reclaim leaked build cache + dangling images ────────
|
|
1505
|
+
// Best-effort, config-gated, after the image reclaim (issue #302).
|
|
1506
|
+
this.pruneAfterBuild(job);
|
|
896
1507
|
// ── Build result ──────────────────────────────────────────────────────
|
|
897
1508
|
const proxyUrls = {};
|
|
898
1509
|
for (const p of generated.ports) {
|
|
@@ -1143,9 +1754,16 @@ class AppInstaller {
|
|
|
1143
1754
|
}
|
|
1144
1755
|
}
|
|
1145
1756
|
const envLines = [];
|
|
1757
|
+
const secretDefaults = generated.secretDefaults ?? {};
|
|
1146
1758
|
for (const key of generated.secretKeys) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1759
|
+
// Precedence: operator-supplied value → declared default → empty. An
|
|
1760
|
+
// empty operator value (UI always sends the field, possibly blank) falls
|
|
1761
|
+
// through to the default, matching how generated keys treat '' as unset.
|
|
1762
|
+
const provided = merged[key];
|
|
1763
|
+
const raw = provided !== undefined && provided !== ''
|
|
1764
|
+
? provided
|
|
1765
|
+
: secretDefaults[key] ?? '';
|
|
1766
|
+
envLines.push(`${key}=${raw.replace(/[\r\n]/g, '')}`);
|
|
1149
1767
|
}
|
|
1150
1768
|
const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
|
|
1151
1769
|
const generatedNames = [];
|
|
@@ -1453,6 +2071,152 @@ class AppInstaller {
|
|
|
1453
2071
|
return [];
|
|
1454
2072
|
}
|
|
1455
2073
|
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Image references (repo:tag) an app's compose file declares, e.g.
|
|
2076
|
+
* "ghcr.io/x/monitor:1.1". Used to reclaim a superseded *pulled* tag on
|
|
2077
|
+
* update — a tag bump the image-ID reclaim misses, because a pulled image
|
|
2078
|
+
* carrying more than one repo tag cannot be removed by ID (issue #302).
|
|
2079
|
+
* Best-effort — returns [] on any error.
|
|
2080
|
+
*/
|
|
2081
|
+
captureComposeImageRefs(appName, dir) {
|
|
2082
|
+
try {
|
|
2083
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--images'], dir, 15000);
|
|
2084
|
+
return [...new Set(stdout.trim().split('\n').map((s) => s.trim()).filter(Boolean))];
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
return [];
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
// ─── Docker housekeeping (issue #302) ───────────────────────────────────────
|
|
2091
|
+
/** Resolve the housekeeping toggles against their conservative defaults. */
|
|
2092
|
+
resolveHousekeeping() {
|
|
2093
|
+
const hk = this.housekeepingConfig ?? {};
|
|
2094
|
+
return {
|
|
2095
|
+
buildCachePrune: hk.buildCachePrune ?? true,
|
|
2096
|
+
buildCacheMaxAgeHours: hk.buildCacheMaxAgeHours ?? 168,
|
|
2097
|
+
danglingImagePrune: hk.danglingImagePrune ?? true,
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Best-effort Docker housekeeping after a successful build (install + update).
|
|
2102
|
+
* Reclaims ONLY provably-unreferenced junk:
|
|
2103
|
+
* - build cache older than the configured window — time-filtered on purpose
|
|
2104
|
+
* so a concurrent build's fresh layers are never evicted;
|
|
2105
|
+
* - dangling `<none>` images with no container (safe by definition).
|
|
2106
|
+
* Gated by config (all toggles off ⇒ no prune calls). Every prune is wrapped
|
|
2107
|
+
* so a failure NEVER fails the parent install/update. Enforces the safety
|
|
2108
|
+
* floor: no `-a`, no `system prune`, no volume prune (issue #302).
|
|
2109
|
+
*/
|
|
2110
|
+
pruneAfterBuild(job) {
|
|
2111
|
+
const hk = this.resolveHousekeeping();
|
|
2112
|
+
if (hk.buildCachePrune) {
|
|
2113
|
+
try {
|
|
2114
|
+
this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
|
|
2115
|
+
this.log(job, `Build cache pruned (older than ${hk.buildCacheMaxAgeHours}h)`);
|
|
2116
|
+
}
|
|
2117
|
+
catch (err) {
|
|
2118
|
+
this.log(job, `Build-cache prune skipped (non-fatal): ${err.message}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (hk.danglingImagePrune) {
|
|
2122
|
+
try {
|
|
2123
|
+
// `image prune -f` (NEVER `-a`) — removes only untagged <none> layers
|
|
2124
|
+
// with no container, so tagged images of other stopped apps survive.
|
|
2125
|
+
this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
|
|
2126
|
+
this.log(job, 'Dangling images pruned');
|
|
2127
|
+
}
|
|
2128
|
+
catch (err) {
|
|
2129
|
+
this.log(job, `Dangling-image prune skipped (non-fatal): ${err.message}`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Reclaim this app's own superseded pulled tags after an update (issue #302).
|
|
2135
|
+
* The image-ID reclaim misses a pulled-tag bump (e.g. monitor:1.1 → :1.2.0):
|
|
2136
|
+
* a pulled image with more than one repo tag can't be removed by ID. Remove
|
|
2137
|
+
* exactly the app's prior refs that the NEW stack no longer references.
|
|
2138
|
+
* `image rm <ref>` fails safely (and is caught) when a container still uses
|
|
2139
|
+
* the image, so an in-use image is never yanked. Only this app's own tags are
|
|
2140
|
+
* ever touched — never a blanket prune.
|
|
2141
|
+
*/
|
|
2142
|
+
reclaimSupersededTags(job, oldImageRefs, newImageRefs) {
|
|
2143
|
+
const newRefSet = new Set(newImageRefs);
|
|
2144
|
+
for (const ref of oldImageRefs) {
|
|
2145
|
+
if (newRefSet.has(ref))
|
|
2146
|
+
continue; // still used by new stack — keep
|
|
2147
|
+
try {
|
|
2148
|
+
this.run(['docker', 'image', 'rm', ref], os.tmpdir(), 60000);
|
|
2149
|
+
this.log(job, `Reclaimed superseded image tag ${ref}`);
|
|
2150
|
+
}
|
|
2151
|
+
catch {
|
|
2152
|
+
/* in use / already gone — non-fatal */
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/** Read-only reclaim report (issue #302). Best-effort; never mutates state. */
|
|
2157
|
+
housekeepingReport() {
|
|
2158
|
+
return this.buildHousekeepingReport();
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Execute the SAFE reclaim (build cache + dangling images only) and return a
|
|
2162
|
+
* fresh report. This is an explicit operator action, so it runs regardless of
|
|
2163
|
+
* the auto-path config toggles — but it still honors the fixed safety floor:
|
|
2164
|
+
* never `-a`, never `system prune`, never a volume/auto delete. The build-cache
|
|
2165
|
+
* window uses the configured value (default 168h).
|
|
2166
|
+
*/
|
|
2167
|
+
housekeepingPrune() {
|
|
2168
|
+
const hk = this.resolveHousekeeping();
|
|
2169
|
+
const pruned = { buildCache: false, danglingImages: false };
|
|
2170
|
+
try {
|
|
2171
|
+
this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
|
|
2172
|
+
pruned.buildCache = true;
|
|
2173
|
+
}
|
|
2174
|
+
catch {
|
|
2175
|
+
/* best-effort */
|
|
2176
|
+
}
|
|
2177
|
+
try {
|
|
2178
|
+
this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
|
|
2179
|
+
pruned.danglingImages = true;
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
/* best-effort */
|
|
2183
|
+
}
|
|
2184
|
+
return { mode: 'prune', pruned, report: this.buildHousekeepingReport() };
|
|
2185
|
+
}
|
|
2186
|
+
buildHousekeepingReport() {
|
|
2187
|
+
return {
|
|
2188
|
+
buildCacheReclaimable: this.readBuildCacheReclaimable(),
|
|
2189
|
+
danglingImageCount: this.splitLines(this.safeRunStdout(['docker', 'image', 'ls', '--filter', 'dangling=true', '--quiet'])).length,
|
|
2190
|
+
orphanVolumes: this.splitLines(this.safeRunStdout(['docker', 'volume', 'ls', '--filter', 'dangling=true', '--quiet'])),
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
/** `docker` stdout, or '' on any error (report helpers must never throw). */
|
|
2194
|
+
safeRunStdout(args, timeoutMs = 30000) {
|
|
2195
|
+
try {
|
|
2196
|
+
return this.run(args, os.tmpdir(), timeoutMs).stdout;
|
|
2197
|
+
}
|
|
2198
|
+
catch {
|
|
2199
|
+
return '';
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
splitLines(s) {
|
|
2203
|
+
return s.trim().split('\n').map((x) => x.trim()).filter(Boolean);
|
|
2204
|
+
}
|
|
2205
|
+
/** Reclaimable build-cache size from the `docker system df` "Build Cache" row. */
|
|
2206
|
+
readBuildCacheReclaimable() {
|
|
2207
|
+
const out = this.safeRunStdout([
|
|
2208
|
+
'docker', 'system', 'df', '--format', '{{.Type}}\t{{.Reclaimable}}',
|
|
2209
|
+
]);
|
|
2210
|
+
for (const line of this.splitLines(out)) {
|
|
2211
|
+
const tab = line.indexOf('\t');
|
|
2212
|
+
if (tab < 0)
|
|
2213
|
+
continue;
|
|
2214
|
+
const type = line.slice(0, tab).trim().toLowerCase();
|
|
2215
|
+
if (type.includes('build cache'))
|
|
2216
|
+
return line.slice(tab + 1).trim();
|
|
2217
|
+
}
|
|
2218
|
+
return '';
|
|
2219
|
+
}
|
|
1456
2220
|
run(args, cwd, timeoutMs = 30000) {
|
|
1457
2221
|
const opts = {
|
|
1458
2222
|
encoding: 'utf-8',
|