github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/generic-autobumper/imagebumper/imagebumper.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package imagebumper
    18  
    19  import (
    20  	"encoding/json"
    21  	"fmt"
    22  	"log"
    23  	"net/http"
    24  	"os"
    25  	"regexp"
    26  	"strconv"
    27  	"strings"
    28  	"time"
    29  )
    30  
    31  var (
    32  	imageRegexp = regexp.MustCompile(`\b((?:[a-z0-9]+\.)?gcr\.io|(?:[a-z0-9-]+)?docker\.pkg\.dev)/([a-z][a-z0-9-]{5,29}/[a-zA-Z0-9][a-zA-Z0-9_./-]+):([a-zA-Z0-9_.-]+)\b`)
    33  	tagRegexp   = regexp.MustCompile(`(v?\d{8}-(?:v\d(?:[.-]\d+)*-g)?[0-9a-f]{6,10}|latest)(-.+)?`)
    34  )
    35  
    36  const (
    37  	imageHostPart  = 1
    38  	imageImagePart = 2
    39  	imageTagPart   = 3
    40  	tagVersionPart = 1
    41  	tagExtraPart   = 2
    42  )
    43  
    44  type Client struct {
    45  	// Keys are <imageHost>/<imageName>:<currentTag>. Values are corresponding tags.
    46  	tagCache   map[string]string
    47  	httpClient *http.Client
    48  }
    49  
    50  func NewClient(httpClient *http.Client) *Client {
    51  	// Shallow copy to adjust Timeout
    52  	httpClientCopy := *httpClient
    53  	httpClientCopy.Timeout = 1 * time.Minute
    54  
    55  	return &Client{
    56  		tagCache:   map[string]string{},
    57  		httpClient: &httpClientCopy,
    58  	}
    59  }
    60  
    61  type manifest map[string]struct {
    62  	TimeCreatedMs string   `json:"timeCreatedMs"`
    63  	Tags          []string `json:"tag"`
    64  }
    65  
    66  // commit | tag-n-gcommit
    67  var commitRegexp = regexp.MustCompile(`^g?([\da-f]+)|(.+?)??(?:-(\d+)-g([\da-f]+))?$`)
    68  
    69  // DeconstructCommit separates a git describe commit into its parts.
    70  
    71  // Examples:
    72  //
    73  //	v0.0.30-14-gdeadbeef => (v0.0.30 14 deadbeef)
    74  //	v0.0.30 => (v0.0.30 0 "")
    75  //	deadbeef => ("", 0, deadbeef)
    76  //
    77  // See man git describe.
    78  func DeconstructCommit(commit string) (string, int, string) {
    79  	parts := commitRegexp.FindStringSubmatch(commit)
    80  	if parts == nil {
    81  		return "", 0, ""
    82  	}
    83  	if parts[1] != "" {
    84  		return "", 0, parts[1]
    85  	}
    86  	var n int
    87  	if s := parts[3]; s != "" {
    88  		var err error
    89  		n, err = strconv.Atoi(s)
    90  		if err != nil {
    91  			panic(err)
    92  		}
    93  	}
    94  	return parts[2], n, parts[4]
    95  }
    96  
    97  // DeconstructTag separates the tag into its vDATE-COMMIT-VARIANT components
    98  //
    99  // COMMIT may be in the form vTAG-NEW-gCOMMIT, use PureCommit to further process
   100  // this down to COMMIT.
   101  func DeconstructTag(tag string) (date, commit, variant string) {
   102  	currentTagParts := tagRegexp.FindStringSubmatch(tag)
   103  	if currentTagParts == nil {
   104  		return "", "", ""
   105  	}
   106  	parts := strings.Split(currentTagParts[tagVersionPart], "-")
   107  	return parts[0][1:], parts[len(parts)-1], currentTagParts[tagExtraPart]
   108  }
   109  
   110  func (cli *Client) getManifest(imageHost, imageName string) (manifest, error) {
   111  	resp, err := cli.httpClient.Get("https://" + imageHost + "/v2/" + imageName + "/tags/list")
   112  	if err != nil {
   113  		return nil, fmt.Errorf("couldn't fetch tag list: %w", err)
   114  	}
   115  	defer resp.Body.Close()
   116  
   117  	result := struct {
   118  		Manifest manifest `json:"manifest"`
   119  	}{}
   120  
   121  	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
   122  		return nil, fmt.Errorf("couldn't parse tag information from registry: %w", err)
   123  	}
   124  
   125  	return result.Manifest, nil
   126  }
   127  
   128  // FindLatestTag returns the latest valid tag for the given image.
   129  func (cli *Client) FindLatestTag(imageHost, imageName, currentTag string) (string, error) {
   130  	k := imageHost + "/" + imageName + ":" + currentTag
   131  	if result, ok := cli.tagCache[k]; ok {
   132  		return result, nil
   133  	}
   134  
   135  	currentTagParts := tagRegexp.FindStringSubmatch(currentTag)
   136  	if currentTagParts == nil {
   137  		return "", fmt.Errorf("couldn't figure out the current tag in %q", currentTag)
   138  	}
   139  	if currentTagParts[tagVersionPart] == "latest" {
   140  		return currentTag, nil
   141  	}
   142  
   143  	imageList, err := cli.getManifest(imageHost, imageName)
   144  	if err != nil {
   145  		return "", err
   146  	}
   147  
   148  	latestTag, err := pickBestTag(currentTagParts, imageList)
   149  	if err != nil {
   150  		return "", err
   151  	}
   152  
   153  	cli.tagCache[k] = latestTag
   154  
   155  	return latestTag, nil
   156  }
   157  
   158  func (cli *Client) TagExists(imageHost, imageName, currentTag string) (bool, error) {
   159  	imageList, err := cli.getManifest(imageHost, imageName)
   160  	if err != nil {
   161  		return false, err
   162  	}
   163  
   164  	for _, v := range imageList {
   165  		for _, tag := range v.Tags {
   166  			if tag == currentTag {
   167  				return true, nil
   168  			}
   169  		}
   170  	}
   171  
   172  	return false, nil
   173  }
   174  
   175  func pickBestTag(currentTagParts []string, manifest manifest) (string, error) {
   176  	// The approach is to find the most recently created image that has the same suffix as the
   177  	// current tag. However, if we find one called "latest" (with appropriate suffix), we assume
   178  	// that's the latest regardless of when it was created.
   179  	var latestTime int64
   180  	latestTag := ""
   181  	for _, v := range manifest {
   182  		bestVariant := ""
   183  		override := false
   184  		for _, t := range v.Tags {
   185  			parts := tagRegexp.FindStringSubmatch(t)
   186  			if parts == nil {
   187  				continue
   188  			}
   189  			if parts[tagExtraPart] != currentTagParts[tagExtraPart] {
   190  				continue
   191  			}
   192  			if parts[tagVersionPart] == "latest" {
   193  				override = true
   194  				continue
   195  			}
   196  			if bestVariant == "" || len(t) < len(bestVariant) {
   197  				bestVariant = t
   198  			}
   199  		}
   200  		if bestVariant == "" {
   201  			continue
   202  		}
   203  		t, err := strconv.ParseInt(v.TimeCreatedMs, 10, 64)
   204  		if err != nil {
   205  			return "", fmt.Errorf("couldn't parse timestamp %q: %w", v.TimeCreatedMs, err)
   206  		}
   207  		if override || t > latestTime {
   208  			latestTime = t
   209  			latestTag = bestVariant
   210  			if override {
   211  				break
   212  			}
   213  		}
   214  	}
   215  
   216  	if latestTag == "" {
   217  		return "", fmt.Errorf("failed to find a good tag")
   218  	}
   219  
   220  	return latestTag, nil
   221  }
   222  
   223  // AddToCache keeps track of changed tags
   224  func (cli *Client) AddToCache(image, newTag string) {
   225  	cli.tagCache[image] = newTag
   226  }
   227  
   228  func updateAllTags(tagPicker func(host, image, tag string) (string, error), content []byte, imageFilter *regexp.Regexp) []byte {
   229  	indexes := imageRegexp.FindAllSubmatchIndex(content, -1)
   230  	// Not finding any images is not an error.
   231  	if indexes == nil {
   232  		return content
   233  	}
   234  
   235  	newContent := make([]byte, 0, len(content))
   236  	lastIndex := 0
   237  	for _, m := range indexes {
   238  		newContent = append(newContent, content[lastIndex:m[imageTagPart*2]]...)
   239  		host := string(content[m[imageHostPart*2]:m[imageHostPart*2+1]])
   240  		image := string(content[m[imageImagePart*2]:m[imageImagePart*2+1]])
   241  		tag := string(content[m[imageTagPart*2]:m[imageTagPart*2+1]])
   242  		lastIndex = m[1]
   243  
   244  		if tag == "" || (imageFilter != nil && !imageFilter.MatchString(host+"/"+image+":"+tag)) {
   245  			newContent = append(newContent, content[m[imageTagPart*2]:m[1]]...)
   246  			continue
   247  		}
   248  
   249  		latest, err := tagPicker(host, image, tag)
   250  		if err != nil {
   251  			log.Printf("Failed to update %s/%s:%s: %v.\n", host, image, tag, err)
   252  			newContent = append(newContent, content[m[imageTagPart*2]:m[1]]...)
   253  			continue
   254  		}
   255  		newContent = append(newContent, []byte(latest)...)
   256  	}
   257  	newContent = append(newContent, content[lastIndex:]...)
   258  
   259  	return newContent
   260  }
   261  
   262  // UpdateFile updates a file in place.
   263  func (cli *Client) UpdateFile(tagPicker func(imageHost, imageName, currentTag string) (string, error),
   264  	path string, imageFilter *regexp.Regexp) error {
   265  	content, err := os.ReadFile(path)
   266  	if err != nil {
   267  		return fmt.Errorf("failed to read %s: %w", path, err)
   268  	}
   269  
   270  	newContent := updateAllTags(tagPicker, content, imageFilter)
   271  
   272  	if err := os.WriteFile(path, newContent, 0644); err != nil {
   273  		return fmt.Errorf("failed to write %s: %w", path, err)
   274  	}
   275  	return nil
   276  }
   277  
   278  // GetReplacements returns the tag replacements that have been made.
   279  func (cli *Client) GetReplacements() map[string]string {
   280  	return cli.tagCache
   281  }