Repository navigation
Expand file tree
/
Copy pathpack.go
More file actions
452 lines (394 loc) · 11.7 KB
/
Copy pathpack.go
File metadata and controls
452 lines (394 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
// 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. The CLI is not yet installed; call EnsureInstalled before using
// methods that require the pack binary. 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(
console input.Console,
commandRunner exec.CommandRunner,
) *Cli {
return newPackCliImpl(
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(
console input.Console,
commandRunner exec.CommandRunner,
transporter policy.Transporter,
extract func(string, string) (string, error)) *Cli {
return &Cli{
runner: commandRunner,
console: console,
transporter: transporter,
extract: extract,
}
}
type Cli struct {
path string
runner exec.CommandRunner
console input.Console
transporter policy.Transporter
extract func(string, string) (string, error)
installInit osutil.LazyRetryInit
}
// EnsureInstalled checks if pack CLI is available and downloads/upgrades if needed.
// This is safe to call multiple times; successful installation is cached and failed attempts are retried.
// Should be called with a request-scoped context before first use.
func (cli *Cli) EnsureInstalled(ctx context.Context) error {
return cli.installInit.Do(func() error {
return cli.ensureInstalled(ctx)
})
}
func (cli *Cli) ensureInstalled(ctx context.Context) error {
if override := os.Getenv("AZD_PACK_TOOL_PATH"); override != "" {
//nolint:gosec // G706: env var in debug log
log.Printf("using external pack tool: %s", override)
cli.path = override
return nil
}
cliPath, err := packCliPath()
if err != nil {
return fmt.Errorf("finding pack: %w", err)
}
if _, err = os.Stat(cliPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("finding pack: %w", err)
}
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(cliPath), osutil.PermissionDirectory); err != nil {
return fmt.Errorf("downloading pack: %w", err)
}
msg := "Acquiring pack cli"
cli.console.ShowSpinner(ctx, msg, input.Step)
err := downloadPack(ctx, cli.transporter, Version, cli.extract, cliPath)
cli.console.StopSpinner(ctx, "", input.Step)
if err != nil {
return fmt.Errorf("downloading pack: %w", err)
}
}
cli.path = cliPath
ver, err := cli.version(ctx)
if err != nil {
return 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 := "Updating pack"
cli.console.ShowSpinner(ctx, msg, input.Step)
err := downloadPack(ctx, cli.transporter, Version, cli.extract, cliPath)
cli.console.StopSpinner(ctx, "", input.Step)
if err != nil {
return fmt.Errorf("updating pack: %w", err)
}
}
log.Printf("using local pack: %s", cliPath)
return nil
}
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)
//nolint:gosec // G703: out is a known install directory
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()) //nolint:gosec // G703: temp file cleanup
}()
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
}