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
Cette révision appartient à :
2026-09-23 09:45:07 +00:00
Parent ddcd179841
révision a44d4a2647
3 fichiers modifiés avec 78 ajouts et 1 suppressions
+3
Voir le fichier
@@ -20,6 +20,9 @@ regress.
# 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>/`
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
upaas container is recreated; logs written under an old hostname directory are
+3 -1
Voir le fichier
@@ -656,11 +656,13 @@ func (c *Client) performClone(
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() {
_ = c.docker.ContainerRemove(
ctx,
gitContainerID.String(),
container.RemoveOptions{Force: true},
container.RemoveOptions{Force: true, RemoveVolumes: true},
)
}()
+72
Voir le fichier
@@ -2,8 +2,16 @@ package docker //nolint:testpackage // tests unexported regexps and Client struc
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/docker/docker/client"
)
// 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")
}
})
}
}