github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/language_toolchain.go (about)

     1  package compute
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/binary"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"strconv"
    10  	"strings"
    11  
    12  	fsterr "github.com/fastly/cli/pkg/errors"
    13  	fstexec "github.com/fastly/cli/pkg/exec"
    14  	"github.com/fastly/cli/pkg/manifest"
    15  	"github.com/fastly/cli/pkg/text"
    16  )
    17  
    18  const (
    19  	// https://webassembly.github.io/spec/core/binary/modules.html#binary-module
    20  	wasmBytes = 4
    21  
    22  	// Defining as a constant avoids gosec G304 issue with command execution.
    23  	binWasmPath = "./bin/main.wasm"
    24  )
    25  
    26  // DefaultBuildErrorRemediation is the message returned to a user when there is
    27  // a build error.
    28  var DefaultBuildErrorRemediation = func() string {
    29  	return fmt.Sprintf(`%s:
    30  
    31  - Re-run the fastly command with the --verbose flag to see more information.
    32  - Is the required language toolchain (node/npm, rust/cargo etc) installed correctly?
    33  - Is the required version (if any) of the language toolchain installed/activated?
    34  - Were the required dependencies (package.json, Cargo.toml etc) installed?
    35  - Did the build script (see fastly.toml [scripts.build]) produce a ./bin/main.wasm binary file?
    36  - Was there a configured [scripts.post_build] step that needs to be double-checked?
    37  
    38  For more information on fastly.toml configuration settings, refer to https://developer.fastly.com/reference/compute/fastly-toml/`,
    39  		text.BoldYellow("Here are some steps you can follow to debug the issue"))
    40  }()
    41  
    42  // Toolchain abstracts a Compute source language toolchain.
    43  type Toolchain interface {
    44  	// Build compiles the user's source code into a Wasm binary.
    45  	Build() error
    46  	// DefaultBuildScript indicates if a default build script was used.
    47  	DefaultBuildScript() bool
    48  	// Dependencies returns all dependencies used by the project.
    49  	Dependencies() map[string]string
    50  }
    51  
    52  // BuildToolchain enables a language toolchain to compile their build script.
    53  type BuildToolchain struct {
    54  	// autoYes is the --auto-yes flag.
    55  	autoYes bool
    56  	// buildFn constructs a `sh -c` command from the buildScript.
    57  	buildFn func(string) (string, []string)
    58  	// buildScript is the [scripts.build] within the fastly.toml manifest.
    59  	buildScript string
    60  	// env is environment variables to be set.
    61  	env []string
    62  	// errlog is an abstraction for recording errors to disk.
    63  	errlog fsterr.LogInterface
    64  	// in is the user's terminal stdin stream
    65  	in io.Reader
    66  	// internalPostBuildCallback is run after the build but before post build.
    67  	internalPostBuildCallback func() error
    68  	// manifestFilename is the name of the manifest file.
    69  	manifestFilename string
    70  	// metadataFilterEnvVars is a comma-separated list of user defined env vars.
    71  	metadataFilterEnvVars string
    72  	// nonInteractive is the --non-interactive flag.
    73  	nonInteractive bool
    74  	// out is the users terminal stdout stream
    75  	out io.Writer
    76  	// postBuild is a custom script executed after the build but before the Wasm
    77  	// binary is added to the .tar.gz archive.
    78  	postBuild string
    79  	// spinner is a terminal progress status indicator.
    80  	spinner text.Spinner
    81  	// timeout is the build execution threshold.
    82  	timeout int
    83  	// verbose indicates if the user set --verbose
    84  	verbose bool
    85  }
    86  
    87  // Build compiles the user's source code into a Wasm binary.
    88  func (bt BuildToolchain) Build() error {
    89  	// Make sure to delete any pre-existing binary otherwise prior metadata will
    90  	// continue to be persisted.
    91  	if _, err := os.Stat(binWasmPath); err == nil {
    92  		os.Remove(binWasmPath)
    93  	}
    94  
    95  	cmd, args := bt.buildFn(bt.buildScript)
    96  
    97  	if bt.verbose {
    98  		buildScript := fmt.Sprintf("%s %s", cmd, strings.Join(args, " "))
    99  		text.Description(bt.out, "Build script to execute", FilterSecretsFromString(buildScript))
   100  
   101  		// IMPORTANT: We filter secrets the best we can before printing env vars.
   102  		// We use two separate processes to do this.
   103  		// First is filtering based on known environment variables.
   104  		// Second is filtering based on a generalised regex pattern.
   105  		if len(bt.env) > 0 {
   106  			ExtendStaticSecretEnvVars(bt.metadataFilterEnvVars)
   107  			s := strings.Join(bt.env, " ")
   108  			text.Description(bt.out, "Build environment variables set", FilterSecretsFromString(s))
   109  		}
   110  	}
   111  
   112  	var err error
   113  	msg := "Running [scripts.build]"
   114  
   115  	// If we're in verbose mode, the build output is shown.
   116  	// So in that case we don't want to have a spinner as it'll interweave output.
   117  	// In non-verbose mode we have a spinner running while the build is happening.
   118  	if !bt.verbose {
   119  		err = bt.spinner.Start()
   120  		if err != nil {
   121  			return err
   122  		}
   123  		bt.spinner.Message(msg + "...")
   124  	}
   125  
   126  	err = bt.execCommand(cmd, args, msg)
   127  	if err != nil {
   128  		// In verbose mode we'll have the failure status AFTER the error output.
   129  		// But we can't just call StopFailMessage() without first starting the spinner.
   130  		if bt.verbose {
   131  			text.Break(bt.out)
   132  			spinErr := bt.spinner.Start()
   133  			if spinErr != nil {
   134  				return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   135  			}
   136  			bt.spinner.Message(msg + "...")
   137  			bt.spinner.StopFailMessage(msg)
   138  			spinErr = bt.spinner.StopFail()
   139  			if spinErr != nil {
   140  				return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   141  			}
   142  		}
   143  		// WARNING: Don't try to add 'StopFailMessage/StopFail' calls here.
   144  		// If we're in non-verbose mode, then the spinner is BEFORE the error output.
   145  		// Also, in non-verbose mode stopping the spinner is handled internally.
   146  		// See the call to StopFailMessage() inside fstexec.Streaming.Exec().
   147  		return bt.handleError(err)
   148  	}
   149  
   150  	// In verbose mode we'll have the failure status AFTER the error output.
   151  	// But we can't just call StopMessage() without first starting the spinner.
   152  	if bt.verbose {
   153  		err = bt.spinner.Start()
   154  		if err != nil {
   155  			return err
   156  		}
   157  		bt.spinner.Message(msg + "...")
   158  		text.Break(bt.out)
   159  	}
   160  
   161  	bt.spinner.StopMessage(msg)
   162  	err = bt.spinner.Stop()
   163  	if err != nil {
   164  		return err
   165  	}
   166  
   167  	// NOTE: internalPostBuildCallback is only used by Rust currently.
   168  	// It's not a step that would be configured by a user in their fastly.toml
   169  	// It enables Rust to move the compiled binary to a different location.
   170  	// This has to happen BEFORE the postBuild step.
   171  	if bt.internalPostBuildCallback != nil {
   172  		err := bt.internalPostBuildCallback()
   173  		if err != nil {
   174  			return bt.handleError(err)
   175  		}
   176  	}
   177  
   178  	// IMPORTANT: The stat check MUST come after the internalPostBuildCallback.
   179  	// This is because for Rust it needs to move the binary first.
   180  	_, err = os.Stat(binWasmPath)
   181  	if err != nil {
   182  		return bt.handleError(err)
   183  	}
   184  
   185  	// NOTE: The logic for checking the Wasm binary is 'valid' is not exhaustive.
   186  	if err := bt.validateWasm(); err != nil {
   187  		return err
   188  	}
   189  
   190  	if bt.postBuild != "" {
   191  		if !bt.autoYes && !bt.nonInteractive {
   192  			manifestFilename := bt.manifestFilename
   193  			if manifestFilename == "" {
   194  				manifestFilename = manifest.Filename
   195  			}
   196  			msg := fmt.Sprintf(CustomPostScriptMessage, "build", manifestFilename)
   197  			err := bt.promptForPostBuildContinue(msg, bt.postBuild, bt.out, bt.in)
   198  			if err != nil {
   199  				return err
   200  			}
   201  		}
   202  
   203  		// If we're in verbose mode, the build output is shown.
   204  		// So in that case we don't want to have a spinner as it'll interweave output.
   205  		// In non-verbose mode we have a spinner running while the build is happening.
   206  		if !bt.verbose {
   207  			err = bt.spinner.Start()
   208  			if err != nil {
   209  				return err
   210  			}
   211  			msg = "Running [scripts.post_build]..."
   212  			bt.spinner.Message(msg)
   213  		}
   214  
   215  		cmd, args := bt.buildFn(bt.postBuild)
   216  		err := bt.execCommand(cmd, args, msg)
   217  		if err != nil {
   218  			// In verbose mode we'll have the failure status AFTER the error output.
   219  			// But we can't just call StopFailMessage() without first starting the spinner.
   220  			if bt.verbose {
   221  				text.Break(bt.out)
   222  				spinErr := bt.spinner.Start()
   223  				if spinErr != nil {
   224  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   225  				}
   226  				bt.spinner.Message(msg + "...")
   227  				bt.spinner.StopFailMessage(msg)
   228  				spinErr = bt.spinner.StopFail()
   229  				if spinErr != nil {
   230  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   231  				}
   232  			}
   233  			// WARNING: Don't try to add 'StopFailMessage/StopFail' calls here.
   234  			// It is handled internally by fstexec.Streaming.Exec().
   235  			return bt.handleError(err)
   236  		}
   237  
   238  		// In verbose mode we'll have the failure status AFTER the error output.
   239  		// But we can't just call StopMessage() without first starting the spinner.
   240  		if bt.verbose {
   241  			err = bt.spinner.Start()
   242  			if err != nil {
   243  				return err
   244  			}
   245  			bt.spinner.Message(msg + "...")
   246  			text.Break(bt.out)
   247  		}
   248  
   249  		bt.spinner.StopMessage(msg)
   250  		err = bt.spinner.Stop()
   251  		if err != nil {
   252  			return err
   253  		}
   254  	}
   255  
   256  	return nil
   257  }
   258  
   259  // The encoding of a module starts with a preamble containing a 4-byte magic
   260  // number (the string '\0asm') and a version field.
   261  //
   262  // Reference:
   263  // https://webassembly.github.io/spec/core/binary/modules.html#binary-module
   264  func (bt BuildToolchain) validateWasm() error {
   265  	f, err := os.Open(binWasmPath)
   266  	if err != nil {
   267  		return bt.handleError(err)
   268  	}
   269  	defer f.Close()
   270  
   271  	// Parse the magic number
   272  	magic := make([]byte, wasmBytes)
   273  	_, err = f.Read(magic)
   274  	if err != nil {
   275  		return bt.handleError(err)
   276  	}
   277  	expectedMagic := []byte{0x00, 0x61, 0x73, 0x6d}
   278  	if !bytes.Equal(magic, expectedMagic) {
   279  		return bt.handleError(fmt.Errorf("unexpected magic: %#v", magic))
   280  	}
   281  	if bt.verbose {
   282  		text.Break(bt.out)
   283  		text.Description(bt.out, "Wasm module 'magic'", fmt.Sprintf("%#v", magic))
   284  	}
   285  
   286  	// Parse the version
   287  	var version uint32
   288  	if err := binary.Read(f, binary.LittleEndian, &version); err != nil {
   289  		return bt.handleError(err)
   290  	}
   291  	if bt.verbose {
   292  		text.Description(bt.out, "Wasm module 'version'", strconv.FormatUint(uint64(version), 10))
   293  	}
   294  	return nil
   295  }
   296  
   297  func (bt BuildToolchain) handleError(err error) error {
   298  	return fsterr.RemediationError{
   299  		Inner:       err,
   300  		Remediation: DefaultBuildErrorRemediation,
   301  	}
   302  }
   303  
   304  // execCommand opens a sub shell to execute the language build script.
   305  //
   306  // NOTE: We pass the spinner and associated message to handle error cases.
   307  // This avoids an issue where the spinner is still running when an error occurs.
   308  // When the error occurs the command output is displayed.
   309  // This causes the spinner message to be displayed twice with different status.
   310  // By passing in the spinner and message we can short-circuit the spinner.
   311  func (bt BuildToolchain) execCommand(cmd string, args []string, spinMessage string) error {
   312  	return fstexec.Command(fstexec.CommandOpts{
   313  		Args:           args,
   314  		Command:        cmd,
   315  		Env:            bt.env,
   316  		ErrLog:         bt.errlog,
   317  		Output:         bt.out,
   318  		Spinner:        bt.spinner,
   319  		SpinnerMessage: spinMessage,
   320  		Timeout:        bt.timeout,
   321  		Verbose:        bt.verbose,
   322  	})
   323  }
   324  
   325  // promptForPostBuildContinue ensures the user is happy to continue with the build
   326  // when there is a post_build in the fastly.toml manifest file.
   327  func (bt BuildToolchain) promptForPostBuildContinue(msg, script string, out io.Writer, in io.Reader) error {
   328  	text.Info(out, "%s:\n", msg)
   329  	text.Indent(out, 4, "%s", script)
   330  
   331  	label := "\nDo you want to run this now? [y/N] "
   332  	answer, err := text.AskYesNo(out, label, in)
   333  	if err != nil {
   334  		return err
   335  	}
   336  	if !answer {
   337  		return fsterr.ErrPostBuildStopped
   338  	}
   339  	text.Break(out)
   340  	return nil
   341  }