feat: garbage-collect dangling images after rebuilds and deletes

Nixpacks moves an installation's image tag to the newly built image,
leaving the previous one dangling. buildInstallation now removes the
superseded image (best-effort, skipped if still referenced), and
removeInstallationContainer removes an installation's image when the
installation itself is deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 03:12:52 +02:00
parent ccb7fcd842
commit b4628ac255

View File

@@ -8,7 +8,13 @@ import {
} from "../db/installations.js";
import { cloneOrUpdateRepo, readEnvExampleKeys, repoDir } from "../build/git.js";
import { runNixpacksBuild } from "../build/nixpacks.js";
import { runInstallationContainer, stopInstallationContainer, waitForContainerHealth } from "../docker/containers.js";
import {
runInstallationContainer,
stopInstallationContainer,
waitForContainerHealth,
getImageId,
removeImageIfUnused,
} from "../docker/containers.js";
import { generateBearerToken } from "../crypto/secrets.js";
export interface DraftResult {
@@ -58,6 +64,8 @@ export async function buildInstallation(id: string): Promise<Installation> {
updateInstallation(id, { status: "building", statusDetail: null });
const imageTag = `mcpmgr/${installation.slug}:latest`;
const previousImageId = installation.imageTag ? await getImageId(installation.imageTag) : undefined;
const result = await runNixpacksBuild(installation.slug, repoDir(installation.slug), imageTag);
if (!result.success) {
@@ -67,6 +75,15 @@ export async function buildInstallation(id: string): Promise<Installation> {
});
}
// Nixpacks moves the tag to the new image, leaving the previous one dangling — clean it up
// so rebuilds don't accumulate untagged images forever.
if (previousImageId) {
const newImageId = await getImageId(imageTag);
if (newImageId && newImageId !== previousImageId) {
await removeImageIfUnused(previousImageId);
}
}
return updateInstallation(id, { status: "built", statusDetail: null, imageTag });
}
@@ -98,4 +115,9 @@ export async function removeInstallationContainer(id: string): Promise<void> {
const installation = getInstallationById(id);
if (!installation) return;
await stopInstallationContainer(installation.containerName);
if (installation.imageTag) {
const imageId = await getImageId(installation.imageTag);
if (imageId) await removeImageIfUnused(imageId);
}
}