diff --git a/cmd/uc/image/ls.go b/cmd/uc/image/ls.go index de1e1f0f..aab84193 100644 --- a/cmd/uc/image/ls.go +++ b/cmd/uc/image/ls.go @@ -91,9 +91,14 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { machines := cli.ExpandCommaSeparatedValues(opts.machines) - clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{ - Machines: machines, - Name: opts.nameFilter, + var clusterImages []api.MachineImages + err = tui.RunSpinner(ctx, "Listing images...", func(ctx context.Context) error { + var err error + clusterImages, err = clusterClient.ListImages(ctx, api.ImageFilter{ + Machines: machines, + Name: opts.nameFilter, + }) + return err }) if err != nil { return fmt.Errorf("list images: %w", err) diff --git a/cmd/uc/image/rm.go b/cmd/uc/image/rm.go new file mode 100644 index 00000000..af30c5c9 --- /dev/null +++ b/cmd/uc/image/rm.go @@ -0,0 +1,137 @@ +package image + +import ( + "context" + "errors" + "fmt" + "os" + + "charm.land/lipgloss/v2" + "github.com/docker/compose/v2/pkg/progress" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/spf13/cobra" +) + +type removeOptions struct { + machines []string + yes bool +} + +func NewRemoveCommand() *cobra.Command { + opts := removeOptions{} + cmd := &cobra.Command{ + Use: "rm IMAGE [IMAGE...]", + Aliases: []string{"remove", "delete"}, + Short: "Remove images from machines in the cluster.", + Long: `Remove images from all or selected machines in the cluster. + +An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove +only that tag. + +Wildcard patterns are supported for image names and tags and should be quoted to prevent your +shell from expanding them. Removing by image ID attempts to remove each name for that image +independently. + +Images used by running or stopped containers cannot be removed. Unused tags can be removed even +when the same image is used under another tag. Failed removals do not prevent other removals.`, + Example: ` # Remove a tag from all machines after confirming the plan. + uc image rm myapp:old + + # Remove all tags for an image name from all machines. + uc image rm myapp + + # Remove matching tags from two machines without confirmation. + uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes + + # Remove all names for an image by ID from all machines. + uc image rm ee8fed0e9618`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM") + }, + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return remove(cmd.Context(), uncli, args, opts) + }, + } + + cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, + "Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list.\n"+ + "If not specified, the matching images will be removed from all machines.") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, + "Auto-confirm the removal plan. Should be explicitly set when running non-interactively,\n"+ + "e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") + + completion.MachinesFlag(cmd) + + return cmd +} + +func remove(ctx context.Context, uncli *cli.CLI, images []string, opts removeOptions) error { + clusterClient, err := uncli.ConnectCluster(ctx) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer clusterClient.Close() + + plan, err := planImageRemoval(ctx, clusterClient, images, cli.ExpandCommaSeparatedValues(opts.machines)) + if err != nil { + return fmt.Errorf("plan image removal: %w", err) + } + directConn := uncli.DirectConnection() + contextName := uncli.ContextOverrideOrCurrent() + cluster := "" + if directConn != "" { + cluster = directConn + lipgloss.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn)) + lipgloss.Println() + } else if contextName != "" && len(uncli.Config.Contexts) > 1 { + // Only show context if there's more than one to avoid unnecessary clutter. + cluster = contextName + lipgloss.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName)) + lipgloss.Println() + } + + lipgloss.Println(plan.Format()) + + if !opts.yes { + if !tui.IsTerminalAvailable() { + return errors.New("cannot ask to confirm image removal in non-interactive mode, " + + "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") + } + + title := "Proceed with image removal?" + if cluster != "" { + isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title + title = "Proceed with image removal on " + tui.NameStyle.Render(cluster) + confirmStyle.Render("?") + } + + confirmed, err := tui.Confirm(title) + if err != nil { + return fmt.Errorf("confirm image removal: %w", err) + } + if !confirmed { + return cli.Cancelled("Image removal cancelled. No images were removed.") + } + } + + untagged := make(map[string]bool) + var errs []error + for i, group := range plan.GroupPlans { + if i > 0 { + fmt.Println() + } + + err := progress.RunWithTitle(ctx, func(ctx context.Context) error { + return group.Execute(ctx, clusterClient, untagged) + }, uncli.ProgressOut(), "Removing image "+group.Image) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/cmd/uc/image/rm_plan.go b/cmd/uc/image/rm_plan.go new file mode 100644 index 00000000..7fb53ba7 --- /dev/null +++ b/cmd/uc/image/rm_plan.go @@ -0,0 +1,426 @@ +package image + +import ( + "context" + "errors" + "fmt" + "path" + "regexp" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/distribution/reference" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stringid" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/pkg/api" +) + +var imageIDRegex = regexp.MustCompile(`^(sha256:)?[a-f0-9]{4,64}$`) + +// imageRemovalPlan contains all image removal operations grouped by the user-supplied image name, ID, or pattern. +type imageRemovalPlan struct { + GroupPlans []*removeImageGroupPlan +} + +func (p *imageRemovalPlan) IsEmpty() bool { return len(p.GroupPlans) == 0 } + +func (p *imageRemovalPlan) Format() string { + var out strings.Builder + machines := make(map[string]struct{}) + removeCount := 0 + for _, group := range p.GroupPlans { + out.WriteString(group.Format()) + for _, op := range group.Operations { + machines[op.MachineName] = struct{}{} + removeCount++ + } + } + + summary := fmt.Sprintf("%s remove %s across %s %s", + tui.BoldRed.Render(fmt.Sprint(removeCount)), + tui.Faint.Render("·"), + tui.Bold.Render(fmt.Sprint(len(machines))), + pluralMachines(len(machines)), + ) + out.WriteString("\n" + tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))) + "\n" + summary + "\n") + + return out.String() +} + +func pluralMachines(count int) string { + if count == 1 { + return "machine" + } + return "machines" +} + +// Execute applies the frozen plan, collecting failures without discovering new targets. +func (p *imageRemovalPlan) Execute(ctx context.Context, cli api.ImageClient) error { + untagged := make(map[string]bool) + var errs []error + for _, group := range p.GroupPlans { + if err := group.Execute(ctx, cli, untagged); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// removeImageGroupPlan groups removals for one image name, ID, or pattern supplied by the user. +type removeImageGroupPlan struct { + // Image is the image name, ID, or pattern supplied by the user. + Image string + Operations []*removeImageOperation +} + +func (o *removeImageGroupPlan) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error { + var errs []error + for _, op := range o.Operations { + if err := ctx.Err(); err != nil { + return errors.Join(append(errs, err)...) + } + if err := op.Execute(ctx, cli, untagged); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (o *removeImageGroupPlan) Format() string { + var out strings.Builder + out.WriteString(tui.BoldRed.Render("-") + " remove image " + tui.NameStyle.Render(o.Image) + "\n") + for i, op := range o.Operations { + branch := " ├── " + if i == len(o.Operations)-1 { + branch = " ╰── " + } + out.WriteString(tui.Faint.Render(branch) + op.Format() + "\n") + } + return out.String() +} + +func (o *removeImageGroupPlan) String() string { + return fmt.Sprintf("removeImageGroupPlan[image=%s]", o.Image) +} + +// removeImageOperation removes an image name or an unnamed image by ID on a machine. +type removeImageOperation struct { + MachineName string + ImageID string + // Image is the exact image name or ID sent to Docker. + Image string +} + +func (o *removeImageOperation) Execute(ctx context.Context, cli api.ImageClient, untagged map[string]bool) error { + if untagged[o.MachineName+"\x00"+o.Image] { + return nil + } + results, err := cli.RemoveImage(ctx, o.Image, api.RemoveImageOptions{Machines: []string{o.MachineName}}) + // A removal may also untag other planned names. Track actual results across all groups, per machine. + for _, result := range results { + for _, removal := range result.Results { + if removal.Untagged != "" { + untagged[result.Metadata.MachineName+"\x00"+removal.Untagged] = true + } + } + } + if errors.Is(err, api.ErrNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("remove image '%s': %w", o.Image, err) + } + return nil +} + +func (o *removeImageOperation) Format() string { + return tui.BoldRed.Render("-") + " " + tui.FormatImage(o.Image, tui.NoStyle) + + tui.Faint.Render(" ("+stringid.TruncateID(o.ImageID)+") on ") + o.MachineName +} + +func (o *removeImageOperation) String() string { + return fmt.Sprintf("removeImageOperation[machine_name=%s image=%s]", o.MachineName, o.Image) +} + +type machineImageInventory struct { + machineName string + images []image.Summary +} + +// planImageRemoval builds a removal plan grouped by input argument from images on the selected machines. +// An empty machines list selects all machines. +// +// Arguments expand as follows: +// - Name: without a tag or digest, select all stored names in that exact repository across image IDs. +// An explicit tag selects only that tag. Familiar names are normalised. +// - ID: select all RepoTags for the matching image, or its full ID if unnamed. A prefix must identify +// one unique full ID across the selected machines. +// - Pattern: match RepoTags only. A matching digest also selects that image's names in the same repository. +// - Digest: resolve through RepoTags, RepoDigests, or the image ID, then select that image's names in +// the same repository. If none exist but RepoDigests contains the reference, select the digest itself. +// +// Selecting all tags for a repository/image on a machine also adds its stored digest references after +// the tags, even if those digests did not match an argument. Stored names are kept as is, and overlapping +// selections produce only one operation per machine and name. +// +// Failed image listings produce warnings and exclude those machines from the plan. If no machine can +// be queried, planning fails. Every argument must match on a successfully queried machine. Unmatched +// or invalid arguments and ambiguous ID prefixes reject the whole plan before any removal. +// +// Planning does not check container usage. The backend checks each removal during execution, and failures +// do not stop other operations. Tag removal may implicitly untag a used digest. Execution skips names +// already untagged by earlier operations and tolerates names that disappeared after planning. +func planImageRemoval(ctx context.Context, cli api.ImageClient, images, machines []string) (*imageRemovalPlan, error) { + if len(images) == 0 { + return nil, errors.New("at least one image is required") + } + + var clusterImages []api.MachineImages + err := tui.RunSpinner(ctx, "Finding images to remove...", func(ctx context.Context) error { + var err error + clusterImages, err = cli.ListImages(ctx, api.ImageFilter{Machines: machines}) + return err + }) + if err != nil { + return nil, fmt.Errorf("list images: %w", err) + } + + var inventory []*machineImageInventory + for _, machineImages := range clusterImages { + if err := machineImages.Metadata.Err(); err != nil { + tui.PrintWarning(fmt.Sprintf("failed to list images on machine '%s': %s", + machineImages.Metadata.MachineName, err)) + continue + } + machine := &machineImageInventory{ + machineName: machineImages.Metadata.MachineName, + images: machineImages.Images, + } + inventory = append(inventory, machine) + } + if len(inventory) == 0 { + return nil, errors.New("failed to list images on any machine") + } + + return planImageRemovalFromInventory(images, inventory) +} + +func planImageRemovalFromInventory(images []string, inventory []*machineImageInventory) (*imageRemovalPlan, error) { + plan := &imageRemovalPlan{} + seen := make(map[string]bool) + var errs []error + for _, imageArg := range images { + group := &removeImageGroupPlan{Image: imageArg} + isID := imageIDRegex.MatchString(imageArg) + isPattern := strings.ContainsAny(imageArg, "*?[") + var namedRef reference.Named + if isPattern { + if _, err := path.Match(imageArg, ""); err != nil { + return nil, fmt.Errorf("invalid image pattern '%s': %w", imageArg, err) + } + } else if !isID { + var err error + namedRef, err = reference.ParseNormalizedNamed(imageArg) + if err != nil { + return nil, fmt.Errorf("invalid image '%s': %w", imageArg, err) + } + } + if isID { + matches := make(map[string]bool) + for _, machine := range inventory { + for _, img := range machine.images { + if imageIDMatches(img.ID, imageArg) { + matches[img.ID] = true + } + } + } + if len(matches) > 1 { + var candidates []string + for id := range matches { + candidates = append(candidates, id) + } + slices.Sort(candidates) + errs = append(errs, + fmt.Errorf("image ID '%s' is ambiguous: %s", imageArg, strings.Join(candidates, ", "))) + continue + } + } + + matched := false + for _, machine := range inventory { + for _, img := range machine.images { + var imagesToRemove []string + switch { + case isID: + if !imageIDMatches(img.ID, imageArg) { + continue + } + matched = true + // Expand only stored names, matching uc image ls. RepoDigests remains available for digest lookup. + imagesToRemove = img.RepoTags + if len(imagesToRemove) == 0 { + imagesToRemove = []string{img.ID} + } + case isPattern: + for _, ref := range img.RepoTags { + if ok, _ := path.Match(imageArg, ref); ok { + named, _ := reference.ParseDockerRef(ref) + if _, digest := named.(reference.Canonical); digest { + imagesToRemove = append(imagesToRemove, + imageRepositoryReferences(img.RepoTags, named.Name())...) + } else { + imagesToRemove = append(imagesToRemove, ref) + } + } + } + case reference.IsNameOnly(namedRef): + imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name()) + default: + if canonical, digest := namedRef.(reference.Canonical); digest { + ref := reference.FamiliarString(namedRef) + if canonical.Digest().String() != img.ID && !slices.Contains(img.RepoTags, ref) && + !slices.Contains(img.RepoDigests, ref) { + continue + } + imagesToRemove = imageRepositoryReferences(img.RepoTags, namedRef.Name()) + if len(imagesToRemove) == 0 && slices.Contains(img.RepoDigests, ref) { + // An explicitly requested digest can resolve even when no stored name is listed. + imagesToRemove = []string{ref} + } + } else if slices.Contains(img.RepoTags, reference.FamiliarString(namedRef)) { + imagesToRemove = []string{reference.FamiliarString(namedRef)} + } + } + for _, ref := range imagesToRemove { + matched = true + key := machine.machineName + "\x00" + ref + if !seen[key] { + seen[key] = true + group.Operations = append(group.Operations, &removeImageOperation{ + MachineName: machine.machineName, ImageID: img.ID, Image: ref, + }) + } + } + } + } + if !matched { + errs = append(errs, fmt.Errorf("image '%s' not found on any machine", imageArg)) + } + slices.SortFunc(group.Operations, compareImageRemovalOperations) + if len(group.Operations) > 0 { + plan.GroupPlans = append(plan.GroupPlans, group) + } + } + + planDigestRemovals(plan, inventory) + if err := errors.Join(errs...); err != nil { + return nil, err + } + return plan, nil +} + +func imageRepositoryReferences(refs []string, repository string) []string { + var matches []string + for _, ref := range refs { + if named, err := reference.ParseDockerRef(ref); err == nil && named.Name() == repository { + matches = append(matches, ref) + } + } + return matches +} + +// planDigestRemovals includes stored digests with the group removing their last repository tag. +// RepoDigests also contains synthesised references, so only digest names stored in RepoTags get operations here. +func planDigestRemovals(plan *imageRemovalPlan, inventory []*machineImageInventory) { + type repository struct { + remainingTags int + digests []string + } + repositories := make(map[string]*repository) + for _, machine := range inventory { + for _, img := range machine.images { + names := slices.Clone(img.RepoTags) + slices.Sort(names) + for _, ref := range slices.Compact(names) { + named, err := reference.ParseDockerRef(ref) + if err != nil { + continue + } + key := machine.machineName + "\x00" + img.ID + "\x00" + named.Name() + repo := repositories[key] + if repo == nil { + repo = &repository{} + repositories[key] = repo + } + if _, tagged := named.(reference.Tagged); tagged { + repo.remainingTags++ + } else if _, digest := named.(reference.Canonical); digest { + repo.digests = append(repo.digests, ref) + } + } + } + } + emitted := make(map[string]bool) + var groups []*removeImageGroupPlan + for _, group := range plan.GroupPlans { + var operations []*removeImageOperation + for _, op := range group.Operations { + key := op.MachineName + "\x00" + op.Image + if emitted[key] { + continue + } + emitted[key] = true + operations = append(operations, op) + named, err := reference.ParseDockerRef(op.Image) + if err != nil { + continue + } + if _, tagged := named.(reference.Tagged); !tagged { + continue + } + repo := repositories[op.MachineName+"\x00"+op.ImageID+"\x00"+named.Name()] + if repo == nil { + continue + } + repo.remainingTags-- + if repo.remainingTags != 0 { + continue + } + for _, digest := range repo.digests { + key := op.MachineName + "\x00" + digest + if !emitted[key] { + emitted[key] = true + operations = append(operations, &removeImageOperation{ + MachineName: op.MachineName, ImageID: op.ImageID, Image: digest, + }) + } + } + } + group.Operations = operations + slices.SortFunc(group.Operations, compareImageRemovalOperations) + if len(group.Operations) > 0 { + groups = append(groups, group) + } + } + plan.GroupPlans = groups +} + +func compareImageRemovalOperations(a, b *removeImageOperation) int { + aDigest, bDigest := strings.Contains(a.Image, "@"), strings.Contains(b.Image, "@") + if aDigest != bDigest { + if aDigest { + return 1 + } + return -1 + } + if c := strings.Compare(a.Image, b.Image); c != 0 { + return c + } + return strings.Compare(a.MachineName, b.MachineName) +} + +func imageIDMatches(id, prefix string) bool { + return strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), strings.TrimPrefix(prefix, "sha256:")) +} diff --git a/cmd/uc/image/rm_plan_test.go b/cmd/uc/image/rm_plan_test.go new file mode 100644 index 00000000..ff9a519b --- /dev/null +++ b/cmd/uc/image/rm_plan_test.go @@ -0,0 +1,405 @@ +package image + +import ( + "strings" + "testing" + + "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) { + idA, idB := testImageID("a"), testImageID("b") + digestRef := "myapp@" + testImageID("c") + tests := []struct { + name string + selector string + inventory []*machineImageInventory + want []removeImageOperation + }{ + { + name: "repository selects all stored names across images and machines", selector: "myapp", + inventory: []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "other:keep"), + testImage(idB, "myapp:latest"), + testImage(testImageID("c"), digestRef), + testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"), + }), + testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}), + }, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idB, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "machine2", ImageID: idB, Image: "myapp:test"}, + {MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef}, + }, + }, + { + name: "repository without latest includes its tags and stored digest", selector: "myapp", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", digestRef, "other:keep"), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + }, + }, + { + name: "explicit latest selects only that tag", selector: "myapp:latest", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "myapp:latest", digestRef), + })}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}}, + }, + { + name: "qualified repository selects familiar names", selector: "docker.io/library/myapp", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "myapp:latest", "other:keep"), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + }, + }, + { + name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new", + "registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"}, + {MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}, + }, + }, + { + name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"), + })}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}}, + }, + { + name: "tag uses each machines local image and allows missing copies", selector: "myapp:old", + inventory: []*machineImageInventory{ + testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}), + testMachineInventory("empty", nil), + testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}), + }, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "machine2", ImageID: idB, Image: "myapp:old"}, + }, + }, + { + name: "qualified names use familiar references", selector: "docker.io/library/myapp:old", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old"), + })}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}}, + }, + { + name: "full ID expands all repositories and deduplicates references per machine", selector: idA, + inventory: []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{ + { + ID: idA, + RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")}, + RepoDigests: []string{ + digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"), + }, + }, + testImage(idB, "unrelated:keep"), + }), + testMachineInventory("machine2", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef}, + }}), + }, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "machine2", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: "other:keep"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + {MachineName: "machine2", ImageID: idA, Image: digestRef}, + {MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")}, + }, + }, + { + name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa", + inventory: []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}), + testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}), + testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}), + }, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "machine2", ImageID: idA, Image: "other:test"}, + }, + }, + { + name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa", + inventory: []*machineImageInventory{testMachineInventory("dev1", + []image.Summary{testImage(idA, "myapp:old")})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}}, + }, + { + name: "unnamed image is removed by its full ID", selector: idA, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}}, + }, + { + name: "ID expands only stored names", selector: idA, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:latest"}, + RepoDigests: []string{"myapp@" + idA, "other@" + idA}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}}, + }, + { + name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoDigests: []string{digestRef}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}}, + }, + { + name: "synthesized digest selects repository tags without a digest row", selector: digestRef, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"}, + RepoDigests: []string{digestRef, "other@" + testImageID("c")}, + }})}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + }, + }, + { + name: "explicit digest lookup works without stored names", selector: digestRef, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoDigests: []string{digestRef}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}}, + }, + { + name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}}, + }, + { + name: "stored digest follows its repository tags", selector: digestRef, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"), + testImage(idB, "myapp@"+idB), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + }, + }, + { + name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"), + testImage(idB, "myapp@"+idB), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + }, + }, + { + name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", digestRef), + {ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}}, + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + }, + }, + { + name: "last repository tag includes its stored digest", selector: "myapp:old", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", digestRef, "other:keep"), + })}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:old"}, + {MachineName: "dev1", ImageID: idA, Image: digestRef}, + }, + }, + { + name: "last tag does not add a synthesized digest row", selector: "myapp:old", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}}, + }, + { + name: "remaining repository tag preserves its stored digest", selector: "myapp:old", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, "myapp:old", "myapp:latest", digestRef), + })}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}}, + }, + { + name: "digest without repository tags remains executable", selector: digestRef, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(idA, digestRef, "other:keep"), + })}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}}, + }, + { + name: "digest-only image keeps its named reference", selector: idA, + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")}, + }})}, + want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}}, + }, + { + name: "tag pattern selects only matching tags", selector: "myapp:1.[12]", + inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef}, + }})}, + want: []removeImageOperation{ + {MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"}, + {MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory) + require.NoError(t, err) + + require.Len(t, plan.GroupPlans, 1) + assert.Equal(t, tt.selector, plan.GroupPlans[0].Image) + + var got []removeImageOperation + for _, op := range plan.GroupPlans[0].Operations { + got = append(got, *op) + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) { + inventory := []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}), + testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}), + } + + plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"}, + inventory) + require.NoError(t, err) + + assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{ + {Image: "myapp:old", Operations: []*removeImageOperation{ + {MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"}, + {MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"}, + }}, + {Image: "myapp", Operations: []*removeImageOperation{ + {MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"}, + {MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"}, + }}, + }}, plan, "the first selector owns each removal and empty groups are omitted") +} + +func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) { + imageID := testImageID("a") + digestRef := "myapp@" + imageID + inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{ + testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"), + })} + + plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory) + require.NoError(t, err) + + assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{ + {Image: "myapp:old", Operations: []*removeImageOperation{ + {MachineName: "dev1", ImageID: imageID, Image: "myapp:old"}, + }}, + {Image: "myapp:latest", Operations: []*removeImageOperation{ + {MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"}, + {MachineName: "dev1", ImageID: imageID, Image: digestRef}, + }}, + }}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation") +} + +func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) { + idA := "sha256:abcdef" + strings.Repeat("a", 58) + idB := "sha256:abcdef" + strings.Repeat("b", 58) + inventory := []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{{ + ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA}, + }}), + testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}), + } + + tests := []struct { + name string + images []string + err string + }{ + {"missing repository", []string{"missing"}, "not found"}, + {"repository only has a derived digest", []string{"derived"}, "not found"}, + {"missing tag", []string{"myapp:missing"}, "not found"}, + {"missing pattern", []string{"other:*"}, "not found"}, + {"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"}, + {"missing ID", []string{testImageID("c")}, "not found"}, + {"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"}, + {"invalid name", []string{"MyApp:old"}, "invalid image"}, + {"invalid pattern", []string{"myapp:["}, "invalid image pattern"}, + {"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"}, + {"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan, err := planImageRemovalFromInventory(tt.images, inventory) + require.ErrorContains(t, err, tt.err) + assert.Nil(t, plan, "a failed selector must not return a partial plan") + }) + } + + t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) { + plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1]) + require.NoError(t, err) + require.Len(t, plan.GroupPlans, 1) + require.Len(t, plan.GroupPlans[0].Operations, 1) + assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID) + }) +} + +func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) { + img := testImage(testImageID("a"), "myapp:old", "myapp:current") + img.Containers = 1 + + for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} { + t.Run(selector, func(t *testing.T) { + plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{ + testMachineInventory("dev1", []image.Summary{img}), + }) + require.NoError(t, err) + assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time") + }) + } +} + +func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) } + +func testImage(imageID string, tags ...string) image.Summary { + return image.Summary{ID: imageID, RepoTags: tags} +} + +func testMachineInventory(name string, images []image.Summary) *machineImageInventory { + return &machineImageInventory{machineName: name, images: images} +} diff --git a/cmd/uc/image/root.go b/cmd/uc/image/root.go index 53ddfee6..05b61a5e 100644 --- a/cmd/uc/image/root.go +++ b/cmd/uc/image/root.go @@ -13,6 +13,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand( NewListCommand(), NewPushCommand(), + NewRemoveCommand(), ) return cmd diff --git a/internal/cli/progress/event.go b/internal/cli/progress/event.go index fc84ce80..30a2a405 100644 --- a/internal/cli/progress/event.go +++ b/internal/cli/progress/event.go @@ -9,7 +9,7 @@ import ( type eventIDKey struct{} -// WithEventID returns a context that overrides the default event ID used by client methods. +// WithEventID returns a context that overrides the event ID already set in the context. func WithEventID(ctx context.Context, eventID string) context.Context { return context.WithValue(ctx, eventIDKey{}, eventID) } @@ -48,7 +48,7 @@ func OldPreDeployHookEventID(serviceName, containerID, machineName string) strin tui.Faint.Render(" on ") + machineName } -// ImageEventID returns a progress event ID for image pull operations. +// ImageEventID returns a progress event ID for image operations on a machine. func ImageEventID(image, machineName string) string { return tui.Faint.Render("Image ") + image + tui.Faint.Render(" on ") + machineName diff --git a/website/docs/9-cli-reference/uc_image.md b/website/docs/9-cli-reference/uc_image.md index 0c121ff0..ad72f37c 100644 --- a/website/docs/9-cli-reference/uc_image.md +++ b/website/docs/9-cli-reference/uc_image.md @@ -22,4 +22,5 @@ Manage images on machines in the cluster. * [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. * [uc image ls](uc_image_ls.md) - List images on machines in the cluster. * [uc image push](uc_image_push.md) - Upload a local Docker image to the cluster. +* [uc image rm](uc_image_rm.md) - Remove images from machines in the cluster. diff --git a/website/docs/9-cli-reference/uc_image_rm.md b/website/docs/9-cli-reference/uc_image_rm.md new file mode 100644 index 00000000..c89f9e4d --- /dev/null +++ b/website/docs/9-cli-reference/uc_image_rm.md @@ -0,0 +1,61 @@ +# uc image rm + +Remove images from machines in the cluster. + +## Synopsis + +Remove images from all or selected machines in the cluster. + +Images used by running or stopped containers cannot be removed. Unused tags can be removed even +when the same image is used under another tag. + +An image name without a tag selects all its tags. Specify a tag, such as myapp:latest, to remove +only that tag. + +Wildcard patterns are supported for image names and tags and should be quoted to prevent your +shell from expanding them. Removing by image ID attempts to remove each name for that image +independently. Failed removals do not prevent other removals. + +``` +uc image rm IMAGE [IMAGE...] [flags] +``` + +## Examples + +``` + # Remove a tag from all machines after confirming the plan. + uc image rm myapp:old + + # Remove all tags for an image name from all machines. + uc image rm myapp + + # Remove matching tags from two machines without confirmation. + uc image rm 'myapp:1.*' 'otherapp:*' -m machine1,machine2 --yes + + # Remove all names for an image by ID from all machines. + uc image rm ee8fed0e9618 +``` + +## Options + +``` + -h, --help help for rm + -m, --machine strings Machine names or IDs to remove images from. Can be specified multiple times or as a comma-separated list. + If not specified, the matching images will be removed from all machines. + -y, --yes Auto-confirm the removal plan. Should be explicitly set when running non-interactively, + e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM] +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock + -c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT] + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc image](uc_image.md) - Manage images on machines in the cluster. +