Remove the git clone container's anonymous volume with it (closes #215)

The pinned alpine/git image declares a volume at /git, so Docker gives
every clone container an anonymous volume. The container was removed
without its volumes, leaving one volume behind per deploy. The removal
now also removes the container's anonymous volumes, on success and on
failure. A test runs a clone against a fake Docker API and checks the
removal asks for volumes to be removed.

Model: opus-5-5
This commit is contained in:
2026-09-23 09:45:07 +00:00
parent ddcd179841
commit a44d4a2647
3 changed files with 78 additions and 1 deletions
+3
View File
@@ -20,6 +20,9 @@ regress.
# Completed Steps # Completed Steps
- 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
+3 -1
View File
@@ -656,11 +656,13 @@ 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.
defer func() { defer func() {
_ = c.docker.ContainerRemove( _ = c.docker.ContainerRemove(
ctx, ctx,
gitContainerID.String(), gitContainerID.String(),
container.RemoveOptions{Force: true}, container.RemoveOptions{Force: true, RemoveVolumes: true},
) )
}() }()
+72
View File
@@ -2,8 +2,16 @@ package docker //nolint:testpackage // tests unexported regexps and Client struc
import ( import (
"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 +157,67 @@ 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 or fails.
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
t.Parallel()
for _, exitCode := range []int{0, 1} {
t.Run(fmt.Sprintf("exit code %d", exitCode), func(t *testing.T) {
t.Parallel()
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"):
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, 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(t.Context(), 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")
}
})
}
}