Remove old images by tag, and test the step after a deploy (closes #216)
Check / check (pull_request) Skipped
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:
@@ -537,12 +537,13 @@ 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,
|
// ListImageTags returns the tags in the given repository, such as
|
||||||
// such as "upaas-myapp".
|
// "upaas-myapp:12" in "upaas-myapp", each with the ID of its image.
|
||||||
func (c *Client) ListImageIDs(
|
// Tags the same image has in other repositories are left out.
|
||||||
|
func (c *Client) ListImageTags(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
repository string,
|
repository string,
|
||||||
) ([]ImageID, error) {
|
) (map[string]ImageID, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return nil, ErrNotConnected
|
return nil, ErrNotConnected
|
||||||
}
|
}
|
||||||
@@ -554,12 +555,35 @@ func (c *Client) ListImageIDs(
|
|||||||
return nil, fmt.Errorf("failed to list images: %w", err)
|
return nil, fmt.Errorf("failed to list images: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := make([]ImageID, 0, len(images))
|
tags := make(map[string]ImageID)
|
||||||
|
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
ids = append(ids, ImageID(img.ID))
|
for _, tag := range img.RepoTags {
|
||||||
|
if strings.HasPrefix(tag, repository+":") {
|
||||||
|
tags[tag] = ImageID(img.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids, nil
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveImageTag removes a tag such as "upaas-myapp:12", without force.
|
||||||
|
// Docker then deletes the image, and the untagged images it was built on,
|
||||||
|
// only if no other tag and no container still uses it.
|
||||||
|
func (c *Client) RemoveImageTag(ctx context.Context, tag string) error {
|
||||||
|
if c.docker == nil {
|
||||||
|
return ErrNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.docker.ImageRemove(ctx, tag, image.RemoveOptions{
|
||||||
|
PruneChildren: true,
|
||||||
|
})
|
||||||
|
if err != nil && !client.IsErrNotFound(err) {
|
||||||
|
return fmt.Errorf("failed to remove image tag %s: %w", tag, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) performBuild(
|
func (c *Client) performBuild(
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -524,18 +526,11 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current image as previous before updating to new one
|
err = svc.recordDeployedImage(bgCtx, app, deployment, imageID)
|
||||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
|
||||||
app.PreviousImageID = app.ImageID
|
|
||||||
}
|
|
||||||
|
|
||||||
err = svc.updateAppRunning(bgCtx, app, imageID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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)
|
||||||
@@ -716,57 +711,68 @@ func (svc *Service) checkCancelled(
|
|||||||
return ErrDeployCancelled
|
return ErrDeployCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUnusedImages removes the app's images (tagged upaas-<app>:<deployment>
|
// recordDeployedImage runs once the new container has started: it makes
|
||||||
// by buildImage) except the one the running container uses and the one
|
// imageID the app's current image, keeps the replaced one as the previous
|
||||||
// Rollback would start. Removing an image also removes the untagged images
|
// image for Rollback, and then removes the app's other images.
|
||||||
// it was built on, unless another image still needs them.
|
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(
|
func (svc *Service) removeUnusedImages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) {
|
||||||
images, err := svc.docker.ListImageIDs(ctx, "upaas-"+app.Name)
|
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
|
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, imageID := range unusedImages(images, app) {
|
for _, tag := range slices.Sorted(maps.Keys(tags)) {
|
||||||
removeErr := svc.docker.RemoveImage(ctx, imageID)
|
imageID := tags[tag].String()
|
||||||
|
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
removeErr := svc.docker.RemoveImageTag(ctx, tag)
|
||||||
if removeErr != nil {
|
if removeErr != nil {
|
||||||
svc.log.Error("failed to remove old image",
|
svc.log.Error("failed to remove old image",
|
||||||
"error", removeErr, "app", app.Name, "image", imageID)
|
"error", removeErr, "app", app.Name, "tag", tag)
|
||||||
_ = deployment.AppendLog(
|
_ = deployment.AppendLog(
|
||||||
ctx,
|
ctx,
|
||||||
"WARNING: failed to remove old image "+
|
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
|
||||||
imageID.String()+": "+removeErr.Error(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
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.
|
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
||||||
func (svc *Service) cleanupCancelledDeploy(
|
func (svc *Service) cleanupCancelledDeploy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -1,48 +1,106 @@
|
|||||||
package deploy_test
|
package deploy_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/upaas/internal/config"
|
||||||
|
"sneak.berlin/go/upaas/internal/database"
|
||||||
"sneak.berlin/go/upaas/internal/docker"
|
"sneak.berlin/go/upaas/internal/docker"
|
||||||
|
"sneak.berlin/go/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"sneak.berlin/go/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentImage = "sha256:current"
|
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
|
||||||
|
// against a fake Docker API. Image one is also tagged for another app,
|
||||||
func TestUnusedImages_KeepsCurrentAndRollbackImage(t *testing.T) {
|
// image two was the previous image, three the current one, four is new.
|
||||||
|
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := &models.App{
|
var (
|
||||||
ImageID: sql.NullString{String: currentImage, Valid: true},
|
mu sync.Mutex
|
||||||
PreviousImageID: sql.NullString{String: "sha256:previous", Valid: true},
|
removed []string
|
||||||
}
|
forced bool
|
||||||
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) {
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
t.Parallel()
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
app := &models.App{
|
switch {
|
||||||
ImageID: sql.NullString{String: currentImage, Valid: true},
|
case r.Method == http.MethodDelete:
|
||||||
|
_, name, _ := strings.Cut(r.URL.Path, "/images/")
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
|
||||||
|
removed = append(removed, name)
|
||||||
|
forced = forced || r.URL.Query().Get("force") != ""
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/images/json"):
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"Id":"sha256:one","RepoTags":["upaas-myapp:1","upaas-otherapp:7"]},
|
||||||
|
{"Id":"sha256:two","RepoTags":["upaas-myapp:2"]},
|
||||||
|
{"Id":"sha256:three","RepoTags":["upaas-myapp:3"]},
|
||||||
|
{"Id":"sha256:four","RepoTags":["upaas-myapp:4"]}
|
||||||
|
]`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
}
|
}
|
||||||
images := []docker.ImageID{currentImage, "sha256:failed"}
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
assert.Equal(t,
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
[]docker.ImageID{"sha256:failed"},
|
lifecycle := fxtest.NewLifecycle(t)
|
||||||
deploy.UnusedImages(images, app),
|
|
||||||
)
|
dockerClient, err := docker.New(lifecycle, docker.Params{
|
||||||
|
Logger: logger.NewForTest(log),
|
||||||
|
Config: &config.Config{DockerHost: "tcp://" + srv.Listener.Addr().String()},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lifecycle.RequireStart()
|
||||||
|
t.Cleanup(lifecycle.RequireStop)
|
||||||
|
|
||||||
|
db := database.NewTestDatabase(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
app := models.NewApp(db)
|
||||||
|
app.ID = "myapp-id"
|
||||||
|
app.Name = "myapp"
|
||||||
|
app.ImageID = sql.NullString{String: "sha256:three", Valid: true}
|
||||||
|
app.PreviousImageID = sql.NullString{String: "sha256:two", Valid: true}
|
||||||
|
require.NoError(t, app.Save(ctx))
|
||||||
|
|
||||||
|
deployment := models.NewDeployment(db)
|
||||||
|
deployment.AppID = app.ID
|
||||||
|
require.NoError(t, deployment.Save(ctx))
|
||||||
|
|
||||||
|
svc := deploy.NewTestServiceWithConfig(log, &config.Config{}, dockerClient)
|
||||||
|
|
||||||
|
err = svc.RecordDeployedImage(ctx, app, deployment, "sha256:four")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "sha256:four", app.ImageID.String)
|
||||||
|
assert.Equal(t, "sha256:three", app.PreviousImageID.String)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"upaas-myapp:1", "upaas-myapp:2"}, removed)
|
||||||
|
assert.False(t, forced, "old image tags must be removed without force")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,14 @@ func (svc *Service) GetBuildDirExported(appName string) string {
|
|||||||
return svc.GetBuildDir(appName)
|
return svc.GetBuildDir(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnusedImages exposes unusedImages for testing.
|
// RecordDeployedImage exposes recordDeployedImage for testing.
|
||||||
func UnusedImages(images []docker.ImageID, app *models.App) []docker.ImageID {
|
func (svc *Service) RecordDeployedImage(
|
||||||
return unusedImages(images, app)
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
imageID docker.ImageID,
|
||||||
|
) error {
|
||||||
|
return svc.recordDeployedImage(ctx, app, deployment, imageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
||||||
|
|||||||
Reference in New Issue
Block a user