Remove images from earlier deploys after a successful deploy #219
@@ -20,6 +20,9 @@ regress.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-23: After a successful deploy, upaas removes the app's images other
|
||||||
|
than the running one and the one rollback would use, together with the
|
||||||
|
untagged images they were built on (#216).
|
||||||
- 2026-09-23: The git clone container is now removed together with its anonymous
|
- 2026-09-23: The git clone container is now removed together with its anonymous
|
||||||
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
|
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
|
||||||
leaves a Docker volume behind (#215).
|
leaves a Docker volume behind (#215).
|
||||||
|
|||||||
@@ -537,6 +537,31 @@ func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImageIDs returns the IDs of all images tagged in the given repository,
|
||||||
|
// such as "upaas-myapp".
|
||||||
|
func (c *Client) ListImageIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
repository string,
|
||||||
|
) ([]ImageID, error) {
|
||||||
|
if c.docker == nil {
|
||||||
|
return nil, ErrNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := c.docker.ImageList(ctx, image.ListOptions{
|
||||||
|
Filters: filters.NewArgs(filters.Arg("reference", repository)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]ImageID, 0, len(images))
|
||||||
|
for _, img := range images {
|
||||||
|
ids = append(ids, ImageID(img.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) performBuild(
|
func (c *Client) performBuild(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
opts BuildImageOptions,
|
opts BuildImageOptions,
|
||||||
|
|||||||
@@ -534,6 +534,8 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.removeUnusedImages(bgCtx, app, deployment)
|
||||||
|
|
||||||
// Use context.WithoutCancel to ensure health check completes even if
|
// Use context.WithoutCancel to ensure health check completes even if
|
||||||
// the parent context is cancelled (e.g., HTTP request ends).
|
// the parent context is cancelled (e.g., HTTP request ends).
|
||||||
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
|
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
|
||||||
@@ -714,6 +716,57 @@ func (svc *Service) checkCancelled(
|
|||||||
return ErrDeployCancelled
|
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.
|
||||||
|
func (svc *Service) removeUnusedImages(
|
||||||
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
) {
|
||||||
|
images, err := svc.docker.ListImageIDs(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)
|
||||||
|
if removeErr != nil {
|
||||||
|
svc.log.Error("failed to remove old image",
|
||||||
|
"error", removeErr, "app", app.Name, "image", imageID)
|
||||||
|
_ = deployment.AppendLog(
|
||||||
|
ctx,
|
||||||
|
"WARNING: failed to remove old image "+
|
||||||
|
imageID.String()+": "+removeErr.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = deployment.AppendLog(ctx, "Removed old image: "+imageID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
||||||
func (svc *Service) cleanupCancelledDeploy(
|
func (svc *Service) cleanupCancelledDeploy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package deploy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"sneak.berlin/go/upaas/internal/docker"
|
||||||
|
"sneak.berlin/go/upaas/internal/models"
|
||||||
|
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentImage = "sha256:current"
|
||||||
|
|
||||||
|
func TestUnusedImages_KeepsCurrentAndRollbackImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := &models.App{
|
||||||
|
ImageID: sql.NullString{String: currentImage, Valid: true},
|
||||||
|
PreviousImageID: sql.NullString{String: "sha256:previous", Valid: true},
|
||||||
|
}
|
||||||
|
images := []docker.ImageID{
|
||||||
|
"sha256:oldest",
|
||||||
|
"sha256:previous",
|
||||||
|
"sha256:older",
|
||||||
|
currentImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]docker.ImageID{"sha256:oldest", "sha256:older"},
|
||||||
|
deploy.UnusedImages(images, app),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnusedImages_FirstDeployHasNoRollbackImage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := &models.App{
|
||||||
|
ImageID: sql.NullString{String: currentImage, Valid: true},
|
||||||
|
}
|
||||||
|
images := []docker.ImageID{currentImage, "sha256:failed"}
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]docker.ImageID{"sha256:failed"},
|
||||||
|
deploy.UnusedImages(images, app),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -90,6 +90,11 @@ func (svc *Service) GetBuildDirExported(appName string) string {
|
|||||||
return svc.GetBuildDir(appName)
|
return svc.GetBuildDir(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnusedImages exposes unusedImages for testing.
|
||||||
|
func UnusedImages(images []docker.ImageID, app *models.App) []docker.ImageID {
|
||||||
|
return unusedImages(images, app)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
||||||
func (svc *Service) BuildContainerOptionsExported(
|
func (svc *Service) BuildContainerOptionsExported(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
Reference in New Issue
Block a user