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

     1  package compute
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"os/exec"
     9  	"regexp"
    10  	"strings"
    11  
    12  	"github.com/Masterminds/semver/v3"
    13  	"golang.org/x/mod/modfile"
    14  
    15  	"github.com/fastly/cli/pkg/config"
    16  	fsterr "github.com/fastly/cli/pkg/errors"
    17  	"github.com/fastly/cli/pkg/text"
    18  )
    19  
    20  // TinyGoDefaultBuildCommand is a build command compiled into the CLI binary so it
    21  // can be used as a fallback for customer's who have an existing Compute project and
    22  // are simply upgrading their CLI version and might not be familiar with the
    23  // changes in the 4.0.0 release with regards to how build logic has moved to the
    24  // fastly.toml manifest.
    25  //
    26  // NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml
    27  // We no longer do that. In 6.x we use the default and just inform the user.
    28  // This makes the experience less confusing as users didn't expect file changes.
    29  const TinyGoDefaultBuildCommand = "tinygo build -target=wasi -gc=conservative -o bin/main.wasm ./"
    30  
    31  // GoSourceDirectory represents the source code directory.
    32  const GoSourceDirectory = "."
    33  
    34  // NewGo constructs a new Go toolchain.
    35  func NewGo(
    36  	c *BuildCommand,
    37  	in io.Reader,
    38  	manifestFilename string,
    39  	out io.Writer,
    40  	spinner text.Spinner,
    41  ) *Go {
    42  	return &Go{
    43  		Shell: Shell{},
    44  
    45  		autoYes:               c.Globals.Flags.AutoYes,
    46  		build:                 c.Globals.Manifest.File.Scripts.Build,
    47  		config:                c.Globals.Config.Language.Go,
    48  		env:                   c.Globals.Manifest.File.Scripts.EnvVars,
    49  		errlog:                c.Globals.ErrLog,
    50  		input:                 in,
    51  		manifestFilename:      manifestFilename,
    52  		metadataFilterEnvVars: c.MetadataFilterEnvVars,
    53  		nonInteractive:        c.Globals.Flags.NonInteractive,
    54  		output:                out,
    55  		postBuild:             c.Globals.Manifest.File.Scripts.PostBuild,
    56  		spinner:               spinner,
    57  		timeout:               c.Flags.Timeout,
    58  		verbose:               c.Globals.Verbose(),
    59  	}
    60  }
    61  
    62  // Go implements a Toolchain for the TinyGo language.
    63  //
    64  // NOTE: Two separate tools are required to support golang development.
    65  //
    66  // 1. Go: for defining required packages in a go.mod project module.
    67  // 2. TinyGo: used to compile the go project.
    68  type Go struct {
    69  	Shell
    70  
    71  	// autoYes is the --auto-yes flag.
    72  	autoYes bool
    73  	// build is a shell command defined in fastly.toml using [scripts.build].
    74  	build string
    75  	// config is the Go specific application configuration.
    76  	config config.Go
    77  	// defaultBuild indicates if the default build script was used.
    78  	defaultBuild bool
    79  	// env is environment variables to be set.
    80  	env []string
    81  	// errlog is an abstraction for recording errors to disk.
    82  	errlog fsterr.LogInterface
    83  	// input is the user's terminal stdin stream
    84  	input io.Reader
    85  	// manifestFilename is the name of the manifest file.
    86  	manifestFilename string
    87  	// metadataFilterEnvVars is a comma-separated list of user defined env vars.
    88  	metadataFilterEnvVars string
    89  	// nonInteractive is the --non-interactive flag.
    90  	nonInteractive bool
    91  	// output is the users terminal stdout stream
    92  	output io.Writer
    93  	// postBuild is a custom script executed after the build but before the Wasm
    94  	// binary is added to the .tar.gz archive.
    95  	postBuild string
    96  	// spinner is a terminal progress status indicator.
    97  	spinner text.Spinner
    98  	// timeout is the build execution threshold.
    99  	timeout int
   100  	// verbose indicates if the user set --verbose
   101  	verbose bool
   102  }
   103  
   104  // DefaultBuildScript indicates if a custom build script was used.
   105  func (g *Go) DefaultBuildScript() bool {
   106  	return g.defaultBuild
   107  }
   108  
   109  // Dependencies returns all dependencies used by the project.
   110  func (g *Go) Dependencies() map[string]string {
   111  	deps := make(map[string]string)
   112  	data, err := os.ReadFile("go.mod")
   113  	if err != nil {
   114  		return deps
   115  	}
   116  	f, err := modfile.ParseLax("go.mod", data, nil)
   117  	if err != nil {
   118  		return deps
   119  	}
   120  	for _, req := range f.Require {
   121  		if req.Indirect {
   122  			continue
   123  		}
   124  		deps[req.Mod.Path] = req.Mod.Version
   125  	}
   126  	return deps
   127  }
   128  
   129  // Build compiles the user's source code into a Wasm binary.
   130  func (g *Go) Build() error {
   131  	var (
   132  		tinygoToolchain     bool
   133  		toolchainConstraint string
   134  	)
   135  
   136  	if g.build == "" {
   137  		g.build = TinyGoDefaultBuildCommand
   138  		g.defaultBuild = true
   139  		tinygoToolchain = true
   140  		toolchainConstraint = g.config.ToolchainConstraintTinyGo
   141  		if !g.verbose {
   142  			text.Break(g.output)
   143  		}
   144  		text.Info(g.output, "No [scripts.build] found in %s. Visit https://developer.fastly.com/learning/compute/go/ to learn how to target standard Go vs TinyGo.\n\n", g.manifestFilename)
   145  		text.Description(g.output, "The following default build command for TinyGo will be used", g.build)
   146  	}
   147  
   148  	if g.build != "" {
   149  		// IMPORTANT: All Fastly starter-kits for Go/TinyGo will have build script.
   150  		//
   151  		// So we'll need to parse the build script to identify if TinyGo is used so
   152  		// we can set the constraints appropriately.
   153  		if strings.Contains(g.build, "tinygo build") {
   154  			tinygoToolchain = true
   155  			toolchainConstraint = g.config.ToolchainConstraintTinyGo
   156  		} else {
   157  			toolchainConstraint = g.config.ToolchainConstraint
   158  		}
   159  	}
   160  
   161  	// IMPORTANT: The Go SDK 0.2.0 bumps the tinygo requirement to 0.28.1
   162  	//
   163  	// This means we need to check the go.mod of the user's project for
   164  	// `compute-sdk-go` and then parse the version and identify if it's less than
   165  	// 0.2.0 version. If it less than, change the TinyGo constraint to 0.26.0
   166  	tinygoConstraint := identifyTinyGoConstraint(g.config.TinyGoConstraint, g.config.TinyGoConstraintFallback)
   167  
   168  	g.toolchainConstraint(
   169  		"go", `go version go(?P<version>\d[^\s]+)`, toolchainConstraint,
   170  	)
   171  
   172  	if tinygoToolchain {
   173  		g.toolchainConstraint(
   174  			"tinygo", `tinygo version (?P<version>\d[^\s]+)`, tinygoConstraint,
   175  		)
   176  	}
   177  
   178  	bt := BuildToolchain{
   179  		autoYes:               g.autoYes,
   180  		buildFn:               g.Shell.Build,
   181  		buildScript:           g.build,
   182  		env:                   g.env,
   183  		errlog:                g.errlog,
   184  		in:                    g.input,
   185  		manifestFilename:      g.manifestFilename,
   186  		metadataFilterEnvVars: g.metadataFilterEnvVars,
   187  		nonInteractive:        g.nonInteractive,
   188  		out:                   g.output,
   189  		postBuild:             g.postBuild,
   190  		spinner:               g.spinner,
   191  		timeout:               g.timeout,
   192  		verbose:               g.verbose,
   193  	}
   194  
   195  	return bt.Build()
   196  }
   197  
   198  // identifyTinyGoConstraint checks the compute-sdk-go version used by the
   199  // project and if it's less than 0.2.0 we'll change the TinyGo constraint to be
   200  // version 0.26.0
   201  //
   202  // We do this because the 0.2.0 release of the compute-sdk-go bumps the TinyGo
   203  // version requirement to 0.28.1 and we want to avoid any scenarios where a
   204  // bump in SDK version causes the user's build to break (which would happen for
   205  // users with a pre-existing project who happen to update their CLI version: the
   206  // new CLI version would have a TinyGo constraint that would be higher than
   207  // before and would stop their build from working).
   208  //
   209  // NOTE: The `configConstraint` is the latest CLI application config version.
   210  // If there are any errors trying to parse the go.mod we'll default to the
   211  // config constraint.
   212  func identifyTinyGoConstraint(configConstraint, fallback string) string {
   213  	moduleName := "github.com/fastly/compute-sdk-go"
   214  	version := ""
   215  
   216  	f, err := os.Open("go.mod")
   217  	if err != nil {
   218  		return configConstraint
   219  	}
   220  	defer f.Close()
   221  
   222  	scanner := bufio.NewScanner(f)
   223  	for scanner.Scan() {
   224  		line := scanner.Text()
   225  		parts := strings.Fields(line)
   226  
   227  		// go.mod has two separate definition possibilities:
   228  		//
   229  		// 1.
   230  		// require github.com/fastly/compute-sdk-go v0.1.7
   231  		//
   232  		// 2.
   233  		// require (
   234  		//   github.com/fastly/compute-sdk-go v0.1.7
   235  		// )
   236  		if len(parts) >= 2 {
   237  			// 1. require [github.com/fastly/compute-sdk-go] v0.1.7
   238  			if parts[1] == moduleName {
   239  				version = strings.TrimPrefix(parts[2], "v")
   240  				break
   241  			}
   242  			// 2. [github.com/fastly/compute-sdk-go] v0.1.7
   243  			if parts[0] == moduleName {
   244  				version = strings.TrimPrefix(parts[1], "v")
   245  				break
   246  			}
   247  		}
   248  	}
   249  
   250  	if err := scanner.Err(); err != nil {
   251  		return configConstraint
   252  	}
   253  
   254  	if version == "" {
   255  		return configConstraint
   256  	}
   257  
   258  	gomodVersion, err := semver.NewVersion(version)
   259  	if err != nil {
   260  		return configConstraint
   261  	}
   262  
   263  	// 0.2.0 introduces the break by bumping the TinyGo minimum version to 0.28.1
   264  	breakingSDKVersion, err := semver.NewVersion("0.2.0")
   265  	if err != nil {
   266  		return configConstraint
   267  	}
   268  
   269  	if gomodVersion.LessThan(breakingSDKVersion) {
   270  		return fallback
   271  	}
   272  
   273  	return configConstraint
   274  }
   275  
   276  // toolchainConstraint warns the user if the required constraint is not met.
   277  //
   278  // NOTE: We don't stop the build as their toolchain may compile successfully.
   279  // The warning is to help a user know something isn't quite right and gives them
   280  // the opportunity to do something about it if they choose.
   281  func (g *Go) toolchainConstraint(toolchain, pattern, constraint string) {
   282  	if g.verbose {
   283  		text.Info(g.output, "The Fastly CLI build step requires a %s version '%s'.\n\n", toolchain, constraint)
   284  	}
   285  
   286  	versionCommand := fmt.Sprintf("%s version", toolchain)
   287  	args := strings.Split(versionCommand, " ")
   288  
   289  	// gosec flagged this:
   290  	// G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments
   291  	// Disabling as we trust the source of the variable.
   292  	// #nosec
   293  	// nosemgrep
   294  	cmd := exec.Command(args[0], args[1:]...)
   295  	stdoutStderr, err := cmd.CombinedOutput()
   296  	output := string(stdoutStderr)
   297  	if err != nil {
   298  		return
   299  	}
   300  
   301  	versionPattern := regexp.MustCompile(pattern)
   302  	match := versionPattern.FindStringSubmatch(output)
   303  	if len(match) < 2 { // We expect a pattern with one capture group.
   304  		return
   305  	}
   306  	version := match[1]
   307  
   308  	v, err := semver.NewVersion(version)
   309  	if err != nil {
   310  		return
   311  	}
   312  
   313  	c, err := semver.NewConstraint(constraint)
   314  	if err != nil {
   315  		return
   316  	}
   317  
   318  	if !c.Check(v) {
   319  		text.Warning(g.output, "The %s version '%s' didn't meet the constraint '%s'\n\n", toolchain, version, constraint)
   320  	}
   321  }