mercury-agent 0.8.8 → 0.8.9
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/package.json +1 -1
- package/src/extensions/image-builder.ts +139 -24
package/package.json
CHANGED
|
@@ -415,42 +415,148 @@ function extImageRepo(agentId: string | undefined): string {
|
|
|
415
415
|
return agentId ? `mercury-agent-ext-${agentId}` : "mercury-agent-ext";
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Runs a `docker` subcommand and returns stdout. Throws on non-zero exit,
|
|
420
|
+
* with the error message carrying the tail of stderr for diagnosis.
|
|
421
|
+
*
|
|
422
|
+
* Injected into the prune helpers so their logic (container reaping, flag
|
|
423
|
+
* fallback, failure logging) is unit-testable without a real Docker daemon.
|
|
424
|
+
*/
|
|
425
|
+
export type DockerRun = (args: string[], timeoutMs?: number) => string;
|
|
426
|
+
|
|
427
|
+
const realDockerRun: DockerRun = (args, timeoutMs = 30_000) => {
|
|
428
|
+
try {
|
|
429
|
+
return execFileSync("docker", args, {
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
timeout: timeoutMs,
|
|
432
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
433
|
+
});
|
|
434
|
+
} catch (err) {
|
|
435
|
+
// execFileSync throws an Error with `.stderr` populated (we piped it).
|
|
436
|
+
// Fold stderr into the message so callers logging `err.message` see why.
|
|
437
|
+
const stderr =
|
|
438
|
+
err && typeof err === "object" && "stderr" in err
|
|
439
|
+
? String((err as { stderr: unknown }).stderr ?? "").trim()
|
|
440
|
+
: "";
|
|
441
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
442
|
+
throw new Error(stderr ? `${base}: ${stderr}` : base);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const errMsg = (err: unknown): string =>
|
|
447
|
+
err instanceof Error ? err.message : String(err);
|
|
448
|
+
|
|
418
449
|
/**
|
|
419
450
|
* Remove all derived images for this agent except the one with `keepHash`.
|
|
420
|
-
*
|
|
451
|
+
*
|
|
452
|
+
* A stale image can be pinned by a leftover *stopped* container (e.g. one that
|
|
453
|
+
* survived an abnormal exit where `--rm` never reaped it). When the first
|
|
454
|
+
* `docker rmi` fails we remove the stopped containers referencing that image
|
|
455
|
+
* (plain `docker rm`, which never touches a *running* container) and retry.
|
|
456
|
+
* If the image is still pinned — a container is genuinely running on it — we
|
|
457
|
+
* `log.warn` and move on rather than swallowing the failure silently, so the
|
|
458
|
+
* accumulation is visible in logs.
|
|
421
459
|
*/
|
|
422
|
-
function pruneStaleExtImages(
|
|
460
|
+
export function pruneStaleExtImages(
|
|
423
461
|
keepHash: string,
|
|
424
462
|
repo: string,
|
|
425
463
|
log: Logger,
|
|
464
|
+
run: DockerRun = realDockerRun,
|
|
426
465
|
): void {
|
|
466
|
+
let tags: string[];
|
|
427
467
|
try {
|
|
428
|
-
const out =
|
|
429
|
-
|
|
430
|
-
["images", repo, "--format", "{{.Tag}}"],
|
|
431
|
-
{ encoding: "utf8", timeout: 30_000 },
|
|
432
|
-
);
|
|
433
|
-
const tags = out
|
|
468
|
+
const out = run(["images", repo, "--format", "{{.Tag}}"]);
|
|
469
|
+
tags = out
|
|
434
470
|
.split("\n")
|
|
435
471
|
.map((t) => t.trim())
|
|
436
472
|
.filter((t) => t && t !== "<none>");
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
473
|
+
} catch (err) {
|
|
474
|
+
log.warn(`Could not list ext images for pruning: ${errMsg(err)}`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const tag of tags) {
|
|
479
|
+
if (tag === keepHash) continue;
|
|
480
|
+
const image = `${repo}:${tag}`;
|
|
481
|
+
try {
|
|
482
|
+
run(["rmi", image]);
|
|
483
|
+
log.info(`Pruned stale ext image ${image}`);
|
|
484
|
+
continue;
|
|
485
|
+
} catch {
|
|
486
|
+
// Likely pinned by a leftover container — reap stopped ones and retry.
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const ids = run(["ps", "-aq", "--filter", `ancestor=${image}`])
|
|
490
|
+
.split("\n")
|
|
491
|
+
.map((s) => s.trim())
|
|
492
|
+
.filter(Boolean);
|
|
493
|
+
if (ids.length > 0) {
|
|
494
|
+
// Plain `rm` (no -f): removes stopped containers, errors on running
|
|
495
|
+
// ones without killing them. Best-effort — ignore its failure and
|
|
496
|
+
// let the retry `rmi` be the source of truth.
|
|
497
|
+
try {
|
|
498
|
+
run(["rm", ...ids]);
|
|
499
|
+
} catch {
|
|
500
|
+
// Some referenced container is still running; it stays alive.
|
|
501
|
+
}
|
|
448
502
|
}
|
|
503
|
+
run(["rmi", image]);
|
|
504
|
+
log.info(
|
|
505
|
+
`Pruned stale ext image ${image} (after reaping ${ids.length} stopped container(s))`,
|
|
506
|
+
);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
log.warn(
|
|
509
|
+
`Could not prune stale ext image ${image} (still referenced by a running container?): ${errMsg(err)}`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Default BuildKit cache size to retain after a successful build. */
|
|
516
|
+
const DEFAULT_BUILD_CACHE_RESERVED = "10GB";
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Bound the BuildKit build cache after a successful build so it doesn't grow
|
|
520
|
+
* without limit (observed reaching 27 GB in the field). Best-effort and
|
|
521
|
+
* non-fatal: any failure is logged and the build result is unaffected.
|
|
522
|
+
*
|
|
523
|
+
* Retained size is configurable via `MERCURY_BUILD_CACHE_RESERVED` (a docker
|
|
524
|
+
* size string like `10GB`); set it to `off`/`0` to disable pruning entirely.
|
|
525
|
+
*
|
|
526
|
+
* Docker renamed the retention flag: modern Docker (buildx) uses
|
|
527
|
+
* `--reserved-space`, older releases used `--keep-storage`. We try the modern
|
|
528
|
+
* flag first and fall back to the legacy one if it's unrecognised.
|
|
529
|
+
*/
|
|
530
|
+
export function pruneBuildCache(
|
|
531
|
+
log: Logger,
|
|
532
|
+
run: DockerRun = realDockerRun,
|
|
533
|
+
): void {
|
|
534
|
+
const configured = process.env.MERCURY_BUILD_CACHE_RESERVED?.trim();
|
|
535
|
+
const reserved = configured || DEFAULT_BUILD_CACHE_RESERVED;
|
|
536
|
+
if (reserved === "0" || reserved.toLowerCase() === "off") {
|
|
537
|
+
log.debug("Build cache prune disabled (MERCURY_BUILD_CACHE_RESERVED=off)");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const flags = ["--reserved-space", "--keep-storage"];
|
|
542
|
+
for (let i = 0; i < flags.length; i++) {
|
|
543
|
+
try {
|
|
544
|
+
run(["builder", "prune", "-f", `${flags[i]}=${reserved}`], 120_000);
|
|
545
|
+
log.info(`Pruned BuildKit build cache (${flags[i]}=${reserved})`);
|
|
546
|
+
return;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
const message = errMsg(err);
|
|
549
|
+
// Docker/cobra emits "unknown flag: --x" / "unknown shorthand flag" when
|
|
550
|
+
// a flag isn't recognised. Match only those — a genuine value/daemon
|
|
551
|
+
// error ("invalid argument …", "failed to prune …") must NOT trigger the
|
|
552
|
+
// legacy retry, so it surfaces as a real warning instead of being masked.
|
|
553
|
+
const unknownFlag = /unknown (flag|shorthand)/i.test(message);
|
|
554
|
+
// Only fall through to the legacy flag when the modern one is
|
|
555
|
+
// unrecognised; any other failure is real and shouldn't be masked.
|
|
556
|
+
if (unknownFlag && i < flags.length - 1) continue;
|
|
557
|
+
log.warn(`Could not prune build cache: ${message}`);
|
|
558
|
+
return;
|
|
449
559
|
}
|
|
450
|
-
} catch (err) {
|
|
451
|
-
log.warn(
|
|
452
|
-
`Could not list ext images for pruning: ${err instanceof Error ? err.message : String(err)}`,
|
|
453
|
-
);
|
|
454
560
|
}
|
|
455
561
|
}
|
|
456
562
|
|
|
@@ -596,7 +702,16 @@ export async function ensureDerivedImage(
|
|
|
596
702
|
const durationMs = Date.now() - startTime;
|
|
597
703
|
|
|
598
704
|
log.info(`Built derived agent image ${derivedTag}`, { durationMs });
|
|
599
|
-
|
|
705
|
+
// Post-build disk hygiene is strictly best-effort: the build already
|
|
706
|
+
// succeeded, so a prune failure must never turn this into the base-image
|
|
707
|
+
// fallback path. Both helpers are internally guarded; this outer catch is
|
|
708
|
+
// belt-and-suspenders so a future edit inside them can't break the success.
|
|
709
|
+
try {
|
|
710
|
+
pruneStaleExtImages(hash, repo, log);
|
|
711
|
+
pruneBuildCache(log);
|
|
712
|
+
} catch (pruneErr) {
|
|
713
|
+
log.warn(`Post-build prune failed (non-fatal): ${String(pruneErr)}`);
|
|
714
|
+
}
|
|
600
715
|
return derivedTag;
|
|
601
716
|
} catch (err: unknown) {
|
|
602
717
|
const stderr =
|