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  }