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 }