diff --git a/cmd/skopeo/list_tags.go b/cmd/skopeo/list_tags.go index 4b16f50645..b39591ab4d 100644 --- a/cmd/skopeo/list_tags.go +++ b/cmd/skopeo/list_tags.go @@ -7,16 +7,23 @@ import ( "fmt" "io" "maps" + "os" "slices" "strings" + "sync" + "time" + commonFlag "github.com/containers/common/pkg/flag" "github.com/containers/common/pkg/retry" "github.com/containers/image/v5/docker" "github.com/containers/image/v5/docker/archive" "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/image" "github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/types" "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/mod/semver" ) // tagListOutput is the output format of (skopeo list-tags), primarily so that we can format it with a simple json.MarshalIndent. @@ -25,10 +32,54 @@ type tagListOutput struct { Tags []string } +type filteredTags struct { + ToPrune []string + ToKeep []string + Invalid []string +} + +func newFilteredTags() *filteredTags { + return &filteredTags{ + ToPrune: make([]string, 0), + ToKeep: make([]string, 0), + Invalid: make([]string, 0), + } +} + +type tagFilterOptions struct { + BeforeVersion commonFlag.OptionalString + BeforeTime commonFlag.OptionalString + VersionLabel commonFlag.OptionalString + Valid commonFlag.OptionalBool + Invalid commonFlag.OptionalBool + SingleStream commonFlag.OptionalBool +} + +func (opts *tagFilterOptions) FilterPresent() bool { + 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") + commonFlag.OptionalBoolFlag(&fs, &opts.SingleStream, "single-stream", "Whether to only list versions matching the Major.Minor of the --before-version threshold") + return fs, &opts +} + type tagsOptions struct { - global *globalOptions - image *imageOptions - retryOpts *retry.Options + global *globalOptions + image *imageOptions + retryOpts *retry.Options + filterOpts *tagFilterOptions } var transportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) (repositoryName string, tagListing []string, err error){ @@ -46,11 +97,13 @@ func tagsCmd(global *globalOptions) *cobra.Command { sharedFlags, sharedOpts := sharedImageFlags() imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, nil, "", "") retryFlags, retryOpts := retryFlags() + filterFlags, filterOpts := filterFlags() opts := tagsOptions{ global: global, image: imageOpts, retryOpts: retryOpts, + filterOpts: filterOpts, } cmd := &cobra.Command{ @@ -71,6 +124,7 @@ See skopeo-list-tags(1) section "REPOSITORY NAMES" for the expected format flags.AddFlagSet(&sharedFlags) flags.AddFlagSet(&imageFlags) flags.AddFlagSet(&retryFlags) + flags.AddFlagSet(&filterFlags) return cmd } @@ -95,14 +149,293 @@ func parseDockerRepositoryReference(refString string) (types.ImageReference, err return docker.NewReference(reference.TagNameOnly(ref)) } +func getValidSemverString(version string) (string, error) { + // Return the version string if it's valid on its own + if semver.IsValid(version) { + return version, nil + } + + // Append a "v" prefix onto the version string if it's missing one + modifiedVersion := fmt.Sprintf("v%s", version) + if semver.IsValid(modifiedVersion) { + return modifiedVersion, nil + } + + // If neither are valid, then the version string itself is bad + return "", fmt.Errorf("invalid semver: %s", version) +} + +func filterDockerTagBySemver(filtered *filteredTags, opts *tagsOptions, thresholdVersion string, tag string, tagVersion string) (error) { + // Parse the threshold & tag versions + validThreshold, err := getValidSemverString(thresholdVersion) + if err != nil { + return fmt.Errorf("invalid semver in version threshold: %w", err) + } + validTagVersion, err := getValidSemverString(tagVersion) + + // If the tag version is invalid, then filter it as such + if err != nil { + filtered.Invalid = append(filtered.Invalid, tag) + return nil + } + + // If single stream, keep all tags not matching threshold Major.Minor + if opts.filterOpts.SingleStream.Present() && opts.filterOpts.SingleStream.Value() { + if semver.MajorMinor(validThreshold) != semver.MajorMinor(validTagVersion) { + filtered.ToKeep = append(filtered.ToKeep, tag) + return nil + } + } + + // Compare the tag semver against the threshold semver + cmp := semver.Compare(validThreshold, validTagVersion) + if cmp < 0 { + filtered.ToKeep = append(filtered.ToKeep, tag) + } else { + filtered.ToPrune = append(filtered.ToPrune, tag) + } + 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 + var threshold string + if opts.filterOpts.BeforeVersion.Present() { + threshold = opts.filterOpts.BeforeVersion.Value() + } else { + // Set as an arbitrary valid version since this isn't going to affect output + threshold = "v0.1.0" + } + + // Loop through each tag and sort into to prune, to keep, and, invalid + filtered := newFilteredTags() + for _, tag := range tags.Tags { + err := filterDockerTagBySemver(filtered, opts, threshold, tag, tag) + if err != nil { + return nil, fmt.Errorf("Error filtering tags: %w", err) + } + } + return filtered, nil +} + +// Use goroutines to parallelize & display progress continuously +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 versionThreshold string + if opts.filterOpts.BeforeVersion.Present() { + versionThreshold = opts.filterOpts.BeforeVersion.Value() + } else { + // Set as an arbitrary valid version since this isn't going to affect output + versionThreshold = "v0.1.0" + } + + // Initialize a zeroed filteredTags struct to return + filtered := newFilteredTags() + + // Set up channels for communication as labels are fetched + var filteredChan = make(chan string, 8) + var errChan = make(chan error, 8) + var doneChan = make(chan struct{}) + + // Blocking channel for limiting concurrent fetches from the registry + var fetchLimitChan = make(chan struct{}, 8) + + // Total tags and counter for filtered tags & errors + totalTags := len(tags.Tags) + tagsFiltered := 0 + numErrors := 0 + + // Goroutine for displaying progress + var readWaitGroup = sync.WaitGroup{} + readWaitGroup.Add(1) + go func(filteredChan <-chan string, errChan <-chan error, doneChan <-chan struct{}) { + // Loop for monitoring progress & filtering tags + filterLoop: + for { + select { + case msg := <-filteredChan: + // Sometimes this case fires off erroneously causing the count to be inaccurate + // I'm not sure why this is the case but checking for valid values sent through + // the channel seems to filter out the erroneous instances and re-align count + if len(msg) > 0 { + // Log progress in-place + tagsFiltered += 1 + 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 inspecting image tags %s\n", err) + } + case <-doneChan: + break filterLoop + } + } + + // Log completion in-place and close the goroutine + fmt.Fprintf(os.Stderr, "\rinspecting image tags\t%d / %d (done)\n", tagsFiltered, totalTags) + readWaitGroup.Done() + }(filteredChan, errChan, doneChan) + + // Goroutines for fetching image metadata + var fetchWaitGroup = sync.WaitGroup{} + for i := 0; i < len(tags.Tags); i++ { + fetchWaitGroup.Add(1) + fetchLimitChan <- struct{}{} + go func(sys *types.SystemContext, repo string, tag string, filteredChan chan<- string, errChan chan<- error, fetchLimitChan <-chan struct{}) { + // Close the channel once complete + defer func() { fetchWaitGroup.Done(); <-fetchLimitChan }() + + // Initialize some variables + var ( + src types.ImageSource + imgInspect *types.ImageInspectInfo + err error + ) + + // Reconstruct the image reference and inspect it, borrowed from inspect implementation + // Hardcode to docker:// as we know only this transport will trigger this function + imageName := fmt.Sprintf("docker://%s:%s", repo, tag) + if err := retry.IfNecessary(ctx, func() error { + src, err = parseImageSource(ctx, opts.image, imageName) + return err + }, opts.retryOpts); err != nil { + errChan <- fmt.Errorf("Error parsing image name %q: %w", imageName, err) + return + } + defer func() { + if err := src.Close(); err != nil { + var retErr error + errChan <- noteCloseFailure(retErr, "closing image", err) + } + }() + unparsedInstance := image.UnparsedInstance(src, nil) + img, err := image.FromUnparsedImage(ctx, sys, unparsedInstance) + if err != nil { + errChan <- fmt.Errorf("Error parsing manifest for tag %s: %w", tag, err) + return + } + if err := retry.IfNecessary(ctx, func() error { + imgInspect, err = img.Inspect(ctx) + return err + }, opts.retryOpts); err != nil { + errChan <- 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 + filteredChan <- "done" + }(sys, tags.Repository, tags.Tags[i], filteredChan, errChan, fetchLimitChan) + } + + // Goroutines for monitoring fetch & read wait groups + var monitorWaitGroup = sync.WaitGroup{} + monitorWaitGroup.Add(2) + go func(doneChan chan struct{}) { + readWaitGroup.Wait() + close(doneChan) + monitorWaitGroup.Done() + }(doneChan) + go func(filteredChan chan string, errChan chan error, fetchLimitChan chan struct{}, doneChan chan struct{}) { + fetchWaitGroup.Wait() + close(filteredChan) + close(errChan) + close(fetchLimitChan) + doneChan <- struct{}{} // Signal completion to the readWaitGroup + monitorWaitGroup.Done() + }(filteredChan, errChan, fetchLimitChan, doneChan) + monitorWaitGroup.Wait() + + // 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 inspect metadata", numErrors) + } + return filtered, nil +} + +func filterDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) (*filteredTags, error) { + tagList := &tagListOutput{ + Repository: repositoryName, + Tags: tags, + } + if opts.filterOpts.InspectFilterPresent() { + return filterDockerTagsByImageMetadata(ctx, sys, opts, tagList) + } + return filterDockerTagsByTagSemver(opts, tagList) +} + +func listFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) (string, []string, error) { + filtered, err := filterDockerTags(ctx, sys, opts, repositoryName, tags) + if err != nil { + return ``, nil, fmt.Errorf("Error filtering tags by semver: %w", err) + } + 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 + return repositoryName, filtered.Invalid, nil + } else { // Then only --valid could have possibly been set + // Optionally only list tags with valid semver + valid := append(filtered.ToKeep, filtered.ToPrune...) + return repositoryName, valid, nil + } +} + // List the tags from a repository contained in the imgRef reference. Any tag value in the reference is ignored -func listDockerTags(ctx context.Context, sys *types.SystemContext, imgRef types.ImageReference) (string, []string, error) { +func listDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, imgRef types.ImageReference) (string, []string, error) { repositoryName := imgRef.DockerReference().Name() tags, err := docker.GetRepositoryTags(ctx, sys, imgRef) if err != nil { return ``, nil, fmt.Errorf("Error listing repository tags: %w", err) } + + // If the user requests all tags before a certain threshold, then filter + if opts.filterOpts.FilterPresent() { + return listFilteredDockerTags(ctx, sys, opts, repositoryName, tags) + } + return repositoryName, tags, nil } @@ -114,7 +447,7 @@ func listDockerRepoTags(ctx context.Context, sys *types.SystemContext, opts *tag return } if err = retry.IfNecessary(ctx, func() error { - repositoryName, tagListing, err = listDockerTags(ctx, sys, imgRef) + repositoryName, tagListing, err = listDockerTags(ctx, sys, opts, imgRef) return err }, opts.retryOpts); err != nil { return diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go index 8b8de3c7bc..9218c2d1d7 100644 --- a/cmd/skopeo/main.go +++ b/cmd/skopeo/main.go @@ -103,6 +103,7 @@ func createApp() (*cobra.Command, *globalOptions) { logoutCmd(&opts), manifestDigestCmd(), proxyCmd(&opts), + pruneCmd(&opts), syncCmd(&opts), standaloneSignCmd(), standaloneVerifyCmd(), diff --git a/cmd/skopeo/prune.go b/cmd/skopeo/prune.go new file mode 100644 index 0000000000..060345f1f7 --- /dev/null +++ b/cmd/skopeo/prune.go @@ -0,0 +1,671 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "slices" + "sort" + "strings" + "sync" + "text/tabwriter" + + "github.com/containers/common/pkg/retry" + "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/docker" + "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 +const ( + _ = iota // Ignore first value + KB = 1 << (10 * iota) // Bit shift each by 10 + MB + GB + TB + PB + EB +) + +// Global map of byte unit names to byte unit sizes +var byteUnits = map[int]string{ + KB: "KB", + MB: "MB", + GB: "GB", + TB: "TB", + PB: "PB", + EB: "EB", +} + +// Formats a number of bytes to the nearest unit +// For example, 2048 -> "2.00 KB" +func bytesToByteUnit(bytes int64) string { + // Initialize variable to track unit decimal + var unitDec float64 + unitSuf := "B" + + // Collect and sort the byte unit sizes + sizes := make([]int, 0) + for k, _ := range byteUnits { + sizes = append(sizes, k) + } + sort.Ints(sizes) + + // Loop through byte units + for _, size := range sizes { + // Divide by the unit size + tempDec := float64(bytes) / float64(size) + + // If less than 1 then break + if tempDec < 1 { + break + } + + // Otherwise overwrite the unit suffix + unitDec = tempDec + unitSuf = byteUnits[size] + } + + // Format and return + return fmt.Sprintf("%.2f %s", unitDec, unitSuf) +} + +var pruneTransportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *pruneOptions, userInput string) error { + docker.Transport.Name(): pruneDockerTags, +} + +// supportedPrneTransports returns all the supported transports for pruning +func supportedPruneTransports(joinStr string) string { + res := slices.Sorted(maps.Keys(pruneTransportHandlers)) + 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 { + return &tagsOptions{ + global: p.global, + image: p.image, + retryOpts: p.retryOpts, + filterOpts: p.filterOpts, + } +} + +// Prune command +func pruneCmd(global *globalOptions) *cobra.Command { + sharedFlags, sharedOpts := sharedImageFlags() + 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{ + Use: "prune [command options] SOURCE-IMAGE", + Short: "Prune tags in the transport/repository specified by the SOURCE-IMAGE", + Long: `Prune the list of tags from the transport/repository "SOURCE-IMAGE" + +Supported transports: +` + supportedPruneTransports(" ") + ` + +See skopeo-prune(1) section "REPOSITORY NAMES" for the expected format +`, + RunE: commandAction(opts.run), + Example: `skopeo prune docker://docker.io/fedora`, + } + adjustUsage(cmd) + flags := cmd.Flags() + flags.AddFlagSet(&sharedFlags) + flags.AddFlagSet(&imageFlags) + flags.AddFlagSet(&retryFlags) + flags.AddFlagSet(&filterFlags) + flags.AddFlagSet(&pruneFlags) + return cmd +} + +// Function that polls for user input confirming to continue with the pruning +func displayPrunePrompt() bool { + // Prompt the user whether they would like to proceed to prune + reader := bufio.NewReader(os.Stdin) + for { + 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) + + if input == "y" || input == "Y" { + fmt.Printf("continuing\n\n") + break + } else if input == "n" || input == "N" { + fmt.Println("done") + return false + } else { + fmt.Println("invalid input. please enter 'y' or 'n'") + } + } + return true +} + +func getLayerSizeMap(layerInfo []manifest.LayerInfo) map[string]int64 { + // Initialize a layer size map, maps layer digest to size + layerSizeMap := make(map[string]int64) + + // Loop through the layerInfo slice + for _, layer := range layerInfo { + // Get layer digest and size + digest := string(layer.Digest) + size := layer.Size + + // Continue if layer is unknown + if digest == "" && size == -1 { + continue + } + + // Add to map + layerSizeMap[digest] = size + } + + // Return the map + return layerSizeMap +} + +func getManifestSizeMaps(rawManifest []byte, mimeType string) (map[string]int64, map[string]int64, error) { + // Convert the raw manifest to a manifest + mfs, err := manifest.FromBlob(rawManifest, mimeType) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert raw manifest to manifest: %w", err) + } + + // Initialize size & size map vars + var configSize int64 + configSizeMap := make(map[string]int64) + layerSizeMap := make(map[string]int64) + + // Get the config size + configInfo := mfs.ConfigInfo() + if string(configInfo.Digest) != "" { + configSize = configInfo.Size + } + + // Initialzie the config size map, maps config digest to size + configSizeMap[string(configInfo.Digest)] = configSize + + // Get layer sizes + layerSizeMap = getLayerSizeMap(mfs.LayerInfos()) + + return configSizeMap, layerSizeMap, nil +} + +func getManifestListSizeMaps(ctx context.Context, sys *types.SystemContext, repo string, rawManifest []byte, mimeType string) (map[string]int64, map[string]int64, error) { + // Convert the raw manifest to a slice of v2 descriptors + manifestList, err := manifest.ListFromBlob(rawManifest, mimeType) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert raw manifest to manifest list: %w", err) + } + + // Initialize maps for the config & layer sizes of each manifest + configSizeMap := make(map[string]int64) + layerSizeMap := make(map[string]int64) + + // Loop through the manifest list and append the size maps for each + for _, digest := range manifestList.Instances() { + // Format the image URL + url := fmt.Sprintf("docker://%s@%s", repo, string(digest)) + + // Parse the image URL into an image reference + ref, err := alltransports.ParseImageName(url) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse image url: %s", err) + } + + // Instantiate an image source from the reference + src, err := ref.NewImageSource(ctx, sys) + if err != nil { + return nil, nil, fmt.Errorf("failed to instantiate image source: %w", err) + } + defer src.Close() + + // Get the raw manifest + rawManifest, _, err := src.GetManifest(ctx, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to get raw manifest: %w", err) + } + mimeType := manifest.GuessMIMEType(rawManifest) + + // Get the size maps for the manifest + tmpConfigSizeMap, tmpLayerSizeMap, err := getManifestSizeMaps(rawManifest, mimeType) + if err != nil { + return nil, nil, fmt.Errorf("failed to get size maps for raw manifest: %w", err) + } + + // Add the size data to the list size maps + for k, v := range tmpConfigSizeMap { + configSizeMap[k] = v + } + for k, v := range tmpLayerSizeMap { + layerSizeMap[k] = v + } + } + return configSizeMap, layerSizeMap, nil +} + +func getSizeMaps(ctx context.Context, sys *types.SystemContext, url string) (map[string]int64, map[string]int64, error) { + // Parse the image URL into an image reference + ref, err := alltransports.ParseImageName(url) + if err != nil { + return nil, nil, fmt.Errorf("Error parsing image reference: %w", err) + } + + // Capture the repository of the image URL + repo := ref.DockerReference().Name() + + // Instantiate an image source from the reference + src, err := ref.NewImageSource(ctx, sys) + if err != nil { + return nil, nil, fmt.Errorf("failed to instantiate image source: %w", err) + } + defer src.Close() + + // Get the raw manifest + rawManifest, _, err := src.GetManifest(ctx, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to get raw manifest: %w", err) + } + + // Get the manifest MIME type + var configSizeMap map[string]int64 + var layerSizeMap map[string]int64 + mimeType := manifest.GuessMIMEType(rawManifest) + + // Get the size maps based on whether the image is multi-arch or single-arch + if manifest.MIMETypeIsMultiImage(mimeType) { + // If multi-arch, then get the multi-arch size maps + configSizeMap, layerSizeMap, err = getManifestListSizeMaps(ctx, sys, repo, rawManifest, mimeType) + if err != nil { + return nil, nil, fmt.Errorf("failed to get manifest list size maps: %w", err) + } + } else { + // Otherwise get the single-arch size maps + configSizeMap, layerSizeMap, err = getManifestSizeMaps(rawManifest, mimeType) + if err != nil { + return nil, nil, fmt.Errorf("failed to get manifest size maps: %w", err) + } + } + + // Return the size maps + return configSizeMap, layerSizeMap, nil +} + +// Function that computes the deduplicated size of a slice of image tags +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) + + // Channels for communication as sizes are calculated + var sizeMapArrChan = make(chan [2]map[string]int64, 8) + var errChan = make(chan error, 8) + var doneChan = make(chan struct{}) + + // Channel for limiting concurrent fetches from the registry + var fetchLimitCh = make(chan struct{}, 8) + + // Total tags and counter for sizes calculated + totalTags := len(tags) + sizesCalculated := 0 + + // Total size int + var totalSize int64 + + // Goroutine for monitoring progress + var readWaitGroup = sync.WaitGroup{} + readWaitGroup.Add(1) + go func(sizeMapArrChan <-chan [2]map[string]int64, errChan <-chan error, doneChan <-chan struct{}) { + // Loop for monitoring progress + sizeCalcLoop: + for { + select { + case sizeMapArr := <-sizeMapArrChan: + // Another case of erroneous firing of the channel, not entirely sure why + // Validating channel output seems to filter out erroneous instances + if len(sizeMapArr[0]) > 0 && len(sizeMapArr[1]) > 0 { + // Log progress in-place + sizesCalculated += 1 + fmt.Printf("\rcalculating deduplicated image sizes\t%d / %d", sizesCalculated, totalTags) + + // Add to the deduplicated maps + for configDigest, configSize := range sizeMapArr[0] { + dedupConfigSizeMap[configDigest] = configSize + } + for layerDigest, layerSize := range sizeMapArr[1] { + dedupLayerSizeMap[layerDigest] = layerSize + } + } + case err := <-errChan: + if err != nil { + // Print the error + fmt.Fprintf(os.Stderr, "\nerror while calculating deduplicated image sizes: %s\n", err) + } + case <-doneChan: + break sizeCalcLoop + } + } + + // Log completion in-place + fmt.Printf("\rcalculating deduplicated image sizes\t%d / %d (done)\n", sizesCalculated, totalTags) + + // Sum the deduplicated config and layer sizes and return + for _, configSize := range dedupConfigSizeMap { + totalSize += configSize + } + for _, layerSize := range dedupLayerSizeMap { + totalSize += layerSize + } + + // Close the goroutine + readWaitGroup.Done() + }(sizeMapArrChan, errChan, doneChan) + + // Goroutines for fetching image sizes + var fetchWaitGroup = sync.WaitGroup{} + for i := 0; i < len(tags); i++ { + fetchWaitGroup.Add(1) + fetchLimitCh <- struct{}{} + url := fmt.Sprintf("docker://%s:%s", repositoryName, tags[i]) + go func(sys *types.SystemContext, url string, sizeMapArrChan chan<- [2]map[string]int64, errChan chan<- error, fetchLimitCh <-chan struct{}) { + // Close the channel once complete + defer func() { fetchWaitGroup.Done(); <-fetchLimitCh }() + + // Get the image size maps + configSizeMap, layerSizeMap, err := getSizeMaps(ctx, sys, url) + if err != nil { + errChan <- err + return + } + + // Initialize a size-two array containing the size maps + sizeMapArr := [2]map[string]int64{configSizeMap, layerSizeMap} + sizeMapArrChan <- sizeMapArr + }(sys, url, sizeMapArrChan, errChan, fetchLimitCh) + } + + // Goroutines for monitoring fetch & read wait groups + var monitorWaitGroup = sync.WaitGroup{} + monitorWaitGroup.Add(2) + go func(doneChan chan struct{}) { + readWaitGroup.Wait() + close(doneChan) + monitorWaitGroup.Done() + }(doneChan) + go func(sizeMapArrChan chan [2]map[string]int64, errChan chan error, fetchLimitCh chan struct{}, doneChan chan struct{}) { + fetchWaitGroup.Wait() + close(sizeMapArrChan) + close(errChan) + close(fetchLimitCh) + doneChan <- struct{}{} // Signal completion to the readWaitGroup + monitorWaitGroup.Done() + }(sizeMapArrChan, errChan, fetchLimitCh, doneChan) + monitorWaitGroup.Wait() + + // Return the calculated total size + return totalSize +} + +// 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, userInput string, toPrune []string, toKeep []string) error { + imgRef, err := parseDockerRepositoryReference(userInput) + if err != nil { + return fmt.Errorf("Error parsing image reference: %w", err) + } + repositoryName := imgRef.DockerReference().Name() + + // Compute the deduplicated size of the images that will be pruned vs kept + pruneSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toPrune) + keepSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toKeep) + + // Summarize the list + fmt.Println("") + w := tabwriter.NewWriter(os.Stdout, 1, 1, 3, ' ', 0) + fmt.Fprintln(w, "ACTION\tTAGS\tSIZE") + pruneDisp := fmt.Sprintf("Prune\t%d\t%s", len(toPrune), bytesToByteUnit(pruneSize)) + 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, repositoryName string, tags []string) error { + // Variable tracking how many tags have been pruned + totalTags := len(tags) + tagsPruned := 0 + + // Channels for communication as tags are pruned + var pruneCh = make(chan int, 8) + var errCh = make(chan error, 8) + var doneCh = make(chan struct{}) + + // Channel for limiting concurrent prunes from the registry + var pruneLimitCh = make(chan struct{}, 8) + + // Goroutine for monitoring progress + var readWaitGroup = sync.WaitGroup{} + readWaitGroup.Add(1) + go func(pruneCh <-chan int, errCh <-chan error, doneCh <-chan struct{}) { + // Close the read wait group once complete + defer readWaitGroup.Done() + + // Loop for monitoring progress + pruneProgressLoop: + for { + select { + case sig := <-pruneCh: + if sig != 0 { + // Log progress in-place + tagsPruned += 1 + fmt.Printf("\rpruning tags\t%d / %d", tagsPruned, totalTags) + } + case err := <-errCh: + if err != nil { + // Print the error + fmt.Fprintf(os.Stderr, "\nerror while pruning tags %s\n", err) + } + case <-doneCh: + break pruneProgressLoop + } + } + + // Log completion in-place + fmt.Printf("\rpruning tags\t%d / %d (done)\n", tagsPruned, totalTags) + }(pruneCh, errCh, doneCh) + + // Goroutines for pruning tags + var pruneWaitGroup = sync.WaitGroup{} + for i := 0; i < len(tags); i++ { + pruneWaitGroup.Add(1) + pruneLimitCh <- struct{}{} + url := fmt.Sprintf("docker://%s:%s", repositoryName, tags[i]) + go func(sys *types.SystemContext, url string, pruneCh chan<- int, errCh chan<- error, pruneLimitCh <-chan struct{}) { + // Close the prune wait group once complete + defer func() { pruneWaitGroup.Done(); <-pruneLimitCh }() + + // Append docker transport to image url + // This assumes all images are in remote distribution registry + url = fmt.Sprintf("docker://%s", url) + + // Parse the image URL into a reference + ref, err := alltransports.ParseImageName(url) + if err != nil { + errCh <- fmt.Errorf("failed to parse image url: %w", err) + return + } + + // Delete the image corresponding to the reference + err = ref.DeleteImage(ctx, sys) + if err != nil { + errCh <- fmt.Errorf("failed to prune tag: %w", err) + return + } + + // Signal prune completion + pruneCh <- 1 + }(sys, url, pruneCh, errCh, pruneLimitCh) + } + + // Goroutines for monitoring prune wait groups + var monitorWaitGroup = sync.WaitGroup{} + monitorWaitGroup.Add(2) + go func(doneCh chan struct{}) { + readWaitGroup.Wait() + close(doneCh) + monitorWaitGroup.Done() + }(doneCh) + go func(pruneCh chan int, errCh chan error, pruneLimitCh chan struct{}, doneCh chan struct{}) { + pruneWaitGroup.Wait() + close(pruneCh) + close(errCh) + close(pruneLimitCh) + doneCh <- struct{}{} // Signal completion to the readWaitGroup + monitorWaitGroup.Done() + }(pruneCh, errCh, pruneLimitCh, doneCh) + monitorWaitGroup.Wait() + return nil +} + +// Function that gets the filtered tags to prune +func getFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) (*filteredTags, error) { + // Get the repo tags + imgRef, err := parseDockerRepositoryReference(userInput) + if err != nil { + return nil, fmt.Errorf("Error parsing image reference: %w", err) + } + repositoryName := imgRef.DockerReference().Name() + tags, err := docker.GetRepositoryTags(ctx, sys, imgRef) + if err != nil { + return nil, fmt.Errorf("Error getting repository tags: %w", err) + } + + // If the user requests all tags before a certain threshold, then filter + if opts.filterOpts.FilterPresent() { + return filterDockerTags(ctx, sys, opts, repositoryName, tags) + } + + // Otherwise, keep all tags as the default behavior to avoid data loss + filtered := &filteredTags{ + ToPrune: make([]string, 0), + ToKeep: tags, + Invalid: make([]string, 0), + } + return filtered, nil +} + +// Function that prunes docker tags +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.intoTagsOptions(), userInput) + if err != nil { + return fmt.Errorf("Error getting filtered docker tags: %w", err) + } + + // Determine which images to prune vs keep based on user input + var toPrune []string + var toKeep []string + if opts.filterOpts.Invalid.Present() { + toPrune = append(filteredTags.ToPrune, filteredTags.Invalid...) + toKeep = filteredTags.ToKeep + } else { + toPrune = filteredTags.ToPrune + toKeep = append(filteredTags.ToKeep, filteredTags.Invalid...) + } + + // Display the prune summary + // TODO: Error check - determine if errors occurred during size calculation + 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 + if !opts.pruneOpts.NonInteractive { + accepted := displayPrunePrompt() + if !accepted{ + return nil // User decided not to prune + } + } + + // Prune the docker tags + err = pruneDockerTagsParallel(ctx, sys, userInput, toPrune) + if err != nil { + return fmt.Errorf("Error pruning tags: %w", err) + } + return nil // Success +} + +func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) { + ctx, cancel := opts.global.commandTimeoutContext() + defer cancel() + + if len(args) != 1 { + return errorShouldDisplayUsage{errors.New("Exactly one non-option argument expected")} + } + + sys, err := opts.image.newSystemContext() + if err != nil { + return err + } + + transport := alltransports.TransportFromImageName(args[0]) + if transport == nil { + return fmt.Errorf("Invalid %q: does not specify a transport", args[0]) + } + + if val, ok := pruneTransportHandlers[transport.Name()]; ok { + err = val(ctx, sys, opts, args[0]) + if err != nil { + return err + } + } else { + return fmt.Errorf("Unsupported transport '%s' for tag listing. Only supported: %s", + transport.Name(), supportedPruneTransports(", ")) + } + + return nil // Success +} diff --git a/go.mod b/go.mod index 172771e5a9..35607209ac 100644 --- a/go.mod +++ b/go.mod @@ -124,7 +124,7 @@ require ( go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect golang.org/x/crypto v0.37.0 // indirect - golang.org/x/mod v0.23.0 // indirect + golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect golang.org/x/sync v0.13.0 // indirect diff --git a/go.sum b/go.sum index 3449571474..30f53bfbaa 100644 --- a/go.sum +++ b/go.sum @@ -395,6 +395,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -484,6 +486,7 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/vendor/golang.org/x/mod/semver/semver.go b/vendor/golang.org/x/mod/semver/semver.go new file mode 100644 index 0000000000..628f8fd687 --- /dev/null +++ b/vendor/golang.org/x/mod/semver/semver.go @@ -0,0 +1,407 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package semver implements comparison of semantic version strings. +// In this package, semantic version strings must begin with a leading "v", +// as in "v1.0.0". +// +// The general form of a semantic version string accepted by this package is +// +// vMAJOR[.MINOR[.PATCH[-PRERELEASE][+BUILD]]] +// +// where square brackets indicate optional parts of the syntax; +// MAJOR, MINOR, and PATCH are decimal integers without extra leading zeros; +// PRERELEASE and BUILD are each a series of non-empty dot-separated identifiers +// using only alphanumeric characters and hyphens; and +// all-numeric PRERELEASE identifiers must not have leading zeros. +// +// This package follows Semantic Versioning 2.0.0 (see semver.org) +// with two exceptions. First, it requires the "v" prefix. Second, it recognizes +// vMAJOR and vMAJOR.MINOR (with no prerelease or build suffixes) +// as shorthands for vMAJOR.0.0 and vMAJOR.MINOR.0. +package semver + +import ( + "slices" + "strings" +) + +// parsed returns the parsed form of a semantic version string. +type parsed struct { + major string + minor string + patch string + short string + prerelease string + build string +} + +// IsValid reports whether v is a valid semantic version string. +func IsValid(v string) bool { + _, ok := parse(v) + return ok +} + +// Canonical returns the canonical formatting of the semantic version v. +// It fills in any missing .MINOR or .PATCH and discards build metadata. +// Two semantic versions compare equal only if their canonical formattings +// are identical strings. +// The canonical invalid semantic version is the empty string. +func Canonical(v string) string { + p, ok := parse(v) + if !ok { + return "" + } + if p.build != "" { + return v[:len(v)-len(p.build)] + } + if p.short != "" { + return v + p.short + } + return v +} + +// Major returns the major version prefix of the semantic version v. +// For example, Major("v2.1.0") == "v2". +// If v is an invalid semantic version string, Major returns the empty string. +func Major(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return v[:1+len(pv.major)] +} + +// MajorMinor returns the major.minor version prefix of the semantic version v. +// For example, MajorMinor("v2.1.0") == "v2.1". +// If v is an invalid semantic version string, MajorMinor returns the empty string. +func MajorMinor(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + i := 1 + len(pv.major) + if j := i + 1 + len(pv.minor); j <= len(v) && v[i] == '.' && v[i+1:j] == pv.minor { + return v[:j] + } + return v[:i] + "." + pv.minor +} + +// Prerelease returns the prerelease suffix of the semantic version v. +// For example, Prerelease("v2.1.0-pre+meta") == "-pre". +// If v is an invalid semantic version string, Prerelease returns the empty string. +func Prerelease(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return pv.prerelease +} + +// Build returns the build suffix of the semantic version v. +// For example, Build("v2.1.0+meta") == "+meta". +// If v is an invalid semantic version string, Build returns the empty string. +func Build(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return pv.build +} + +// Compare returns an integer comparing two versions according to +// semantic version precedence. +// The result will be 0 if v == w, -1 if v < w, or +1 if v > w. +// +// An invalid semantic version string is considered less than a valid one. +// All invalid semantic version strings compare equal to each other. +func Compare(v, w string) int { + pv, ok1 := parse(v) + pw, ok2 := parse(w) + if !ok1 && !ok2 { + return 0 + } + if !ok1 { + return -1 + } + if !ok2 { + return +1 + } + if c := compareInt(pv.major, pw.major); c != 0 { + return c + } + if c := compareInt(pv.minor, pw.minor); c != 0 { + return c + } + if c := compareInt(pv.patch, pw.patch); c != 0 { + return c + } + return comparePrerelease(pv.prerelease, pw.prerelease) +} + +// Max canonicalizes its arguments and then returns the version string +// that compares greater. +// +// Deprecated: use [Compare] instead. In most cases, returning a canonicalized +// version is not expected or desired. +func Max(v, w string) string { + v = Canonical(v) + w = Canonical(w) + if Compare(v, w) > 0 { + return v + } + return w +} + +// ByVersion implements [sort.Interface] for sorting semantic version strings. +type ByVersion []string + +func (vs ByVersion) Len() int { return len(vs) } +func (vs ByVersion) Swap(i, j int) { vs[i], vs[j] = vs[j], vs[i] } +func (vs ByVersion) Less(i, j int) bool { return compareVersion(vs[i], vs[j]) < 0 } + +// Sort sorts a list of semantic version strings using [Compare] and falls back +// to use [strings.Compare] if both versions are considered equal. +func Sort(list []string) { + slices.SortFunc(list, compareVersion) +} + +func compareVersion(a, b string) int { + cmp := Compare(a, b) + if cmp != 0 { + return cmp + } + return strings.Compare(a, b) +} + +func parse(v string) (p parsed, ok bool) { + if v == "" || v[0] != 'v' { + return + } + p.major, v, ok = parseInt(v[1:]) + if !ok { + return + } + if v == "" { + p.minor = "0" + p.patch = "0" + p.short = ".0.0" + return + } + if v[0] != '.' { + ok = false + return + } + p.minor, v, ok = parseInt(v[1:]) + if !ok { + return + } + if v == "" { + p.patch = "0" + p.short = ".0" + return + } + if v[0] != '.' { + ok = false + return + } + p.patch, v, ok = parseInt(v[1:]) + if !ok { + return + } + if len(v) > 0 && v[0] == '-' { + p.prerelease, v, ok = parsePrerelease(v) + if !ok { + return + } + } + if len(v) > 0 && v[0] == '+' { + p.build, v, ok = parseBuild(v) + if !ok { + return + } + } + if v != "" { + ok = false + return + } + ok = true + return +} + +func parseInt(v string) (t, rest string, ok bool) { + if v == "" { + return + } + if v[0] < '0' || '9' < v[0] { + return + } + i := 1 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + if v[0] == '0' && i != 1 { + return + } + return v[:i], v[i:], true +} + +func parsePrerelease(v string) (t, rest string, ok bool) { + // "A pre-release version MAY be denoted by appending a hyphen and + // a series of dot separated identifiers immediately following the patch version. + // Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + // Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes." + if v == "" || v[0] != '-' { + return + } + i := 1 + start := 1 + for i < len(v) && v[i] != '+' { + if !isIdentChar(v[i]) && v[i] != '.' { + return + } + if v[i] == '.' { + if start == i || isBadNum(v[start:i]) { + return + } + start = i + 1 + } + i++ + } + if start == i || isBadNum(v[start:i]) { + return + } + return v[:i], v[i:], true +} + +func parseBuild(v string) (t, rest string, ok bool) { + if v == "" || v[0] != '+' { + return + } + i := 1 + start := 1 + for i < len(v) { + if !isIdentChar(v[i]) && v[i] != '.' { + return + } + if v[i] == '.' { + if start == i { + return + } + start = i + 1 + } + i++ + } + if start == i { + return + } + return v[:i], v[i:], true +} + +func isIdentChar(c byte) bool { + return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' +} + +func isBadNum(v string) bool { + i := 0 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + return i == len(v) && i > 1 && v[0] == '0' +} + +func isNum(v string) bool { + i := 0 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + return i == len(v) +} + +func compareInt(x, y string) int { + if x == y { + return 0 + } + if len(x) < len(y) { + return -1 + } + if len(x) > len(y) { + return +1 + } + if x < y { + return -1 + } else { + return +1 + } +} + +func comparePrerelease(x, y string) int { + // "When major, minor, and patch are equal, a pre-release version has + // lower precedence than a normal version. + // Example: 1.0.0-alpha < 1.0.0. + // Precedence for two pre-release versions with the same major, minor, + // and patch version MUST be determined by comparing each dot separated + // identifier from left to right until a difference is found as follows: + // identifiers consisting of only digits are compared numerically and + // identifiers with letters or hyphens are compared lexically in ASCII + // sort order. Numeric identifiers always have lower precedence than + // non-numeric identifiers. A larger set of pre-release fields has a + // higher precedence than a smaller set, if all of the preceding + // identifiers are equal. + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < + // 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0." + if x == y { + return 0 + } + if x == "" { + return +1 + } + if y == "" { + return -1 + } + for x != "" && y != "" { + x = x[1:] // skip - or . + y = y[1:] // skip - or . + var dx, dy string + dx, x = nextIdent(x) + dy, y = nextIdent(y) + if dx != dy { + ix := isNum(dx) + iy := isNum(dy) + if ix != iy { + if ix { + return -1 + } else { + return +1 + } + } + if ix { + if len(dx) < len(dy) { + return -1 + } + if len(dx) > len(dy) { + return +1 + } + } + if dx < dy { + return -1 + } else { + return +1 + } + } + } + if x == "" { + return -1 + } else { + return +1 + } +} + +func nextIdent(x string) (dx, rest string) { + i := 0 + for i < len(x) && x[i] != '.' { + i++ + } + return x[:i], x[i:] +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 3798fc08de..39abd32e42 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -645,8 +645,9 @@ golang.org/x/crypto/pbkdf2 golang.org/x/crypto/salsa20/salsa golang.org/x/crypto/scrypt golang.org/x/crypto/sha3 -# golang.org/x/mod v0.23.0 -## explicit; go 1.22.0 +# golang.org/x/mod v0.26.0 +## explicit; go 1.23.0 +golang.org/x/mod/semver golang.org/x/mod/sumdb/note # golang.org/x/net v0.38.0 ## explicit; go 1.23.0