cli/azd/pkg/tools/pack/pack.go (357 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package pack import ( "archive/tar" "archive/zip" "compress/gzip" "context" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/blang/semver/v4" ) // Version is the minimum version of pack that we require (and the one we fetch when we fetch pack on behalf of a // user). var Version semver.Version = semver.MustParse("0.30.0") var statusCodeFailureRegexp = regexp.MustCompile(`failed with status code: (\d+)`) // All buildpacks groups have failed to detect w/o error. // See https://buildpacks.io/docs/concepts/components/lifecycle/detect/#exit-codes const StatusCodeUndetectedNoError = 20 // StatusCodeError is a status code error provided by pack CLI. type StatusCodeError struct { Err error // See all available status codes https://buildpacks.io/docs/concepts/components/lifecycle/create/ Code int } func (s *StatusCodeError) Error() string { return s.Err.Error() } func (s *StatusCodeError) Unwrap() error { return s.Err } // NewCli creates a new PackCli. azd manages its own copy of the pack CLI, stored in `$AZD_CONFIG_DIR/bin`. If // pack is not present at this location, or if it is present but is older than the minimum supported version, it is // downloaded. func NewCli( ctx context.Context, console input.Console, commandRunner exec.CommandRunner, ) (*Cli, error) { return newPackCliImpl( ctx, console, commandRunner, http.DefaultClient, extractCli) } func NewPackCliWithPath( commandRunner exec.CommandRunner, cliPath string, ) *Cli { return &Cli{ path: cliPath, runner: commandRunner, } } // packCliPath returns the path where we store our local copy of pack ($AZD_CONFIG_DIR/bin). func packCliPath() (string, error) { configDir, err := config.GetUserConfigDir() if err != nil { return "", err } if runtime.GOOS == "windows" { return filepath.Join(configDir, "bin", "pack.exe"), nil } return filepath.Join(configDir, "bin", "pack"), nil } func newPackCliImpl( ctx context.Context, console input.Console, commandRunner exec.CommandRunner, transporter policy.Transporter, extract func(string, string) (string, error)) (*Cli, error) { if override := os.Getenv("AZD_PACK_TOOL_PATH"); override != "" { log.Printf("using external pack tool: %s", override) return &Cli{ path: override, runner: commandRunner, }, nil } cliPath, err := packCliPath() if err != nil { return nil, fmt.Errorf("finding pack: %w", err) } if _, err = os.Stat(cliPath); err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("finding pack: %w", err) } if errors.Is(err, os.ErrNotExist) { if err := os.MkdirAll(filepath.Dir(cliPath), osutil.PermissionDirectory); err != nil { return nil, fmt.Errorf("downloading pack: %w", err) } msg := "Acquiring pack cli" console.ShowSpinner(ctx, msg, input.Step) err := downloadPack(ctx, transporter, Version, extract, cliPath) console.StopSpinner(ctx, "", input.Step) if err != nil { return nil, fmt.Errorf("downloading pack: %w", err) } } cli := &Cli{ path: cliPath, runner: commandRunner, } ver, err := cli.version(ctx) if err != nil { return nil, fmt.Errorf("checking pack version: %w", err) } log.Printf("pack version: %s", ver) if ver.LT(Version) { log.Printf("installed pack version %s is older than %s; updating.", ver.String(), Version.String()) msg := "Upgrading pack" console.ShowSpinner(ctx, msg, input.Step) err := downloadPack(ctx, transporter, Version, extract, cliPath) console.StopSpinner(ctx, "", input.Step) if err != nil { return nil, fmt.Errorf("upgrading pack: %w", err) } } log.Printf("using local pack: %s", cliPath) return cli, nil } type Cli struct { path string runner exec.CommandRunner } func (cli *Cli) version(ctx context.Context) (semver.Version, error) { packRes, err := cli.runner.Run(ctx, exec.NewRunArgs(cli.path, "--version")) if err != nil { return semver.Version{}, err } version, err := tools.ExtractVersion(packRes.Stdout) if err != nil { return semver.Version{}, err } return version, nil } func (cli *Cli) enableExperimental(ctx context.Context) error { runArgs := exec.NewRunArgs(cli.path, "config", "experimental", "true") runArgs.Interactive = false _, err := cli.runner.Run(ctx, runArgs) if err != nil { return err } return nil } func (cli *Cli) Build( ctx context.Context, cwd string, builder string, imageName string, environ []string, progressWriter io.Writer, ) error { err := cli.enableExperimental(ctx) if err != nil { return err } envArgs := make([]string, 0, 2*len(environ)) for _, e := range environ { envArgs = append(envArgs, "--env", e) } runArgs := exec.NewRunArgs(cli.path, "build", imageName, "--builder", builder, "--path", cwd) runArgs.Args = append(runArgs.Args, envArgs...) if progressWriter != nil { runArgs = runArgs.WithStdOut(progressWriter).WithStdErr(progressWriter) } res, err := cli.runner.Run(ctx, runArgs) if err != nil { return wrapStatusCodeErr(err, res) } return nil } func wrapStatusCodeErr(err error, res exec.RunResult) error { if err == nil { return err } matches := statusCodeFailureRegexp.FindStringSubmatch(res.Stderr) if len(matches) == 2 { code, parseErr := strconv.Atoi(matches[1]) if parseErr == nil { return &StatusCodeError{ Err: err, Code: code, } } } return err } func packName() string { if runtime.GOOS == "windows" { return "pack.exe" } else { return "pack" } } func extractFromZip( zipped string, out string) (string, error) { zipReader, err := zip.OpenReader(zipped) if err != nil { return "", err } log.Printf("extract from %s", zipped) defer zipReader.Close() var extractedAt string for _, file := range zipReader.File { fileName := file.FileInfo().Name() if !file.FileInfo().IsDir() && fileName == packName() { log.Printf("found cli at: %s", file.Name) fileReader, err := file.Open() if err != nil { return extractedAt, err } filePath := filepath.Join(out, fileName) cliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) if err != nil { return extractedAt, err } defer cliFile.Close() /* #nosec G110 - decompression bomb false positive */ _, err = io.Copy(cliFile, fileReader) if err != nil { return extractedAt, err } extractedAt = filePath break } } if extractedAt != "" { log.Printf("extracted to: %s", extractedAt) return extractedAt, nil } return extractedAt, fmt.Errorf("pack cli binary was not found within the zip file") } func extractFromTar( zipped string, out string) (string, error) { gzFile, err := os.Open(zipped) if err != nil { return "", err } defer gzFile.Close() gzReader, err := gzip.NewReader(gzFile) if err != nil { return "", err } defer gzReader.Close() var extractedAt string // tarReader doesn't need to be closed as it is closed by the gz reader tarReader := tar.NewReader(gzReader) for { fileHeader, err := tarReader.Next() if errors.Is(err, io.EOF) { return extractedAt, fmt.Errorf("did not find pack cli within tar file") } if fileHeader == nil { continue } if err != nil { return extractedAt, err } // Tha name contains the path, remove it fileNameParts := strings.Split(fileHeader.Name, "/") fileName := fileNameParts[len(fileNameParts)-1] // cspell: disable-next-line `Typeflag` is comming fron *tar.Header if fileHeader.Typeflag == tar.TypeReg && fileName == "pack" { filePath := filepath.Join(out, fileName) packCliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileHeader.FileInfo().Mode()) if err != nil { return extractedAt, err } defer packCliFile.Close() /* #nosec G110 - decompression bomb false positive */ _, err = io.Copy(packCliFile, tarReader) if err != nil { return extractedAt, err } extractedAt = filePath break } } if extractedAt != "" { return extractedAt, nil } return extractedAt, fmt.Errorf("unable to find pack cli within archive") } // extractCli gets the pack cli from either a zip or a tar.gz func extractCli(src, dst string) (string, error) { if strings.HasSuffix(src, ".zip") { return extractFromZip(src, dst) } else if strings.HasSuffix(src, ".tgz") { return extractFromTar(src, dst) } return "", fmt.Errorf("unknown format while trying to extract") } // downloadPack downloads a given version of pack cli from the release site. func downloadPack( ctx context.Context, transporter policy.Transporter, version semver.Version, extractFile func(src, dst string) (string, error), path string) error { systemArch := runtime.GOARCH archString := "" // amd64 is the implicit default if systemArch != "amd64" { archString = fmt.Sprintf("-%s", systemArch) } var releaseName string switch runtime.GOOS { case "windows": releaseName = fmt.Sprintf("pack-v%s-windows%s.zip", version, archString) case "darwin": releaseName = fmt.Sprintf("pack-v%s-macos%s.tgz", version, archString) case "linux": releaseName = fmt.Sprintf("pack-v%s-linux%s.tgz", version, archString) default: return fmt.Errorf("unsupported platform") } // example: https://github.com/buildpacks/pack/releases/download/v0.29.0/pack-v0.29.0-windows.zip ghReleaseUrl := fmt.Sprintf("https://github.com/buildpacks/pack/releases/download/v%s/%s", version, releaseName) log.Printf("downloading pack cli release %s -> %s", ghReleaseUrl, releaseName) req, err := http.NewRequestWithContext(ctx, "GET", ghReleaseUrl, nil) if err != nil { return err } resp, err := transporter.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("http error %d", resp.StatusCode) } tmpPath := filepath.Dir(path) compressedRelease, err := os.CreateTemp(tmpPath, releaseName) if err != nil { return err } defer func() { _ = compressedRelease.Close() _ = os.Remove(compressedRelease.Name()) }() if _, err := io.Copy(compressedRelease, resp.Body); err != nil { return err } if err := compressedRelease.Close(); err != nil { return err } // change file name from temporal name to the final name, as the download has completed compressedFileName := filepath.Join(tmpPath, releaseName) if err := osutil.Rename(ctx, compressedRelease.Name(), compressedFileName); err != nil { return err } defer func() { log.Printf("delete %s", compressedFileName) _ = os.Remove(compressedFileName) }() // unzip downloaded file log.Printf("extracting file %s", compressedFileName) _, err = extractFile(compressedFileName, tmpPath) if err != nil { return err } return nil }