diff --git a/CHANGELOG.md b/CHANGELOG.md index c461e0e..b95a581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Fixed + +- `teploy remove` now deletes the server-built images (`-build-`) + of the containers it removes. Before, they stayed forever: the deploy-time + 72-hour image prune skips images a kept container still uses, and remove + took the containers away without the images. Images are taken from the + app's own containers (never a name pattern), removal is never forced, and + pulled images are left alone. `--json` reports them as `removed_images`. + ## [0.1.46] - 2026-10-21 ### Fixed diff --git a/internal/cli/remove.go b/internal/cli/remove.go index 7c9ad6c..1736890 100644 --- a/internal/cli/remove.go +++ b/internal/cli/remove.go @@ -8,9 +8,11 @@ import ( "io" "os" "os/signal" + "slices" "strings" "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/build" "github.com/useteploy/teploy/internal/caddy" "github.com/useteploy/teploy/internal/confighome" "github.com/useteploy/teploy/internal/docker" @@ -28,8 +30,13 @@ func newRemoveCmd(flags *Flags) *cobra.Command { Aliases: []string{"destroy"}, Short: "Retire an app: containers, proxy route, and deploy state", Long: `Remove is the inverse of deploy. It stops and removes the app's containers, -removes its Caddy route (reloading the proxy), and deletes its deploy state -under /deployments/. +removes its Caddy route (reloading the proxy), deletes the server-built images +those containers ran (-build-; pulled images are left alone), +and deletes its deploy state under /deployments/. + +The removal itself is recorded in the app's event history (who removed it, +and whether --purge was used). With --purge, that record is all that stays, +so the server history can still answer what happened to the app. Data is preserved by default: volumes/, accessory data, and running accessory containers are left in place so a removal is never silently destructive. @@ -61,6 +68,7 @@ type removeSummary struct { App string `json:"app"` Server string `json:"server"` RemovedContainers []string `json:"removed_containers"` + RemovedImages []string `json:"removed_images,omitempty"` KeptAccessories []string `json:"kept_accessories,omitempty"` RemovedAutomation []string `json:"removed_automation,omitempty"` Route string `json:"route"` // "removed", "redirected", "none" @@ -175,6 +183,10 @@ func executeRemove(ctx context.Context, exec ssh.Executor, app string, domains [ if err != nil { return nil, fmt.Errorf("listing containers: %w", err) } + // Containers created by image ID report the ID; resolve it to the tags + // so their build images are found too. + containers = dk.ResolveImageTags(ctx, containers) + var buildImages []string for _, c := range containers { if c.Labels["teploy.role"] == "accessory" && !opts.Purge { summary.KeptAccessories = append(summary.KeptAccessories, c.Name) @@ -192,6 +204,19 @@ func executeRemove(ctx context.Context, exec ssh.Executor, app string, domains [ return nil, err } summary.RemovedContainers = append(summary.RemovedContainers, c.Name) + tags := c.ImageTags + if len(tags) == 0 { + tags = []string{c.Image} + } + for _, tag := range tags { + if strings.HasPrefix(tag, build.ImageTag(app, "")) && !slices.Contains(buildImages, tag) { + buildImages = append(buildImages, tag) + } + } + } + summary.RemovedImages = removeBuildImages(ctx, exec, buildImages) + for _, img := range summary.RemovedImages { + fmt.Fprintf(out, "Removed image %s\n", img) } // 2. Route. Servers running only ingress:host apps have no Caddyfile — @@ -360,3 +385,34 @@ func removeAppAutomation(ctx context.Context, exec ssh.Executor, app string, pur } return removed, nil } + +// removeBuildImages deletes the server-built images (-build-) +// of the containers remove just took away. Nothing can deploy or roll back +// to them once the app is gone, and the deploy-time 72h prune never reached +// them while a kept container still used them. The names come from the +// app's own containers, never a name pattern, so another app's images are +// never matched. Best-effort and never forced: an image some other +// container still uses stays, and a failure does not fail the remove. +func removeBuildImages(ctx context.Context, exec ssh.Executor, images []string) []string { + if len(images) == 0 { + return nil + } + quoted := make([]string, len(images)) + for i, img := range images { + quoted[i] = ssh.ShellQuote(img) + } + out, _ := exec.Run(ctx, "docker rmi "+strings.Join(quoted, " ")+" 2>/dev/null || true") + var removed []string + for _, line := range strings.Split(out, "\n") { + tag, ok := strings.CutPrefix(strings.TrimSpace(line), "Untagged: ") + if !ok { + continue + } + for _, img := range images { + if tag == img || tag == img+":latest" { + removed = append(removed, img) + } + } + } + return removed +} diff --git a/internal/cli/remove_test.go b/internal/cli/remove_test.go index 74dbcb1..c729fbe 100644 --- a/internal/cli/remove_test.go +++ b/internal/cli/remove_test.go @@ -365,3 +365,45 @@ func TestExecuteRemoveRefusesWhileDeployRuns(t *testing.T) { } } } + +func TestExecuteRemoveDeletesItsBuildImages(t *testing.T) { + cmds := append([]ssh.MockCommand{ + { + Match: "docker ps --all --filter label=teploy.app=", + Output: `{"ID":"aaa111","Names":"scratch-web-aaa","Image":"scratch-build-aaa","State":"running","Status":"Up","Labels":"teploy.app=scratch,teploy.process=web"} +{"ID":"aaa112","Names":"scratch-worker-aaa","Image":"scratch-build-aaa","State":"running","Status":"Up","Labels":"teploy.app=scratch,teploy.process=worker"} +{"ID":"ccc333","Names":"scratch-web-ccc","Image":"scratch-build-ccc","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"} +{"ID":"ddd444","Names":"scratch-web-ddd","Image":"nginx:alpine","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"} +{"ID":"eee555","Names":"scratch-web-eee","Image":"be6a74814c55","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"}`, + }, + // A container created by image ID reports the ID; its tag is resolved. + {Match: "docker image inspect --format", Output: "sha256:be6a74814c55" + strings.Repeat("0", 52) + ` ["scratch-build-eee:latest"]`}, + {Match: "docker stop -t 10", Output: ""}, + {Match: "docker rm ", Output: ""}, + // ccc's image is still used elsewhere: docker refuses it, remove carries on. + {Match: "docker rmi ", Output: "Untagged: scratch-build-aaa:latest\nUntagged: scratch-build-eee:latest\nDeleted: sha256:0123"}, + {Match: "rm -rf '/deployments/scratch'", Output: ""}, + }, caddyMocks()...) + exec := ssh.NewMockExecutor("test-host", append(cmds, removeAutomationMocks()...)...) + + sum, err := executeRemove(context.Background(), exec, "scratch", nil, removeOptions{Purge: true}, io.Discard) + if err != nil { + t.Fatalf("executeRemove: %v", err) + } + var rmi string + for _, call := range exec.Calls { + if strings.HasPrefix(call, "docker rmi ") { + if rmi != "" { + t.Fatalf("docker rmi ran twice: %v", exec.Calls) + } + rmi = call + } + } + // Each build image once, never forced, never a pulled image. + if rmi != "docker rmi 'scratch-build-aaa' 'scratch-build-ccc' 'scratch-build-eee:latest' 2>/dev/null || true" { + t.Fatalf("rmi call = %q", rmi) + } + if fmt.Sprint(sum.RemovedImages) != "[scratch-build-aaa scratch-build-eee:latest]" { + t.Fatalf("removed images = %v", sum.RemovedImages) + } +} diff --git a/internal/cli/trigger_identity.go b/internal/cli/trigger_identity.go index cd2e9a7..52ed9a8 100644 --- a/internal/cli/trigger_identity.go +++ b/internal/cli/trigger_identity.go @@ -56,6 +56,7 @@ func canonicalRepository(raw string) (string, error) { } return u.String(), nil } + // sameRepositoryPath compares two canonical repositories by host and path. // The forge API reports its HTTP clone URL, while a checkout may use SSH on // a different port (self-hosted forges almost always do); the transport