Tag built images with the commit's short hash (closes #239)

Builds are tagged upaas-<app>:<short hash>, git's own short form of
the commit checked out, instead of the deployment number.

A redeploy of a commit gives the tag to the new image. The cleanup
after a deploy now also finds the app's untagged images among those
its deployments recorded, and removes them by ID once the app neither
runs them nor would roll back to them. It keeps every image any app
uses, since apps that build the same commit can share one.

The clone reads the commits from git's output after removing Docker's
log headers, which had hidden the COMMIT: line; commit_sha is now
saved on update so manual deploys keep the commit they recorded.

Model: opus-5-5
This commit is contained in:
2026-09-29 11:09:42 +00:00
parent 9754b73f27
commit 6ca93aa29c
9 changed files with 559 additions and 90 deletions
+81 -19
View File
@@ -735,44 +735,103 @@ func (svc *Service) recordDeployedImage(
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.
// removeUnusedImages removes the app's images except those an app's running
// container uses or its Rollback would start. Docker deletes a tagged 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,
) {
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
images, err := svc.findAppImages(ctx, app)
if err != nil {
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
return
}
for _, tag := range slices.Sorted(maps.Keys(tags)) {
imageID := tags[tag].String()
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
keep, err := svc.imagesToKeep(ctx)
if err != nil {
svc.log.Error("failed to list the images apps use", "error", err, "app", app.Name)
return
}
for _, name := range slices.Sorted(maps.Keys(images)) {
if keep[images[name].String()] {
continue
}
removeErr := svc.docker.RemoveImageTag(ctx, tag)
removeErr := svc.docker.RemoveImageTag(ctx, name)
if removeErr != nil {
svc.log.Error("failed to remove old image",
"error", removeErr, "app", app.Name, "tag", tag)
"error", removeErr, "app", app.Name, "image", name)
_ = deployment.AppendLog(
ctx,
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
"WARNING: failed to remove old image "+name+": "+removeErr.Error(),
)
continue
}
_ = deployment.AppendLog(ctx, "Removed old image: "+tag)
_ = deployment.AppendLog(ctx, "Removed old image: "+name)
}
}
// findAppImages returns the app's images, each under the name it is removed
// by: its tag, upaas-<app>:<short hash> as set by buildImage, or its ID if
// it has none. A redeploy of a commit gives the commit's tag to the new
// image, so the old one is found among the images the app's deployments
// recorded.
func (svc *Service) findAppImages(
ctx context.Context,
app *models.App,
) (map[string]docker.ImageID, error) {
images, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
if err != nil {
return nil, fmt.Errorf("failed to list image tags: %w", err)
}
untagged, err := svc.docker.ListUntaggedImages(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list untagged images: %w", err)
}
recorded, err := models.FindDeploymentImageIDs(ctx, svc.db, app.ID)
if err != nil {
return nil, fmt.Errorf("failed to find deployment images: %w", err)
}
for _, imageID := range untagged {
if slices.Contains(recorded, imageID.String()) {
images[imageID.String()] = imageID
}
}
return images, nil
}
// imagesToKeep returns the IDs of the image each app's running container
// uses and the one its Rollback would start. It covers every app because
// apps that build the same commit can share an image, and a redeploy can
// take the tag that kept the image for one of them.
func (svc *Service) imagesToKeep(ctx context.Context) (map[string]bool, error) {
apps, err := models.AllApps(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to list apps: %w", err)
}
keep := make(map[string]bool)
for _, app := range apps {
keep[app.ImageID.String] = true
keep[app.PreviousImageID.String] = true
}
return keep, nil
}
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
@@ -912,14 +971,14 @@ func (svc *Service) buildImage(
app *models.App,
deployment *models.Deployment,
) (docker.ImageID, error) {
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
workDir, shortSHA, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
}
defer cleanup()
imageTag := fmt.Sprintf("upaas-%s:%d", app.Name, deployment.ID)
imageTag := "upaas-" + app.Name + ":" + shortSHA
// Create log writer that flushes build output to deployment logs every second
logWriter := newDeploymentLogWriter(ctx, deployment)
@@ -954,11 +1013,14 @@ func (svc *Service) buildImage(
return imageID, nil
}
// cloneRepository clones the app's repository for a build. It returns the
// directory of the clone, git's short form of the commit checked out, and a
// function that removes the clone.
func (svc *Service) cloneRepository(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, func(), error) {
) (string, string, func(), error) {
// Use a subdirectory of DataDir for builds since it's mounted from the host
// and accessible to Docker for bind mounts (unlike /tmp inside the container).
// Structure: builds/<appname>/<deployment-id>-<random>/
@@ -975,7 +1037,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to create builds dir: %w", err),
)
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
return "", "", nil, fmt.Errorf("failed to create builds dir: %w", err)
}
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
@@ -987,7 +1049,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to create temp dir: %w", err),
)
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
return "", "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() { _ = os.RemoveAll(buildDir) }
@@ -1023,7 +1085,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to clone repo: %w", cloneErr),
)
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
return "", "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
}
svc.processCloneResult(ctx, app, deployment, cloneResult, commitSHA)
@@ -1031,7 +1093,7 @@ func (svc *Service) cloneRepository(
// Return the 'work' subdirectory where the repo was cloned
workDir := filepath.Join(buildDir, "work")
return workDir, cleanup, nil
return workDir, cloneResult.ShortSHA, cleanup, nil
}
// processCloneResult handles the result of a git clone operation.