diff --git a/cmd/skopeo/list_tags.go b/cmd/skopeo/list_tags.go index f9461803..b39591ab 100644 --- a/cmd/skopeo/list_tags.go +++ b/cmd/skopeo/list_tags.go @@ -11,6 +11,7 @@ import ( "slices" "strings" "sync" + "time" commonFlag "github.com/containers/common/pkg/flag" "github.com/containers/common/pkg/retry" @@ -47,6 +48,7 @@ func newFilteredTags() *filteredTags { type tagFilterOptions struct { BeforeVersion commonFlag.OptionalString + BeforeTime commonFlag.OptionalString VersionLabel commonFlag.OptionalString Valid commonFlag.OptionalBool Invalid commonFlag.OptionalBool @@ -54,13 +56,18 @@ type tagFilterOptions struct { } func (opts *tagFilterOptions) FilterPresent() bool { - return opts.BeforeVersion.Present() || opts.Valid.Present() || opts.Invalid.Present() + return opts.BeforeVersion.Present() || opts.Valid.Present() || opts.Invalid.Present() || opts.BeforeTime.Present() +} + +func (opts *tagFilterOptions) InspectFilterPresent() bool { + return (opts.BeforeVersion.Present() && opts.VersionLabel.Present()) || opts.BeforeTime.Present() } func filterFlags() (pflag.FlagSet, *tagFilterOptions) { opts := tagFilterOptions{} fs := pflag.FlagSet{} fs.Var(commonFlag.NewOptionalStringValue(&opts.BeforeVersion), "before-version", "A version threshold prior to which to list tags") + fs.Var(commonFlag.NewOptionalStringValue(&opts.BeforeTime), "before-time", "A date threshold prior to which to list tags") fs.Var(commonFlag.NewOptionalStringValue(&opts.VersionLabel), "version-label", "A label from which to derive the version for each tag") commonFlag.OptionalBoolFlag(&fs, &opts.Valid, "valid", "Whether to list only tags with valid semver") commonFlag.OptionalBoolFlag(&fs, &opts.Invalid, "invalid", "Whether to list only tags with invalid semver") @@ -190,6 +197,16 @@ func filterDockerTagBySemver(filtered *filteredTags, opts *tagsOptions, threshol return nil } +func filterDockerTagByDate(filtered *filteredTags, opts *tagsOptions, timeThreshold time.Time, tag string, created time.Time) (error) { + // Compare the created date against the threshold date + if created.Before(timeThreshold) { + filtered.ToPrune = append(filtered.ToPrune, tag) + } else { + filtered.ToKeep = append(filtered.ToKeep, tag) + } + return nil +} + func filterDockerTagsByTagSemver(opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) { // Get the user-provided threshold version // This will be validated later when the comparison takes place @@ -213,15 +230,15 @@ func filterDockerTagsByTagSemver(opts *tagsOptions, tags *tagListOutput) (*filte } // Use goroutines to parallelize & display progress continuously -func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) { +func filterDockerTagsByImageMetadata(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) { // Get the user-provided threshold version // This will be validated later when the comparison takes place - var threshold string + var versionThreshold string if opts.filterOpts.BeforeVersion.Present() { - threshold = opts.filterOpts.BeforeVersion.Value() + versionThreshold = opts.filterOpts.BeforeVersion.Value() } else { // Set as an arbitrary valid version since this isn't going to affect output - threshold = "v0.1.0" + versionThreshold = "v0.1.0" } // Initialize a zeroed filteredTags struct to return @@ -255,13 +272,13 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext if len(msg) > 0 { // Log progress in-place tagsFiltered += 1 - fmt.Printf("\rfetching image labels\t%d / %d", tagsFiltered, totalTags) + fmt.Fprintf(os.Stderr, "\rinspecting image tags\t%d / %d", tagsFiltered, totalTags) } case err := <-errChan: // Print the error and append to the errors slice if err != nil { numErrors += 1 - fmt.Fprintf(os.Stderr, "\nerror while fetching image labels %s\n", err) + fmt.Fprintf(os.Stderr, "\nerror while inspecting image tags %s\n", err) } case <-doneChan: break filterLoop @@ -269,11 +286,11 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext } // Log completion in-place and close the goroutine - fmt.Printf("\rfetching image labels\t%d / %d (done)\n", tagsFiltered, totalTags) + fmt.Fprintf(os.Stderr, "\rinspecting image tags\t%d / %d (done)\n", tagsFiltered, totalTags) readWaitGroup.Done() }(filteredChan, errChan, doneChan) - // Goroutines for fetching image labels + // Goroutines for fetching image metadata var fetchWaitGroup = sync.WaitGroup{} for i := 0; i < len(tags.Tags); i++ { fetchWaitGroup.Add(1) @@ -319,17 +336,31 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext return } - // Get the version label and filter it into the filteredTags struct - versionLabel := opts.filterOpts.VersionLabel.Value() - tagVersion, ok := imgInspect.Labels[versionLabel] - if !ok { - errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel) - return - } - err = filterDockerTagBySemver(filtered, opts, threshold, tag, tagVersion) - if err != nil { - errChan <- fmt.Errorf("Error filtering tags: %w", err) - return + if opts.filterOpts.VersionLabel.Present() { + // If there is a version label threshold, then filter by the version label + versionLabel := opts.filterOpts.VersionLabel.Value() + tagVersion, ok := imgInspect.Labels[versionLabel] + if !ok { + errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel) + return + } + err = filterDockerTagBySemver(filtered, opts, versionThreshold, tag, tagVersion) + if err != nil { + errChan <- fmt.Errorf("For tag %s: error filtering: %w", tag, err) + return + } + } else { + // If there is a time threshold, then filter by the created date + timeThreshold, err := time.Parse(time.RFC3339, opts.filterOpts.BeforeTime.Value()) + if err != nil { + errChan <- fmt.Errorf("Error parsing time threshold for filtering: %w", err) + return + } + err = filterDockerTagByDate(filtered, opts, timeThreshold, tag, *imgInspect.Created) + if err != nil { + errChan <- fmt.Errorf("For tag %s: error filtering: %w", tag, err) + return + } } // If successful, then signal completion @@ -357,7 +388,7 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext // Return the filtered tags, and an error summary if any errors occurred if numErrors > 0 { - return filtered, fmt.Errorf("Encountered %d errors while filtering tags by label semver", numErrors) + return filtered, fmt.Errorf("Encountered %d errors while filtering tags by inspect metadata", numErrors) } return filtered, nil } @@ -367,8 +398,8 @@ func filterDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsO Repository: repositoryName, Tags: tags, } - if opts.filterOpts.VersionLabel.Present() { - return filterDockerTagsByLabelSemver(ctx, sys, opts, tagList) + if opts.filterOpts.InspectFilterPresent() { + return filterDockerTagsByImageMetadata(ctx, sys, opts, tagList) } return filterDockerTagsByTagSemver(opts, tagList) } @@ -378,8 +409,8 @@ func listFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts if err != nil { return ``, nil, fmt.Errorf("Error filtering tags by semver: %w", err) } - if opts.filterOpts.BeforeVersion.Present() { - // Optionally only list tags prior to given semver threshold + if opts.filterOpts.BeforeVersion.Present() || opts.filterOpts.BeforeTime.Present() { + // Optionally only list tags prior to given semver or date threshold return repositoryName, filtered.ToPrune, nil } else if opts.filterOpts.Invalid.Present() { // Optionally only list tags with invalid semver diff --git a/cmd/skopeo/prune.go b/cmd/skopeo/prune.go index 0b66d072..060345f1 100644 --- a/cmd/skopeo/prune.go +++ b/cmd/skopeo/prune.go @@ -20,6 +20,7 @@ import ( "github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/types" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // Enumerated constant for byte units @@ -76,7 +77,7 @@ func bytesToByteUnit(bytes int64) string { return fmt.Sprintf("%.2f %s", unitDec, unitSuf) } -var pruneTransportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) error { +var pruneTransportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *pruneOptions, userInput string) error { docker.Transport.Name(): pruneDockerTags, } @@ -86,11 +87,25 @@ func supportedPruneTransports(joinStr string) string { return strings.Join(res, joinStr) } +type pruneUserOptions struct { + SkipSummary bool + NonInteractive bool +} + +func pruneFlags() (pflag.FlagSet, *pruneUserOptions) { + opts := pruneUserOptions{} + fs := pflag.FlagSet{} + fs.BoolVarP(&opts.SkipSummary, "skip-summary", "s", false, "Skip computing the prune summary of freed storage space") + fs.BoolVarP(&opts.NonInteractive, "non-interactive", "y", false, "Do not display an interactive prompt for the user to confirm before beginning puning") + return fs, &opts +} + type pruneOptions struct { global *globalOptions image *imageOptions retryOpts *retry.Options filterOpts *tagFilterOptions + pruneOpts *pruneUserOptions } func (p *pruneOptions) intoTagsOptions() *tagsOptions { @@ -108,12 +123,14 @@ func pruneCmd(global *globalOptions) *cobra.Command { imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, nil, "", "") retryFlags, retryOpts := retryFlags() filterFlags, filterOpts := filterFlags() + pruneFlags, pruneOpts := pruneFlags() opts := pruneOptions{ global: global, image: imageOpts, retryOpts: retryOpts, filterOpts: filterOpts, + pruneOpts: pruneOpts, } cmd := &cobra.Command{ @@ -135,6 +152,7 @@ See skopeo-prune(1) section "REPOSITORY NAMES" for the expected format flags.AddFlagSet(&imageFlags) flags.AddFlagSet(&retryFlags) flags.AddFlagSet(&filterFlags) + flags.AddFlagSet(&pruneFlags) return cmd } @@ -143,7 +161,7 @@ func displayPrunePrompt() bool { // Prompt the user whether they would like to proceed to prune reader := bufio.NewReader(os.Stdin) for { - fmt.Println("\nwarning: continuing to prune will lead to irreversible data loss") + fmt.Println("warning: continuing to prune will lead to irreversible data loss") fmt.Print("continue to prune? (y/n): ") input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) @@ -312,7 +330,7 @@ func getSizeMaps(ctx context.Context, sys *types.SystemContext, url string) (map } // Function that computes the deduplicated size of a slice of image tags -func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) int64 { +func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext, repositoryName string, tags []string) int64 { // Get the config & layer size maps dedupConfigSizeMap := make(map[string]int64) dedupLayerSizeMap := make(map[string]int64) @@ -428,7 +446,7 @@ func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext, // Function that displays the prune summary of size freed in pruning // TODO: Determine if errors occurred during size calculation -func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string, toPrune []string, toKeep []string) error { +func displayPruneSummary(ctx context.Context, sys *types.SystemContext, userInput string, toPrune []string, toKeep []string) error { imgRef, err := parseDockerRepositoryReference(userInput) if err != nil { return fmt.Errorf("Error parsing image reference: %w", err) @@ -436,8 +454,8 @@ func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *ta repositoryName := imgRef.DockerReference().Name() // Compute the deduplicated size of the images that will be pruned vs kept - pruneSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toPrune) - keepSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toKeep) + pruneSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toPrune) + keepSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toKeep) // Summarize the list fmt.Println("") @@ -447,12 +465,13 @@ func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *ta keepDisp := fmt.Sprintf("Keep\t%d\t%s", len(toKeep), bytesToByteUnit(keepSize)) fmt.Fprintln(w, pruneDisp) fmt.Fprintln(w, keepDisp) + fmt.Fprintln(w, "") w.Flush() return nil // Success } // Function that prunes the tags identified for pruning and displays progress -func pruneDockerTagsParallel(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) error { +func pruneDockerTagsParallel(ctx context.Context, sys *types.SystemContext, repositoryName string, tags []string) error { // Variable tracking how many tags have been pruned totalTags := len(tags) tagsPruned := 0 @@ -577,9 +596,9 @@ func getFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts * } // Function that prunes docker tags -func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) error { +func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *pruneOptions, userInput string) error { // Get the filtered docker tags for the given repository - filteredTags, err := getFilteredDockerTags(ctx, sys, opts, userInput) + filteredTags, err := getFilteredDockerTags(ctx, sys, opts.intoTagsOptions(), userInput) if err != nil { return fmt.Errorf("Error getting filtered docker tags: %w", err) } @@ -597,19 +616,23 @@ func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOp // Display the prune summary // TODO: Error check - determine if errors occurred during size calculation - err = displayPruneSummary(ctx, sys, opts, userInput, toPrune, toKeep) - if err != nil { - return fmt.Errorf("Error displaying prune summary: %w", err) + if !opts.pruneOpts.SkipSummary { + err = displayPruneSummary(ctx, sys, userInput, toPrune, toKeep) + if err != nil { + return fmt.Errorf("Error displaying prune summary: %w", err) + } } // Display the prune prompt - accepted := displayPrunePrompt() - if !accepted{ - return nil // User decided not to prune + if !opts.pruneOpts.NonInteractive { + accepted := displayPrunePrompt() + if !accepted{ + return nil // User decided not to prune + } } // Prune the docker tags - err = pruneDockerTagsParallel(ctx, sys, opts, userInput, toPrune) + err = pruneDockerTagsParallel(ctx, sys, userInput, toPrune) if err != nil { return fmt.Errorf("Error pruning tags: %w", err) } @@ -635,7 +658,7 @@ func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) { } if val, ok := pruneTransportHandlers[transport.Name()]; ok { - err = val(ctx, sys, opts.intoTagsOptions(), args[0]) + err = val(ctx, sys, opts, args[0]) if err != nil { return err } @@ -646,5 +669,3 @@ func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) { return nil // Success } - -// TODO: Prune-many command