github.com/buildpacks/pack@v0.33.3-0.20240516162812-884dd1837311/acceptance/config/github_asset_fetcher.go (about) 1 //go:build acceptance 2 // +build acceptance 3 4 package config 5 6 import ( 7 "archive/tar" 8 "archive/zip" 9 "context" 10 "encoding/json" 11 "fmt" 12 "io" 13 "net/http" 14 "os" 15 "path" 16 "path/filepath" 17 "regexp" 18 "sort" 19 "strconv" 20 "strings" 21 "testing" 22 "time" 23 24 "github.com/Masterminds/semver" 25 "github.com/google/go-github/v30/github" 26 "github.com/pkg/errors" 27 "golang.org/x/oauth2" 28 29 "github.com/buildpacks/pack/pkg/blob" 30 "github.com/buildpacks/pack/pkg/logging" 31 ) 32 33 const ( 34 assetCacheDir = "test-assets-cache" 35 assetCacheManifest = "github.json" 36 cacheManifestLifetime = 1 * time.Hour 37 ) 38 39 type GithubAssetFetcher struct { 40 ctx context.Context 41 testObject *testing.T 42 githubClient *github.Client 43 cacheDir string 44 } 45 46 type assetCache map[string]map[string]cachedRepo 47 type cachedRepo struct { 48 Assets cachedAssets 49 Sources cachedSources 50 Versions cachedVersions 51 } 52 type cachedAssets map[string][]string 53 type cachedSources map[string]string 54 type cachedVersions map[string]string 55 56 func NewGithubAssetFetcher(t *testing.T, githubToken string) (*GithubAssetFetcher, error) { 57 t.Helper() 58 59 relativeCacheDir := filepath.Join("..", "out", "tests", assetCacheDir) 60 cacheDir, err := filepath.Abs(relativeCacheDir) 61 if err != nil { 62 return nil, errors.Wrapf(err, "getting absolute path for %s", relativeCacheDir) 63 } 64 if err := os.MkdirAll(cacheDir, 0755); err != nil { 65 return nil, errors.Wrapf(err, "creating directory %s", cacheDir) 66 } 67 68 ctx := context.TODO() 69 httpClient := new(http.Client) 70 if githubToken != "" { 71 t.Log("using provided github token") 72 tokenSource := oauth2.StaticTokenSource(&oauth2.Token{ 73 AccessToken: githubToken, 74 }) 75 httpClient = oauth2.NewClient(ctx, tokenSource) 76 } 77 78 return &GithubAssetFetcher{ 79 ctx: ctx, 80 testObject: t, 81 githubClient: github.NewClient(httpClient), 82 cacheDir: cacheDir, 83 }, nil 84 } 85 86 // Fetch a GitHub release asset for the given repo that matches the regular expression. 87 // The expression is something like 'pack-v\d+.\d+.\d+-macos'. 88 // The asset may be found in the local cache or downloaded from GitHub. 89 // The return value is the location of the asset on disk, or any error encountered. 90 func (f *GithubAssetFetcher) FetchReleaseAsset(owner, repo, version string, expr *regexp.Regexp, extract bool) (string, error) { 91 f.testObject.Helper() 92 93 if destPath, _ := f.cachedAsset(owner, repo, version, expr); destPath != "" { 94 f.testObject.Logf("found %s in cache for %s/%s %s", destPath, owner, repo, version) 95 return destPath, nil 96 } 97 98 release, _, err := f.githubClient.Repositories.GetReleaseByTag(f.ctx, owner, repo, version) 99 if err != nil { 100 return "", errors.Wrap(err, "getting release") 101 } 102 103 var desiredAsset *github.ReleaseAsset 104 for _, asset := range release.Assets { 105 if expr.MatchString(*asset.Name) { 106 desiredAsset = asset 107 break 108 } 109 } 110 if desiredAsset == nil { 111 return "", fmt.Errorf("could not find asset matching expression %s", expr.String()) 112 } 113 114 var returnPath string 115 extractType := extractType(extract, *desiredAsset.Name) 116 switch extractType { 117 case "tgz": 118 targetDir := filepath.Join(f.cacheDir, stripExtension(*desiredAsset.Name)) 119 if err := os.MkdirAll(targetDir, 0755); err != nil { 120 return "", errors.Wrapf(err, "creating directory %s", targetDir) 121 } 122 123 if err := f.downloadAndExtractTgz(*desiredAsset.BrowserDownloadURL, targetDir); err != nil { 124 return "", err 125 } 126 127 returnPath = targetDir 128 case "zip": 129 targetPath := filepath.Join(f.cacheDir, *desiredAsset.Name) 130 if err := f.downloadAndExtractZip(*desiredAsset.BrowserDownloadURL, targetPath); err != nil { 131 return "", err 132 } 133 134 returnPath = stripExtension(targetPath) 135 default: 136 targetPath := filepath.Join(f.cacheDir, *desiredAsset.Name) 137 if err := f.downloadAndSave(*desiredAsset.BrowserDownloadURL, targetPath); err != nil { 138 return "", err 139 } 140 141 returnPath = targetPath 142 } 143 144 err = f.writeCacheManifest(owner, repo, func(cache assetCache) { 145 existingAssets, found := cache[owner][repo].Assets[version] 146 if found { 147 cache[owner][repo].Assets[version] = append(existingAssets, returnPath) 148 } 149 cache[owner][repo].Assets[version] = []string{returnPath} 150 }) 151 if err != nil { 152 f.testObject.Log(errors.Wrap(err, "writing cache").Error()) 153 } 154 return returnPath, nil 155 } 156 157 func extractType(extract bool, assetName string) string { 158 if extract && strings.Contains(assetName, ".tgz") { 159 return "tgz" 160 } 161 if extract && strings.Contains(assetName, ".zip") { 162 return "zip" 163 } 164 return "none" 165 } 166 167 func (f *GithubAssetFetcher) FetchReleaseSource(owner, repo, version string) (string, error) { 168 f.testObject.Helper() 169 170 if destDir, _ := f.cachedSource(owner, repo, version); destDir != "" { 171 f.testObject.Logf("found %s in cache for %s/%s %s", destDir, owner, repo, version) 172 return destDir, nil 173 } 174 175 release, _, err := f.githubClient.Repositories.GetReleaseByTag(f.ctx, owner, repo, version) 176 if err != nil { 177 return "", errors.Wrap(err, "getting release") 178 } 179 180 destDir := filepath.Join(f.cacheDir, strings.ReplaceAll(*release.Name, " ", "-")+"-source") 181 if err := os.MkdirAll(destDir, 0755); err != nil { 182 return "", errors.Wrapf(err, "creating directory %s", destDir) 183 } 184 185 if err := f.downloadAndExtractTgz(*release.TarballURL, destDir); err != nil { 186 return "", err 187 } 188 189 err = f.writeCacheManifest(owner, repo, func(cache assetCache) { 190 cache[owner][repo].Sources[version] = destDir 191 }) 192 if err != nil { 193 f.testObject.Log(errors.Wrap(err, "writing cache").Error()) 194 } 195 return destDir, nil 196 } 197 198 // Fetch a GitHub release version that is n minor versions older than the latest. 199 // Ex: when n is 0, the latest release version is returned. 200 // Ex: when n is -1, the latest patch of the previous minor version is returned. 201 func (f *GithubAssetFetcher) FetchReleaseVersion(owner, repo string, n int) (string, error) { 202 f.testObject.Helper() 203 204 if version, _ := f.cachedVersion(owner, repo, n); version != "" { 205 f.testObject.Logf("found %s in cache for %s/%s %d", version, owner, repo, n) 206 return version, nil 207 } 208 209 // get all release versions 210 rawReleases, _, err := f.githubClient.Repositories.ListReleases(f.ctx, owner, repo, nil) 211 if err != nil { 212 return "", errors.Wrap(err, "listing releases") 213 } 214 if len(rawReleases) == 0 { 215 return "", fmt.Errorf("no releases found for %s/%s", owner, repo) 216 } 217 218 // exclude drafts and pre-releases 219 var releases []*github.RepositoryRelease 220 for _, release := range rawReleases { 221 if !*release.Draft && !*release.Prerelease { 222 releases = append(releases, release) 223 } 224 } 225 if len(releases) == 0 { 226 return "", fmt.Errorf("no non-draft releases found for %s/%s", owner, repo) 227 } 228 229 // sort all release versions 230 versions := make([]*semver.Version, len(releases)) 231 for i, release := range releases { 232 version, err := semver.NewVersion(*release.TagName) 233 if err != nil { 234 return "", errors.Wrap(err, "parsing semver") 235 } 236 versions[i] = version 237 } 238 sort.Sort(semver.Collection(versions)) 239 240 latestVersion := versions[len(versions)-1] 241 242 // get latest patch of previous minor 243 constraint, err := semver.NewConstraint( 244 fmt.Sprintf("~%d.%d.x", latestVersion.Major(), latestVersion.Minor()+int64(n)), 245 ) 246 if err != nil { 247 return "", errors.Wrap(err, "parsing semver constraint") 248 } 249 var latestPatchOfPreviousMinor *semver.Version 250 for i := len(versions) - 1; i >= 0; i-- { 251 if constraint.Check(versions[i]) { 252 latestPatchOfPreviousMinor = versions[i] 253 break 254 } 255 } 256 if latestPatchOfPreviousMinor == nil { 257 return "", errors.New("obtaining latest patch of previous minor") 258 } 259 formattedVersion := fmt.Sprintf("v%s", latestPatchOfPreviousMinor.String()) 260 261 err = f.writeCacheManifest(owner, repo, func(cache assetCache) { 262 cache[owner][repo].Versions[strconv.Itoa(n)] = formattedVersion 263 }) 264 if err != nil { 265 f.testObject.Log(errors.Wrap(err, "writing cache").Error()) 266 } 267 return formattedVersion, nil 268 } 269 270 func (f *GithubAssetFetcher) cachedAsset(owner, repo, version string, expr *regexp.Regexp) (string, error) { 271 f.testObject.Helper() 272 273 cache, err := f.loadCacheManifest() 274 if err != nil { 275 return "", errors.Wrap(err, "loading cache") 276 } 277 278 assets, found := cache[owner][repo].Assets[version] 279 if found { 280 for _, asset := range assets { 281 if expr.MatchString(asset) { 282 return asset, nil 283 } 284 } 285 } 286 return "", nil 287 } 288 289 func (f *GithubAssetFetcher) cachedSource(owner, repo, version string) (string, error) { 290 f.testObject.Helper() 291 292 cache, err := f.loadCacheManifest() 293 if err != nil { 294 return "", errors.Wrap(err, "loading cache") 295 } 296 297 value, found := cache[owner][repo].Sources[version] 298 if found { 299 return value, nil 300 } 301 return "", nil 302 } 303 304 func (f *GithubAssetFetcher) cachedVersion(owner, repo string, n int) (string, error) { 305 f.testObject.Helper() 306 307 cache, err := f.loadCacheManifest() 308 if err != nil { 309 return "", errors.Wrap(err, "loading cache") 310 } 311 312 value, found := cache[owner][repo].Versions[strconv.Itoa(n)] 313 if found { 314 return value, nil 315 } 316 return "", nil 317 } 318 319 func (f *GithubAssetFetcher) loadCacheManifest() (assetCache, error) { 320 f.testObject.Helper() 321 322 cacheManifest, err := os.Stat(filepath.Join(f.cacheDir, assetCacheManifest)) 323 if os.IsNotExist(err) { 324 return assetCache{}, nil 325 } 326 327 // invalidate cache manifest that is too old 328 if time.Since(cacheManifest.ModTime()) > cacheManifestLifetime { 329 return assetCache{}, nil 330 } 331 332 content, err := os.ReadFile(filepath.Join(f.cacheDir, assetCacheManifest)) 333 if err != nil { 334 return nil, errors.Wrap(err, "reading cache manifest") 335 } 336 337 var cache assetCache 338 err = json.Unmarshal(content, &cache) 339 if err != nil { 340 return nil, errors.Wrap(err, "unmarshaling cache manifest content") 341 } 342 343 return cache, nil 344 } 345 346 func (f *GithubAssetFetcher) writeCacheManifest(owner, repo string, op func(cache assetCache)) error { 347 f.testObject.Helper() 348 349 cache, err := f.loadCacheManifest() 350 if err != nil { 351 return errors.Wrap(err, "loading cache") 352 } 353 354 // init keys for owner and repo 355 if _, found := cache[owner]; !found { 356 cache[owner] = map[string]cachedRepo{} 357 } 358 if _, found := cache[owner][repo]; !found { 359 cache[owner][repo] = cachedRepo{ 360 Assets: cachedAssets{}, 361 Sources: cachedSources{}, 362 Versions: cachedVersions{}, 363 } 364 } 365 366 op(cache) 367 368 content, err := json.Marshal(cache) 369 if err != nil { 370 return errors.Wrap(err, "marshaling cache manifest content") 371 } 372 373 return os.WriteFile(filepath.Join(f.cacheDir, assetCacheManifest), content, 0644) 374 } 375 376 func (f *GithubAssetFetcher) downloadAndSave(assetURI, destPath string) error { 377 f.testObject.Helper() 378 379 downloader := blob.NewDownloader(logging.NewSimpleLogger(&testWriter{t: f.testObject}), f.cacheDir) 380 381 assetBlob, err := downloader.Download(f.ctx, assetURI) 382 if err != nil { 383 return errors.Wrapf(err, "downloading blob %s", assetURI) 384 } 385 386 assetReader, err := assetBlob.Open() 387 if err != nil { 388 return errors.Wrap(err, "opening blob") 389 } 390 defer assetReader.Close() 391 392 destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0644) 393 if err != nil { 394 return errors.Wrapf(err, "opening file %s", destPath) 395 } 396 defer destFile.Close() 397 398 if _, err = io.Copy(destFile, assetReader); err != nil { 399 return errors.Wrap(err, "copying data") 400 } 401 402 return nil 403 } 404 405 func (f *GithubAssetFetcher) downloadAndExtractTgz(assetURI, destDir string) error { 406 f.testObject.Helper() 407 408 downloader := blob.NewDownloader(logging.NewSimpleLogger(&testWriter{t: f.testObject}), f.cacheDir) 409 410 assetBlob, err := downloader.Download(f.ctx, assetURI) 411 if err != nil { 412 return errors.Wrapf(err, "downloading blob %s", assetURI) 413 } 414 415 assetReader, err := assetBlob.Open() 416 if err != nil { 417 return errors.Wrapf(err, "opening blob") 418 } 419 defer assetReader.Close() 420 421 if err := extractTgz(assetReader, destDir); err != nil { 422 return errors.Wrap(err, "extracting tgz") 423 } 424 425 return nil 426 } 427 428 func (f *GithubAssetFetcher) downloadAndExtractZip(assetURI, destPath string) error { 429 f.testObject.Helper() 430 431 if err := f.downloadAndSave(assetURI, destPath); err != nil { 432 return err 433 } 434 435 if err := extractZip(destPath); err != nil { 436 return errors.Wrap(err, "extracting zip") 437 } 438 439 return nil 440 } 441 442 func stripExtension(assetFilename string) string { 443 return strings.TrimSuffix(assetFilename, path.Ext(assetFilename)) 444 } 445 446 func extractTgz(reader io.Reader, destDir string) error { 447 tarReader := tar.NewReader(reader) 448 449 for { 450 header, err := tarReader.Next() 451 452 switch err { 453 case nil: 454 // keep going 455 case io.EOF: 456 return nil 457 default: 458 return err 459 } 460 461 target := filepath.Join(destDir, header.Name) 462 463 switch header.Typeflag { 464 case tar.TypeDir: 465 if err := os.MkdirAll(target, 0755); err != nil { 466 return err 467 } 468 case tar.TypeReg: 469 targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) 470 if err != nil { 471 return err 472 } 473 474 if _, err := io.Copy(targetFile, tarReader); err != nil { 475 return err 476 } 477 478 targetFile.Close() 479 } 480 } 481 } 482 483 func extractZip(zipPath string) error { 484 zipReader, err := zip.OpenReader(zipPath) 485 if err != nil { 486 return err 487 } 488 defer zipReader.Close() 489 490 parentDir := filepath.Dir(zipPath) 491 492 for _, f := range zipReader.File { 493 target := filepath.Join(parentDir, f.Name) 494 495 if f.FileInfo().IsDir() { 496 os.MkdirAll(target, f.Mode()) 497 continue 498 } 499 500 targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, f.Mode()) 501 if err != nil { 502 return err 503 } 504 505 sourceFile, err := f.Open() 506 if err != nil { 507 return err 508 } 509 510 _, err = io.Copy(targetFile, sourceFile) 511 if err != nil { 512 return err 513 } 514 515 sourceFile.Close() 516 targetFile.Close() 517 } 518 519 return nil 520 } 521 522 type testWriter struct { 523 t *testing.T 524 } 525 526 func (w *testWriter) Write(p []byte) (n int, err error) { 527 w.t.Log(string(p)) 528 return len(p), nil 529 }