3 Commits
Author SHA1 Message Date
sneak 4c6d3f464d 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
2026-09-23 10:09:47 +00:00
sneak 8c61b5ae77 Remove images from earlier deploys after a successful deploy (closes #216)
After a successful deploy, upaas now removes the app's images other than
the one the running container uses and the previous one, which rollback
starts. Removing an image also removes the untagged images it was built on
unless another image still needs them. Images left by other stages of a
multi-stage build are not tied to the app and stay.

Model: opus-5-5
2026-09-23 10:05:02 +00:00
clawbot 56345bc6f6 Remove the git clone container's anonymous volume with it (closes #215)
The pinned `alpine/git` image declares a volume at `/git`, so every clone container got an anonymous volume, and the container was removed without its volumes, leaving one volume behind per deploy. The clone container is now removed together with its volumes, whether the clone succeeds, fails or is cancelled; the removal uses a context that outlives cancellation. Tests cover success, failure and cancellation against a fake Docker API, and a manual check against real Docker is recorded on the PR. The old app container's own anonymous volumes are still kept on redeploy, since deleting them could discard app data.

Model: opus-5-5
2026-09-23 12:04:32 +02:00
6 changed files with 268 additions and 79 deletions
+3
View File
@@ -23,6 +23,9 @@ regress.
- 2026-09-23: After a successful deploy, upaas removes the app's images other - 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 than the running one and the one rollback would use, together with the
untagged images they were built on (#216). untagged images they were built on (#216).
- 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
leaves a Docker volume behind (#215).
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/` - 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
upaas container is recreated; logs written under an old hostname directory are upaas container is recreated; logs written under an old hostname directory are
+36 -9
View File
@@ -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(
@@ -681,11 +705,14 @@ func (c *Client) performClone(
return nil, err return nil, err
} }
// The git image declares a volume, so Docker gives each clone container
// an anonymous volume; remove it with the container. The removal must
// still run when the deploy is cancelled.
defer func() { defer func() {
_ = c.docker.ContainerRemove( _ = c.docker.ContainerRemove(
ctx, context.WithoutCancel(ctx),
gitContainerID.String(), gitContainerID.String(),
container.RemoveOptions{Force: true}, container.RemoveOptions{Force: true, RemoveVolumes: true},
) )
}() }()
+90
View File
@@ -1,9 +1,18 @@
package docker //nolint:testpackage // tests unexported regexps and Client struct package docker //nolint:testpackage // tests unexported regexps and Client struct
import ( import (
"context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing" "testing"
"github.com/docker/docker/client"
) )
// mainBranch is the branch name used across validation tests. // mainBranch is the branch name used across validation tests.
@@ -149,3 +158,84 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
}) })
} }
} }
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
// API and checks that the clone container is removed together with its
// anonymous volumes, whether the clone succeeds, fails, or is cancelled.
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
exitCode int
cancel bool
}{
{name: "succeeds", exitCode: 0},
{name: "fails", exitCode: 1},
{name: "cancelled", cancel: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
removeQuery := make(chan url.Values, 1)
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodDelete:
removeQuery <- r.URL.Query()
case strings.HasSuffix(r.URL.Path, "/containers/create"):
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/wait") && tt.cancel:
// Cancel the deploy while the clone is running.
cancel()
<-r.Context().Done()
case strings.HasSuffix(r.URL.Path, "/wait"):
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
default:
_, _ = w.Write([]byte(`{}`))
}
},
))
t.Cleanup(srv.Close)
dockerAPI, err := client.NewClientWithOpts(
client.WithHost("tcp://" + srv.Listener.Addr().String()),
)
if err != nil {
t.Fatal(err)
}
c := &Client{docker: dockerAPI, log: slog.Default()}
dir := t.TempDir()
cfg := &cloneConfig{
repoURL: "git@example.com:repo.git",
branch: mainBranch,
sshPrivateKey: "fake-key",
containerDir: filepath.Join(dir, "repo"),
hostDir: filepath.Join(dir, "repo"),
keyFile: filepath.Join(dir, "deploy_key"),
hostKeyFile: filepath.Join(dir, "deploy_key"),
}
_, _ = c.performClone(ctx, cfg)
select {
case query := <-removeQuery:
if query.Get("v") != "1" {
t.Errorf("clone container removed without its volumes: %v", query)
}
default:
t.Error("clone container was not removed")
}
})
}
}
+42 -36
View File
@@ -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,
+89 -31
View File
@@ -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) {
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),
) )
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
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(`{}`))
}
},
))
t.Cleanup(srv.Close)
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
lifecycle := fxtest.NewLifecycle(t)
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")
} }
+8 -3
View File
@@ -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.