-
Notifications
You must be signed in to change notification settings - Fork 60
/
serve.go
815 lines (719 loc) · 26.1 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
package compute
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"log"
"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/check"
"github.com/fastly/cli/pkg/cmd"
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"
"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 {
cmd.Base
manifest manifest.Data
build *BuildCommand
av github.AssetVersioner
// Build fields
includeSrc cmd.OptionalBool
lang cmd.OptionalString
packageName cmd.OptionalString
timeout cmd.OptionalInt
// Serve fields
addr string
debug bool
env cmd.OptionalString
file string
forceCheckViceroyLatest bool
profileGuest bool
profileGuestDir cmd.OptionalString
skipBuild bool
viceroyBinPath string
watch bool
watchDir cmd.OptionalString
}
// NewServeCommand returns a usable command registered under the parent.
func NewServeCommand(parent cmd.Registerer, g *global.Data, build *BuildCommand, av github.AssetVersioner, m manifest.Data) *ServeCommand {
var c ServeCommand
c.build = build
c.av = av
c.Globals = g
c.CmdClause = parent.Command("serve", "Build and run a Compute@Edge package locally")
c.manifest = m
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("env", "The environment configuration to use (e.g. stage)").Action(c.env.Set).StringVar(&c.env.Value)
c.CmdClause.Flag("file", "The Wasm file to run").Default("bin/main.wasm").StringVar(&c.file)
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("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value)
c.CmdClause.Flag("profile-guest", "Profile the Wasm guest under Viceroy. 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-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.",
}
}
if !c.skipBuild {
err = c.Build(in, out)
if err != nil {
return err
}
}
c.setBackendsWithDefaultOverrideHostIfMissing(out)
spinner, err := text.NewSpinner(out)
if err != nil {
return err
}
bin, err := GetViceroy(spinner, out, c.av, c.Globals, c.viceroyBinPath, c.forceCheckViceroyLatest)
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
}
for {
err = local(bin, c.file, c.addr, c.env.Value, c.debug, c.profileGuest, c.profileGuestDir, c.watch, c.watchDir, c.Globals.Verbose(), out, c.Globals.ErrLog)
if err != nil {
if err != fsterr.ErrViceroyRestart {
if err == fsterr.ErrSignalInterrupt || err == fsterr.ErrSignalKilled {
text.Info(out, "Local 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)
}
}
}
}
// 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.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
}
err := c.build.Exec(in, out)
if err != nil {
return err
}
text.Break(out)
return nil
}
// 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) {
var missingOverrideHost bool
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://developer.fastly.com/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
missingOverrideHost = true
}
}
}
}
if missingOverrideHost && c.Globals.Verbose() {
text.Break(out)
}
}
// GetViceroy returns the path to the installed binary.
//
// NOTE: if Viceroy is installed then it is updated, otherwise download the
// latest version and 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 GetViceroy(
spinner text.Spinner,
out io.Writer,
av github.AssetVersioner,
g *global.Data,
viceroyBinPath string,
forceCheckViceroyLatest bool,
) (bin string, err error) {
if viceroyBinPath != "" {
if g.Verbose() {
text.Info(out, "Using user provided install of Viceroy via --viceroy-path flag: %s", viceroyBinPath)
text.Break(out)
}
return filepath.Abs(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 g.Verbose() {
text.Info(out, "Using user provided install of Viceroy via $PATH lookup: %s", path)
text.Break(out)
}
return filepath.Abs(path)
}
bin = filepath.Join(InstallDir, av.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
c := exec.Command(bin, "--version")
var installedVersion string
stdoutStderr, err := c.CombinedOutput()
if err != nil {
g.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 := av.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 fastly.toml `viceroy_version` value '%s' (under the [local_server] section) is a valid semver (https://semver.org/), e.g. `0.1.0`)", versionToInstall),
}
}
}
err = installViceroy(installedVersion, versionToInstall, forceCheckViceroyLatest, spinner, av, bin, g)
if err != nil {
g.ErrLog.Add(err)
return bin, err
}
err = setBinPerms(bin)
if err != nil {
g.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
}
// InstallDir represents the directory where the Viceroy binary should be
// installed.
//
// NOTE: This is a package level variable as it makes testing the behaviour of
// the package easier because the test code can replace the value when running
// the test suite.
var InstallDir = func() string {
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "fastly")
}
if dir, err := os.UserHomeDir(); err == nil {
return filepath.Join(dir, ".fastly")
}
panic("unable to deduce user config dir or user home dir")
}()
// 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 installViceroy(
installedVersion, versionToInstall string,
forceCheckViceroyLatest bool,
spinner text.Spinner,
av github.AssetVersioner,
bin string,
g *global.Data,
) error {
var (
err error
msg, tmpBin string
)
switch {
case installedVersion == "": // Viceroy not installed
if g.Verbose() {
text.Info(g.Output, "Viceroy is not already installed, so we will install the %s version.", versionToInstall)
text.Break(g.Output)
}
err = spinner.Start()
if err != nil {
return err
}
msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall)
spinner.Message(msg + "...")
if versionToInstall == "latest" {
tmpBin, err = av.DownloadLatest()
} else {
tmpBin, err = av.DownloadVersion(versionToInstall)
}
case versionToInstall != "latest":
if installedVersion == versionToInstall {
if g.Verbose() {
text.Info(g.Output, "Viceroy is already installed, and the installed version matches the required version (%s) in the fastly.toml file.", versionToInstall)
text.Break(g.Output)
}
return nil
}
if g.Verbose() {
text.Info(g.Output, "Viceroy is already installed, but the installed version (%s) doesn't match the required version (%s) specified in the fastly.toml file.", installedVersion, versionToInstall)
text.Break(g.Output)
}
err = spinner.Start()
if err != nil {
return err
}
msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall)
spinner.Message(msg + "...")
tmpBin, err = av.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 := g.Config.Viceroy.LastChecked != "" && g.Config.Viceroy.LatestVersion != "" && check.Stale(g.Config.Viceroy.LastChecked, g.Config.Viceroy.TTL)
if !stale && !forceCheckViceroyLatest {
if g.Verbose() {
text.Info(g.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.")
text.Break(g.Output)
}
return nil
}
err = spinner.Start()
if err != nil {
return err
}
msg = "Checking latest Viceroy release"
spinner.Message(msg + "...")
// IMPORTANT: We declare separately so to shadow `err` from parent scope.
var latestVersion string
latestVersion, err = av.LatestVersion()
if err != nil {
spinner.StopFailMessage(msg)
spinErr := spinner.StopFail()
if spinErr != nil {
return spinErr
}
return fsterr.RemediationError{
Inner: fmt.Errorf("error fetching latest version: %w", err),
Remediation: fsterr.NetworkRemediation,
}
}
spinner.StopMessage(msg)
err = spinner.Stop()
if err != nil {
return err
}
viceroyConfig := g.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).
g.Config.Viceroy = viceroyConfig
err = g.Config.Write(g.ConfigPath)
if err != nil {
return err
}
if g.Verbose() {
text.Info(g.Output, "The CLI config (`fastly config`) has been updated with the latest Viceroy version: %s", latestVersion)
text.Break(g.Output)
}
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 = av.DownloadLatest()
}
if err != nil {
spinner.StopFailMessage(msg)
spinErr := spinner.StopFail()
if spinErr != nil {
return spinErr
}
return fmt.Errorf("error downloading Viceroy release: %w", err)
}
defer os.RemoveAll(tmpBin)
if err := os.Rename(tmpBin, bin); err != nil {
if err := filesystem.CopyFile(tmpBin, bin); err != nil {
spinner.StopFailMessage(msg)
spinErr := spinner.StopFail()
if spinErr != nil {
return spinErr
}
return fmt.Errorf("error moving latest Viceroy binary in place: %w", err)
}
}
spinner.StopMessage(msg)
return spinner.Stop()
}
// setBinPerms ensures 0777 perms are set on the Viceroy binary.
func setBinPerms(bin string) error {
// G302 (CWE-276): Expect file permissions to be 0600 or less
// gosec flagged this:
// Disabling as the file was not executable without it and we need all users
// to be able to execute the binary.
/* #nosec */
err := os.Chmod(bin, 0o777)
if err != nil {
return fmt.Errorf("error setting executable permissions on Viceroy binary: %w", err)
}
return nil
}
// local spawns a subprocess that runs the compiled binary.
func local(bin, file, addr, env string, debug, profileGuest bool, profileGuestDir cmd.OptionalString, watch bool, watchDir cmd.OptionalString, verbose bool, out io.Writer, errLog fsterr.LogInterface) error {
if env != "" {
env = "." + env
}
wd, err := os.Getwd()
if err != nil {
errLog.Add(err)
return err
}
manifestPath := filepath.Join(wd, fmt.Sprintf("fastly%s.toml", env))
// 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", manifestPath, "--addr", addr, file}
if debug {
args = append(args, "--debug")
}
if profileGuest {
directory := "guest-profiles"
if profileGuestDir.WasSet {
directory = profileGuestDir.Value
}
args = append(args, "--profile-guest="+directory)
if verbose {
text.Info(out, "Saving per-request profiles to %s.", directory)
}
}
if verbose {
text.Break(out)
text.Output(out, "%s: %s", text.BoldYellow("Manifest"), manifestPath)
text.Output(out, "%s: %s", text.BoldYellow("Wasm binary"), file)
text.Output(out, "%s:\n%s", text.BoldYellow("Viceroy binary"), 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(bin, "--version")
if output, err := c.Output(); err == nil {
text.Output(out, "%s:\n%s", text.BoldYellow("Viceroy version"), string(output))
}
} else {
// IMPORTANT: Viceroy 0.4.0 changed its INFO log output behind a -v flag.
// We display the address unless in verbose mode to avoid duplicate output.
text.Info(out, "Listening on http://%s", addr)
}
s := &fstexec.Streaming{
Args: args,
Command: bin,
Env: os.Environ(),
ForceOutput: true,
Output: out,
SignalCh: make(chan os.Signal, 1),
}
s.MonitorSignals()
restart := make(chan bool)
if watch {
root := "."
if watchDir.WasSet {
root = watchDir.Value
}
if verbose {
text.Info(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).", root)
}
gi := ignoreFiles(watchDir)
go watchFiles(root, gi, verbose, s, out, restart)
}
// NOTE: Once we run the viceroy executable, then it can be stopped by one of
// two separate mechanisms:
//
// 1. File modification
// 2. Explicit signal (SIGINT, SIGTERM etc).
//
// 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 cmd.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.
if err := s.Exec(); err != nil {
if !strings.Contains(err.Error(), "signal: ") {
errLog.Add(err)
}
e := strings.TrimSpace(err.Error())
if strings.Contains(e, "interrupt") {
return fsterr.ErrSignalInterrupt
}
if strings.Contains(e, "killed") {
select {
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) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
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 {
log.Fatal(err)
}
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 {
log.Fatal(err)
}
if verbose {
text.Output(out, "%s", text.BoldYellow("Watching..."))
text.Break(out)
text.Output(out, buf.String())
text.Break(out)
}
<-done
}
// ignoreFiles returns the specific ignore rules being respected.
//
// NOTE: We also ignore the .git directory.
func ignoreFiles(watchDir cmd.OptionalString) *ignore.GitIgnore {
var patterns []string
root := ""
if watchDir.WasSet {
root = watchDir.Value
if !strings.HasPrefix(root, "/") {
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)
}
}