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  }