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 }