From e2374e8f23a8cf841ea9dc685308bf28ccefe459 Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 11:34:19 +0200 Subject: [PATCH 1/6] fix(test): app must be uninstalled before removal Signed-off-by: Mike Sul --- test/integration/update_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/update_test.go b/test/integration/update_test.go index a25cbc8..c80e460 100644 --- a/test/integration/update_test.go +++ b/test/integration/update_test.go @@ -528,8 +528,8 @@ services: // We need to run "bad" app stop before trying to install a new valid app because one of the bad app containers // could have started before the other failed to start, and it would interfere with the new app installation. badApp.Stop(t) - defer badApp.Uninstall(t) defer badApp.Remove(t) + defer badApp.Uninstall(t) app := f.NewApp(t, appComposeDef) app.Publish(t) From 903f4e743470234dabbd296ca3d3306f62f2ffbf Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 11:40:51 +0200 Subject: [PATCH 2/6] compose: remove compose dir for installed apps with multiple versions When multiple versions of the same app exist in the store, uninstall no longer always preserves the compose directory. Use CheckAppsStatus to determine accurately whether a specific app version is installed. It verifies the app bundle index, including the expected hashes of each bundle file. If the app being removed is installed, remove its compose directory even when other versions are present. Preserve the directory only when the removed version is not installed, since another installed version can be have its compose project files in this directory. Signed-off-by: Mike Sul --- pkg/compose/uninstall.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/compose/uninstall.go b/pkg/compose/uninstall.go index ee5d2f9..62d4065 100644 --- a/pkg/compose/uninstall.go +++ b/pkg/compose/uninstall.go @@ -54,8 +54,14 @@ func UninstallApps(ctx context.Context, cfg *Config, appRefs []string, options . } for _, app := range status.Apps { if appsInStore[app.Name()] > 1 { - // Cannot remove compose app dir if there is another version of this app in the store - continue + // Multiple versions of the same app exist in the store. + // If the version being removed is not installed, another version may still be + // installed and using the same compose directory. In that case, keep the app + // compose directory; otherwise we could remove compose files needed by the + // other installed version. + if _, isNotInstalled := status.NotInstalledCompose[app.Ref().Digest]; isNotInstalled { + continue + } } err = os.RemoveAll(cfg.GetAppComposeDir(app.Name())) if err != nil { From e09ee4382b216a855c8de334e094fb149d26dd40 Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 11:55:47 +0200 Subject: [PATCH 3/6] test: mark composectl app helpers as test helpers Add t.Helper() to App fixture helper methods that wrap composectl commands such as pull, install, uninstall, run, and stop. This improves test failure reporting by attributing errors to the caller test line instead of the helper implementation, making failures easier to diagnose. Signed-off-by: Mike Sul --- test/fixtures/composectl_cmds.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/fixtures/composectl_cmds.go b/test/fixtures/composectl_cmds.go index efb2ff5..6e1e34d 100644 --- a/test/fixtures/composectl_cmds.go +++ b/test/fixtures/composectl_cmds.go @@ -188,26 +188,32 @@ func (a *App) Publish(t *testing.T, publishOpts ...func(*PublishOpts)) { } func (a *App) Pull(t *testing.T) { + t.Helper() a.runCmd(t, "pull app", "pull", a.PublishedUri, "-u", "90") } func (a *App) Remove(t *testing.T) { + t.Helper() a.runCmd(t, "remove app", "rm", a.PublishedUri) } func (a *App) Install(t *testing.T) { + t.Helper() a.runCmd(t, "install app", "install", a.PublishedUri) } func (a *App) Uninstall(t *testing.T) { + t.Helper() a.runCmd(t, "uninstall app", "uninstall", "--prune=true", a.Name) } func (a *App) Run(t *testing.T) { + t.Helper() a.runCmd(t, "run app", "run", a.Name) } func (a *App) Up(t *testing.T) { + t.Helper() t.Run("compose up", func(t *testing.T) { composeRoot := path.Join(AppComposeRootRoot, a.Name) @@ -219,6 +225,7 @@ func (a *App) Up(t *testing.T) { } func (a *App) Stop(t *testing.T) { + t.Helper() a.runCmd(t, "stop app", "stop", a.Name) } From 931fcdf44939585e00aeb92e6f0b7638f94d63f9 Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 11:57:18 +0200 Subject: [PATCH 4/6] composectl: use shared uninstall implementation Replace command-specific uninstall logic with compose.UninstallApps(). Extend checkUserListedApps() with optional multi-version support so an app name can resolve to all matching versions in the local store. This allows uninstall to remove every stored version when users specify only the app name, while exact app URIs still target a specific version. The change centralizes uninstall checks and cleanup behavior, keeping the CLI consistent with shared compose package logic. Signed-off-by: Mike Sul --- cmd/composectl/cmd/root.go | 29 +++++++++++++++++------------ cmd/composectl/cmd/uninstall.go | 26 +++----------------------- 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/cmd/composectl/cmd/root.go b/cmd/composectl/cmd/root.go index 1228f58..963cd66 100644 --- a/cmd/composectl/cmd/root.go +++ b/cmd/composectl/cmd/root.go @@ -160,7 +160,7 @@ func getDockerConfig() (*configfile.ConfigFile, error) { // many app operations such as "run", "rm", etc. // If `userListedApps` is empty, then return all apps from the local store. // Return the list of validated app URIs. -func checkUserListedApps(ctx context.Context, cfg *compose.Config, userListedApps []string, checkIfExist bool) []string { +func checkUserListedApps(ctx context.Context, cfg *compose.Config, userListedApps []string, checkIfExist bool, allowMultipleVersions ...bool) []string { // Get the list of all apps in the local store apps, err := compose.ListApps(ctx, cfg) DieNotNil(err) @@ -173,25 +173,28 @@ func checkUserListedApps(ctx context.Context, cfg *compose.Config, userListedApp } } - checkedApps := map[string]compose.App{} + checkedApps := map[string][]compose.App{} for _, appNameOrURI := range inputAppRefs { var foundName bool var foundURI bool - var foundApp compose.App + // Keep track of all found apps with the same name, + // since more than one version of the same app can be in the local store, + // and the user can specify the app by name without version, which is an ambiguous reference to the app. + var foundApps []compose.App // Search for the app in the local app store by name or by URI for _, app := range apps { if app.Name() == appNameOrURI { - if foundName { + if (len(allowMultipleVersions) == 0 || !allowMultipleVersions[0]) && foundName { DieNotNil(fmt.Errorf("more than two versions of the same app found in the local app store:"+ - " %s (%s and %s)", app.Name(), foundApp.Ref().String(), app.Ref().String())) + " %s (%s and %s)", app.Name(), foundApps[0].Ref().String(), app.Ref().String())) } foundName = true - foundApp = app + foundApps = append(foundApps, app) // Continue searching because there might be more than one version of the same app in the store } else if app.Ref().String() == appNameOrURI { foundURI = true - foundApp = app + foundApps = append(foundApps, app) // No need to continue searching because app URIs are unique break } @@ -205,16 +208,18 @@ func checkUserListedApps(ctx context.Context, cfg *compose.Config, userListedApp continue } - if _, exists := checkedApps[foundApp.Name()]; exists { - DieNotNil(fmt.Errorf("the same app specified more than once: %s", foundApp.Name())) + if _, exists := checkedApps[foundApps[0].Name()]; exists { + DieNotNil(fmt.Errorf("the same app specified more than once: %s", foundApps[0].Name())) } else { - checkedApps[foundApp.Name()] = foundApp + checkedApps[foundApps[0].Name()] = foundApps } } var appURIs []string - for _, app := range checkedApps { - appURIs = append(appURIs, app.Ref().String()) + for _, allAppVersions := range checkedApps { + for _, app := range allAppVersions { + appURIs = append(appURIs, app.Ref().String()) + } } return appURIs } diff --git a/cmd/composectl/cmd/uninstall.go b/cmd/composectl/cmd/uninstall.go index 700f82e..d5efa62 100644 --- a/cmd/composectl/cmd/uninstall.go +++ b/cmd/composectl/cmd/uninstall.go @@ -1,11 +1,8 @@ package composectl import ( - "fmt" - "github.com/docker/docker/api/types/filters" "github.com/foundriesio/composeapp/pkg/compose" "github.com/spf13/cobra" - "os" ) type ( @@ -18,7 +15,7 @@ type ( func init() { uninstallCmd := &cobra.Command{ Use: "uninstall", - Short: "uninstall []", + Short: "uninstall []", Long: ``, Args: cobra.MinimumNArgs(1), } @@ -33,23 +30,6 @@ func init() { } func uninstallApps(cmd *cobra.Command, args []string, opts *uninstallOptions) { - apps := getAllAppStatuses(cmd.Context(), false) - for _, app := range args { - if _, ok := apps[app]; ok { - DieNotNil(fmt.Errorf("cannot uninstall running app: %s", app)) - } - appComposeDir := config.GetAppComposeDir(app) - if !opts.ignoreNonInstalled { - if _, err := os.Stat(appComposeDir); os.IsNotExist(err) { - DieNotNil(fmt.Errorf("app is not installed: %s", app)) - } - } - DieNotNil(os.RemoveAll(appComposeDir)) - } - if opts.prune { - cli, err := compose.GetDockerClient(dockerHost) - DieNotNil(err) - _, err = cli.ImagesPrune(cmd.Context(), filters.NewArgs(filters.Arg("dangling", "false"))) - DieNotNil(err) - } + appURIs := checkUserListedApps(cmd.Context(), config, args, !opts.ignoreNonInstalled, true) + DieNotNil(compose.UninstallApps(cmd.Context(), config, appURIs)) } From 7452563ba75f559b3e04294c5638ea6af329c30c Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 13:35:34 +0200 Subject: [PATCH 5/6] compose: support configurable image pruning on uninstall Add prune type support to UninstallApps() so callers can choose how image cleanup is performed after uninstall. Introduce two pruning modes: - only-app-images: prune only images of apps being uninstalled - all-unused-images: prune all unused images composectl uninstall now defaults to pruning images related to the apps being removed, while --prune enables full unused image cleanup. This keeps default uninstall behavior more targeted, while still allowing broader cleanup when explicitly requested. Note: the only-app-images mode is not fully correct yet. A follow-up test exposes the issue, and subsequent commits will address it. Signed-off-by: Mike Sul --- cmd/composectl/cmd/uninstall.go | 6 +++++- pkg/compose/uninstall.go | 24 ++++++++++++++++++++---- test/fixtures/composectl_cmds.go | 2 +- test/integration/update_test.go | 14 ++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/cmd/composectl/cmd/uninstall.go b/cmd/composectl/cmd/uninstall.go index d5efa62..6946501 100644 --- a/cmd/composectl/cmd/uninstall.go +++ b/cmd/composectl/cmd/uninstall.go @@ -31,5 +31,9 @@ func init() { func uninstallApps(cmd *cobra.Command, args []string, opts *uninstallOptions) { appURIs := checkUserListedApps(cmd.Context(), config, args, !opts.ignoreNonInstalled, true) - DieNotNil(compose.UninstallApps(cmd.Context(), config, appURIs)) + pruneType := compose.PruneTypeOnlyAppImages + if opts.prune { + pruneType = compose.PruneTypeAllUnusedImages + } + DieNotNil(compose.UninstallApps(cmd.Context(), config, appURIs, compose.WithImagePruning(pruneType))) } diff --git a/pkg/compose/uninstall.go b/pkg/compose/uninstall.go index 62d4065..614e786 100644 --- a/pkg/compose/uninstall.go +++ b/pkg/compose/uninstall.go @@ -9,18 +9,27 @@ import ( type ( UninstallOpts struct { - Prune bool + Prune bool + PruneType PruneType } UninstallOpt func(*UninstallOpts) + + PruneType string ) var ( - ErrUninstallRunningApps = errors.New("failed to uninstall apps: some apps are still running, please stop them first") + ErrUninstallRunningApps = errors.New("failed to uninstall apps: some apps are still running, please stop them first") + PruneTypeAllUnusedImages PruneType = "all-unused-images" + PruneTypeOnlyAppImages PruneType = "only-app-images" ) -func WithImagePruning() UninstallOpt { +func WithImagePruning(pruneType ...PruneType) UninstallOpt { return func(opts *UninstallOpts) { opts.Prune = true + opts.PruneType = PruneTypeOnlyAppImages + if len(pruneType) > 0 { + opts.PruneType = pruneType[0] + } } } @@ -79,7 +88,14 @@ func UninstallApps(ctx context.Context, cfg *Config, appRefs []string, options . // TODO: consider pruning volumes and networks if needed. // TODO: consider pruning only those images that are related to the uninstalled apps, // otherwise it prunes all dangling images including those that are not managed by composectl - _, err = cli.ImagesPrune(ctx, filters.NewArgs(filters.Arg("dangling", "true"))) + var dangling string + switch opts.PruneType { + case PruneTypeAllUnusedImages: + dangling = "false" + case PruneTypeOnlyAppImages: + dangling = "true" + } + _, err = cli.ImagesPrune(ctx, filters.NewArgs(filters.Arg("dangling", dangling))) } return err } diff --git a/test/fixtures/composectl_cmds.go b/test/fixtures/composectl_cmds.go index 6e1e34d..c07472d 100644 --- a/test/fixtures/composectl_cmds.go +++ b/test/fixtures/composectl_cmds.go @@ -204,7 +204,7 @@ func (a *App) Install(t *testing.T) { func (a *App) Uninstall(t *testing.T) { t.Helper() - a.runCmd(t, "uninstall app", "uninstall", "--prune=true", a.Name) + a.runCmd(t, "uninstall app", "uninstall", "--prune=false", a.Name) } func (a *App) Run(t *testing.T) { diff --git a/test/integration/update_test.go b/test/integration/update_test.go index c80e460..854becd 100644 --- a/test/integration/update_test.go +++ b/test/integration/update_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/docker/docker/api/types" "github.com/foundriesio/composeapp/pkg/compose" "github.com/foundriesio/composeapp/pkg/update" f "github.com/foundriesio/composeapp/test/fixtures" @@ -638,4 +639,17 @@ services: f.Check(t, compose.StopApps(ctx, cfg, oneAppURI)) f.Check(t, compose.UninstallApps(ctx, cfg, oneAppURI, compose.WithImagePruning())) f.Check(t, compose.RemoveApps(ctx, cfg, oneAppURI)) + + // check if no images are left after uninstalling the only app with image pruning + cli, err := compose.GetDockerClient("") + f.Check(t, err) + defer cli.Close() + images, err := cli.ImageList(ctx, types.ImageListOptions{All: true}) + f.Check(t, err) + if len(images) != 0 { + for _, img := range images { + t.Logf("unexpected image left after pruning: ID=%s, RepoTags=%v, RepoDigests=%v\n", img.ID, img.RepoTags, img.RepoDigests) + } + t.Fatalf("no images are expected to be left after pruning, found %d", len(images)) + } } From 979c33b8c8c6ef7f6367568ee09fad36a8d97ca3 Mon Sep 17 00:00:00 2001 From: Mike Sul Date: Thu, 23 Apr 2026 17:35:54 +0200 Subject: [PATCH 6/6] compose: fix app image pruning and add all-unused-images mode Fix image pruning in only-app-images mode. The previous implementation used the Docker image prune API with dangling=true, which only removes untagged dangling images and did not prune unused images that were still referenced by tags or digests. Uninstall now explicitly detects images not used by any container and, in only-app-images mode, removes only those images related to the apps being uninstalled. Add a new all-unused-images mode that removes every image not used by any container, regardless of whether it is related to compose apps. This makes uninstall cleanup more accurate for app-specific pruning while providing an optional broader system cleanup mode. Signed-off-by: Mike Sul --- pkg/compose/uninstall.go | 111 ++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 14 deletions(-) diff --git a/pkg/compose/uninstall.go b/pkg/compose/uninstall.go index 614e786..cbd40dd 100644 --- a/pkg/compose/uninstall.go +++ b/pkg/compose/uninstall.go @@ -3,8 +3,14 @@ package compose import ( "context" "errors" - "github.com/docker/docker/api/types/filters" + "fmt" "os" + + "github.com/containerd/containerd/reference/docker" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" ) type ( @@ -18,7 +24,8 @@ type ( ) var ( - ErrUninstallRunningApps = errors.New("failed to uninstall apps: some apps are still running, please stop them first") + ErrUninstallRunningApps = errors.New("failed to uninstall apps: some apps are still running, please stop them first") + PruneTypeAllUnusedImages PruneType = "all-unused-images" PruneTypeOnlyAppImages PruneType = "only-app-images" ) @@ -72,8 +79,7 @@ func UninstallApps(ctx context.Context, cfg *Config, appRefs []string, options . continue } } - err = os.RemoveAll(cfg.GetAppComposeDir(app.Name())) - if err != nil { + if err = os.RemoveAll(cfg.GetAppComposeDir(app.Name())); err != nil { return err } } @@ -81,21 +87,98 @@ func UninstallApps(ctx context.Context, cfg *Config, appRefs []string, options . if opts.Prune { cli, errClient := GetDockerClient(cfg.DockerHost) if errClient != nil { - return errClient + return fmt.Errorf("failed to create docker client: %w", errClient) + } + + var err error + var allImages []image.Summary + if allImages, err = cli.ImageList(ctx, types.ImageListOptions{All: true}); err != nil { + return fmt.Errorf("failed to list images: %w", err) + } + + imagesNotInUse := make(map[string]image.Summary) + for _, img := range allImages { + imagesNotInUse[img.ID] = img + } + + var allContainers []types.Container + if allContainers, err = cli.ContainerList(ctx, container.ListOptions{All: true}); err != nil { + return fmt.Errorf("failed to list containers: %w", err) } - // Prune only dangling images. - // The dangling images are the ones that are not tagged and not referenced by any container. - // TODO: consider pruning volumes and networks if needed. - // TODO: consider pruning only those images that are related to the uninstalled apps, - // otherwise it prunes all dangling images including those that are not managed by composectl - var dangling string + for _, ctr := range allContainers { + delete(imagesNotInUse, ctr.ImageID) + } + switch opts.PruneType { case PruneTypeAllUnusedImages: - dangling = "false" + // Remove all images that are not in use by any container. + for imgID := range imagesNotInUse { + // TODO: print debug message about which image is being removed and any error that occurs during removal. + _, _ = cli.ImageRemove(ctx, imgID, types.ImageRemoveOptions{Force: true, PruneChildren: true}) + } case PruneTypeOnlyAppImages: - dangling = "true" + // Build a map of image refs to image summary for images that are not in use by any container. + // We will use this map to check if an image ref related to the uninstalled apps is used by any container before removing it. + imageRefsNotInUse := make(map[string]image.Summary) + setAllImageRefVariants := func(ref string) { + imageRefsNotInUse[ref] = imagesNotInUse[ref] + // Make sure to consider all variants of the same image ref, "normalized" and "familiar" + if anyRef, err := docker.ParseAnyReference(ref); err == nil { + imageRefsNotInUse[anyRef.String()] = imagesNotInUse[ref] + if familiarRef := docker.FamiliarString(anyRef); len(familiarRef) > 0 { + imageRefsNotInUse[familiarRef] = imagesNotInUse[ref] + } + } + } + for _, img := range imagesNotInUse { + for _, ref := range img.RepoDigests { + setAllImageRefVariants(ref) + } + for _, ref := range img.RepoTags { + setAllImageRefVariants(ref) + } + } + // Remove images that are referenced by the apps being uninstalled and are not in use by any container. + removeAppImageRefs(ctx, status.Apps, cli, imageRefsNotInUse) } - _, err = cli.ImagesPrune(ctx, filters.NewArgs(filters.Arg("dangling", dangling))) } return err } + +func removeAppImageRefs(ctx context.Context, apps []App, cli *client.Client, imageRefsNotInUse map[string]image.Summary) { + // Collect image refs related to the uninstalled apps. + var imageRefsToPrune []string + for _, app := range apps { + for _, imageRoot := range app.GetComposeRoot().Children { + curImageRoot := imageRoot + for { + imageRef := curImageRoot.Ref() + // Add a digest ref + imageRefsToPrune = append(imageRefsToPrune, imageRef) + if ref, err := ParseImageRef(imageRef); err == nil { + // Add a tag ref + imageRefsToPrune = append(imageRefsToPrune, ref.GetTagRef()) + } + if curImageRoot.Type == BlobTypeImageManifest || len(curImageRoot.Children) == 0 { + break + } + // the image root points to an image index, let's add refs that point to the image manifest + curImageRoot = curImageRoot.Children[0] + } + } + } + // Remove image refs related to the uninstalled apps and images the refs point to are not in use by any container. + // If the removed ref is the only ref for the image, the image will also be removed; + // if there are other refs for the image, only the removed ref will be removed. + // This is the best effort to remove images related to the uninstalled apps without + // affecting other apps that may share the same images. + // In some case it can remove an image for which there is no container but some other utility reference it + // by the same reference as the uninstalled app, but that is an acceptable edge case and best effort + // to clean up images related to the uninstalled apps. + for _, ref := range imageRefsToPrune { + if _, notInUse := imageRefsNotInUse[ref]; notInUse { + // TODO: print debug message about which image is being removed and any error that occurs during removal. + _, _ = cli.ImageRemove(ctx, ref, types.ImageRemoveOptions{Force: false, PruneChildren: true}) + } + } +}