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 }