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

Merged
clawbot merged 2 commits from issue-215-clone-volume into next2 2026-09-23 12:04:32 +02:00
3 changed files with 78 additions and 1 deletions
Showing only changes of commit a44d4a2647 - Show all commits
+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")
}
})
}
}