-
Notifications
You must be signed in to change notification settings - Fork 56
/
serve.go
907 lines (816 loc) · 31.5 KB
/
serve.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
package compute
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/bep/debounce"
"github.com/blang/semver"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
ignore "github.com/sabhiram/go-gitignore"
"github.com/fastly/cli/pkg/argparser"
"github.com/fastly/cli/pkg/check"
fsterr "github.com/fastly/cli/pkg/errors"
fstexec "github.com/fastly/cli/pkg/exec"
"github.com/fastly/cli/pkg/filesystem"
"github.com/fastly/cli/pkg/github"
"github.com/fastly/cli/pkg/global"
"github.com/fastly/cli/pkg/manifest"
fstruntime "github.com/fastly/cli/pkg/runtime"
"github.com/fastly/cli/pkg/text"
)
var viceroyError = fsterr.RemediationError{
Inner: fmt.Errorf("a Viceroy version was not found"),
Remediation: fsterr.BugRemediation,
}
// ServeCommand produces and runs an artifact from files on the local disk.
type ServeCommand struct {
argparser.Base
build *BuildCommand
// Build fields
dir argparser.OptionalString
includeSrc argparser.OptionalBool
lang argparser.OptionalString
metadataDisable argparser.OptionalBool
metadataFilterEnvVars argparser.OptionalString
metadataShow argparser.OptionalBool
packageName argparser.OptionalString
timeout argparser.OptionalInt
// Serve public fields (public for testing purposes)
ForceCheckViceroyLatest bool
ViceroyBinExtraArgs string
ViceroyBinPath string
ViceroyVersioner github.AssetVersioner
// Serve private fields
addr string
debug bool
env argparser.OptionalString
file argparser.OptionalString
profileGuest bool
profileGuestDir argparser.OptionalString
projectDir string
skipBuild bool
watch bool
watchDir argparser.OptionalString
}
// NewServeCommand returns a usable command registered under the parent.
func NewServeCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand) *ServeCommand {
var c ServeCommand
c.build = build
c.Globals = g
c.ViceroyVersioner = g.Versioners.Viceroy
c.CmdClause = parent.Command("serve", "Build and run a Compute package locally")
c.CmdClause.Flag("addr", "The IPv4 address and port to listen on").Default("127.0.0.1:7676").StringVar(&c.addr)
c.CmdClause.Flag("debug", "Run the server in Debug Adapter mode").Hidden().BoolVar(&c.debug)
c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value)
c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value)
c.CmdClause.Flag("file", "The Wasm file to run (causes build process to be skipped)").Action(c.file.Set).StringVar(&c.file.Value)
c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value)
c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value)
c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value)
c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value)
c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value)
c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value)
c.CmdClause.Flag("profile-guest", "Profile the Wasm guest under Viceroy (requires Viceroy 0.9.1 or higher). View profiles at https://profiler.firefox.com/.").BoolVar(&c.profileGuest)
c.CmdClause.Flag("profile-guest-dir", "The directory where the per-request profiles are saved to. Defaults to guest-profiles.").Action(c.profileGuestDir.Set).StringVar(&c.profileGuestDir.Value)
c.CmdClause.Flag("skip-build", "Skip the build step").BoolVar(&c.skipBuild)
c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value)
c.CmdClause.Flag("viceroy-args", "Additional arguments to pass to the Viceroy binary, separated by space").StringVar(&c.ViceroyBinExtraArgs)
c.CmdClause.Flag("viceroy-check", "Force the CLI to check for a newer version of the Viceroy binary").BoolVar(&c.ForceCheckViceroyLatest)
c.CmdClause.Flag("viceroy-path", "The path to a user installed version of the Viceroy binary").StringVar(&c.ViceroyBinPath)
c.CmdClause.Flag("watch", "Watch for file changes, then rebuild project and restart local server").BoolVar(&c.watch)
c.CmdClause.Flag("watch-dir", "The directory to watch files from (can be relative or absolute). Defaults to current directory.").Action(c.watchDir.Set).StringVar(&c.watchDir.Value)
return &c
}
// Exec implements the command interface.
func (c *ServeCommand) Exec(in io.Reader, out io.Writer) (err error) {
if c.skipBuild && c.watch {
return fsterr.ErrIncompatibleServeFlags
}
if runtime.GOARCH == "386" {
return fsterr.RemediationError{
Inner: errors.New("this command doesn't support the '386' architecture"),
Remediation: "Although the Fastly CLI supports '386', the `compute serve` command requires https://github.com/fastly/Viceroy which does not.",
}
}
manifestFilename := EnvironmentManifest(c.env.Value)
if c.env.Value != "" {
if c.Globals.Verbose() {
text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename)
}
}
wd, err := os.Getwd()
if err != nil {
c.Globals.ErrLog.Add(err)
return fmt.Errorf("failed to get current working directory: %w", err)
}
defer func() {
_ = os.Chdir(wd)
}()
manifestPath := filepath.Join(wd, manifestFilename)
c.projectDir, err = ChangeProjectDirectory(c.dir.Value)
if err != nil {
return err
}
if c.projectDir != "" {
if c.Globals.Verbose() {
text.Info(out, ProjectDirMsg, c.projectDir)
}
manifestPath = filepath.Join(c.projectDir, manifestFilename)
}
wasmBinaryToRun := binWasmPath
if c.file.WasSet {
wasmBinaryToRun = c.file.Value
}
// We skip the build if explicitly told to with --skip-build but also when the
// user sets --file to specify their own wasm binary to pass to Viceroy. This
// is typically for users who compile a Wasm binary using an unsupported
// programming language for the Fastly Compute platform.
if !c.skipBuild && !c.file.WasSet {
err = c.Build(in, out)
if err != nil {
return err
}
text.Break(out)
}
c.setBackendsWithDefaultOverrideHostIfMissing(out)
spinner, err := text.NewSpinner(out)
if err != nil {
return err
}
// NOTE: We read again the manifest to catch a skip-build scenario.
//
// For example, a user runs `compute build` then `compute serve --skip-build`.
// In that scenario our in-memory manifest could be invalid as the user might
// have also called `compute serve --skip-build --env <...> --dir <...>`.
//
// If the user doesn't set --skip-build then `compute serve` will call
// `compute build` and the logic there will update the manifest in-memory data
// with the relevant project directory and environment manifest content.
if c.skipBuild || c.file.WasSet {
err := c.Globals.Manifest.File.Read(manifestPath)
if err != nil {
return fmt.Errorf("failed to parse manifest '%s': %w", manifestPath, err)
}
c.ViceroyVersioner.SetRequestedVersion(c.Globals.Manifest.File.LocalServer.ViceroyVersion)
if c.Globals.Verbose() {
if c.skipBuild || c.file.WasSet {
text.Break(out)
}
text.Info(out, "Fastly manifest set to: %s\n\n", manifestPath)
}
}
bin, err := c.GetViceroy(spinner, out, manifestPath)
if err != nil {
return err
}
err = spinner.Start()
if err != nil {
return err
}
msg := "Running local server"
spinner.Message(msg + "...")
spinner.StopMessage(msg)
err = spinner.Stop()
if err != nil {
return err
}
if c.Globals.Verbose() {
text.Break(out)
}
var restart bool
for {
err = local(localOpts{
addr: c.addr,
bin: bin,
debug: c.debug,
errLog: c.Globals.ErrLog,
extraArgs: c.ViceroyBinExtraArgs,
manifestPath: manifestPath,
out: out,
profileGuest: c.profileGuest,
profileGuestDir: c.profileGuestDir,
restarted: restart,
verbose: c.Globals.Verbose(),
wasmBinPath: wasmBinaryToRun,
watch: c.watch,
watchDir: c.watchDir,
})
if err != nil {
if err != fsterr.ErrViceroyRestart {
if err == fsterr.ErrSignalInterrupt || err == fsterr.ErrSignalKilled {
text.Info(out, "\nLocal server stopped")
return nil
}
return err
}
// Before restarting Viceroy we should rebuild.
text.Break(out)
err = c.Build(in, out)
if err != nil {
// NOTE: build errors at this point are going to be user related, so we
// should display the error but keep watching the files so we can
// rebuild successfully once the user has fixed the issues.
fsterr.Deduce(err).Print(color.Error)
}
restart = true
}
}
}
// Build constructs and executes the build logic.
func (c *ServeCommand) Build(in io.Reader, out io.Writer) error {
// Reset the fields on the BuildCommand based on ServeCommand values.
if c.dir.WasSet {
c.build.Flags.Dir = c.dir.Value
}
if c.env.WasSet {
c.build.Flags.Env = c.env.Value
}
if c.includeSrc.WasSet {
c.build.Flags.IncludeSrc = c.includeSrc.Value
}
if c.lang.WasSet {
c.build.Flags.Lang = c.lang.Value
}
if c.packageName.WasSet {
c.build.Flags.PackageName = c.packageName.Value
}
if c.timeout.WasSet {
c.build.Flags.Timeout = c.timeout.Value
}
if c.metadataDisable.WasSet {
c.build.MetadataDisable = c.metadataDisable.Value
}
if c.metadataFilterEnvVars.WasSet {
c.build.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value
}
if c.metadataShow.WasSet {
c.build.MetadataShow = c.metadataShow.Value
}
if c.projectDir != "" {
c.build.SkipChangeDir = true // we've already changed directory
}
return c.build.Exec(in, out)
}
// setBackendsWithDefaultOverrideHostIfMissing sets an override_host for any
// local_server.backends that is missing that property. The value will only be
// set if the URL defined uses a hostname (e.g. http://127.0.0.1/ won't) so we
// can set the override_host to match the hostname.
func (c *ServeCommand) setBackendsWithDefaultOverrideHostIfMissing(out io.Writer) {
for k, backend := range c.Globals.Manifest.File.LocalServer.Backends {
if backend.OverrideHost == "" {
if u, err := url.Parse(backend.URL); err == nil {
segs := strings.Split(u.Host, ":") // avoid parsing IP with port
if ip := net.ParseIP(segs[0]); ip == nil {
if c.Globals.Verbose() {
text.Info(out, "[local_server.backends.%s] (%s) is configured without an `override_host`. We will use %s as a default to help avoid any unexpected errors. See https://www.fastly.com/documentation/reference/compute/fastly-toml#local-server for more details.", k, backend.URL, u.Host)
}
backend.OverrideHost = u.Host
c.Globals.Manifest.File.LocalServer.Backends[k] = backend
}
}
}
}
}
// GetViceroy returns the path to the installed binary.
//
// If Viceroy is installed we either update it or pin it to the version defined
// in the fastly.toml [viceroy.viceroy_version]. Otherwise, if not installed, we
// install it in the same directory as the application configuration data.
//
// In the case of a network failure we fallback to the latest installed version of the
// Viceroy binary as long as one is installed and has the correct permissions.
func (c *ServeCommand) GetViceroy(spinner text.Spinner, out io.Writer, manifestPath string) (bin string, err error) {
if c.ViceroyBinPath != "" {
if c.Globals.Verbose() {
text.Info(out, "Using user provided install of Viceroy via --viceroy-path flag: %s\n\n", c.ViceroyBinPath)
}
return filepath.Abs(c.ViceroyBinPath)
}
// Allows a user to use a version of Viceroy that is installed in the $PATH.
if usePath := os.Getenv("FASTLY_VICEROY_USE_PATH"); checkViceroyEnvVar(usePath) {
path, err := exec.LookPath("viceroy")
if err != nil {
return "", fmt.Errorf("failed to lookup viceroy binary in user $PATH (user has set $FASTLY_VICEROY_USE_PATH): %w", err)
}
if c.Globals.Verbose() {
text.Info(out, "Using user provided install of Viceroy via $PATH lookup: %s\n\n", path)
}
return filepath.Abs(path)
}
bin = filepath.Join(github.InstallDir, c.ViceroyVersioner.BinaryName())
// NOTE: When checking if Viceroy is installed we don't use
// exec.LookPath("viceroy") because PATH is unreliable across OS platforms,
// but also we actually install Viceroy in the same location as the
// application configuration, which means it wouldn't be found looking up by
// the PATH env var. We could pass the path for the application configuration
// into exec.LookPath() but it's simpler to just execute the binary.
//
// gosec flagged this:
// G204 (CWE-78): Subprocess launched with variable
// Disabling as the variables come from trusted sources.
/* #nosec */
// nosemgrep
command := exec.Command(bin, "--version")
var installedVersion string
stdoutStderr, err := command.CombinedOutput()
if err != nil {
c.Globals.ErrLog.Add(err)
} else {
// Check the version output has the expected format: `viceroy 0.1.0`
installedVersion = strings.TrimSpace(string(stdoutStderr))
segs := strings.Split(installedVersion, " ")
if len(segs) < 2 {
return bin, viceroyError
}
installedVersion = segs[1]
}
// If the user hasn't explicitly set a Viceroy version, then we'll use
// whatever the latest version is.
versionToInstall := "latest"
if v := c.ViceroyVersioner.RequestedVersion(); v != "" {
versionToInstall = v
if _, err := semver.Parse(versionToInstall); err != nil {
return bin, fsterr.RemediationError{
Inner: fmt.Errorf("failed to parse configured version as a semver: %w", err),
Remediation: fmt.Sprintf("Ensure the %s `viceroy_version` value '%s' (under the [local_server] section) is a valid semver (https://semver.org/), e.g. `0.1.0`)", manifestPath, versionToInstall),
}
}
}
err = c.InstallViceroy(installedVersion, versionToInstall, manifestPath, bin, spinner)
if err != nil {
c.Globals.ErrLog.Add(err)
return bin, err
}
err = github.SetBinPerms(bin)
if err != nil {
c.Globals.ErrLog.Add(err)
return bin, err
}
return bin, nil
}
// checkViceroyEnvVar indicates if the CLI should use a Viceroy binary exposed
// on the user's $PATH.
func checkViceroyEnvVar(value string) bool {
switch strings.ToUpper(value) {
case "1", "TRUE":
return true
}
return false
}
// InstallViceroy downloads the binary from GitHub.
//
// The logic flow is as follows:
//
// 1. Check if version to install is "latest"
// 2. If so, check the latest release matches the installed version.
// 3. If not latest, check the installed version matches the expected version.
func (c *ServeCommand) InstallViceroy(
installedVersion, versionToInstall, manifestPath, bin string,
spinner text.Spinner,
) error {
var (
err error
msg, tmpBin string
)
switch {
case installedVersion == "": // Viceroy not installed
if c.Globals.Verbose() {
text.Info(c.Globals.Output, "Viceroy is not already installed, so we will install the %s version.\n\n", versionToInstall)
}
err = spinner.Start()
if err != nil {
return err
}
msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall)
spinner.Message(msg + "...")
if versionToInstall == "latest" {
tmpBin, err = c.ViceroyVersioner.DownloadLatest()
} else {
tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall)
}
case versionToInstall != "latest":
if installedVersion == versionToInstall {
if c.Globals.Verbose() {
text.Info(c.Globals.Output, "Viceroy is already installed, and the installed version matches the required version (%s) in the %s file.\n\n", versionToInstall, manifestPath)
}
return nil
}
if c.Globals.Verbose() {
text.Info(c.Globals.Output, "Viceroy is already installed, but the installed version (%s) doesn't match the required version (%s) specified in the %s file.\n\n", installedVersion, versionToInstall, manifestPath)
}
err = spinner.Start()
if err != nil {
return err
}
msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall)
spinner.Message(msg + "...")
tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall)
case versionToInstall == "latest":
// Viceroy is already installed, so we check if the installed version matches the latest.
// But we'll skip that check if the TTL for the Viceroy LastChecked hasn't expired.
stale := check.Stale(c.Globals.Config.Viceroy.LastChecked, c.Globals.Config.Viceroy.TTL)
if !stale && !c.ForceCheckViceroyLatest {
if c.Globals.Verbose() {
text.Info(c.Globals.Output, "Viceroy is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired. To force a refresh, re-run the command with the `--viceroy-check` flag.\n\n")
}
return nil
}
// IMPORTANT: We declare separately so to shadow `err` from parent scope.
var latestVersion string
err = spinner.Process("Checking latest Viceroy release", func(_ *text.SpinnerWrapper) error {
latestVersion, err = c.ViceroyVersioner.LatestVersion()
if err != nil {
return fsterr.RemediationError{
Inner: fmt.Errorf("error fetching latest version: %w", err),
Remediation: fsterr.NetworkRemediation,
}
}
return nil
})
if err != nil {
return err
}
viceroyConfig := c.Globals.Config.Viceroy
viceroyConfig.LatestVersion = latestVersion
viceroyConfig.LastChecked = time.Now().Format(time.RFC3339)
// Before attempting to write the config data back to disk we need to
// ensure we reassign the modified struct which is a copy (not reference).
c.Globals.Config.Viceroy = viceroyConfig
err = c.Globals.Config.Write(c.Globals.ConfigPath)
if err != nil {
return err
}
if c.Globals.Verbose() {
text.Info(c.Globals.Output, "\nThe CLI config (`fastly config`) has been updated with the latest Viceroy version: %s\n\n", latestVersion)
}
if installedVersion != "" && installedVersion == latestVersion {
return nil
}
err = spinner.Start()
if err != nil {
return err
}
msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall)
spinner.Message(msg + "...")
tmpBin, err = c.ViceroyVersioner.DownloadLatest()
}
// NOTE: The above `switch` needs to shadow the function-level `err` variable.
if err != nil {
err = fmt.Errorf("error downloading Viceroy release: %w", err)
spinner.StopFailMessage(msg)
spinErr := spinner.StopFail()
if spinErr != nil {
return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
}
return err
}
defer os.RemoveAll(tmpBin)
if err := os.Rename(tmpBin, bin); err != nil {
err = fmt.Errorf("failed to rename/move file: %w", err)
if copyErr := filesystem.CopyFile(tmpBin, bin); copyErr != nil {
err = fmt.Errorf("failed to copy file: %w (original error: %w)", copyErr, err)
spinner.StopFailMessage(msg)
spinErr := spinner.StopFail()
if spinErr != nil {
return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
}
return err
}
}
spinner.StopMessage(msg)
return spinner.Stop()
}
// localOpts represents the inputs for `local()`.
type localOpts struct {
addr string
bin string
debug bool
errLog fsterr.LogInterface
extraArgs string
manifestPath string
out io.Writer
profileGuest bool
profileGuestDir argparser.OptionalString
restarted bool
verbose bool
wasmBinPath string
watch bool
watchDir argparser.OptionalString
}
// local spawns a subprocess that runs the compiled binary.
func local(opts localOpts) error {
// NOTE: Viceroy no longer displays errors unless in verbose mode.
// This can cause confusion for customers: https://github.com/fastly/cli/issues/913
// So regardless of CLI --verbose flag we'll always set verbose for Viceroy.
args := []string{"-v", "-C", opts.manifestPath, "--addr", opts.addr, opts.wasmBinPath}
if opts.debug {
args = append(args, "--debug")
}
if opts.profileGuest {
directory := "guest-profiles"
if opts.profileGuestDir.WasSet {
directory = opts.profileGuestDir.Value
}
args = append(args, "--profile=guest,"+directory)
if opts.verbose {
text.Info(opts.out, "Saving per-request profiles to %s.", directory)
}
}
if opts.extraArgs != "" {
extraArgs := strings.Split(opts.extraArgs, " ")
args = append(args, extraArgs...)
}
if opts.verbose {
if opts.restarted {
text.Break(opts.out)
}
text.Output(opts.out, "%s: %s", text.BoldYellow("Manifest"), opts.manifestPath)
text.Output(opts.out, "%s: %s", text.BoldYellow("Wasm binary"), opts.wasmBinPath)
text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy command"), strings.Join(args, " "))
text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy binary"), opts.bin)
// gosec flagged this:
// G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments
// Disabling as we trust the source of the variable.
// #nosec
// nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command
c := exec.Command(opts.bin, "--version")
if output, err := c.Output(); err == nil {
text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy version"), string(output))
}
text.Info(opts.out, "Listening on http://%s", opts.addr)
if opts.watch {
text.Break(opts.out)
}
}
s := &fstexec.Streaming{
Args: args,
Command: opts.bin,
Env: os.Environ(),
ForceOutput: true,
Output: opts.out,
SignalCh: make(chan os.Signal, 1),
}
s.MonitorSignals()
failure := make(chan error)
restart := make(chan bool)
if opts.watch {
root := "."
if opts.watchDir.WasSet {
root = opts.watchDir.Value
}
if opts.verbose {
text.Info(opts.out, "Watching files for changes (using --watch-dir=%s). To ignore certain files, define patterns within a .fastlyignore config file (uses .fastlyignore from --watch-dir).\n\n", root)
}
gi := ignoreFiles(opts.watchDir)
go watchFiles(root, gi, opts.verbose, s, opts.out, restart, failure)
}
// NOTE: The viceroy executable can be stopped by one of three mechanisms.
//
// 1. File modification
// 2. Explicit signal (SIGINT, SIGTERM etc).
// 3. Irrecoverable error (i.e. error watching files).
//
// In the case of a signal (e.g. user presses Ctrl-c) the listener logic
// inside of (*fstexec.Streaming).MonitorSignals() will call
// (*fstexec.Streaming).Signal(signal os.Signal) to kill the process.
//
// In the case of a file modification the viceroy executable needs to first
// be killed (handled by the watchFiles() function) and then we can stop the
// signal listeners (handled below by sending a message to argparser.SignalCh).
//
// If we don't tell the signal listening channel to close, then the restart
// of the viceroy executable will cause the user to end up with N number of
// listeners. This will result in a "os: process already finished" error when
// we do finally come to stop the `serve` command (e.g. user presses Ctrl-c).
// How big an issue this is depends on how many file modifications a user
// makes, because having lots of signal listeners could exhaust resources.
//
// When there is an error setting up the watching of files, if we error we
// need to signal the error using a channel as watching files happens
// asynchronously in a goroutine. We also need to be able to signal the
// viceroy process to be killed, and we do that using `s.Signal(os.Kill)` from
// within the relevant error handling blocks in `watchFiles`, where upon the
// below `select` statement will pull the error message from the `failure`
// channel and return it to the user. If we fail to kill the Viceroy process
// then we still want to pull an error from the `failure` channel and so we
// have a separate `select` statement to check for any initial errors prior to
// the Viceroy executable starting and an error occurring in `watchFiles`.
select {
case asyncErr := <-failure:
s.SignalCh <- syscall.SIGTERM
return asyncErr
case <-time.After(1 * time.Second):
// no-op: allow logic to flow to starting up Viceroy executable.
}
if err := s.Exec(); err != nil {
errPrefix := "signal: "
errKilled := "killed"
if fstruntime.Windows {
errPrefix = "exit status"
errKilled = errPrefix + " 1"
}
if !strings.Contains(err.Error(), errPrefix) {
opts.errLog.Add(err)
}
e := strings.TrimSpace(err.Error())
if strings.Contains(e, "interrupt") {
return fsterr.ErrSignalInterrupt
}
if strings.Contains(e, errKilled) {
select {
case asyncErr := <-failure:
s.SignalCh <- syscall.SIGTERM
return asyncErr
case <-restart:
s.SignalCh <- syscall.SIGTERM
return fsterr.ErrViceroyRestart
case <-time.After(1 * time.Second):
return fsterr.ErrSignalKilled
}
}
return err
}
return nil
}
// watchFiles watches the language source directory and restarts the viceroy
// executable when changes are detected.
func watchFiles(root string, gi *ignore.GitIgnore, verbose bool, s *fstexec.Streaming, out io.Writer, restart chan<- bool, failure chan<- error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
signalErr := s.Signal(os.Kill)
if signalErr != nil {
failure <- fmt.Errorf("failed to stop Viceroy executable while trying to create a fsnotify.Watcher: %w: %w", signalErr, err)
return
}
failure <- fmt.Errorf("failed to create a fsnotify.Watcher: %w", err)
return
}
defer watcher.Close()
done := make(chan bool)
debounced := debounce.New(1 * time.Second)
eventHandler := func(modifiedFile string, _ fsnotify.Op) {
// NOTE: We avoid describing the file operation (e.g. created, modified,
// deleted, renamed etc) rather than checking the fsnotify.Op iota/enum type
// because the output can be confusing depending on the application used to
// edit a file.
//
// For example, modifying a file in Vim might cause the file to be
// temporarily copied/renamed and this can cause the watcher to report an
// existing file has been 'created' or 'renamed' when from a user's
// perspective the file already exists and was only modified.
text.Break(out)
text.Output(out, "%s Restarting local server (%s)", text.BoldGreen("✓"), modifiedFile)
// NOTE: We force closing the watcher by pushing true into a done channel.
// We do this because if we didn't, then we'd get an error after one
// restart of the viceroy executable: "os: process already finished".
//
// This error happens happens because the compute.watchFiles() function is
// run in a goroutine and so it will keep running with a copy of the
// fstexec.Streaming command instance that wraps a process which has
// already been terminated.
done <- true
// NOTE: To be able to force both the current viceroy process signal listener
// to close, and to restart the viceroy executable, we need to kill the
// process and also send 'true' to a restart channel.
//
// If we only sent a message to the restart channel, but didn't terminate
// the process, then we'd end up in a deadlock because we wouldn't be able
// to take a message from the restart channel inside the local() function
// because we need to have the process terminate first in order for us to
// execute the flushing of channel messages.
//
// When we stop the signal listener it will internally try to kill the
// process and discover it has already been killed and return an error:
// `os: process already finished`. This is why we don't do error handling
// within (*fstexec.Streaming).MonitorSignalsAsync() as the process could
// well be killed already when a user is doing local development with the
// --watch flag. The obvious downside to this logic flow is that if the
// user is running `compute serve` just to validate the program once, then
// there might be an unhandled error when they press Ctrl-c to stop the
// serve command from blocking their terminal. That said, this is unlikely
// and is a low risk concern.
err := s.Signal(os.Kill)
if err != nil {
failure <- fmt.Errorf("failed to stop Viceroy executable while trying to restart the process: %w", err)
return
}
restart <- true
}
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
debounced(func() {
eventHandler(event.Name, event.Op)
})
case err, ok := <-watcher.Errors:
if !ok {
return
}
text.Output(out, "error event while watching files: %v", err)
}
}
}()
var buf bytes.Buffer
// Walk all directories and files starting from the project's root directory.
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("error configuring watching for file changes: %w", err)
}
// If there's no ignore file, we'll default to watching all directories
// within the specified top-level directory.
//
// NOTE: Watching a directory implies watching all files within the root of
// the directory. This means we don't need to call Add(path) for each file.
if gi == nil && entry.IsDir() {
watchFile(path, watcher, verbose, &buf)
}
if gi != nil && !entry.IsDir() && !gi.MatchesPath(path) {
// If there is an ignore file, we avoid watching directories and instead
// will only add files that don't match the exclusion patterns defined.
watchFile(path, watcher, verbose, &buf)
}
return nil
})
if err != nil {
signalErr := s.Signal(os.Kill)
if signalErr != nil {
failure <- fmt.Errorf("failed to stop Viceroy executable while trying to walk directory tree for watching files: %w: %w", signalErr, err)
return
}
failure <- fmt.Errorf("failed to walk directory tree for watching files: %w", err)
return
}
if verbose {
text.Output(out, "%s\n\n", text.BoldYellow("Watching..."))
fmt.Fprintln(out, buf.String()) // IMPORTANT: Avoid text.Output() as it fails to render with large buffer.
text.Break(out)
}
<-done
}
// ignoreFiles returns the specific ignore rules being respected.
//
// NOTE: We also ignore the .git directory.
func ignoreFiles(watchDir argparser.OptionalString) *ignore.GitIgnore {
var patterns []string
root := ""
if watchDir.WasSet {
root = watchDir.Value
if !strings.HasPrefix(root, "/") {
root += "/"
}
}
fastlyIgnore := root + ".fastlyignore"
// NOTE: Using a loop to allow for future ignore files to be respected.
for _, file := range []string{fastlyIgnore} {
patterns = append(patterns, readIgnoreFile(file)...)
}
patterns = append(patterns, ".git/")
return ignore.CompileIgnoreLines(patterns...)
}
// readIgnoreFile reads path and splits content into lines.
//
// NOTE: If there's an error reading the given path, then we'll return an empty
// string slice so that the caller can continue to function as expected.
func readIgnoreFile(path string) (lines []string) {
// gosec flagged this:
// G304 (CWE-22): Potential file inclusion via variable
//
// Disabling as the input is either provided by our own package or in the
// case of identifying the user's global git ignore we need to read it from
// their global git configuration.
/* #nosec */
bs, err := os.ReadFile(path)
if err != nil {
return lines
}
return strings.Split(string(bs), "\n")
}
func watchFile(path string, watcher *fsnotify.Watcher, verbose bool, out io.Writer) {
absolute, err := filepath.Abs(path)
if err != nil && verbose {
text.Warning(out, "Unable to convert '%s' to an absolute path", path)
return
}
err = watcher.Add(absolute)
if err != nil {
text.Output(out, "%s %s", text.BoldRed("✗"), absolute)
} else if verbose {
text.Output(out, "%s", absolute)
}
}