Remove old images by tag, and test the step after a deploy (closes #216)
Check / check (pull_request) Skipped

Old images are now removed by their upaas-<app>:<N> tag, without force,
so Docker deletes an image only when no other tag (such as another app's
build of the same source) and no container still uses it. Removing by ID
with force could delete another app's rollback image.

The step after a deploy (record the new image, keep the replaced one for
rollback, remove the rest) is its own function, tested against a fake
Docker API, so the test fails if the removal is dropped or runs before
the images are updated.

Model: opus-5-5
This commit is contained in:
2026-09-23 10:09:47 +00:00
parent 8c61b5ae77
commit 4c6d3f464d
4 changed files with 170 additions and 77 deletions
+42 -36
View File
@@ -9,8 +9,10 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -524,18 +526,11 @@ func (svc *Service) runBuildAndDeploy(
return err
}
// Save current image as previous before updating to new one
if app.ImageID.Valid && app.ImageID.String != "" {
app.PreviousImageID = app.ImageID
}
err = svc.updateAppRunning(bgCtx, app, imageID)
err = svc.recordDeployedImage(bgCtx, app, deployment, imageID)
if err != nil {
return err
}
svc.removeUnusedImages(bgCtx, app, deployment)
// Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
@@ -716,57 +711,68 @@ func (svc *Service) checkCancelled(
return ErrDeployCancelled
}
// removeUnusedImages removes the app's images (tagged upaas-<app>:<deployment>
// by buildImage) except the one the running container uses and the one
// Rollback would start. Removing an image also removes the untagged images
// it was built on, unless another image still needs them.
// recordDeployedImage runs once the new container has started: it makes
// imageID the app's current image, keeps the replaced one as the previous
// image for Rollback, and then removes the app's other images.
func (svc *Service) recordDeployedImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
) error {
// Save current image as previous before updating to new one
if app.ImageID.Valid && app.ImageID.String != "" {
app.PreviousImageID = app.ImageID
}
err := svc.updateAppRunning(ctx, app, imageID)
if err != nil {
return err
}
svc.removeUnusedImages(ctx, app, deployment)
return nil
}
// removeUnusedImages removes the app's tags (upaas-<app>:<deployment>, set by
// buildImage) except those of the image the running container uses and the
// one Rollback would start. Docker deletes an image only once no other tag,
// such as another app's, and no container still uses it.
func (svc *Service) removeUnusedImages(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
images, err := svc.docker.ListImageIDs(ctx, "upaas-"+app.Name)
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
if err != nil {
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
return
}
for _, imageID := range unusedImages(images, app) {
removeErr := svc.docker.RemoveImage(ctx, imageID)
for _, tag := range slices.Sorted(maps.Keys(tags)) {
imageID := tags[tag].String()
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
continue
}
removeErr := svc.docker.RemoveImageTag(ctx, tag)
if removeErr != nil {
svc.log.Error("failed to remove old image",
"error", removeErr, "app", app.Name, "image", imageID)
"error", removeErr, "app", app.Name, "tag", tag)
_ = deployment.AppendLog(
ctx,
"WARNING: failed to remove old image "+
imageID.String()+": "+removeErr.Error(),
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
)
continue
}
_ = deployment.AppendLog(ctx, "Removed old image: "+imageID.String())
_ = deployment.AppendLog(ctx, "Removed old image: "+tag)
}
}
// unusedImages returns the images that are neither the app's current image
// nor its previous image, which Rollback uses.
func unusedImages(images []docker.ImageID, app *models.App) []docker.ImageID {
var unused []docker.ImageID
for _, imageID := range images {
if imageID.String() == app.ImageID.String ||
imageID.String() == app.PreviousImageID.String {
continue
}
unused = append(unused, imageID)
}
return unused
}
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,