diff --git a/.golangci.yml b/.golangci.yml index d0c64008..8efedc18 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,7 @@ run: skip-dirs: - pkg/worker - cmd/webworker + - web issues: exclude-rules: - path: 'internal/gowasm/(.+)\.go' diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 1f338f41..e2aadf7a 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -11,8 +11,8 @@ import ( "github.com/gorilla/mux" "github.com/x1unix/foundation/app" "github.com/x1unix/go-playground/internal/analyzer" - "github.com/x1unix/go-playground/internal/compiler" - "github.com/x1unix/go-playground/internal/compiler/storage" + "github.com/x1unix/go-playground/internal/builder" + "github.com/x1unix/go-playground/internal/builder/storage" "github.com/x1unix/go-playground/internal/config" "github.com/x1unix/go-playground/internal/langserver" "github.com/x1unix/go-playground/internal/langserver/webutil" @@ -39,7 +39,7 @@ func main() { analyzer.SetLogger(logger) defer logger.Sync() //nolint:errcheck - goRoot, err := compiler.GOROOT() + goRoot, err := builder.GOROOT() if err != nil { logger.Fatal("Failed to find GOROOT environment variable value", zap.Error(err)) } @@ -58,24 +58,28 @@ func start(goRoot string, logger *zap.Logger, cfg *config.Config) error { return fmt.Errorf("failed to read packages file %q: %s", cfg.Build.PackagesFile, err) } - store, err := storage.NewLocalStorage(logger.Sugar(), cfg.Build.BuildDir) + store, err := storage.NewLocalStorage(logger, cfg.Build.BuildDir) if err != nil { return err } ctx, _ := app.GetApplicationContext() wg := &sync.WaitGroup{} - go store.StartCleaner(ctx, cfg.Build.CleanupInterval, nil) // Initialize services playgroundClient := goplay.NewClient(cfg.Playground.PlaygroundURL, goplay.DefaultUserAgent, cfg.Playground.ConnectTimeout) - buildCfg := compiler.BuildEnvironmentConfig{ + buildCfg := builder.BuildEnvironmentConfig{ + KeepGoModCache: cfg.Build.SkipModuleCleanup, IncludedEnvironmentVariables: osutil.SelectEnvironmentVariables(cfg.Build.BypassEnvVarsList...), } logger.Debug("Loaded list of environment variables used by compiler", zap.Any("vars", buildCfg.IncludedEnvironmentVariables)) - buildSvc := compiler.NewBuildService(zap.S(), buildCfg, store) + buildSvc := builder.NewBuildService(zap.L(), buildCfg, store) + + // Start cleanup service + cleanupSvc := builder.NewCleanupDispatchService(zap.L(), cfg.Build.CleanupInterval, buildSvc, store) + go cleanupSvc.Start(ctx) // Initialize API endpoints r := mux.NewRouter() diff --git a/go.mod b/go.mod index 00da366e..b3b56cc1 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,14 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/pkg/errors v0.8.1 github.com/samber/lo v1.38.1 - github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.2 github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 github.com/traefik/yaegi v0.15.1 github.com/x1unix/foundation v1.0.0 + go.uber.org/mock v0.4.0 go.uber.org/zap v1.21.0 golang.org/x/exp v0.0.0-20230321023759-10a507213a29 - golang.org/x/mod v0.10.0 + golang.org/x/mod v0.14.0 golang.org/x/sync v0.2.0 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 ) @@ -25,9 +25,7 @@ require ( github.com/benbjohnson/clock v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/getsentry/sentry-go v0.13.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect golang.org/x/sys v0.3.0 // indirect diff --git a/go.sum b/go.sum index 5f02ba2f..27734f6b 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,6 @@ github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHS github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,8 +13,6 @@ github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6 github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -28,13 +25,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -56,6 +48,8 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= @@ -67,8 +61,8 @@ golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAb golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/internal/builder/check.go b/internal/builder/check.go new file mode 100644 index 00000000..0bd1a93c --- /dev/null +++ b/internal/builder/check.go @@ -0,0 +1,48 @@ +package builder + +import ( + "bytes" + "strings" + + "github.com/x1unix/go-playground/pkg/goplay" +) + +const ( + maxPathDepth = 5 + maxFileCount = 12 +) + +func checkFileEntries(entries map[string][]byte) error { + if len(entries) == 0 { + return newBuildError("no buildable Go source files") + } + + if len(entries) > maxFileCount { + return newBuildError("too many files (max: %d)", maxFileCount) + } + + for name, contents := range entries { + if len(bytes.TrimSpace(contents)) == 0 { + return newBuildError("file %s is empty", name) + } + + if err := checkFilePath(name); err != nil { + return err + } + } + + return nil +} + +func checkFilePath(fpath string) error { + if err := goplay.ValidateFilePath(fpath, true); err != nil { + return newBuildError(err.Error()) + } + + pathDepth := strings.Count(fpath, "/") + if pathDepth > maxPathDepth { + return newBuildError("file path is too deep: %s", fpath) + } + + return nil +} diff --git a/internal/builder/cleanup.go b/internal/builder/cleanup.go new file mode 100644 index 00000000..0d26db1a --- /dev/null +++ b/internal/builder/cleanup.go @@ -0,0 +1,87 @@ +package builder + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/tevino/abool" + "go.uber.org/zap" +) + +type Cleaner interface { + CleanJobName() string + Clean(ctx context.Context) error +} + +// CleanupDispatchService calls cleanup entries after periodical interval of time. +type CleanupDispatchService struct { + isRunning abool.AtomicBool + logger *zap.Logger + interval time.Duration + cleaners []Cleaner +} + +func NewCleanupDispatchService(logger *zap.Logger, interval time.Duration, cleaners ...Cleaner) *CleanupDispatchService { + return &CleanupDispatchService{ + logger: logger.Named("cleanup"), + interval: interval, + cleaners: cleaners, + } +} + +func (c *CleanupDispatchService) Start(ctx context.Context) { + t := time.NewTicker(c.interval) + defer t.Stop() + + c.logger.Info("started cleanup service", zap.Duration("interval", c.interval)) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + go c.dispatchCleanup(ctx) + } + } +} + +func (c *CleanupDispatchService) dispatchCleanup(ctx context.Context) { + if c.isRunning.IsSet() { + c.logger.Info("previous job not finished yet, skip") + return + } + + c.isRunning.Set() + defer c.isRunning.UnSet() + + c.logger.Info("starting cleanup job") + startTime := time.Now() + jobCtx, cancelFn := context.WithTimeout(ctx, c.interval) + defer cancelFn() + + wg := new(sync.WaitGroup) + for _, cleaner := range c.cleaners { + wg.Add(1) + go func(cleaner Cleaner) { + defer wg.Done() + if err := cleaner.Clean(jobCtx); err != nil { + if errors.Is(err, context.Canceled) { + return + } + c.logger.Error( + "cleaner returned an error", + zap.Error(err), zap.String("cleaner", cleaner.CleanJobName()), + ) + } + }(cleaner) + } + + wg.Wait() + duration := time.Since(startTime) + if duration > c.interval { + c.logger.Warn("cleanup job took too long!", zap.Duration("duration", duration)) + return + } + c.logger.Info("cleanup job finished", zap.Duration("duration", duration)) +} diff --git a/internal/builder/compiler.go b/internal/builder/compiler.go new file mode 100644 index 00000000..718c1384 --- /dev/null +++ b/internal/builder/compiler.go @@ -0,0 +1,190 @@ +package builder + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "syscall" + "time" + + "github.com/x1unix/go-playground/internal/builder/storage" + "github.com/x1unix/go-playground/pkg/util/osutil" + "go.uber.org/zap" +) + +// predefinedBuildVars is list of environment vars which contain build values +var predefinedBuildVars = osutil.EnvironmentVariables{ + "CGO_ENABLED": "0", + "GOOS": "js", + "GOARCH": "wasm", + "HOME": os.Getenv("HOME"), +} + +// Result is WASM build result +type Result struct { + // FileName is artifact file name + FileName string +} + +// BuildEnvironmentConfig is BuildService environment configuration. +type BuildEnvironmentConfig struct { + // IncludedEnvironmentVariables is a list included environment variables for build. + IncludedEnvironmentVariables osutil.EnvironmentVariables + + // KeepGoModCache disables Go modules cache cleanup. + KeepGoModCache bool +} + +// BuildService is WASM build service +type BuildService struct { + log *zap.Logger + config BuildEnvironmentConfig + storage storage.StoreProvider + cmdRunner CommandRunner +} + +// NewBuildService is BuildService constructor +func NewBuildService(log *zap.Logger, cfg BuildEnvironmentConfig, store storage.StoreProvider) BuildService { + return BuildService{ + log: log.Named("builder"), + config: cfg, + storage: store, + cmdRunner: OSCommandRunner{}, + } +} + +func (s BuildService) getEnvironmentVariables() []string { + if len(s.config.IncludedEnvironmentVariables) == 0 { + return predefinedBuildVars.Join() + } + + return s.config.IncludedEnvironmentVariables.Concat(predefinedBuildVars).Join() +} + +// GetArtifact returns artifact by id +func (s BuildService) GetArtifact(id storage.ArtifactID) (storage.ReadCloseSizer, error) { + return s.storage.GetItem(id) +} + +// Build compiles Go source to WASM and returns result +func (s BuildService) Build(ctx context.Context, files map[string][]byte) (*Result, error) { + if err := checkFileEntries(files); err != nil { + return nil, err + } + + aid, err := storage.GetArtifactID(files) + if err != nil { + return nil, err + } + + result := &Result{FileName: aid.Ext(storage.ExtWasm)} + isCached, err := s.storage.HasItem(aid) + if err != nil { + s.log.Error("failed to check cache", zap.Stringer("artifact", aid), zap.Error(err)) + return nil, err + } + + if isCached { + // Just return precompiled result if data is cached already + s.log.Debug("build cached, returning cached file", zap.Stringer("artifact", aid)) + return result, nil + } + + // Go module is required to build project + if _, ok := files["go.mod"]; !ok { + files["go.mod"] = generateGoMod(aid.String()) + } + + workspace, err := s.storage.CreateWorkspace(aid, files) + if err != nil { + if errors.Is(err, syscall.ENOSPC) { + // Immediately schedule cleanup job! + s.handleNoSpaceLeft() + } + return nil, err + } + + err = s.buildSource(ctx, workspace) + return result, err +} + +func (s BuildService) buildSource(ctx context.Context, workspace *storage.Workspace) error { + // Populate go.mod and go.sum files. + if err := s.runGoTool(ctx, workspace.WorkDir, "mod", "tidy"); err != nil { + return err + } + + return s.runGoTool(ctx, workspace.WorkDir, "build", "-o", workspace.BinaryPath, ".") +} + +func (s BuildService) handleNoSpaceLeft() { + s.log.Warn("no space left on device, immediate clean triggered!") + ctx, cancelFn := context.WithTimeout(context.Background(), time.Minute) + defer cancelFn() + + if err := s.storage.Clean(ctx); err != nil { + s.log.Error("failed to clear storage", zap.Error(err)) + } + if err := s.Clean(ctx); err != nil { + s.log.Error("failed to clear Go cache", zap.Error(err)) + } +} + +func (s BuildService) runGoTool(ctx context.Context, workDir string, args ...string) error { + cmd := newGoToolCommand(ctx, args...) + cmd.Dir = workDir + cmd.Env = s.getEnvironmentVariables() + buff := &bytes.Buffer{} + cmd.Stderr = buff + + s.log.Debug( + "starting go command", zap.Strings("command", cmd.Args), zap.Strings("env", cmd.Env), + ) + + if err := s.cmdRunner.RunCommand(cmd); err != nil { + s.log.Debug( + "build failed", + zap.Error(err), zap.Strings("cmd", cmd.Args), zap.Stringer("stderr", buff), + ) + + return newBuildErrorFromStdout(err, buff) + } + + return nil +} + +// CleanJobName implements' builder.Cleaner interface. +func (s BuildService) CleanJobName() string { + return "gocache" +} + +// Clean implements' builder.Cleaner interface. +// +// Cleans go build and modules cache. +func (s BuildService) Clean(ctx context.Context) error { + if s.config.KeepGoModCache { + s.log.Info("go mod cache cleanup is disabled, skip") + return nil + } + + cmd := newGoToolCommand(ctx, "clean", "-modcache", "-cache", "-testcache", "-fuzzcache") + cmd.Env = s.getEnvironmentVariables() + buff := &bytes.Buffer{} + cmd.Stderr = buff + + if err := s.cmdRunner.RunCommand(cmd); err != nil { + return fmt.Errorf("process returned error: %s. Stderr: %s", err, buff.String()) + } + + return nil +} + +func newBuildErrorFromStdout(err error, buff *bytes.Buffer) error { + if buff.Len() > 0 { + return newBuildError(buff.String()) + } + + return newBuildError("Process returned error: %s", err) +} diff --git a/internal/builder/compiler_test.go b/internal/builder/compiler_test.go new file mode 100644 index 00000000..7dd1bc06 --- /dev/null +++ b/internal/builder/compiler_test.go @@ -0,0 +1,363 @@ +package builder + +import ( + "context" + "errors" + "io/ioutil" + "os" + "os/exec" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + "github.com/x1unix/go-playground/internal/builder/storage" + "github.com/x1unix/go-playground/pkg/testutil" + "github.com/x1unix/go-playground/pkg/util/osutil" + "go.uber.org/mock/gomock" + "go.uber.org/zap/zaptest" +) + +type testReadCloser struct { + strings.Reader +} + +func (ts *testReadCloser) Close() error { + return nil +} + +type testStorage struct { + hasItem func(id storage.ArtifactID) (bool, error) + getItem func(id storage.ArtifactID) (storage.ReadCloseSizer, error) + createWorkspace func(id storage.ArtifactID, entries map[string][]byte) (*storage.Workspace, error) + clean func(ctx context.Context) error +} + +func (ts testStorage) HasItem(id storage.ArtifactID) (bool, error) { + return ts.hasItem(id) +} + +func (ts testStorage) GetItem(id storage.ArtifactID) (storage.ReadCloseSizer, error) { + return ts.getItem(id) +} + +func (ts testStorage) CreateWorkspace(id storage.ArtifactID, entries map[string][]byte) (*storage.Workspace, error) { + return ts.createWorkspace(id, entries) +} + +func (ts testStorage) Clean(ctx context.Context) error { + if ts.clean != nil { + return ts.clean(ctx) + } + + return errors.New("not implemented") +} + +func TestBuildService_GetArtifact(t *testing.T) { + cases := map[string]struct { + artifactID storage.ArtifactID + wantErr string + beforeRun func(t *testing.T) storage.StoreProvider + }{ + "works": { + artifactID: "test", + beforeRun: func(t *testing.T) storage.StoreProvider { + return testStorage{ + getItem: func(id storage.ArtifactID) (storage.ReadCloseSizer, error) { + require.Equal(t, "test", string(id)) + return &testReadCloser{}, nil + }, + } + }, + }, + "handle error": { + artifactID: "foobar", + wantErr: "test error", + beforeRun: func(t *testing.T) storage.StoreProvider { + return testStorage{ + getItem: func(id storage.ArtifactID) (storage.ReadCloseSizer, error) { + require.Equal(t, "foobar", string(id)) + return nil, errors.New("test error") + }, + } + }, + }, + } + + for k, v := range cases { + t.Run(k, func(t *testing.T) { + ts := v.beforeRun(t) + bs := NewBuildService(zaptest.NewLogger(t), BuildEnvironmentConfig{}, ts) + got, err := bs.GetArtifact(v.artifactID) + if v.wantErr != "" { + require.Error(t, err) + require.EqualError(t, err, v.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} + +func TestBuildService_Build(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "tempstore") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + cases := map[string]struct { + skip bool + files map[string][]byte + cmdRunner func(t *testing.T, ctrl *gomock.Controller) CommandRunner + wantErr string + wantResult func(files map[string][]byte) *Result + beforeRun func(t *testing.T) + onErrorCheck func(t *testing.T, err error) + store func(t *testing.T, files map[string][]byte) (storage.StoreProvider, func() error) + }{ + "bad store": { + wantErr: "test error", + files: map[string][]byte{ + "foo.go": []byte("test"), + }, + store: func(t *testing.T, files map[string][]byte) (storage.StoreProvider, func() error) { + return testStorage{ + hasItem: func(id storage.ArtifactID) (bool, error) { + return false, errors.New("test error") + }, + }, nil + }, + }, + "cached build": { + files: map[string][]byte{ + "file.go": []byte("test"), + }, + wantResult: func(files map[string][]byte) *Result { + return &Result{ + FileName: mustArtifactID(t, files).String() + ".wasm", + } + }, + store: func(t *testing.T, files map[string][]byte) (storage.StoreProvider, func() error) { + return testStorage{ + hasItem: func(id storage.ArtifactID) (bool, error) { + w := mustArtifactID(t, files) + require.Equal(t, w, id) + return true, nil + }, + }, nil + }, + }, + "new build": { + wantErr: "can't load package", + files: map[string][]byte{ + "foo.go": []byte("test"), + }, + store: func(t *testing.T, files map[string][]byte) (storage.StoreProvider, func() error) { + s, err := storage.NewLocalStorage(zaptest.NewLogger(t), tempDir) + require.NoError(t, err) + return s, func() error { + return os.RemoveAll(tempDir) + } + }, + onErrorCheck: func(t *testing.T, err error) { + _, ok := err.(*BuildError) + require.True(t, ok, "expected compiler error") + }, + }, + "bad environment": { + wantErr: `executable file not found`, + files: map[string][]byte{ + "foo.go": []byte("test"), + }, + store: func(t *testing.T, files map[string][]byte) (storage.StoreProvider, func() error) { + t.Setenv("PATH", ".") + s, err := storage.NewLocalStorage(zaptest.NewLogger(t), tempDir) + require.NoError(t, err) + return s, func() error { + return os.RemoveAll(tempDir) + } + }, + }, + "empty project": { + wantErr: "no buildable Go source files", + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "too many files": { + wantErr: "too many files", + files: map[string][]byte{ + "0": nil, + "1": nil, + "2": nil, + "3": nil, + "4": nil, + "5": nil, + "6": nil, + "7": nil, + "8": nil, + "9": nil, + "10": nil, + "11": nil, + "12": nil, + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "path too deep": { + wantErr: "file path is too deep", + files: map[string][]byte{ + "a/b/c/d/e/f/g/h/i.go": []byte("package main"), + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "empty file": { + wantErr: "file main.go is empty", + files: map[string][]byte{ + "main.go": []byte(" "), + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "bad path": { + wantErr: "invalid file name", + files: map[string][]byte{ + "../main.go": []byte("a"), + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "non go files": { + wantErr: "invalid file name", + files: map[string][]byte{ + "text.png": []byte("a"), + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return nil, nil + }, + }, + "handle no space left": { + wantErr: "no space left", + files: map[string][]byte{ + "main.go": []byte("package main"), + }, + store: func(t *testing.T, _ map[string][]byte) (storage.StoreProvider, func() error) { + return testStorage{ + hasItem: func(id storage.ArtifactID) (bool, error) { + return false, nil + }, + createWorkspace: func(id storage.ArtifactID, entries map[string][]byte) (*storage.Workspace, error) { + return nil, &os.PathError{Err: syscall.ENOSPC, Op: "write"} + }, + clean: func(_ context.Context) error { + t.Log("cleanup called") + return nil + }, + }, nil + }, + cmdRunner: func(t *testing.T, ctrl *gomock.Controller) CommandRunner { + m := NewMockCommandRunner(ctrl) + m.EXPECT().RunCommand(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + require.Equal(t, cmd.Args, []string{ + "go", "clean", "-modcache", "-cache", "-testcache", "-fuzzcache", + }) + return nil + }).Times(1) + return m + }, + }, + } + + for n, c := range cases { + if c.skip { + continue + } + t.Run(n, func(t *testing.T) { + ctrl := gomock.NewController(t) + if c.beforeRun != nil { + c.beforeRun(t) + } + + store, cancel := c.store(t, c.files) + if cancel != nil { + defer func() { + if err := cancel(); err != nil { + t.Logf("Warning: %s", err) + return + } + t.Log("defer success") + }() + } + + bs := NewBuildService(zaptest.NewLogger(t), BuildEnvironmentConfig{}, store) + if c.cmdRunner != nil { + bs.cmdRunner = c.cmdRunner(t, ctrl) + } + + got, err := bs.Build(context.TODO(), c.files) + if c.wantErr != "" { + if c.onErrorCheck != nil { + c.onErrorCheck(t, err) + return + } + testutil.ContainsError(t, err, c.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, c.wantResult(c.files), got) + }) + } +} + +func TestBuildService_getEnvironmentVariables(t *testing.T) { + cases := map[string]struct { + includedVars osutil.EnvironmentVariables + check func(t *testing.T, included osutil.EnvironmentVariables, result []string) + }{ + "include vars": { + includedVars: osutil.EnvironmentVariables{ + "FOOBAR": "BAZ", + "GOOS": "stub-value", + }, + check: func(t *testing.T, included osutil.EnvironmentVariables, result []string) { + got := osutil.SplitEnvironmentValues(result) + expect := included.Concat(predefinedBuildVars) + require.Equal(t, expect, got) + }, + }, + "ignore vars if empty": { + check: func(t *testing.T, _ osutil.EnvironmentVariables, result []string) { + got := osutil.SplitEnvironmentValues(result) + require.Equal(t, predefinedBuildVars, got) + }, + }, + } + + for n, c := range cases { + t.Run(n, func(t *testing.T) { + cfg := BuildEnvironmentConfig{ + IncludedEnvironmentVariables: c.includedVars, + } + svc := NewBuildService(zaptest.NewLogger(t), cfg, nil) + got := svc.getEnvironmentVariables() + c.check(t, c.includedVars, got) + }) + } +} + +func mustArtifactID(t *testing.T, files map[string][]byte) storage.ArtifactID { + t.Helper() + a, err := storage.GetArtifactID(files) + require.NoError(t, err) + return a +} + +func TestBuildError_Error(t *testing.T) { + msg := "test" + require.Equal(t, msg, newBuildError(msg).Error()) +} diff --git a/internal/builder/error.go b/internal/builder/error.go new file mode 100644 index 00000000..ed746107 --- /dev/null +++ b/internal/builder/error.go @@ -0,0 +1,33 @@ +package builder + +import ( + "errors" + "fmt" +) + +// BuildError is build error +type BuildError struct { + message string +} + +// Error implements error +func (e *BuildError) Error() string { + return e.message +} + +func newBuildError(msg string, args ...any) *BuildError { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + + return &BuildError{message: msg} +} + +func IsBuildError(err error) bool { + if err == nil { + return false + } + + dst := new(BuildError) + return errors.As(err, &dst) +} diff --git a/internal/compiler/goenv.go b/internal/builder/goenv.go similarity index 98% rename from internal/compiler/goenv.go rename to internal/builder/goenv.go index a167a4ac..7f120e5f 100644 --- a/internal/compiler/goenv.go +++ b/internal/builder/goenv.go @@ -1,4 +1,4 @@ -package compiler +package builder import ( "bytes" diff --git a/internal/compiler/goenv_test.go b/internal/builder/goenv_test.go similarity index 98% rename from internal/compiler/goenv_test.go rename to internal/builder/goenv_test.go index ff8afefd..eb3fff92 100644 --- a/internal/compiler/goenv_test.go +++ b/internal/builder/goenv_test.go @@ -1,4 +1,4 @@ -package compiler +package builder import ( "context" diff --git a/internal/builder/mock_runner.go b/internal/builder/mock_runner.go new file mode 100644 index 00000000..05cf6c3d --- /dev/null +++ b/internal/builder/mock_runner.go @@ -0,0 +1,54 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/x1unix/go-playground/internal/builder (interfaces: CommandRunner) +// +// Generated by this command: +// +// mockgen -destination=mock_runner.go -package=builder github.com/x1unix/go-playground/internal/builder CommandRunner +// + +// Package builder is a generated GoMock package. +package builder + +import ( + exec "os/exec" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockCommandRunner is a mock of CommandRunner interface. +type MockCommandRunner struct { + ctrl *gomock.Controller + recorder *MockCommandRunnerMockRecorder +} + +// MockCommandRunnerMockRecorder is the mock recorder for MockCommandRunner. +type MockCommandRunnerMockRecorder struct { + mock *MockCommandRunner +} + +// NewMockCommandRunner creates a new mock instance. +func NewMockCommandRunner(ctrl *gomock.Controller) *MockCommandRunner { + mock := &MockCommandRunner{ctrl: ctrl} + mock.recorder = &MockCommandRunnerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandRunner) EXPECT() *MockCommandRunnerMockRecorder { + return m.recorder +} + +// RunCommand mocks base method. +func (m *MockCommandRunner) RunCommand(arg0 *exec.Cmd) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunCommand", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RunCommand indicates an expected call of RunCommand. +func (mr *MockCommandRunnerMockRecorder) RunCommand(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunCommand", reflect.TypeOf((*MockCommandRunner)(nil).RunCommand), arg0) +} diff --git a/internal/builder/runner.go b/internal/builder/runner.go new file mode 100644 index 00000000..df2b6e1c --- /dev/null +++ b/internal/builder/runner.go @@ -0,0 +1,26 @@ +package builder + +import ( + "os/exec" +) + +//go:generate mockgen -destination=mock_runner.go -package=builder github.com/x1unix/go-playground/internal/builder CommandRunner + +// CommandRunner is abstract command cmdRunner. +type CommandRunner interface { + RunCommand(cmd *exec.Cmd) error +} + +type OSCommandRunner struct{} + +func (_ OSCommandRunner) RunCommand(cmd *exec.Cmd) error { + if err := cmd.Start(); err != nil { + return err + } + + if err := cmd.Wait(); err != nil { + return err + } + + return nil +} diff --git a/internal/compiler/storage/artifact.go b/internal/builder/storage/artifact.go similarity index 63% rename from internal/compiler/storage/artifact.go rename to internal/builder/storage/artifact.go index 12514bdb..2f915c72 100644 --- a/internal/compiler/storage/artifact.go +++ b/internal/builder/storage/artifact.go @@ -27,9 +27,20 @@ func (a ArtifactID) String() string { } // GetArtifactID generates new artifact ID from contents -func GetArtifactID(data []byte) (ArtifactID, error) { +func GetArtifactID(entries map[string][]byte) (ArtifactID, error) { h := md5.New() - _, _ = h.Write(bytes.TrimSpace(data)) + + isFirst := true + for name, contents := range entries { + if !isFirst { + _, _ = h.Write([]byte("\n")) + } + isFirst = false + _, _ = h.Write([]byte("-- ")) + _, _ = h.Write([]byte(name)) + _, _ = h.Write([]byte(" --\n")) + _, _ = h.Write(bytes.TrimSpace(contents)) + } fName := hex.EncodeToString(h.Sum(nil)) return ArtifactID(fName), nil } diff --git a/internal/compiler/storage/local.go b/internal/builder/storage/local.go similarity index 57% rename from internal/compiler/storage/local.go rename to internal/builder/storage/local.go index 446b1c22..f17936bc 100644 --- a/internal/compiler/storage/local.go +++ b/internal/builder/storage/local.go @@ -2,6 +2,7 @@ package storage import ( "context" + "fmt" "io" "os" "path/filepath" @@ -39,9 +40,9 @@ func (c cachedFile) Read(p []byte) (n int, err error) { return c.ReadCloser.Read(p) } -// LocalStorage is local build artigact storage +// LocalStorage is local build artifact storage type LocalStorage struct { - log *zap.SugaredLogger + log *zap.Logger useLock *sync.Mutex dirty *abool.AtomicBool gcRun *abool.AtomicBool @@ -51,7 +52,7 @@ type LocalStorage struct { } // NewLocalStorage constructs new local storage -func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (ls *LocalStorage, err error) { +func NewLocalStorage(log *zap.Logger, baseDir string) (ls *LocalStorage, err error) { var isDirty bool logger := log.Named("storage") workDir := filepath.Join(baseDir, workDirName) @@ -63,7 +64,7 @@ func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (ls *LocalStorage, isDirty, err = isDirDirty(workDir) if err != nil { - logger.Errorw("failed to check if work dir is dirty", "err", err) + logger.Error("failed to check if work dir is dirty", zap.Error(err)) } if isDirty { @@ -138,40 +139,72 @@ func (s LocalStorage) GetItem(id ArtifactID) (ReadCloseSizer, error) { }, err } -// CreateLocationAndDo implements storage interface -func (s LocalStorage) CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error { +// CreateWorkspace implements storage interface +func (s LocalStorage) CreateWorkspace(id ArtifactID, files map[string][]byte) (*Workspace, error) { s.useLock.Lock() defer s.useLock.Unlock() s.dirty.Set() // mark storage as dirty + + // Ensure bin dir exists + if err := os.MkdirAll(s.binDir, perm); err != nil { + if !os.IsExist(err) { + s.log.Error("failed to create a binary directory", + zap.Stringer("artifact", id), + zap.String("dir", s.binDir), + zap.Error(err), + ) + + return nil, fmt.Errorf("failed to create artifact directory: %w", err) + } + } + + // Write entries tmpSrcDir := filepath.Join(s.srcDir, id.String()) if err := os.MkdirAll(tmpSrcDir, perm); err != nil { if !os.IsExist(err) { - s.log.Errorw("failed to create a temporary build directory", - "artifact", id.String(), - "dir", tmpSrcDir, - "err", err.Error(), + s.log.Error("failed to create a temporary build directory", + zap.Stringer("artifact", id), + zap.String("dir", tmpSrcDir), + zap.Error(err), ) - return errors.Wrapf(err, "failed to create temporary build directory") + + return nil, fmt.Errorf("failed to create temporary build directory: %w", err) } - s.log.Debugw("build directory already exists", "artifact", id.String()) + s.log.Debug("build directory already exists", zap.Stringer("artifact", id)) } wasmLocation := s.getOutputLocation(id) - goFileName := id.Ext(ExtGo) - srcFile := filepath.Join(tmpSrcDir, goFileName) - if err := os.WriteFile(srcFile, data, perm); err != nil { - s.log.Errorw( - "failed to save source file", - "artifact", id.String(), - "file", srcFile, - "err", err, - ) + fileNames := make([]string, 0, len(files)) - return errors.Wrapf(err, "failed to save source file %q", goFileName) + for name, data := range files { + if err := createParentDir(tmpSrcDir, name); err != nil { + return nil, err + } + + filePath := filepath.Join(tmpSrcDir, name) + fileNames = append(fileNames, filePath) + if err := os.WriteFile(filePath, data, perm); err != nil { + if err := os.RemoveAll(tmpSrcDir); err != nil { + s.log.Warn("failed to remove workspace", zap.String("dir", tmpSrcDir), zap.Error(err)) + } + + s.log.Error( + "failed to save source file", + zap.Stringer("artifact", id), + zap.String("file", filePath), + zap.Error(err), + ) + + return nil, fmt.Errorf("failed to store file %q: %w", name, err) + } } - return cb(wasmLocation, srcFile) + return &Workspace{ + WorkDir: tmpSrcDir, + BinaryPath: wasmLocation, + Files: fileNames, + }, nil } func (s LocalStorage) clean() error { @@ -182,7 +215,7 @@ func (s LocalStorage) clean() error { s.log.Debug("cleanup start") t := time.AfterFunc(maxCleanTime, func() { - s.log.Warnf("cleanup took more than %.0f seconds!", maxCleanTime.Seconds()) + s.log.Warn("cleanup timeout exceeded", zap.Duration("timeout", maxCleanTime)) }) s.useLock.Lock() defer s.useLock.Unlock() @@ -197,7 +230,7 @@ func (s LocalStorage) clean() error { return errors.Wrapf(err, "failed to remove %q", dir) } - s.log.Debugf("cleaner: removed directory %q", dir) + s.log.Debug("cleaner: removed directory", zap.String("dir", dir)) } s.dirty.UnSet() // remove dirty flag @@ -205,25 +238,31 @@ func (s LocalStorage) clean() error { return nil } -// StartCleaner implements storage interface -func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration, wg *sync.WaitGroup) { +// CleanJobName implements builder.Cleaner interface. +func (s LocalStorage) CleanJobName() string { + return "storage" +} + +// Clean implements' builder.Cleaner interface. +func (s LocalStorage) Clean(ctx context.Context) error { s.gcRun.Set() - s.log.Debug("cleaner worker starter") - for { - select { - case <-ctx.Done(): - s.log.Debug("context done, cleaner worker stopped") - s.gcRun.UnSet() - if wg != nil { - wg.Done() - } - return - default: - } + if err := ctx.Err(); err != nil { + return err + } - <-time.After(interval) - if err := s.clean(); err != nil { - s.log.Error(err) - } + return s.clean() +} + +func createParentDir(workDir, fileName string) error { + dirName := filepath.Dir(fileName) + if dirName == "." { + return nil } + + absDirName := filepath.Join(workDir, dirName) + if err := os.MkdirAll(absDirName, perm); err != nil { + return fmt.Errorf("can't create %q: %s", absDirName, err) + } + + return nil } diff --git a/internal/builder/storage/local_test.go b/internal/builder/storage/local_test.go new file mode 100644 index 00000000..392ee490 --- /dev/null +++ b/internal/builder/storage/local_test.go @@ -0,0 +1,145 @@ +package storage + +import ( + "context" + "io" + "io/ioutil" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tevino/abool" + "github.com/x1unix/go-playground/pkg/testutil" + "go.uber.org/zap/zaptest" +) + +func getTestDir(t *testing.T) string { + t.Helper() + d, err := os.MkdirTemp(os.TempDir(), "storage_test") + require.NoError(t, err) + return d +} + +func TestLocalStorage_GetItem(t *testing.T) { + r := require.New(t) + testDir := getTestDir(t) + defer os.RemoveAll(testDir) + s, err := NewLocalStorage(zaptest.NewLogger(t), testDir) + r.NoError(err, "failed to create test storage") + r.Falsef(s.dirty.IsSet(), "dirty flag is not false") + + entries := map[string][]byte{ + "test1.go": []byte("foo"), + "foo/bar.go": []byte("bar"), + } + aid, err := GetArtifactID(entries) + require.NoError(t, err, "failed to create a test artifact ID") + + // check not existing item + ok, err := s.HasItem(aid) + require.NoError(t, err) + require.False(t, ok) + + _, err = s.GetItem(aid) + r.EqualError(err, ErrNotExists.Error(), "got unexpected error type") + + // Start trash collector in background + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(cancelFunc) + + // Create some data + workspace, err := s.CreateWorkspace(aid, entries) + require.NoError(t, err, "Workspace create error") + strEndsWith(t, workspace.BinaryPath, ExtWasm) + require.Len(t, workspace.Files, len(entries)) + + for fileName, expectData := range entries { + absPath := filepath.Join(workspace.WorkDir, fileName) + require.Contains(t, workspace.Files, absPath, "missing file in workspace") + gotData, err := os.ReadFile(absPath) + + require.NoError(t, err, "can't access expected file") + require.Equalf(t, expectData, gotData, "file content mismatch: %q", absPath) + } + + // Check storage dirty state + r.True(s.dirty.IsSet(), "dirty flag should be true after file manipulation") + + binData := []byte("TEST") + require.NoError(t, os.WriteFile(workspace.BinaryPath, binData, perm), "binary path not writable") + + // Try to get item from storage + has, err := s.HasItem(aid) + require.NoError(t, err) + require.True(t, has) + dataFile, err := s.GetItem(aid) + defer dataFile.Close() + r.NoError(err, "failed to get saved bin data") + + gotBinData, err := io.ReadAll(dataFile) + require.NoError(t, err, "can't read back bin data") + r.Equal(binData, gotBinData, "bin data mismatch") + + // Trash collector should clean all our garbage after some time + require.NoError(t, s.Clean(ctx)) + r.False(s.dirty.IsSet(), "storage is still dirty after cleanup") + _, err = s.GetItem(aid) + r.Error(err, "test item was not removed after cleanup") + r.EqualError(err, ErrNotExists.Error(), "should return ErrNotExists") + cancelFunc() + + require.NoError(t, os.RemoveAll(testDir), "failed to remove test dir after exit") +} + +func TestLocalStorage_clean(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "tempstore") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + cases := map[string]struct { + dir string + store *LocalStorage + wantErr string + }{ + "clean existing dir": { + dir: tempDir, + }, + "clean error": { + store: &LocalStorage{ + log: zaptest.NewLogger(t), + workDir: "/a/b/c/d", + useLock: &sync.Mutex{}, + dirty: abool.NewBool(false), + gcRun: abool.NewBool(false), + binDir: filepath.Join("/dev", binDirName), + srcDir: filepath.Join("/dev", srcDirName), + }, + }, + } + + for n, c := range cases { + t.Run(n, func(t *testing.T) { + if c.store == nil { + store, err := NewLocalStorage(zaptest.NewLogger(t), c.dir) + require.NoError(t, err) + c.store = store + } + + c.store.dirty.Set() + err := c.store.clean() + if c.wantErr != "" { + testutil.ContainsError(t, err, c.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func strEndsWith(t *testing.T, str string, suffix string) { + t.Helper() + got := str[len(str)-len(suffix):] + require.Equal(t, suffix, got) +} diff --git a/internal/builder/storage/storage.go b/internal/builder/storage/storage.go new file mode 100644 index 00000000..a12b2e3a --- /dev/null +++ b/internal/builder/storage/storage.go @@ -0,0 +1,45 @@ +package storage + +import ( + "context" + "errors" + "io" +) + +// ErrNotExists is item not found error +var ErrNotExists = errors.New("item not exists") + +type Workspace struct { + // WorkDir is workspace directory + WorkDir string + + // BinaryPath is absolute path for output binary file. + BinaryPath string + + // Files is list of files in workspace + Files []string +} + +// Callback is location callback +type Callback = func(workspace Workspace) error + +type ReadCloseSizer interface { + io.ReadCloser + + Size() int64 +} + +// StoreProvider is abstract artifact storage +type StoreProvider interface { + // HasItem checks if item exists + HasItem(id ArtifactID) (bool, error) + + // GetItem returns item by id + GetItem(id ArtifactID) (ReadCloseSizer, error) + + // CreateWorkspace creates workspace entry in storage + CreateWorkspace(id ArtifactID, files map[string][]byte) (*Workspace, error) + + // Clean truncates storage contents + Clean(ctx context.Context) error +} diff --git a/internal/builder/utils.go b/internal/builder/utils.go new file mode 100644 index 00000000..43be4afd --- /dev/null +++ b/internal/builder/utils.go @@ -0,0 +1,12 @@ +package builder + +import ( + "runtime" + "strings" +) + +var goVersion = strings.TrimPrefix(runtime.Version(), "go") + +func generateGoMod(modName string) []byte { + return []byte("module " + modName + "\ngo " + goVersion) +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go deleted file mode 100644 index 42f7130c..00000000 --- a/internal/compiler/compiler.go +++ /dev/null @@ -1,107 +0,0 @@ -package compiler - -import ( - "bytes" - "context" - "os" - - "github.com/x1unix/go-playground/internal/compiler/storage" - "github.com/x1unix/go-playground/pkg/util/osutil" - "go.uber.org/zap" -) - -// predefinedBuildVars is list of environment vars which contain build values -var predefinedBuildVars = osutil.EnvironmentVariables{ - "CGO_ENABLED": "0", - "GOOS": "js", - "GOARCH": "wasm", - "HOME": os.Getenv("HOME"), -} - -// Result is WASM build result -type Result struct { - // FileName is artifact file name - FileName string -} - -// BuildEnvironmentConfig is BuildService environment configuration. -type BuildEnvironmentConfig struct { - // IncludedEnvironmentVariables is a list included environment variables for build. - IncludedEnvironmentVariables osutil.EnvironmentVariables -} - -// BuildService is WASM build service -type BuildService struct { - log *zap.SugaredLogger - config BuildEnvironmentConfig - storage storage.StoreProvider -} - -// NewBuildService is BuildService constructor -func NewBuildService(log *zap.SugaredLogger, cfg BuildEnvironmentConfig, store storage.StoreProvider) BuildService { - return BuildService{ - log: log.Named("builder"), - config: cfg, - storage: store, - } -} - -func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLocation string) error { - cmd := newGoToolCommand(ctx, "build", "-o", outputLocation, sourceLocation) - cmd.Env = s.getEnvironmentVariables() - buff := &bytes.Buffer{} - cmd.Stderr = buff - - s.log.Debugw("starting go build", "command", cmd.Args, "env", cmd.Env) - if err := cmd.Start(); err != nil { - return err - } - - if err := cmd.Wait(); err != nil { - errMsg := buff.String() - s.log.Debugw("build failed", "err", err, "stderr", errMsg) - return newBuildError(errMsg) - } - - return nil -} - -func (s BuildService) getEnvironmentVariables() []string { - if len(s.config.IncludedEnvironmentVariables) == 0 { - return predefinedBuildVars.Join() - } - - return s.config.IncludedEnvironmentVariables.Concat(predefinedBuildVars).Join() -} - -// GetArtifact returns artifact by id -func (s BuildService) GetArtifact(id storage.ArtifactID) (storage.ReadCloseSizer, error) { - return s.storage.GetItem(id) -} - -// Build compiles Go source to WASM and returns result -func (s BuildService) Build(ctx context.Context, data []byte) (*Result, error) { - aid, err := storage.GetArtifactID(data) - if err != nil { - return nil, err - } - - result := &Result{FileName: aid.Ext(storage.ExtWasm)} - isCached, err := s.storage.HasItem(aid) - if err != nil { - s.log.Errorw("failed to check cache", "artifact", aid.String(), "err", err) - return nil, err - } - - if isCached { - // Just return precompiled result if data is cached already - s.log.Debugw("build cached, returning cached file", "artifact", aid.String()) - return result, nil - } - - err = s.storage.CreateLocationAndDo(aid, data, func(wasmLocation, sourceLocation string) error { - return s.buildSource(ctx, wasmLocation, sourceLocation) - }) - - return result, err -} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go deleted file mode 100644 index 7fff0eda..00000000 --- a/internal/compiler/compiler_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package compiler - -import ( - "context" - "errors" - "io/ioutil" - "os" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "github.com/x1unix/go-playground/internal/compiler/storage" - "github.com/x1unix/go-playground/pkg/testutil" - "github.com/x1unix/go-playground/pkg/util/osutil" - "go.uber.org/zap/zaptest" -) - -type testReadCloser struct { - strings.Reader -} - -func (ts *testReadCloser) Close() error { - return nil -} - -type testStorage struct { - hasItem func(id storage.ArtifactID) (bool, error) - getItem func(id storage.ArtifactID) (storage.ReadCloseSizer, error) - createLocationAndDo func(id storage.ArtifactID, data []byte, cb storage.Callback) error -} - -// HasItem checks if item exists -func (ts testStorage) HasItem(id storage.ArtifactID) (bool, error) { - return ts.hasItem(id) -} - -// GetItem returns item by id -func (ts testStorage) GetItem(id storage.ArtifactID) (storage.ReadCloseSizer, error) { - return ts.getItem(id) -} - -// CreateLocationAndDo creates entry in storage and runs specified callback with new location -func (ts testStorage) CreateLocationAndDo(id storage.ArtifactID, data []byte, cb storage.Callback) error { - return ts.createLocationAndDo(id, data, cb) -} - -func TestBuildService_GetArtifact(t *testing.T) { - cases := map[string]struct { - artifactID storage.ArtifactID - wantErr string - beforeRun func(t *testing.T) storage.StoreProvider - }{ - "works": { - artifactID: "test", - beforeRun: func(t *testing.T) storage.StoreProvider { - return testStorage{ - getItem: func(id storage.ArtifactID) (storage.ReadCloseSizer, error) { - require.Equal(t, "test", string(id)) - return &testReadCloser{}, nil - }, - } - }, - }, - "handle error": { - artifactID: "foobar", - wantErr: "test error", - beforeRun: func(t *testing.T) storage.StoreProvider { - return testStorage{ - getItem: func(id storage.ArtifactID) (storage.ReadCloseSizer, error) { - require.Equal(t, "foobar", string(id)) - return nil, errors.New("test error") - }, - } - }, - }, - } - - for k, v := range cases { - t.Run(k, func(t *testing.T) { - ts := v.beforeRun(t) - bs := NewBuildService(zaptest.NewLogger(t).Sugar(), BuildEnvironmentConfig{}, ts) - got, err := bs.GetArtifact(v.artifactID) - if v.wantErr != "" { - require.Error(t, err) - require.EqualError(t, err, v.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, got) - }) - } -} - -func TestBuildService_Build(t *testing.T) { - tempDir, err := ioutil.TempDir(os.TempDir(), "tempstore") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - cases := map[string]struct { - skip bool - data []byte - wantErr string - wantResult *Result - beforeRun func(t *testing.T) - onErrorCheck func(t *testing.T, err error) - store func(t *testing.T) (storage.StoreProvider, func() error) - }{ - "bad store": { - wantErr: "test error", - store: func(t *testing.T) (storage.StoreProvider, func() error) { - return testStorage{ - hasItem: func(id storage.ArtifactID) (bool, error) { - return false, errors.New("test error") - }, - }, nil - }, - }, - "cached build": { - data: []byte("test"), - wantResult: &Result{ - FileName: mustArtifactID(t, []byte("test")).String() + ".wasm", - }, - store: func(t *testing.T) (storage.StoreProvider, func() error) { - return testStorage{ - hasItem: func(id storage.ArtifactID) (bool, error) { - w := mustArtifactID(t, []byte("test")) - require.Equal(t, w, id) - return true, nil - }, - }, nil - }, - }, - "new build": { - wantErr: "can't load package", - store: func(t *testing.T) (storage.StoreProvider, func() error) { - s, err := storage.NewLocalStorage(zaptest.NewLogger(t).Sugar(), tempDir) - require.NoError(t, err) - return s, func() error { - return os.RemoveAll(tempDir) - } - }, - onErrorCheck: func(t *testing.T, err error) { - _, ok := err.(*BuildError) - require.True(t, ok, "expected compiler error") - }, - }, - "bad environment": { - wantErr: `executable file not found`, - store: func(t *testing.T) (storage.StoreProvider, func() error) { - t.Setenv("PATH", ".") - s, err := storage.NewLocalStorage(zaptest.NewLogger(t).Sugar(), tempDir) - require.NoError(t, err) - return s, func() error { - return os.RemoveAll(tempDir) - } - }, - }, - } - - for n, c := range cases { - if c.skip { - continue - } - t.Run(n, func(t *testing.T) { - if c.beforeRun != nil { - c.beforeRun(t) - } - - store, cancel := c.store(t) - if cancel != nil { - defer func() { - if err := cancel(); err != nil { - t.Logf("Warning: %s", err) - return - } - t.Log("defer success") - }() - } - - bs := NewBuildService(zaptest.NewLogger(t).Sugar(), BuildEnvironmentConfig{}, store) - got, err := bs.Build(context.TODO(), c.data) - if c.wantErr != "" { - if c.onErrorCheck != nil { - c.onErrorCheck(t, err) - return - } - testutil.ContainsError(t, err, c.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, got) - require.Equal(t, c.wantResult, got) - }) - } -} - -func TestBuildService_getEnvironmentVariables(t *testing.T) { - cases := map[string]struct { - includedVars osutil.EnvironmentVariables - check func(t *testing.T, included osutil.EnvironmentVariables, result []string) - }{ - "include vars": { - includedVars: osutil.EnvironmentVariables{ - "FOOBAR": "BAZ", - "GOOS": "stub-value", - }, - check: func(t *testing.T, included osutil.EnvironmentVariables, result []string) { - got := osutil.SplitEnvironmentValues(result) - expect := included.Concat(predefinedBuildVars) - require.Equal(t, expect, got) - }, - }, - "ignore vars if empty": { - check: func(t *testing.T, _ osutil.EnvironmentVariables, result []string) { - got := osutil.SplitEnvironmentValues(result) - require.Equal(t, predefinedBuildVars, got) - }, - }, - } - - for n, c := range cases { - t.Run(n, func(t *testing.T) { - cfg := BuildEnvironmentConfig{ - IncludedEnvironmentVariables: c.includedVars, - } - svc := NewBuildService(zaptest.NewLogger(t).Sugar(), cfg, nil) - got := svc.getEnvironmentVariables() - c.check(t, c.includedVars, got) - }) - } -} - -func mustArtifactID(t *testing.T, data []byte) storage.ArtifactID { - t.Helper() - a, err := storage.GetArtifactID(data) - require.NoError(t, err) - return a -} - -func TestBuildError_Error(t *testing.T) { - msg := "test" - require.Equal(t, msg, newBuildError(msg).Error()) -} diff --git a/internal/compiler/error.go b/internal/compiler/error.go deleted file mode 100644 index 768849de..00000000 --- a/internal/compiler/error.go +++ /dev/null @@ -1,15 +0,0 @@ -package compiler - -// BuildError is build error -type BuildError struct { - message string -} - -// Error implements error -func (e *BuildError) Error() string { - return e.message -} - -func newBuildError(msg string) *BuildError { - return &BuildError{message: msg} -} diff --git a/internal/compiler/storage/local_test.go b/internal/compiler/storage/local_test.go deleted file mode 100644 index a95f2962..00000000 --- a/internal/compiler/storage/local_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package storage - -import ( - "context" - "errors" - "fmt" - "io/ioutil" - "os" - "os/exec" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tevino/abool" - "github.com/x1unix/go-playground/pkg/testutil" - "go.uber.org/zap/zaptest" -) - -func getTestDir(t *testing.T) string { - t.Helper() - d, err := ioutil.TempDir(os.TempDir(), "storage_test") - require.NoError(t, err) - return d -} - -func TestLocalStorage_GetItem(t *testing.T) { - r := require.New(t) - testDir := getTestDir(t) - defer os.RemoveAll(testDir) - s, err := NewLocalStorage(zaptest.NewLogger(t).Sugar(), testDir) - r.NoError(err, "failed to create test storage") - r.Falsef(s.dirty.IsSet(), "dirty flag is not false") - expectData := []byte("foo") - aid, err := GetArtifactID(expectData) - must(t, err, "failed to create a test artifact ID") - - // check not existing item - ok, err := s.HasItem(aid) - require.NoError(t, err) - require.False(t, ok) - - _, err = s.GetItem(aid) - r.EqualError(err, ErrNotExists.Error(), "got unexpected error type") - - // Start trash collector in background - ctx, cancelFunc := context.WithCancel(context.Background()) - cleanInterval := time.Second * 2 - go s.StartCleaner(ctx, cleanInterval, nil) - defer cancelFunc() - runtime.Gosched() // Ask Go to switch to cleaner goroutine - r.True(s.gcRun.IsSet(), "gc start flag not true") - - // Create some data - expErr := errors.New("create error") - err = s.CreateLocationAndDo(aid, expectData, func(wasmLocation, sourceLocation string) error { - strEndsWith(t, wasmLocation, ExtWasm) - strEndsWith(t, sourceLocation, ExtGo) - t.Logf("\nWASM:\t%s\nSRC:\t%s", wasmLocation, sourceLocation) - f, err := os.Open(sourceLocation) - r.NoError(err, "failed to open source file") - data, err := ioutil.ReadAll(f) - r.NoError(err, "failed to read test file") - - r.Equal(data, expectData, "input and result don't match") - if err := os.Mkdir(filepath.Join(s.binDir), perm); !os.IsExist(err) { - must(t, err, "failed to create bin dir") - } - err = ioutil.WriteFile(wasmLocation, expectData, perm) - must(t, err, "failed to write dest file") - return expErr - }) - - // Check storage dirty state - r.EqualError(err, expErr.Error(), "expected and returned error mismatch") - r.True(s.dirty.IsSet(), "dirty flag should be true after file manipulation") - - // Try to get item from storage - has, err := s.HasItem(aid) - require.NoError(t, err) - require.True(t, has) - dataFile, err := s.GetItem(aid) - defer dataFile.Close() - r.NoError(err, "failed to get saved cached data") - - contents, err := ioutil.ReadAll(dataFile) - must(t, err, "failed to read saved file") - r.Equal(expectData, contents) - - // Trash collector should clean all our garbage after some time - runtime.Gosched() - time.Sleep(cleanInterval + time.Second) - r.False(s.dirty.IsSet(), "storage is still dirty after cleanup") - _, err = s.GetItem(aid) - r.Error(err, "test item was not removed after cleanup") - r.EqualError(err, ErrNotExists.Error(), "should return ErrNotExists") - cancelFunc() - - // Ensure that collector stopped after context done - time.Sleep(cleanInterval) - r.False(s.gcRun.IsSet(), "collector not stopped after context death") - - must(t, os.RemoveAll(testDir), "failed to remove test dir after exit") -} - -func TestLocalStorage_clean(t *testing.T) { - tempDir, err := ioutil.TempDir(os.TempDir(), "tempstore") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - cases := map[string]struct { - dir string - store *LocalStorage - wantErr string - }{ - "clean existing dir": { - dir: tempDir, - }, - "clean error": { - store: &LocalStorage{ - log: testutil.GetLogger(t), - workDir: "/a/b/c/d", - useLock: &sync.Mutex{}, - dirty: abool.NewBool(false), - gcRun: abool.NewBool(false), - binDir: filepath.Join("/dev", binDirName), - srcDir: filepath.Join("/dev", srcDirName), - }, - }, - } - - for n, c := range cases { - t.Run(n, func(t *testing.T) { - if c.store == nil { - store, err := NewLocalStorage(testutil.GetLogger(t), c.dir) - require.NoError(t, err) - c.store = store - } - - c.store.dirty.Set() - err := c.store.clean() - if c.wantErr != "" { - testutil.ContainsError(t, err, c.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestLocalStorage_CreateLocationAndDo(t *testing.T) { - tempDir, err := ioutil.TempDir(os.TempDir(), "tempstore") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - cases := map[string]struct { - skip bool - dir string - data []byte - artifact ArtifactID - err string - before func() error - after func() error - }{ - "inaccessible dir": { - dir: "/root/foo", - artifact: "testartifactid", - err: "failed to create temporary build directory", - }, - "no perm": { - dir: "/tmp/testdir", - artifact: "../../../../../../../../../../../foobar", - err: "failed to create temporary build directory", - }, - "ok": { - dir: tempDir, - artifact: mustArtifactID(t, "test"), - data: []byte("test"), - after: func() error { - art := mustArtifactID(t, "test") - f := filepath.Join(tempDir, srcDirName, art.String(), art.Ext(ExtGo)) - _, err := os.Stat(f) - if err != nil { - t.Log(f) - return fmt.Errorf("created file not exists - %w", err) - } - return nil - }, - }, - "unwritable": { - err: "failed to save source file", - dir: tempDir, - artifact: mustArtifactID(t, "test1"), - data: []byte("test1"), - before: func() error { - art := mustArtifactID(t, "test1") - f := filepath.Join(tempDir, srcDirName, art.String()) - if err := os.MkdirAll(f, perm); err != nil { - return err - } - - // create broken symlink to create unwritable file - f = filepath.Join(f, art.Ext(ExtGo)) - cmd := exec.Command("ln", "-s", "/dev/badpath", f) - out, err := cmd.CombinedOutput() - if err != nil { - t.Log(cmd.String()) - return fmt.Errorf("%s (%w)", string(out), err) - } - return nil - }, - }, - } - - for n, c := range cases { - t.Run(n, func(t *testing.T) { - if c.skip { - t.Skip() - return - } - ls := &LocalStorage{ - log: zaptest.NewLogger(t).Sugar(), - workDir: c.dir, - useLock: &sync.Mutex{}, - dirty: abool.NewBool(false), - gcRun: abool.NewBool(false), - binDir: filepath.Join(c.dir, binDirName), - srcDir: filepath.Join(c.dir, srcDirName), - } - if c.before != nil { - assert.NoError(t, c.before(), "c.before() returned an error") - } - defer func() { - if c.after != nil { - assert.NoError(t, c.after(), "c.after() returned an error") - } - }() - err := ls.CreateLocationAndDo(c.artifact, c.data, func(wasmLocation, sourceLocation string) error { - t.Logf("Callback call: %q, %q", wasmLocation, sourceLocation) - return nil - }) - if c.err != "" { - testutil.ContainsError(t, err, c.err) - return - } - require.NoError(t, err) - }) - } -} - -func must(t *testing.T, err error, msg string) { - if err == nil { - return - } - t.Helper() - t.Fatalf("test internal error:\t%s - %s", msg, err) -} - -func strEndsWith(t *testing.T, str string, suffix string) { - t.Helper() - got := str[len(str)-len(suffix):] - require.Equal(t, suffix, got) -} - -func mustArtifactID(t *testing.T, data string) ArtifactID { - t.Helper() - a, err := GetArtifactID([]byte(data)) - require.NoError(t, err) - return a -} diff --git a/internal/compiler/storage/storage.go b/internal/compiler/storage/storage.go deleted file mode 100644 index c2f6429a..00000000 --- a/internal/compiler/storage/storage.go +++ /dev/null @@ -1,30 +0,0 @@ -package storage - -import ( - "errors" - "io" -) - -// ErrNotExists is item not found error -var ErrNotExists = errors.New("item not exists") - -// Callback is location callback -type Callback = func(wasmLocation, sourceLocation string) error - -type ReadCloseSizer interface { - io.ReadCloser - - Size() int64 -} - -// StoreProvider is abstract artifact storage -type StoreProvider interface { - // HasItem checks if item exists - HasItem(id ArtifactID) (bool, error) - - // GetItem returns item by id - GetItem(id ArtifactID) (ReadCloseSizer, error) - - // CreateLocationAndDo creates entry in storage and runs specified callback with new location - CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error -} diff --git a/internal/config/config.go b/internal/config/config.go index bda12e0f..ecc860ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,6 +53,9 @@ type BuildConfig struct { // CleanupInterval is WebAssembly build artifact cache clean interval CleanupInterval time.Duration `envconfig:"APP_CLEAN_INTERVAL" json:"cleanupInterval"` + // SkipModuleCleanup disables Go module cache cleanup. + SkipModuleCleanup bool `envconfig:"APP_SKIP_MOD_CLEANUP" json:"skipModuleCleanup"` + // BypassEnvVarsList is allow-list of environment variables // that will be passed to Go compiler. // @@ -63,6 +66,7 @@ type BuildConfig struct { func (cfg *BuildConfig) mountFlagSet(f *flag.FlagSet) { f.StringVar(&cfg.PackagesFile, "f", "packages.json", "Path to packages index JSON file") f.StringVar(&cfg.BuildDir, "wasm-build-dir", os.TempDir(), "Directory for WASM builds") + f.BoolVar(&cfg.SkipModuleCleanup, "skip-mod-clean", false, "Skip Go module cache cleanup") f.DurationVar(&cfg.CleanupInterval, "clean-interval", 10*time.Minute, "Build directory cleanup interval") f.Var(cmdutil.NewStringsListValue(&cfg.BypassEnvVarsList), "permit-env-vars", "Comma-separated allow list of environment variables passed to Go compiler tool") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 33bfd75d..135c7169 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,6 +24,7 @@ func TestFromFlags(t *testing.T) { PackagesFile: "pkgfile", CleanupInterval: 1 * time.Hour, BypassEnvVarsList: []string{"FOO", "BAR"}, + SkipModuleCleanup: true, }, Services: ServicesConfig{GoogleAnalyticsID: "GA-123456"}, Log: LogConfig{ @@ -52,6 +53,7 @@ func TestFromFlags(t *testing.T) { "-log-format=console", "-sentry-dsn=testdsn", "-sentry-breadcrumbs=1", + "-skip-mod-clean", } fl := flag.NewFlagSet("app", flag.PanicOnError) @@ -106,6 +108,7 @@ func TestFromEnv(t *testing.T) { PackagesFile: "pkgfile", CleanupInterval: 1 * time.Hour, BypassEnvVarsList: []string{"FOO", "BAR"}, + SkipModuleCleanup: true, }, Services: ServicesConfig{GoogleAnalyticsID: "GA-123456"}, Log: LogConfig{ @@ -132,6 +135,7 @@ func TestFromEnv(t *testing.T) { "APP_DEBUG": "1", "APP_LOG_LEVEL": "warn", "APP_LOG_FORMAT": "console", + "APP_SKIP_MOD_CLEANUP": "true", "SENTRY_DSN": "testdsn", "SENTRY_USE_BREADCRUMBS": "1", "SENTRY_BREADCRUMB_LEVEL": "debug", diff --git a/internal/langserver/handler_v1.go b/internal/langserver/handler_v1.go index b1abe6e2..fac8d019 100644 --- a/internal/langserver/handler_v1.go +++ b/internal/langserver/handler_v1.go @@ -11,8 +11,8 @@ import ( "github.com/gorilla/mux" "github.com/x1unix/go-playground/internal/analyzer" - "github.com/x1unix/go-playground/internal/compiler" - "github.com/x1unix/go-playground/internal/compiler/storage" + "github.com/x1unix/go-playground/internal/builder" + "github.com/x1unix/go-playground/internal/builder/storage" "github.com/x1unix/go-playground/pkg/goplay" "go.uber.org/zap" "golang.org/x/time/rate" @@ -39,7 +39,7 @@ type APIv1Handler struct { config ServiceConfig log *zap.SugaredLogger index analyzer.PackageIndex - compiler compiler.BuildService + compiler builder.BuildService versionProvider BackendVersionProvider client *goplay.Client @@ -51,7 +51,7 @@ type ServiceConfig struct { } // NewAPIv1Handler is APIv1Handler constructor -func NewAPIv1Handler(cfg ServiceConfig, client *goplay.Client, packages []*analyzer.Package, builder compiler.BuildService) *APIv1Handler { +func NewAPIv1Handler(cfg ServiceConfig, client *goplay.Client, packages []*analyzer.Package, builder builder.BuildService) *APIv1Handler { return &APIv1Handler{ config: cfg, compiler: builder, @@ -346,14 +346,12 @@ func (s *APIv1Handler) HandleCompile(w http.ResponseWriter, r *http.Request) err } } - result, err := s.compiler.Build(ctx, src) + result, err := s.compiler.Build(ctx, blobToFiles(src)) + if builder.IsBuildError(err) { + return NewHTTPError(http.StatusBadRequest, err) + } if err != nil { - switch err.(type) { - case *compiler.BuildError: - return NewHTTPError(http.StatusBadRequest, err) - default: - return err - } + return err } resp := BuildResponse{FileName: result.FileName} @@ -388,3 +386,7 @@ func (s *APIv1Handler) goImportsCode(ctx context.Context, src []byte, backend go changed := resp.Body != string(src) return []byte(resp.Body), changed, nil } + +func blobToFiles(blob []byte) map[string][]byte { + return map[string][]byte{"main.go": blob} +} diff --git a/internal/langserver/handler_v2.go b/internal/langserver/handler_v2.go index 32efceec..2452413b 100644 --- a/internal/langserver/handler_v2.go +++ b/internal/langserver/handler_v2.go @@ -1,13 +1,16 @@ package langserver import ( + "context" "encoding/json" "errors" "fmt" + "golang.org/x/time/rate" "net/http" + "time" "github.com/gorilla/mux" - "github.com/x1unix/go-playground/internal/compiler" + "github.com/x1unix/go-playground/internal/builder" "github.com/x1unix/go-playground/pkg/goplay" "go.uber.org/zap" ) @@ -16,15 +19,17 @@ var ErrEmptyRequest = errors.New("empty request") type APIv2Handler struct { logger *zap.Logger - compiler compiler.BuildService + compiler builder.BuildService client *goplay.Client + limiter *rate.Limiter } -func NewAPIv2Handler(client *goplay.Client, builder compiler.BuildService) *APIv2Handler { +func NewAPIv2Handler(client *goplay.Client, builder builder.BuildService) *APIv2Handler { return &APIv2Handler{ logger: zap.L().Named("api.v2"), compiler: builder, client: client, + limiter: rate.NewLimiter(rate.Every(frameTime), compileRequestsPerFrame), } } @@ -43,7 +48,7 @@ func (h *APIv2Handler) HandleGetSnippet(w http.ResponseWriter, r *http.Request) } files, err := goplay.SplitFileSet(snippet.Contents, goplay.SplitFileOpts{ - DefaultFileName: snippet.FileName, + DefaultFileName: "main.go", CheckPaths: false, }) if err != nil { @@ -161,14 +166,76 @@ func (h *APIv2Handler) HandleRun(w http.ResponseWriter, r *http.Request) error { return nil } +func (h *APIv2Handler) HandleCompile(w http.ResponseWriter, r *http.Request) error { + // Limit for request timeout + ctx, cancel := context.WithDeadline(r.Context(), time.Now().Add(maxBuildTimeDuration)) + defer cancel() + + // Wait for our queue in line for compilation + if err := h.limiter.Wait(ctx); err != nil { + return NewHTTPError(http.StatusTooManyRequests, err) + } + + files, err := buildFilesFromRequest(r) + if err != nil { + return err + } + + result, err := h.compiler.Build(ctx, files) + if builder.IsBuildError(err) { + return NewHTTPError(http.StatusBadRequest, err) + } + if err != nil { + return err + } + + WriteJSON(w, BuildResponse{ + FileName: result.FileName, + }) + return nil +} + func (h *APIv2Handler) Mount(r *mux.Router) { r.Path("/run").Methods(http.MethodPost).HandlerFunc(WrapHandler(h.HandleRun)) r.Path("/format").Methods(http.MethodPost).HandlerFunc(WrapHandler(h.HandleFormat)) r.Path("/share").Methods(http.MethodPost).HandlerFunc(WrapHandler(h.HandleShare)) r.Path("/share/{id}").Methods(http.MethodGet).HandlerFunc(WrapHandler(h.HandleGetSnippet)) + r.Path("/compile").Methods(http.MethodPost).HandlerFunc(WrapHandler(h.HandleCompile)) } func fileSetFromRequest(r *http.Request) (goplay.FileSet, []string, error) { + body, err := filesPayloadFromRequest(r) + if err != nil { + return goplay.FileSet{}, nil, err + } + + payload := goplay.NewFileSet(goplay.MaxSnippetSize) + fileNames := make([]string, 0, len(body.Files)) + for name, contents := range body.Files { + fileNames = append(fileNames, name) + if err := payload.Add(name, []byte(contents)); err != nil { + return payload, fileNames, NewBadRequestError(err) + } + } + + return payload, fileNames, nil +} + +func buildFilesFromRequest(r *http.Request) (map[string][]byte, error) { + body, err := filesPayloadFromRequest(r) + if err != nil { + return nil, err + } + + files := make(map[string][]byte, len(body.Files)) + for name, contents := range body.Files { + files[name] = []byte(contents) + } + + return files, nil +} + +func filesPayloadFromRequest(r *http.Request) (*FilesPayload, error) { reader := http.MaxBytesReader(nil, r.Body, goplay.MaxSnippetSize) defer reader.Close() @@ -176,24 +243,15 @@ func fileSetFromRequest(r *http.Request) (goplay.FileSet, []string, error) { if err := json.NewDecoder(reader).Decode(body); err != nil { maxBytesErr := new(http.MaxBytesError) if errors.As(err, &maxBytesErr) { - return goplay.FileSet{}, nil, ErrSnippetTooLarge + return nil, ErrSnippetTooLarge } - return goplay.FileSet{}, nil, NewBadRequestError(err) + return nil, NewBadRequestError(err) } if len(body.Files) == 0 { - return goplay.FileSet{}, nil, ErrEmptyRequest + return nil, ErrEmptyRequest } - payload := goplay.NewFileSet(goplay.MaxSnippetSize) - fileNames := make([]string, 0, len(body.Files)) - for name, contents := range body.Files { - fileNames = append(fileNames, name) - if err := payload.Add(name, contents); err != nil { - return payload, fileNames, NewBadRequestError(err) - } - } - - return payload, fileNames, nil + return body, nil } diff --git a/pkg/goplay/files.go b/pkg/goplay/files.go index 2c8ef82f..b32ac869 100644 --- a/pkg/goplay/files.go +++ b/pkg/goplay/files.go @@ -24,8 +24,8 @@ func NewFileSet(bufSize int) FileSet { } // Add adds a file to the buffer. -func (f FileSet) Add(name, src string) error { - if src == "" { +func (f FileSet) Add(name string, src []byte) error { + if len(src) == 0 { return fmt.Errorf("file %q is empty", name) } @@ -45,7 +45,7 @@ func (f FileSet) Add(name, src string) error { f.buf.WriteString("-- ") f.buf.WriteString(name) f.buf.WriteString(" --\n") - f.buf.WriteString(src) + f.buf.Write(src) if !f.hasTrailingNewline() { f.buf.WriteByte('\n') diff --git a/pkg/goplay/files_test.go b/pkg/goplay/files_test.go index dbed00f3..c59f890f 100644 --- a/pkg/goplay/files_test.go +++ b/pkg/goplay/files_test.go @@ -25,7 +25,7 @@ func TestFileSet(t *testing.T) { } for _, entry := range files { - require.NoError(t, fset.Add(entry.name, entry.content)) + require.NoError(t, fset.Add(entry.name, []byte(entry.content))) } expected := fmt.Sprintf( diff --git a/web/src/components/features/workspace/Workspace/utils.ts b/web/src/components/features/workspace/Workspace/utils.ts index 292400dc..e9c47346 100644 --- a/web/src/components/features/workspace/Workspace/utils.ts +++ b/web/src/components/features/workspace/Workspace/utils.ts @@ -1,9 +1,4 @@ -const goModTemplate = ` -// Replace "example" with your actual Go module name. -// See: https://go.dev/doc/modules/gomod-ref - -module example -`.trimStart() +import { goModTemplate } from '~/services/examples' const splitPath = (fileName: string) => { const parts = fileName.split('/') diff --git a/web/src/components/modals/Notification/Notification.tsx b/web/src/components/modals/Notification/Notification.tsx index 405d8e32..56169b38 100644 --- a/web/src/components/modals/Notification/Notification.tsx +++ b/web/src/components/modals/Notification/Notification.tsx @@ -137,8 +137,15 @@ export const Notification: React.FunctionComponent = ({ )} {actions?.length && ( - {actions.map(({ key, ...props }, i) => ( - + {actions.map(({ key, onClick, ...props }, i) => ( + { + onClick?.() + onClose?.() + }} + /> ))} )} diff --git a/web/src/components/modals/Notification/NotificationHost.tsx b/web/src/components/modals/Notification/NotificationHost.tsx index e43d67a4..9547cbdb 100644 --- a/web/src/components/modals/Notification/NotificationHost.tsx +++ b/web/src/components/modals/Notification/NotificationHost.tsx @@ -16,8 +16,8 @@ const NotificationHostBase: React.FunctionComponent = ({ notifications, d {notifications ? Object.entries(notifications).map(([key, notification]) => ( { dispatch?.(newRemoveNotificationAction(key)) }} diff --git a/web/src/services/api/client.ts b/web/src/services/api/client.ts index ff710a4c..050a2051 100644 --- a/web/src/services/api/client.ts +++ b/web/src/services/api/client.ts @@ -33,8 +33,8 @@ export class Client implements IAPIClient { return await this.get(`/suggest?${queryParams}`) } - async build(code: string, format: boolean): Promise { - return await this.post(`/compile?format=${Boolean(format)}`, code) + async build(files: Record): Promise { + return await this.post(`/v2/compile`, { files }) } async getArtifact(fileName: string): Promise { diff --git a/web/src/services/api/interface.ts b/web/src/services/api/interface.ts index 37fa6f4f..c6df9283 100644 --- a/web/src/services/api/interface.ts +++ b/web/src/services/api/interface.ts @@ -20,7 +20,7 @@ export interface IAPIClient { format: (files: Record) => Promise - build: (code: string, format: boolean) => Promise + build: (files: Record) => Promise getArtifact: (fileName: string) => Promise diff --git a/web/src/services/examples/index.ts b/web/src/services/examples/index.ts index 2bc8110d..5c996137 100644 --- a/web/src/services/examples/index.ts +++ b/web/src/services/examples/index.ts @@ -3,5 +3,6 @@ import type { Snippets } from './types' export * from './types.ts' export * from './client.ts' +export * from './utils.ts' export const getSnippetsList = () => snippets as Snippets diff --git a/web/src/services/examples/utils.ts b/web/src/services/examples/utils.ts new file mode 100644 index 00000000..e1fb1de9 --- /dev/null +++ b/web/src/services/examples/utils.ts @@ -0,0 +1,28 @@ +export const goModFile = 'go.mod' + +export const goModTemplate = ` +// Replace "example" with your actual Go module name. +// See: https://go.dev/doc/modules/gomod-ref + +module example +`.trimStart() + +/** + * Checks if passed workspace requires go.mod file. + * + * Returns true if there is a sub-package and no go.mod file. + * @param files + */ +export const isProjectRequiresGoMod = (files: Record) => { + if (goModFile in files) { + return false + } + + for (const fileName in files) { + if (fileName.includes('/')) { + return true + } + } + + return false +} diff --git a/web/src/services/gorepl/service.ts b/web/src/services/gorepl/service.ts index 477a4800..721244ad 100644 --- a/web/src/services/gorepl/service.ts +++ b/web/src/services/gorepl/service.ts @@ -4,7 +4,12 @@ import { ConsoleStreamType } from '~/lib/gowasm/bindings/stdio' import { newErrorAction, newProgramFinishAction, newProgramStartAction, newProgramWriteAction } from '~/store/actions' import { type DispatchFn, type StateProvider } from '~/store/helpers' -import { newAddNotificationAction, newRemoveNotificationAction, NotificationType } from '~/store/notifications' +import { + newAddNotificationAction, + newRemoveNotificationAction, + NotificationType, + NotificationIDs, +} from '~/store/notifications' import { EvalState, type PackageManagerEvent, PMEventType, type ProgramStateChangeEvent } from './worker/types' import { defaultWorkerConfig, @@ -16,9 +21,6 @@ import { type WorkerInterface, } from './worker/interface' -const PKG_MGR_NOTIFICATION_ID = 'packageManager' -const WORKER_NOTIFICATION_ID = 'goWorker' - /** * Worker client singleton */ @@ -70,7 +72,7 @@ export const getWorkerInstance = async ( dispatcher( newAddNotificationAction({ - id: WORKER_NOTIFICATION_ID, + id: NotificationIDs.GoWorkerStatus, type: NotificationType.Info, title: 'Starting Go worker', canDismiss: false, @@ -104,13 +106,13 @@ export const getWorkerInstance = async ( }, }) } catch (err) { - dispatcher(newRemoveNotificationAction(WORKER_NOTIFICATION_ID)) + dispatcher(newRemoveNotificationAction(NotificationIDs.GoWorkerStatus)) worker.terminate() client.dispose() throw err } - dispatcher(newRemoveNotificationAction(WORKER_NOTIFICATION_ID)) + dispatcher(newRemoveNotificationAction(NotificationIDs.GoWorkerStatus)) clientInstance = new WorkerClient(client, worker) // Populate program execution events to Redux @@ -143,7 +145,7 @@ const handleWorkerBootEvent = (dispatcher: DispatchFn, { eventType, progress, co dispatcher(newProgramFinishAction()) dispatcher( newAddNotificationAction({ - id: WORKER_NOTIFICATION_ID, + id: NotificationIDs.GoWorkerStatus, type: NotificationType.Error, title: 'Go worker crashed', description: `WebAssembly worker crashed with exit code ${code}.`, @@ -156,7 +158,7 @@ const handleWorkerBootEvent = (dispatcher: DispatchFn, { eventType, progress, co case GoWorkerBootEventType.Downloading: dispatcher( newAddNotificationAction({ - id: WORKER_NOTIFICATION_ID, + id: NotificationIDs.GoWorkerStatus, type: NotificationType.Info, title: 'Starting Go worker', description: 'Downloading WebAssembly worker...', @@ -171,7 +173,7 @@ const handleWorkerBootEvent = (dispatcher: DispatchFn, { eventType, progress, co case GoWorkerBootEventType.Starting: dispatcher( newAddNotificationAction({ - id: WORKER_NOTIFICATION_ID, + id: NotificationIDs.GoWorkerStatus, type: NotificationType.Info, title: 'Starting Go worker', description: 'Starting WebAssembly worker...', @@ -183,7 +185,7 @@ const handleWorkerBootEvent = (dispatcher: DispatchFn, { eventType, progress, co ) return case GoWorkerBootEventType.Complete: - dispatcher(newRemoveNotificationAction(WORKER_NOTIFICATION_ID)) + dispatcher(newRemoveNotificationAction(NotificationIDs.GoWorkerStatus)) break default: } @@ -220,13 +222,13 @@ const handlePackageManagerEvent = (dispatcher: DispatchFn, event: PackageManager switch (event.eventType) { case PMEventType.DependencyCheckFinish: if (success) { - dispatcher(newRemoveNotificationAction(PKG_MGR_NOTIFICATION_ID)) + dispatcher(newRemoveNotificationAction(NotificationIDs.PackageManager)) return } dispatcher( newAddNotificationAction({ - id: PKG_MGR_NOTIFICATION_ID, + id: NotificationIDs.PackageManager, type: NotificationType.Error, title: 'Package installation failed', description: context, @@ -237,7 +239,7 @@ const handlePackageManagerEvent = (dispatcher: DispatchFn, event: PackageManager case PMEventType.DependencyResolveStart: dispatcher( newAddNotificationAction({ - id: PKG_MGR_NOTIFICATION_ID, + id: NotificationIDs.PackageManager, type: NotificationType.Info, title: 'Installing dependencies', description: `Searching ${totalItems} packages...`, @@ -251,7 +253,7 @@ const handlePackageManagerEvent = (dispatcher: DispatchFn, event: PackageManager case PMEventType.PackageSearchStart: dispatcher( newAddNotificationAction({ - id: PKG_MGR_NOTIFICATION_ID, + id: NotificationIDs.PackageManager, type: NotificationType.Info, title: 'Installing dependencies', description: `Downloading ${context}`, @@ -265,7 +267,7 @@ const handlePackageManagerEvent = (dispatcher: DispatchFn, event: PackageManager case PMEventType.PackageDownload: dispatcher( newAddNotificationAction({ - id: PKG_MGR_NOTIFICATION_ID, + id: NotificationIDs.PackageManager, type: NotificationType.Info, title: 'Installing dependencies', description: `Downloading ${context}`, @@ -280,7 +282,7 @@ const handlePackageManagerEvent = (dispatcher: DispatchFn, event: PackageManager case PMEventType.PackageExtract: dispatcher( newAddNotificationAction({ - id: PKG_MGR_NOTIFICATION_ID, + id: NotificationIDs.PackageManager, type: NotificationType.Info, title: 'Installing dependencies', description: `Extracting ${context}`, diff --git a/web/src/store/dispatchers/build.ts b/web/src/store/dispatchers/build.ts index d95547b8..aeebdaab 100644 --- a/web/src/store/dispatchers/build.ts +++ b/web/src/store/dispatchers/build.ts @@ -4,9 +4,15 @@ import { getImportObject, goRun } from '~/services/go' import { setTimeoutNanos, SECOND } from '~/utils/duration' import { instantiateStreaming } from '~/lib/go' import client, { type EvalEvent, EvalEventKind } from '~/services/api' +import { isProjectRequiresGoMod, goModFile, goModTemplate } from '~/services/examples' import { type DispatchFn, type StateProvider } from '../helpers' -import { newAddNotificationAction, newRemoveNotificationAction, NotificationType } from '../notifications' +import { + newAddNotificationAction, + newRemoveNotificationAction, + NotificationType, + NotificationIDs, +} from '../notifications' import { newErrorAction, newLoadingAction, @@ -17,10 +23,7 @@ import { import { type Dispatcher } from './utils' import { wrapResponseWithProgress } from '~/utils/http' -import { type BulkFileUpdatePayload, WorkspaceAction } from '~/store/workspace/actions' - -const WASM_APP_DOWNLOAD_NOTIFICATION = 'WASM_APP_DOWNLOAD_NOTIFICATION' -const WASM_APP_EXIT_ERROR = 'WASM_APP_EXIT_ERROR' +import { type BulkFileUpdatePayload, type FileUpdatePayload, WorkspaceAction } from '~/store/workspace/actions' /** * Go program execution timeout in nanoseconds @@ -99,7 +102,7 @@ const fetchWasmWithProgress = async (dispatch: DispatchFn, fileName: string) => try { dispatch( newAddNotificationAction({ - id: WASM_APP_DOWNLOAD_NOTIFICATION, + id: NotificationIDs.WASMAppDownload, type: NotificationType.Info, title: 'Downloading compiled program', canDismiss: false, @@ -113,7 +116,7 @@ const fetchWasmWithProgress = async (dispatch: DispatchFn, fileName: string) => const rspWithProgress = wrapResponseWithProgress(rsp, ({ totalBytes, currentBytes }) => { dispatch( newAddNotificationAction({ - id: WASM_APP_DOWNLOAD_NOTIFICATION, + id: NotificationIDs.WASMAppDownload, type: NotificationType.Info, title: 'Downloading compiled application', canDismiss: false, @@ -127,14 +130,14 @@ const fetchWasmWithProgress = async (dispatch: DispatchFn, fileName: string) => return await instantiateStreaming(rspWithProgress, getImportObject()) } catch (err) { - dispatch(newRemoveNotificationAction(WASM_APP_DOWNLOAD_NOTIFICATION)) + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) throw err } } export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getState: StateProvider) => { - dispatch(newLoadingAction()) - dispatch(newRemoveNotificationAction(WASM_APP_EXIT_ERROR)) + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppExitError)) + dispatch(newRemoveNotificationAction(NotificationIDs.GoModMissing)) try { const { @@ -149,6 +152,38 @@ export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getSta return } + if (target !== TargetType.Interpreter && isProjectRequiresGoMod(files)) { + dispatch( + newAddNotificationAction({ + id: NotificationIDs.GoModMissing, + type: NotificationType.Error, + title: 'Go.mod file is missing', + description: 'Go.mod file is required to import sub-packages.', + canDismiss: true, + actions: [ + { + key: 'ok', + label: 'Create go.mod', + primary: true, + onClick: () => { + dispatch({ + type: WorkspaceAction.ADD_FILE, + payload: [ + { + filename: goModFile, + content: goModTemplate, + }, + ], + }) + }, + }, + ], + }), + ) + return + } + + dispatch(newLoadingAction()) if (settings.autoFormat) { const rsp = await client.format(files, backend) files = rsp.files @@ -166,12 +201,10 @@ export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getSta break } case TargetType.WebAssembly: { - // TODO: support ApiV2 - const source = files[selectedFile] - const { fileName } = await client.build(source, settings.autoFormat) + const { fileName } = await client.build(files) const instance = await fetchWasmWithProgress(dispatch, fileName) - dispatch(newRemoveNotificationAction(WASM_APP_DOWNLOAD_NOTIFICATION)) + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) dispatch(newProgramStartAction()) goRun(instance) @@ -181,7 +214,7 @@ export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getSta .catch((err) => { dispatch( newAddNotificationAction({ - id: WASM_APP_EXIT_ERROR, + id: NotificationIDs.WASMAppExitError, type: NotificationType.Error, title: 'Failed to run WebAssembly program', description: err.toString(), @@ -234,7 +267,7 @@ export const createGoLifecycleAdapter = (dispatch: DispatchFn) => ({ dispatch( newAddNotificationAction({ - id: WASM_APP_EXIT_ERROR, + id: NotificationIDs.WASMAppExitError, type: NotificationType.Warning, title: 'Go program finished', description: `Go program exited with non zero code: ${code}`, diff --git a/web/src/store/notifications/index.ts b/web/src/store/notifications/index.ts index c2500e8d..b8ebbce4 100644 --- a/web/src/store/notifications/index.ts +++ b/web/src/store/notifications/index.ts @@ -1,2 +1,3 @@ export * from './state' export * from './actions' +export * from './predefined' diff --git a/web/src/store/notifications/predefined.ts b/web/src/store/notifications/predefined.ts new file mode 100644 index 00000000..03e7dbf2 --- /dev/null +++ b/web/src/store/notifications/predefined.ts @@ -0,0 +1,10 @@ +/** + * List of standard predefined notification IDs. + */ +export enum NotificationIDs { + GoModMissing = 'GoModMissing', + WASMAppDownload = 'WASMDownload', + WASMAppExitError = 'WASMAppExitError', + PackageManager = 'PackageManager', + GoWorkerStatus = 'GoWorkerStatus', +} diff --git a/web/src/store/workspace/dispatchers/snippet.ts b/web/src/store/workspace/dispatchers/snippet.ts index 5f6500f3..ccef9043 100644 --- a/web/src/store/workspace/dispatchers/snippet.ts +++ b/web/src/store/workspace/dispatchers/snippet.ts @@ -6,6 +6,7 @@ import { newAddNotificationAction, newNotificationId, newRemoveNotificationAction, + NotificationIDs, NotificationType, } from '~/store/notifications' import { newLoadingAction, newErrorAction, newUIStateChangeAction } from '~/store/actions/ui' @@ -18,6 +19,7 @@ import { loadWorkspaceState } from '../config' * @param source */ export const dispatchLoadSnippetFromSource = (source: SnippetSource) => async (dispatch: DispatchFn) => { + dispatch(newRemoveNotificationAction(NotificationIDs.GoModMissing)) dispatch({ type: WorkspaceAction.SNIPPET_LOAD_START, payload: source.basePath, @@ -59,6 +61,7 @@ export const dispatchLoadSnippet = return } + dispatch(newRemoveNotificationAction(NotificationIDs.GoModMissing)) const { workspace: { snippet }, ui,