diff --git a/internal/contracttest/docker_prune_test.go b/internal/contracttest/docker_prune_test.go new file mode 100644 index 0000000..bfd3268 --- /dev/null +++ b/internal/contracttest/docker_prune_test.go @@ -0,0 +1,87 @@ +package contracttest + +import ( + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestDockerPrunePreservesNamedRollbackImages(t *testing.T) { + root := repositoryRoot(t) + script := filepath.Join(root, "rootfs", "home", "cloud-compose", "docker-prune.sh") + temporary := t.TempDir() + profile := filepath.Join(temporary, "profile.sh") + lock := filepath.Join(temporary, "docker-prune.lock") + logPath := filepath.Join(temporary, "docker.log") + binDirectory := filepath.Join(temporary, "bin") + + if err := os.Mkdir(binDirectory, 0o755); err != nil { + t.Fatalf("create mock binary directory: %v", err) + } + if err := os.WriteFile(profile, []byte("# test profile\n"), 0o600); err != nil { + t.Fatalf("write test profile: %v", err) + } + mockDocker := []byte("#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$*\" >> \"${DOCKER_PRUNE_TEST_LOG:?}\"\n") + if err := os.WriteFile(filepath.Join(binDirectory, "docker"), mockDocker, 0o755); err != nil { + t.Fatalf("write mock Docker binary: %v", err) + } + + command := exec.Command("bash", script) + command.Env = append(os.Environ(), + "CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED=true", + "CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH="+lock, + "CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL=72h", + "CLOUD_COMPOSE_PROFILE_PATH="+profile, + "DOCKER_PRUNE_TEST_LOG="+logPath, + "PATH="+binDirectory+":"+os.Getenv("PATH"), + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("run Docker prune policy: %v\n%s", err, output) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read Docker invocation log: %v", err) + } + commands := strings.Split(strings.TrimSpace(string(logBytes)), "\n") + want := []string{ + "container prune --force --filter until=72h", + "network prune --force --filter until=72h", + "image prune --force --filter until=72h", + "builder prune --force --filter until=72h", + } + if !slices.Equal(commands, want) { + t.Fatalf("Docker prune commands = %#v, want %#v", commands, want) + } + for _, invocation := range commands { + if strings.Contains(invocation, "system prune") || strings.Contains(invocation, "image prune --all") { + t.Fatalf("destructive Docker prune invocation remains: %s", invocation) + } + } +} + +func TestDockerPruneIsDisabledByDefault(t *testing.T) { + root := repositoryRoot(t) + script := filepath.Join(root, "rootfs", "home", "cloud-compose", "docker-prune.sh") + temporary := t.TempDir() + profile := filepath.Join(temporary, "profile.sh") + if err := os.WriteFile(profile, []byte("# test profile\n"), 0o600); err != nil { + t.Fatalf("write test profile: %v", err) + } + + command := exec.Command("bash", script) + command.Env = append(os.Environ(), + "CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED=", + "CLOUD_COMPOSE_PROFILE_PATH="+profile, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run disabled Docker prune policy: %v\n%s", err, output) + } + if !strings.Contains(string(output), "Docker pruning is disabled") { + t.Fatalf("disabled Docker prune output = %q", output) + } +} diff --git a/rootfs/home/cloud-compose/docker-prune.sh b/rootfs/home/cloud-compose/docker-prune.sh index e8a5568..22e21e4 100755 --- a/rootfs/home/cloud-compose/docker-prune.sh +++ b/rootfs/home/cloud-compose/docker-prune.sh @@ -2,8 +2,11 @@ set -euo pipefail -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" + +# shellcheck disable=SC1090 +source "$profile_path" case "${CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED:-false}" in true | TRUE | 1 | yes | YES) ;; @@ -19,11 +22,15 @@ if [[ ! "$prune_until" =~ ^[0-9]+(s|m|h)$ ]]; then exit 2 fi -exec 9>/run/cloud-compose-docker-prune.lock +lock_path="${CLOUD_COMPOSE_DOCKER_PRUNE_LOCK_PATH:-/run/cloud-compose-docker-prune.lock}" +exec 9>"$lock_path" if command -v flock >/dev/null 2>&1 && ! flock -n 9; then echo "A Cloud Compose Docker prune is already running" exit 0 fi -echo "Pruning unused Docker data older than $prune_until" -docker system prune --all --force --filter "until=$prune_until" +echo "Pruning stopped containers, unused networks, dangling images, and build cache older than $prune_until" +docker container prune --force --filter "until=$prune_until" +docker network prune --force --filter "until=$prune_until" +docker image prune --force --filter "until=$prune_until" +docker builder prune --force --filter "until=$prune_until"