mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 04:34:17 +00:00
feat(images): add 'uc image rm' command to remove images from cluster machines
Co-authored-by: Miek Gieben <miek@miek.nl>
This commit is contained in:
co-authored by
Miek Gieben
parent
7c6d1be8a7
commit
e9eab32e6d
+8
-3
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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:"))
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ func NewRootCommand() *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
NewListCommand(),
|
||||
NewPushCommand(),
|
||||
NewRemoveCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
Reference in New Issue
Block a user