get.porter.sh/porter@v1.3.0/pkg/pkgmgmt/client/install.go (about)

     1  package client
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"path"
    12  	"path/filepath"
    13  	"runtime"
    14  	"strings"
    15  	"time"
    16  
    17  	"get.porter.sh/porter/pkg"
    18  	"get.porter.sh/porter/pkg/pkgmgmt"
    19  	"get.porter.sh/porter/pkg/pkgmgmt/feed"
    20  	"get.porter.sh/porter/pkg/tracing"
    21  )
    22  
    23  const PackageCacheJSON string = "cache.json"
    24  
    25  func (fs *FileSystem) Install(ctx context.Context, opts pkgmgmt.InstallOptions) error {
    26  	var err error
    27  	if opts.FeedURL != "" {
    28  		err = fs.InstallFromFeedURL(ctx, opts)
    29  	} else {
    30  		err = fs.InstallFromURL(ctx, opts)
    31  	}
    32  	if err != nil {
    33  		return err
    34  	}
    35  	return fs.savePackageInfo(ctx, opts)
    36  }
    37  
    38  func (fs *FileSystem) savePackageInfo(ctx context.Context, opts pkgmgmt.InstallOptions) error {
    39  	log := tracing.LoggerFromContext(ctx)
    40  
    41  	parentDir, _ := fs.GetPackagesDir()
    42  	cacheJSONPath := filepath.Join(parentDir, "/", PackageCacheJSON)
    43  	exists, _ := fs.FileSystem.Exists(cacheJSONPath)
    44  	if !exists {
    45  		_, err := fs.FileSystem.Create(cacheJSONPath)
    46  		if err != nil {
    47  			return log.Error(fmt.Errorf("error creating %s package cache.json: %w", fs.PackageType, err))
    48  		}
    49  	}
    50  
    51  	cacheContentsB, err := fs.FileSystem.ReadFile(cacheJSONPath)
    52  	if err != nil {
    53  		return log.Error(fmt.Errorf("error reading package %s cache.json: %w", fs.PackageType, err))
    54  	}
    55  
    56  	pkgDataJSON := &packages{}
    57  	if len(cacheContentsB) > 0 {
    58  		err = json.Unmarshal(cacheContentsB, &pkgDataJSON)
    59  		if err != nil {
    60  			return log.Error(fmt.Errorf("error unmarshalling from %s package cache.json: %w", fs.PackageType, err))
    61  		}
    62  	}
    63  	//if a package exists, skip.
    64  	for _, pkg := range pkgDataJSON.Packages {
    65  		if pkg.Name == opts.Name {
    66  			return nil
    67  		}
    68  	}
    69  	updatedPkgList := append(pkgDataJSON.Packages, PackageInfo{Name: opts.Name, FeedURL: opts.FeedURL, URL: opts.URL})
    70  	pkgDataJSON.Packages = updatedPkgList
    71  	updatedPkgInfo, err := json.MarshalIndent(&pkgDataJSON, "", "  ")
    72  	if err != nil {
    73  		return log.Error(fmt.Errorf("error marshalling to %s package cache.json: %w", fs.PackageType, err))
    74  	}
    75  	err = fs.FileSystem.WriteFile(cacheJSONPath, updatedPkgInfo, pkg.FileModeWritable)
    76  
    77  	if err != nil {
    78  		return log.Error(fmt.Errorf("error adding package info to %s cache.json: %w", fs.PackageType, err))
    79  	}
    80  	return nil
    81  }
    82  
    83  type PackageInfo struct {
    84  	Name    string `json:"name"`
    85  	FeedURL string `json:"URL,omitempty"`
    86  	URL     string `json:"url,omitempty"`
    87  }
    88  
    89  type packages struct {
    90  	Packages []PackageInfo `json:"packages"`
    91  }
    92  
    93  func (fs *FileSystem) InstallFromURL(ctx context.Context, opts pkgmgmt.InstallOptions) error {
    94  	return fs.installFromURLFor(ctx, opts, runtime.GOOS, runtime.GOARCH)
    95  }
    96  
    97  func (fs *FileSystem) installFromURLFor(ctx context.Context, opts pkgmgmt.InstallOptions, os string, arch string) error {
    98  	log := tracing.LoggerFromContext(ctx)
    99  
   100  	clientUrl := opts.GetParsedURL()
   101  	clientUrl.Path = path.Join(clientUrl.Path, opts.Version, fmt.Sprintf("%s-%s-%s%s", opts.Name, os, arch, pkgmgmt.FileExt))
   102  
   103  	runtimeUrl := opts.GetParsedURL()
   104  	runtimeUrl.Path = path.Join(runtimeUrl.Path, opts.Version, fmt.Sprintf("%s-linux-amd64", opts.Name))
   105  
   106  	err := fs.downloadPackage(ctx, opts.Name, clientUrl, runtimeUrl)
   107  	if err != nil && os == "darwin" && arch == "arm64" {
   108  		// Until we have full support for M1 chipsets, rely on rossetta functionality in macos and use the amd64 binary
   109  		log.Debugf("%s @ %s did not publish a download for darwin/amd64, falling back to darwin/amd64", opts.Name, opts.Version)
   110  		return fs.installFromURLFor(ctx, opts, "darwin", "amd64")
   111  	}
   112  
   113  	return err
   114  }
   115  
   116  func (fs *FileSystem) InstallFromFeedURL(ctx context.Context, opts pkgmgmt.InstallOptions) error {
   117  	log := tracing.LoggerFromContext(ctx)
   118  
   119  	feedUrl := opts.GetParsedFeedURL()
   120  	tmpDir, err := fs.FileSystem.TempDir("", "porter")
   121  	if err != nil {
   122  		return log.Error(fmt.Errorf("error creating temp directory: %w", err))
   123  	}
   124  	defer func() {
   125  		err = errors.Join(err, fs.FileSystem.RemoveAll(tmpDir))
   126  	}()
   127  	feedPath := filepath.Join(tmpDir, "atom.xml")
   128  
   129  	err = fs.downloadFile(ctx, feedUrl, feedPath, false)
   130  	if err != nil {
   131  		return err
   132  	}
   133  
   134  	searchFeed := feed.NewMixinFeed(fs.Context)
   135  	err = searchFeed.Load(ctx, feedPath)
   136  	if err != nil {
   137  		return err
   138  	}
   139  
   140  	result := searchFeed.Search(opts.Name, opts.Version)
   141  	if result == nil {
   142  		return log.Error(fmt.Errorf("the feed at %s does not contain an entry for %s @ %s", opts.FeedURL, opts.Name, opts.Version))
   143  	}
   144  
   145  	clientUrl := result.FindDownloadURL(ctx, runtime.GOOS, runtime.GOARCH)
   146  	if clientUrl == nil {
   147  		return log.Error(fmt.Errorf("%s @ %s did not publish a download for %s/%s", opts.Name, opts.Version, runtime.GOOS, runtime.GOARCH))
   148  	}
   149  
   150  	runtimeUrl := result.FindDownloadURL(ctx, "linux", "amd64")
   151  	if runtimeUrl == nil {
   152  		return log.Error(fmt.Errorf("%s @ %s did not publish a download for linux/amd64", opts.Name, opts.Version))
   153  	}
   154  
   155  	return errors.Join(err, fs.downloadPackage(ctx, opts.Name, *clientUrl, *runtimeUrl))
   156  }
   157  
   158  func (fs *FileSystem) downloadPackage(ctx context.Context, name string, clientUrl url.URL, runtimeUrl url.URL) error {
   159  	parentDir, err := fs.GetPackagesDir()
   160  	if err != nil {
   161  		return err
   162  	}
   163  	pkgDir := filepath.Join(parentDir, name)
   164  
   165  	clientPath := fs.BuildClientPath(pkgDir, name)
   166  	err = fs.downloadFile(ctx, clientUrl, clientPath, true)
   167  	if err != nil {
   168  		return err
   169  	}
   170  
   171  	runtimePath := filepath.Join(pkgDir, "runtimes", name+"-runtime")
   172  	err = fs.downloadFile(ctx, runtimeUrl, runtimePath, true)
   173  	if err != nil {
   174  		err = errors.Join(err, fs.FileSystem.RemoveAll(pkgDir)) // If the runtime download fails, cleanup the package so it's not half installed
   175  		return err
   176  	}
   177  
   178  	return nil
   179  }
   180  
   181  func (fs *FileSystem) downloadFile(ctx context.Context, url url.URL, destPath string, executable bool) error {
   182  	log := tracing.LoggerFromContext(ctx)
   183  	log.Debugf("Downloading %s to %s\n", url.String(), destPath)
   184  
   185  	req, err := http.NewRequest(http.MethodGet, url.String(), nil)
   186  	if err != nil {
   187  		return log.Error(fmt.Errorf("error creating web request to %s: %w", url.String(), err))
   188  	}
   189  
   190  	// Retry configuration
   191  	maxRetries := 3
   192  	baseDelay := 1 * time.Second
   193  	var lastErr error
   194  	var resp *http.Response
   195  
   196  	// Retry loop for HTTP request only
   197  	for attempt := 0; attempt < maxRetries; attempt++ {
   198  		if attempt > 0 {
   199  			// Calculate exponential backoff delay
   200  			delay := baseDelay * time.Duration(1<<uint(attempt-1))
   201  			log.Debugf("Retrying download after %v delay (attempt %d/%d)", delay, attempt+1, maxRetries)
   202  
   203  			// Check if context is cancelled before sleeping
   204  			if ctx.Err() != nil {
   205  				return ctx.Err()
   206  			}
   207  			time.Sleep(delay)
   208  		}
   209  
   210  		resp, err = http.DefaultClient.Do(req)
   211  		if err != nil {
   212  			lastErr = err
   213  			// Check for retryable errors
   214  			if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) || strings.Contains(err.Error(), "TLS handshake timeout") {
   215  				continue // Retry on retryable errors
   216  			}
   217  			return log.Error(fmt.Errorf("error downloading %s: %w", url.String(), err))
   218  		}
   219  
   220  		if resp.StatusCode != 200 {
   221  			resp.Body.Close()
   222  			err := fmt.Errorf("bad status returned when downloading %s (%d) %s", url.String(), resp.StatusCode, resp.Status)
   223  			log.Debugf(err.Error()) // Only debug log this since higher up on the stack we may handle this error
   224  			return err
   225  		}
   226  
   227  		// If we get here, we have a successful response
   228  		break
   229  	}
   230  
   231  	if resp == nil {
   232  		return log.Error(fmt.Errorf("failed to download %s after %d attempts: %w", url.String(), maxRetries, lastErr))
   233  	}
   234  	defer resp.Body.Close()
   235  
   236  	// Ensure the parent directories exist
   237  	parentDir := filepath.Dir(destPath)
   238  	parentDirExists, err := fs.FileSystem.DirExists(parentDir)
   239  	if err != nil {
   240  		return log.Error(fmt.Errorf("unable to check if directory exists %s: %w", parentDir, err))
   241  	}
   242  
   243  	cleanup := func() error { return nil }
   244  	if !parentDirExists {
   245  		err = fs.FileSystem.MkdirAll(parentDir, pkg.FileModeDirectory)
   246  		if err != nil {
   247  			return log.Error(fmt.Errorf("unable to create parent directory %s: %w", parentDir, err))
   248  		}
   249  		cleanup = func() error {
   250  			// If we can't download the file, don't leave traces of it
   251  			if err = fs.FileSystem.RemoveAll(parentDir); err != nil {
   252  				return err
   253  			}
   254  			return nil
   255  		}
   256  	}
   257  
   258  	destFile, err := fs.FileSystem.Create(destPath)
   259  	if err != nil {
   260  		_ = cleanup()
   261  		return log.Error(fmt.Errorf("could not create the file at %s: %w", destPath, err))
   262  	}
   263  	defer destFile.Close()
   264  
   265  	if executable {
   266  		err = fs.FileSystem.Chmod(destPath, pkg.FileModeExecutable)
   267  		if err != nil {
   268  			_ = cleanup()
   269  			return log.Error(fmt.Errorf("could not set the file as executable at %s: %w", destPath, err))
   270  		}
   271  	}
   272  
   273  	_, err = io.Copy(destFile, resp.Body)
   274  	if err != nil {
   275  		_ = cleanup()
   276  		return log.Error(fmt.Errorf("error writing the file to %s: %w", destPath, err))
   277  	}
   278  
   279  	return nil
   280  }