github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/build.go (about) 1 package compute 2 3 import ( 4 "bufio" 5 "crypto/rand" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "math" 11 "os" 12 "os/exec" 13 "path/filepath" 14 "runtime" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/kennygrant/sanitize" 20 "github.com/mholt/archiver/v3" 21 "golang.org/x/text/cases" 22 textlang "golang.org/x/text/language" 23 24 "github.com/fastly/cli/pkg/argparser" 25 "github.com/fastly/cli/pkg/check" 26 fsterr "github.com/fastly/cli/pkg/errors" 27 "github.com/fastly/cli/pkg/filesystem" 28 "github.com/fastly/cli/pkg/github" 29 "github.com/fastly/cli/pkg/global" 30 "github.com/fastly/cli/pkg/manifest" 31 "github.com/fastly/cli/pkg/revision" 32 "github.com/fastly/cli/pkg/text" 33 ) 34 35 // IgnoreFilePath is the filepath name of the Fastly ignore file. 36 const IgnoreFilePath = ".fastlyignore" 37 38 // CustomPostScriptMessage is the message displayed to a user when there is 39 // either a post_init or post_build script defined. 40 const CustomPostScriptMessage = "This project has a custom post_%s script defined in the %s manifest" 41 42 // ErrWasmtoolsNotFound represents an error finding the binary installed. 43 var ErrWasmtoolsNotFound = fsterr.RemediationError{ 44 Inner: fmt.Errorf("wasm-tools not found"), 45 Remediation: fsterr.BugRemediation, 46 } 47 48 // Flags represents the flags defined for the command. 49 type Flags struct { 50 Dir string 51 Env string 52 IncludeSrc bool 53 Lang string 54 PackageName string 55 Timeout int 56 } 57 58 // BuildCommand produces a deployable artifact from files on the local disk. 59 type BuildCommand struct { 60 argparser.Base 61 62 // NOTE: Composite commands require these build flags to be public. 63 // e.g. serve, publish, hashsum, hash-files 64 // This is so they can set values appropriately before calling Build.Exec(). 65 Flags Flags 66 MetadataDisable bool 67 MetadataFilterEnvVars string 68 MetadataShow bool 69 SkipChangeDir bool // set by parent composite commands (e.g. serve, publish) 70 } 71 72 // NewBuildCommand returns a usable command registered under the parent. 73 func NewBuildCommand(parent argparser.Registerer, g *global.Data) *BuildCommand { 74 var c BuildCommand 75 c.Globals = g 76 c.CmdClause = parent.Command("build", "Build a Compute package locally") 77 78 // NOTE: when updating these flags, be sure to update the composite commands: 79 // `compute publish` and `compute serve`. 80 c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').StringVar(&c.Flags.Dir) 81 c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").StringVar(&c.Flags.Env) 82 c.CmdClause.Flag("include-source", "Include source code in built package").BoolVar(&c.Flags.IncludeSrc) 83 c.CmdClause.Flag("language", "Language type").StringVar(&c.Flags.Lang) 84 c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").BoolVar(&c.MetadataDisable) 85 c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").StringVar(&c.MetadataFilterEnvVars) 86 c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").BoolVar(&c.MetadataShow) 87 c.CmdClause.Flag("package-name", "Package name").StringVar(&c.Flags.PackageName) 88 c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").IntVar(&c.Flags.Timeout) 89 90 return &c 91 } 92 93 // Exec implements the command interface. 94 func (c *BuildCommand) Exec(in io.Reader, out io.Writer) (err error) { 95 // We'll restore this at the end to print a final successful build output. 96 originalOut := out 97 if c.Globals.Flags.Quiet { 98 out = io.Discard 99 } 100 101 manifestFilename := EnvironmentManifest(c.Flags.Env) 102 if c.Flags.Env != "" { 103 if c.Globals.Verbose() { 104 text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename) 105 } 106 } 107 wd, err := os.Getwd() 108 if err != nil { 109 return fmt.Errorf("failed to get current working directory: %w", err) 110 } 111 defer func() { 112 _ = os.Chdir(wd) 113 }() 114 manifestPath := filepath.Join(wd, manifestFilename) 115 116 var projectDir string 117 if !c.SkipChangeDir { 118 projectDir, err = ChangeProjectDirectory(c.Flags.Dir) 119 if err != nil { 120 return err 121 } 122 if projectDir != "" { 123 if c.Globals.Verbose() { 124 text.Info(out, ProjectDirMsg, projectDir) 125 } 126 manifestPath = filepath.Join(projectDir, manifestFilename) 127 } 128 } 129 130 spinner, err := text.NewSpinner(out) 131 if err != nil { 132 return err 133 } 134 135 defer func(errLog fsterr.LogInterface) { 136 if err != nil { 137 errLog.Add(err) 138 } 139 }(c.Globals.ErrLog) 140 141 if c.Globals.Verbose() { 142 text.Break(out) 143 } 144 err = spinner.Process(fmt.Sprintf("Verifying %s", manifestFilename), func(_ *text.SpinnerWrapper) error { 145 // The check for c.SkipChangeDir here is because we might need to attempt 146 // another read of the manifest file. To explain: if we're skipping the 147 // change of directory, it means we were called from a composite command, 148 // which has already changed directory to one that contains the fastly.toml 149 // file. This means we should try reading the manifest file from the new 150 // location as the potential ReadError() would have been based on the 151 // initial directory the CLI was invoked from. 152 if c.SkipChangeDir || projectDir != "" || c.Flags.Env != "" { 153 err = c.Globals.Manifest.File.Read(manifestPath) 154 } else { 155 err = c.Globals.Manifest.File.ReadError() 156 } 157 if err != nil { 158 if errors.Is(err, os.ErrNotExist) { 159 err = fsterr.ErrReadingManifest 160 } 161 c.Globals.ErrLog.Add(err) 162 return err 163 } 164 return nil 165 }) 166 if err != nil { 167 return err 168 } 169 170 wasmtools, wasmtoolsErr := GetWasmTools(spinner, out, c.Globals.Versioners.WasmTools, c.Globals) 171 172 var pkgName string 173 err = spinner.Process("Identifying package name", func(_ *text.SpinnerWrapper) error { 174 pkgName, err = c.PackageName(manifestFilename) 175 return err 176 }) 177 if err != nil { 178 return err 179 } 180 181 var toolchain string 182 err = spinner.Process("Identifying toolchain", func(_ *text.SpinnerWrapper) error { 183 toolchain, err = identifyToolchain(c) 184 return err 185 }) 186 if err != nil { 187 return err 188 } 189 190 language, err := language(toolchain, manifestFilename, c, in, out, spinner) 191 if err != nil { 192 return err 193 } 194 195 err = binDir(c) 196 if err != nil { 197 return err 198 } 199 200 if err := language.Build(); err != nil { 201 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 202 "Language": language.Name, 203 }) 204 return err 205 } 206 207 // IMPORTANT: We ignore errors downloading wasm-tools. 208 // This is because we don't want to block a user from building their project. 209 // Annotating the compiled binary with metadata isn't that important. 210 if wasmtoolsErr == nil { 211 metadataProcessedBy := fmt.Sprintf( 212 "--processed-by=fastly=%s (%s)", 213 revision.AppVersion, cases.Title(textlang.English).String(language.Name), 214 ) 215 metadataArgs := []string{ 216 "metadata", "add", "bin/main.wasm", metadataProcessedBy, 217 } 218 219 metadataDisable, _ := strconv.ParseBool(c.Globals.Env.WasmMetadataDisable) 220 if !c.MetadataDisable && !metadataDisable { 221 if err := c.AnnotateWasmBinaryLong(wasmtools, metadataArgs, language); err != nil { 222 return err 223 } 224 } else { 225 if err := c.AnnotateWasmBinaryShort(wasmtools, metadataArgs); err != nil { 226 return err 227 } 228 } 229 if c.MetadataShow { 230 if err := c.ShowMetadata(wasmtools, out); err != nil { 231 return err 232 } 233 } 234 } else { 235 if !c.Globals.Verbose() { 236 text.Break(out) 237 } 238 text.Info(out, "There was an error downloading the wasm-tools (used for binary annotations) but we don't let that block you building your project. For reference here is the error (in case you want to let us know about it): %s\n\n", wasmtoolsErr.Error()) 239 } 240 241 dest := filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", pkgName)) 242 err = spinner.Process("Creating package archive", func(_ *text.SpinnerWrapper) error { 243 // IMPORTANT: The minimum package requirement is `fastly.toml` and `main.wasm`. 244 // 245 // The Fastly platform will reject a package that doesn't have a manifest 246 // named exactly fastly.toml which means if the user is building and 247 // deploying a package with an environment manifest (e.g. fastly.stage.toml) 248 // then we need to: 249 // 250 // 1. Rename any existing fastly.toml to fastly.toml.backup.<TIMESTAMP> 251 // 2. Make a temp copy of the environment manifest and name it fastly.toml 252 // 3. Remove the newly created fastly.toml once the packaging is done 253 // 4. Rename the fastly.toml.backup back to fastly.toml 254 if c.Flags.Env != "" { 255 // 1. Rename any existing fastly.toml to fastly.toml.backup.<TIMESTAMP> 256 // 257 // For example, the user is trying to deploy a fastly.stage.toml rather 258 // than the standard fastly.toml manifest. 259 if _, err := os.Stat(manifest.Filename); err == nil { 260 backup := fmt.Sprintf("%s.backup.%d", manifest.Filename, time.Now().Unix()) 261 if err := os.Rename(manifest.Filename, backup); err != nil { 262 return fmt.Errorf("failed to backup primary manifest file: %w", err) 263 } 264 defer func() { 265 // 4. Rename the fastly.toml.backup back to fastly.toml 266 if err = os.Rename(backup, manifest.Filename); err != nil { 267 text.Error(out, err.Error()) 268 } 269 }() 270 } else { 271 // 3. Remove the newly created fastly.toml once the packaging is done 272 // 273 // If there wasn't an existing fastly.toml because the user only wants 274 // to work with environment manifests (e.g. fastly.stage.toml and 275 // fastly.production.toml) then we should remove the fastly.toml that we 276 // created just for the packaging process (see step 2. below). 277 defer func() { 278 if err = os.Remove(manifest.Filename); err != nil { 279 text.Error(out, err.Error()) 280 } 281 }() 282 } 283 // 2. Make a temp copy of the environment manifest and name it fastly.toml 284 // 285 // If there was no existing fastly.toml then this step will create one, so 286 // we need to make sure we remove it after packaging has finished so as to 287 // not confuse the user with a fastly.toml that has suddenly appeared (see 288 // step 3. above). 289 if err := filesystem.CopyFile(manifestFilename, manifest.Filename); err != nil { 290 return fmt.Errorf("failed to copy environment manifest file: %w", err) 291 } 292 } 293 294 files := []string{ 295 manifest.Filename, 296 "bin/main.wasm", 297 } 298 files, err = c.includeSourceCode(files, language.SourceDirectory) 299 if err != nil { 300 return err 301 } 302 err = CreatePackageArchive(files, dest) 303 if err != nil { 304 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 305 "Files": files, 306 "Destination": dest, 307 }) 308 return fmt.Errorf("error creating package archive: %w", err) 309 } 310 return nil 311 }) 312 if err != nil { 313 return err 314 } 315 316 out = originalOut 317 text.Success(out, "\nBuilt package (%s)", dest) 318 return nil 319 } 320 321 // AnnotateWasmBinaryShort annotates the Wasm binary with only the CLI version. 322 func (c *BuildCommand) AnnotateWasmBinaryShort(wasmtools string, args []string) error { 323 return c.Globals.ExecuteWasmTools(wasmtools, args) 324 } 325 326 // AnnotateWasmBinaryLong annotates the Wasm binary will all available data. 327 func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, language *Language) error { 328 var ms runtime.MemStats 329 runtime.ReadMemStats(&ms) 330 331 // Allow customer to specify their own env variables to be filtered. 332 ExtendStaticSecretEnvVars(c.MetadataFilterEnvVars) 333 334 dc := DataCollection{} 335 336 metadata := c.Globals.Config.WasmMetadata 337 338 // Only record basic data if user has disabled all other metadata collection. 339 if metadata.BuildInfo == "disable" && metadata.MachineInfo == "disable" && metadata.PackageInfo == "disable" && metadata.ScriptInfo == "disable" { 340 return c.AnnotateWasmBinaryShort(wasmtools, args) 341 } 342 343 if metadata.BuildInfo == "enable" { 344 dc.BuildInfo = DataCollectionBuildInfo{ 345 MemoryHeapAlloc: bucketMB(bytesToMB(ms.HeapAlloc)) + "MB", 346 } 347 } 348 if metadata.MachineInfo == "enable" { 349 dc.MachineInfo = DataCollectionMachineInfo{ 350 Arch: runtime.GOARCH, 351 CPUs: runtime.NumCPU(), 352 Compiler: runtime.Compiler, 353 GoVersion: runtime.Version(), 354 OS: runtime.GOOS, 355 } 356 } 357 if metadata.PackageInfo == "enable" { 358 dc.PackageInfo = DataCollectionPackageInfo{ 359 ClonedFrom: c.Globals.Manifest.File.ClonedFrom, 360 Packages: language.Dependencies(), 361 } 362 } 363 if metadata.ScriptInfo == "enable" { 364 dc.ScriptInfo = DataCollectionScriptInfo{ 365 DefaultBuildUsed: language.DefaultBuildScript(), 366 BuildScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.Build), 367 EnvVars: FilterSecretsFromSlice(c.Globals.Manifest.File.Scripts.EnvVars), 368 PostInitScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.PostInit), 369 PostBuildScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.PostBuild), 370 } 371 } 372 373 data, err := json.Marshal(dc) 374 if err != nil { 375 return err 376 } 377 378 args = append(args, fmt.Sprintf("--processed-by=fastly_data=%s", data)) 379 380 return c.Globals.ExecuteWasmTools(wasmtools, args) 381 } 382 383 // ShowMetadata displays the metadata attached to the Wasm binary. 384 func (c *BuildCommand) ShowMetadata(wasmtools string, out io.Writer) error { 385 // gosec flagged this: 386 // G204 (CWE-78): Subprocess launched with variable 387 // Disabling as the variables come from trusted sources. 388 // #nosec 389 // nosemgrep 390 command := exec.Command(wasmtools, "metadata", "show", "bin/main.wasm") 391 wasmtoolsOutput, err := command.Output() 392 if err != nil { 393 return fmt.Errorf("failed to execute wasm-tools metadata command: %w", err) 394 } 395 text.Info(out, "\nBelow is the metadata attached to the Wasm binary\n\n") 396 fmt.Fprintln(out, string(wasmtoolsOutput)) 397 text.Break(out) 398 return nil 399 } 400 401 // includeSourceCode calculates what source code files to include in the final 402 // package.tar.gz that is uploaded to the Fastly API. 403 // 404 // TODO: Investigate possible change to --include-source flag. 405 // The following implementation presumes source code is stored in a constant 406 // location, which might not be true for all users. We should look at whether 407 // we should change the --include-source flag to not be a boolean but to 408 // accept a 'source code' path instead. 409 func (c *BuildCommand) includeSourceCode(files []string, srcDir string) ([]string, error) { 410 empty := make([]string, 0) 411 412 if c.Flags.IncludeSrc { 413 ignoreFiles, err := GetIgnoredFiles(IgnoreFilePath) 414 if err != nil { 415 c.Globals.ErrLog.Add(err) 416 return empty, err 417 } 418 419 binFiles, err := GetNonIgnoredFiles("bin", ignoreFiles) 420 if err != nil { 421 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 422 "Ignore files": ignoreFiles, 423 }) 424 return empty, err 425 } 426 files = append(files, binFiles...) 427 428 srcFiles, err := GetNonIgnoredFiles(srcDir, ignoreFiles) 429 if err != nil { 430 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 431 "Source directory": srcDir, 432 "Ignore files": ignoreFiles, 433 }) 434 return empty, err 435 } 436 files = append(files, srcFiles...) 437 } 438 439 return files, nil 440 } 441 442 // PackageName acquires the package name from either a flag or manifest. 443 // Additionally it will sanitize the name. 444 func (c *BuildCommand) PackageName(manifestFilename string) (string, error) { 445 var name string 446 447 switch { 448 case c.Flags.PackageName != "": 449 name = c.Flags.PackageName 450 case c.Globals.Manifest.File.Name != "": 451 name = c.Globals.Manifest.File.Name // use the project name as a fallback 452 default: 453 return "", fsterr.RemediationError{ 454 Inner: fmt.Errorf("package name is missing"), 455 Remediation: fmt.Sprintf("Add a name to the %s 'name' field. Reference: https://developer.fastly.com/reference/compute/fastly-toml/", manifestFilename), 456 } 457 } 458 459 return sanitize.BaseName(name), nil 460 } 461 462 // ExecuteWasmTools calls the wasm-tools binary. 463 func ExecuteWasmTools(wasmtools string, args []string) error { 464 // gosec flagged this: 465 // G204 (CWE-78): Subprocess launched with function call as argument or command arguments 466 // Disabling as we trust the source of the variable. 467 // #nosec 468 // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command 469 command := exec.Command(wasmtools, args...) 470 wasmtoolsOutput, err := command.Output() 471 if err != nil { 472 return fmt.Errorf("failed to annotate binary with metadata: %w", err) 473 } 474 // Ensure the Wasm binary can be executed. 475 // 476 // G302 (CWE-276): Expect file permissions to be 0600 or less 477 // gosec flagged this: 478 // Disabling as we want all users to be able to execute this binary. 479 // #nosec 480 err = os.WriteFile("bin/main.wasm", wasmtoolsOutput, 0o777) 481 if err != nil { 482 return fmt.Errorf("failed to annotate binary with metadata: %w", err) 483 } 484 return nil 485 } 486 487 // GetWasmTools returns the path to the wasm-tools binary. 488 // If there is no version installed, install the latest version. 489 // If there is a version installed, update to the latest version if not already. 490 func GetWasmTools(spinner text.Spinner, out io.Writer, wasmtoolsVersioner github.AssetVersioner, g *global.Data) (binPath string, err error) { 491 binPath = wasmtoolsVersioner.InstallPath() 492 493 // NOTE: When checking if wasm-tools is installed we don't use $PATH. 494 // 495 // $PATH is unreliable across OS platforms, but also we actually install 496 // wasm-tools in the same location as the CLI's app config, which means it 497 // wouldn't be found in the $PATH any way. We could pass the path for the app 498 // config into exec.LookPath() but it's simpler to attempt executing the binary. 499 // 500 // gosec flagged this: 501 // G204 (CWE-78): Subprocess launched with variable 502 // Disabling as the variables come from trusted sources. 503 // #nosec 504 // nosemgrep 505 c := exec.Command(binPath, "--version") 506 507 var installedVersion string 508 509 stdoutStderr, err := c.CombinedOutput() 510 if err != nil { 511 g.ErrLog.Add(err) 512 } else { 513 // Check the version output has the expected format: `wasm-tools 1.0.40` 514 installedVersion = strings.TrimSpace(string(stdoutStderr)) 515 segs := strings.Split(installedVersion, " ") 516 if len(segs) < 2 { 517 return binPath, ErrWasmtoolsNotFound 518 } 519 installedVersion = segs[1] 520 } 521 522 if installedVersion == "" { 523 if g.Verbose() { 524 text.Info(out, "\nwasm-tools is not already installed, so we will install the latest version.\n\n") 525 } 526 err = installLatestWasmtools(binPath, spinner, wasmtoolsVersioner) 527 if err != nil { 528 g.ErrLog.Add(err) 529 return binPath, err 530 } 531 532 latestVersion, err := wasmtoolsVersioner.LatestVersion() 533 if err != nil { 534 return binPath, fmt.Errorf("failed to retrieve wasm-tools latest version: %w", err) 535 } 536 537 g.Config.WasmTools.LatestVersion = latestVersion 538 g.Config.WasmTools.LastChecked = time.Now().Format(time.RFC3339) 539 540 err = g.Config.Write(g.ConfigPath) 541 if err != nil { 542 return binPath, err 543 } 544 } 545 546 if installedVersion != "" { 547 err = updateWasmtools(binPath, spinner, out, g, wasmtoolsVersioner, installedVersion) 548 if err != nil { 549 g.ErrLog.Add(err) 550 return binPath, err 551 } 552 } 553 554 err = github.SetBinPerms(binPath) 555 if err != nil { 556 g.ErrLog.Add(err) 557 return binPath, err 558 } 559 560 return binPath, nil 561 } 562 563 func installLatestWasmtools(binPath string, spinner text.Spinner, wasmtoolsVersioner github.AssetVersioner) error { 564 return spinner.Process("Fetching latest wasm-tools release", func(_ *text.SpinnerWrapper) error { 565 tmpBin, err := wasmtoolsVersioner.DownloadLatest() 566 if err != nil { 567 return fmt.Errorf("failed to download latest wasm-tools release: %w", err) 568 } 569 defer os.RemoveAll(tmpBin) 570 if err := os.Rename(tmpBin, binPath); err != nil { 571 if err := filesystem.CopyFile(tmpBin, binPath); err != nil { 572 return fmt.Errorf("failed to move wasm-tools binary to accessible location: %w", err) 573 } 574 } 575 return nil 576 }) 577 } 578 579 func updateWasmtools( 580 binPath string, 581 spinner text.Spinner, 582 out io.Writer, 583 g *global.Data, 584 wasmtoolsVersioner github.AssetVersioner, 585 installedVersion string, 586 ) error { 587 cfg := g.Config 588 cfgPath := g.ConfigPath 589 590 // NOTE: We shouldn't see LastChecked with no value if wasm-tools installed. 591 if cfg.WasmTools.LastChecked == "" { 592 cfg.WasmTools.LastChecked = time.Now().Format(time.RFC3339) 593 if err := cfg.Write(cfgPath); err != nil { 594 return err 595 } 596 } 597 if !check.Stale(cfg.WasmTools.LastChecked, cfg.WasmTools.TTL) { 598 if g.Verbose() { 599 text.Info(out, "\nwasm-tools is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired.\n\n") 600 } 601 return nil 602 } 603 604 var latestVersion string 605 err := spinner.Process("Checking latest wasm-tools release", func(_ *text.SpinnerWrapper) error { 606 var err error 607 latestVersion, err = wasmtoolsVersioner.LatestVersion() 608 if err != nil { 609 return fsterr.RemediationError{ 610 Inner: fmt.Errorf("error fetching latest version: %w", err), 611 Remediation: fsterr.NetworkRemediation, 612 } 613 } 614 return nil 615 }) 616 if err != nil { 617 return err 618 } 619 620 cfg.WasmTools.LatestVersion = latestVersion 621 cfg.WasmTools.LastChecked = time.Now().Format(time.RFC3339) 622 623 err = cfg.Write(cfgPath) 624 if err != nil { 625 return err 626 } 627 if g.Verbose() { 628 text.Info(out, "\nThe CLI config (`fastly config`) has been updated with the latest wasm-tools version: %s\n\n", latestVersion) 629 } 630 if installedVersion == latestVersion { 631 return nil 632 } 633 634 return installLatestWasmtools(binPath, spinner, wasmtoolsVersioner) 635 } 636 637 // identifyToolchain determines the programming language. 638 // 639 // It prioritises the --language flag over the manifest field. 640 // Will error if neither are provided. 641 // Lastly, it will normalise with a trim and lowercase. 642 func identifyToolchain(c *BuildCommand) (string, error) { 643 var toolchain string 644 645 switch { 646 case c.Flags.Lang != "": 647 toolchain = c.Flags.Lang 648 case c.Globals.Manifest.File.Language != "": 649 toolchain = c.Globals.Manifest.File.Language 650 default: 651 return "", fmt.Errorf("language cannot be empty, please provide a language") 652 } 653 654 return strings.ToLower(strings.TrimSpace(toolchain)), nil 655 } 656 657 // language returns a pointer to a supported language. 658 // 659 // TODO: Fix the mess that is New<language>()'s argument list. 660 func language(toolchain, manifestFilename string, c *BuildCommand, in io.Reader, out io.Writer, spinner text.Spinner) (*Language, error) { 661 var language *Language 662 switch toolchain { 663 case "assemblyscript": 664 language = NewLanguage(&LanguageOptions{ 665 Name: "assemblyscript", 666 SourceDirectory: AsSourceDirectory, 667 Toolchain: NewAssemblyScript(c, in, manifestFilename, out, spinner), 668 }) 669 case "go": 670 language = NewLanguage(&LanguageOptions{ 671 Name: "go", 672 SourceDirectory: GoSourceDirectory, 673 Toolchain: NewGo(c, in, manifestFilename, out, spinner), 674 }) 675 case "javascript": 676 language = NewLanguage(&LanguageOptions{ 677 Name: "javascript", 678 SourceDirectory: JsSourceDirectory, 679 Toolchain: NewJavaScript(c, in, manifestFilename, out, spinner), 680 }) 681 case "rust": 682 language = NewLanguage(&LanguageOptions{ 683 Name: "rust", 684 SourceDirectory: RustSourceDirectory, 685 Toolchain: NewRust(c, in, manifestFilename, out, spinner), 686 }) 687 case "other": 688 language = NewLanguage(&LanguageOptions{ 689 Name: "other", 690 Toolchain: NewOther(c, in, manifestFilename, out, spinner), 691 }) 692 default: 693 return nil, fmt.Errorf("unsupported language %s", toolchain) 694 } 695 696 return language, nil 697 } 698 699 // binDir ensures a ./bin directory exists. 700 // The directory is required so a main.wasm can be placed inside it. 701 func binDir(c *BuildCommand) error { 702 if c.Globals.Verbose() { 703 text.Info(c.Globals.Output, "\nCreating ./bin directory (for Wasm binary)\n\n") 704 } 705 dir, err := os.Getwd() 706 if err != nil { 707 c.Globals.ErrLog.Add(err) 708 return fmt.Errorf("failed to identify the current working directory: %w", err) 709 } 710 binDir := filepath.Join(dir, "bin") 711 if err := filesystem.MakeDirectoryIfNotExists(binDir); err != nil { 712 c.Globals.ErrLog.Add(err) 713 return fmt.Errorf("failed to create bin directory: %w", err) 714 } 715 return nil 716 } 717 718 // CreatePackageArchive packages build artifacts as a Fastly package. 719 // The package must be a GZipped Tar archive. 720 // 721 // Due to a behavior of archiver.Archive() which recursively writes all files in 722 // a provided directory to the archive we first copy our input files to a 723 // temporary directory to ensure only the specified files are included and not 724 // any in the directory which may be ignored. 725 func CreatePackageArchive(files []string, destination string) error { 726 // Create temporary directory to copy files into. 727 p := make([]byte, 8) 728 n, err := rand.Read(p) 729 if err != nil { 730 return fmt.Errorf("error creating temporary directory: %w", err) 731 } 732 733 tmpDir := filepath.Join( 734 os.TempDir(), 735 fmt.Sprintf("fastly-build-%x", p[:n]), 736 ) 737 738 if err := os.MkdirAll(tmpDir, 0o700); err != nil { 739 return fmt.Errorf("error creating temporary directory: %w", err) 740 } 741 defer os.RemoveAll(tmpDir) 742 743 // Create implicit top-level directory within temp which will become the 744 // root of the archive. This replaces the `tar.ImplicitTopLevelFolder` 745 // behavior. 746 dir := filepath.Join(tmpDir, FileNameWithoutExtension(destination)) 747 if err := os.Mkdir(dir, 0o700); err != nil { 748 return fmt.Errorf("error creating temporary directory: %w", err) 749 } 750 751 for _, src := range files { 752 dst := filepath.Join(dir, src) 753 if err = filesystem.CopyFile(src, dst); err != nil { 754 return fmt.Errorf("error copying file: %w", err) 755 } 756 } 757 758 tar := archiver.NewTarGz() 759 tar.OverwriteExisting = true // 760 tar.MkdirAll = true // make destination directory if it doesn't exist 761 762 return tar.Archive([]string{dir}, destination) 763 } 764 765 // FileNameWithoutExtension returns a filename with its extension stripped. 766 func FileNameWithoutExtension(filename string) string { 767 base := filepath.Base(filename) 768 firstDot := strings.Index(base, ".") 769 if firstDot > -1 { 770 return base[:firstDot] 771 } 772 return base 773 } 774 775 // GetIgnoredFiles reads the .fastlyignore file line-by-line and expands the 776 // glob pattern into a map containing all files it matches. If no ignore file 777 // is present it returns an empty map. 778 func GetIgnoredFiles(filePath string) (files map[string]bool, err error) { 779 files = make(map[string]bool) 780 781 if !filesystem.FileExists(filePath) { 782 return files, nil 783 } 784 785 // gosec flagged this: 786 // G304 (CWE-22): Potential file inclusion via variable 787 // Disabling as we trust the source of the filepath variable as it comes 788 // from the IgnoreFilePath constant. 789 /* #nosec */ 790 file, err := os.Open(filePath) 791 if err != nil { 792 return files, err 793 } 794 defer func() { 795 cerr := file.Close() 796 if err == nil { 797 err = cerr 798 } 799 }() 800 801 scanner := bufio.NewScanner(file) 802 for scanner.Scan() { 803 glob := strings.TrimSpace(scanner.Text()) 804 globFiles, err := filepath.Glob(glob) 805 if err != nil { 806 return files, fmt.Errorf("parsing glob %s: %w", glob, err) 807 } 808 for _, f := range globFiles { 809 files[f] = true 810 } 811 } 812 813 if err := scanner.Err(); err != nil { 814 return files, fmt.Errorf("reading %s file: %w", filePath, err) 815 } 816 817 return files, nil 818 } 819 820 // GetNonIgnoredFiles walks a filepath and returns all files that don't exist in 821 // the provided ignore files map. 822 func GetNonIgnoredFiles(base string, ignoredFiles map[string]bool) ([]string, error) { 823 var files []string 824 err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { 825 if err != nil { 826 return err 827 } 828 if info.IsDir() { 829 return nil 830 } 831 if ignoredFiles[path] { 832 return nil 833 } 834 files = append(files, path) 835 return nil 836 }) 837 838 return files, err 839 } 840 841 // bytesToMB converts the runtime.MemStats.HeapAlloc bytes into megabytes. 842 func bytesToMB(bytes uint64) uint64 { 843 return uint64(math.Round(float64(bytes) / (1024 * 1024))) 844 } 845 846 // bucketMB determines a consistent bucket size for heap allocation. 847 // NOTE: This is to avoid building a package with a fluctuating hashsum. 848 // e.g. `fastly compute hash-files` should be consistent unless memory increase is significant. 849 func bucketMB(mb uint64) string { 850 switch { 851 case mb < 2: 852 return "<2" 853 case mb >= 2 && mb < 5: 854 return "2-5" 855 case mb >= 5 && mb < 10: 856 return "5-10" 857 case mb >= 10 && mb < 20: 858 return "10-20" 859 case mb >= 20 && mb < 30: 860 return "20-30" 861 case mb >= 30 && mb < 40: 862 return "30-40" 863 case mb >= 40 && mb < 50: 864 return "40-50" 865 default: 866 return ">50" 867 } 868 } 869 870 // DataCollection represents data annotated onto the Wasm binary. 871 type DataCollection struct { 872 BuildInfo DataCollectionBuildInfo `json:"build_info,omitempty"` 873 MachineInfo DataCollectionMachineInfo `json:"machine_info,omitempty"` 874 PackageInfo DataCollectionPackageInfo `json:"package_info,omitempty"` 875 ScriptInfo DataCollectionScriptInfo `json:"script_info,omitempty"` 876 } 877 878 // DataCollectionBuildInfo represents build data annotated onto the Wasm binary. 879 type DataCollectionBuildInfo struct { 880 MemoryHeapAlloc string `json:"mem_heap_alloc,omitempty"` 881 } 882 883 // DataCollectionMachineInfo represents machine data annotated onto the Wasm binary. 884 type DataCollectionMachineInfo struct { 885 Arch string `json:"arch,omitempty"` 886 CPUs int `json:"cpus,omitempty"` 887 Compiler string `json:"compiler,omitempty"` 888 GoVersion string `json:"go_version,omitempty"` 889 OS string `json:"os,omitempty"` 890 } 891 892 // DataCollectionPackageInfo represents package data annotated onto the Wasm binary. 893 type DataCollectionPackageInfo struct { 894 // ClonedFrom indicates if the Starter Kit used was cloned from a specific 895 // repository (e.g. using the `compute init` --from flag). 896 ClonedFrom string `json:"cloned_from,omitempty"` 897 // Packages is a map where the key is the name of the package and the value is 898 // the package version. 899 Packages map[string]string `json:"packages,omitempty"` 900 } 901 902 // DataCollectionScriptInfo represents script data annotated onto the Wasm binary. 903 type DataCollectionScriptInfo struct { 904 DefaultBuildUsed bool `json:"default_build_used,omitempty"` 905 BuildScript string `json:"build_script,omitempty"` 906 EnvVars []string `json:"env_vars,omitempty"` 907 PostInitScript string `json:"post_init_script,omitempty"` 908 PostBuildScript string `json:"post_build_script,omitempty"` 909 }