blob: e0e113f4914a57f58422c0b6132f05e75c3ce82b [file] [log] [blame]
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package fake
import (
"context"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/tools/internal/gocommand"
"golang.org/x/tools/internal/testenv"
"golang.org/x/tools/txtar"
)
// Sandbox holds a collection of temporary resources to use for working with Go
// code in tests.
type Sandbox struct {
gopath string
rootdir string
goproxy string
Workdir *Workdir
goCommandRunner gocommand.Runner
}
// SandboxConfig controls the behavior of a test sandbox. The zero value
// defines a reasonable default.
type SandboxConfig struct {
// RootDir sets the base directory to use when creating temporary
// directories. If not specified, defaults to a new temporary directory.
RootDir string
// Files holds a txtar-encoded archive of files to populate the initial state
// of the working directory.
//
// For convenience, the special substring "$SANDBOX_WORKDIR" is replaced with
// the sandbox's resolved working directory before writing files.
Files map[string][]byte
// InGoPath specifies that the working directory should be within the
// temporary GOPATH.
InGoPath bool
// Workdir configures the working directory of the Sandbox. It behaves as
// follows:
// - if set to an absolute path, use that path as the working directory.
// - if set to a relative path, create and use that path relative to the
// sandbox.
// - if unset, default to a the 'work' subdirectory of the sandbox.
//
// This option is incompatible with InGoPath or Files.
Workdir string
// ProxyFiles holds a txtar-encoded archive of files to populate a file-based
// Go proxy.
ProxyFiles map[string][]byte
// GOPROXY is the explicit GOPROXY value that should be used for the sandbox.
//
// This option is incompatible with ProxyFiles.
GOPROXY string
}
// NewSandbox creates a collection of named temporary resources, with a
// working directory populated by the txtar-encoded content in srctxt, and a
// file-based module proxy populated with the txtar-encoded content in
// proxytxt.
//
// If rootDir is non-empty, it will be used as the root of temporary
// directories created for the sandbox. Otherwise, a new temporary directory
// will be used as root.
//
// TODO(rfindley): the sandbox abstraction doesn't seem to carry its weight.
// Sandboxes should be composed out of their building-blocks, rather than via a
// monolithic configuration.
func NewSandbox(config *SandboxConfig) (_ *Sandbox, err error) {
if config == nil {
config = new(SandboxConfig)
}
if err := validateConfig(*config); err != nil {
return nil, fmt.Errorf("invalid SandboxConfig: %v", err)
}
sb := &Sandbox{}
defer func() {
// Clean up if we fail at any point in this constructor.
if err != nil {
sb.Close()
}
}()
rootDir := config.RootDir
if rootDir == "" {
rootDir, err = ioutil.TempDir(config.RootDir, "gopls-sandbox-")
if err != nil {
return nil, fmt.Errorf("creating temporary workdir: %v", err)
}
}
sb.rootdir = rootDir
sb.gopath = filepath.Join(sb.rootdir, "gopath")
if err := os.Mkdir(sb.gopath, 0755); err != nil {
return nil, err
}
if config.GOPROXY != "" {
sb.goproxy = config.GOPROXY
} else {
proxydir := filepath.Join(sb.rootdir, "proxy")
if err := os.Mkdir(proxydir, 0755); err != nil {
return nil, err
}
sb.goproxy, err = WriteProxy(proxydir, config.ProxyFiles)
if err != nil {
return nil, err
}
}
// Short-circuit writing the workdir if we're given an absolute path, since
// this is used for running in an existing directory.
// TODO(findleyr): refactor this to be less of a workaround.
if filepath.IsAbs(config.Workdir) {
sb.Workdir = NewWorkdir(config.Workdir)
return sb, nil
}
var workdir string
if config.Workdir == "" {
if config.InGoPath {
// Set the working directory as $GOPATH/src.
workdir = filepath.Join(sb.gopath, "src")
} else if workdir == "" {
workdir = filepath.Join(sb.rootdir, "work")
}
} else {
// relative path
workdir = filepath.Join(sb.rootdir, config.Workdir)
}
if err := os.MkdirAll(workdir, 0755); err != nil {
return nil, err
}
sb.Workdir = NewWorkdir(workdir)
if err := sb.Workdir.writeInitialFiles(config.Files); err != nil {
return nil, err
}
return sb, nil
}
// Tempdir creates a new temp directory with the given txtar-encoded files. It
// is the responsibility of the caller to call os.RemoveAll on the returned
// file path when it is no longer needed.
func Tempdir(files map[string][]byte) (string, error) {
dir, err := ioutil.TempDir("", "gopls-tempdir-")
if err != nil {
return "", err
}
for name, data := range files {
if err := WriteFileData(name, data, RelativeTo(dir)); err != nil {
return "", fmt.Errorf("writing to tempdir: %w", err)
}
}
return dir, nil
}
func UnpackTxt(txt string) map[string][]byte {
dataMap := make(map[string][]byte)
archive := txtar.Parse([]byte(txt))
for _, f := range archive.Files {
if _, ok := dataMap[f.Name]; ok {
panic(fmt.Sprintf("found file %q twice", f.Name))
}
dataMap[f.Name] = f.Data
}
return dataMap
}
func validateConfig(config SandboxConfig) error {
if filepath.IsAbs(config.Workdir) && (len(config.Files) > 0 || config.InGoPath) {
return errors.New("absolute Workdir cannot be set in conjunction with Files or InGoPath")
}
if config.Workdir != "" && config.InGoPath {
return errors.New("Workdir cannot be set in conjunction with InGoPath")
}
if config.GOPROXY != "" && config.ProxyFiles != nil {
return errors.New("GOPROXY cannot be set in conjunction with ProxyFiles")
}
return nil
}
// splitModuleVersionPath extracts module information from files stored in the
// directory structure modulePath@version/suffix.
// For example:
//
// splitModuleVersionPath("mod.com@v1.2.3/package") = ("mod.com", "v1.2.3", "package")
func splitModuleVersionPath(path string) (modulePath, version, suffix string) {
parts := strings.Split(path, "/")
var modulePathParts []string
for i, p := range parts {
if strings.Contains(p, "@") {
mv := strings.SplitN(p, "@", 2)
modulePathParts = append(modulePathParts, mv[0])
return strings.Join(modulePathParts, "/"), mv[1], strings.Join(parts[i+1:], "/")
}
modulePathParts = append(modulePathParts, p)
}
// Default behavior: this is just a module path.
return path, "", ""
}
func (sb *Sandbox) RootDir() string {
return sb.rootdir
}
// GOPATH returns the value of the Sandbox GOPATH.
func (sb *Sandbox) GOPATH() string {
return sb.gopath
}
// GoEnv returns the default environment variables that can be used for
// invoking Go commands in the sandbox.
func (sb *Sandbox) GoEnv() map[string]string {
vars := map[string]string{
"GOPATH": sb.GOPATH(),
"GOPROXY": sb.goproxy,
"GO111MODULE": "",
"GOSUMDB": "off",
"GOPACKAGESDRIVER": "off",
}
if testenv.Go1Point() >= 5 {
vars["GOMODCACHE"] = ""
}
return vars
}
// goCommandInvocation returns a new gocommand.Invocation initialized with the
// sandbox environment variables and working directory.
func (sb *Sandbox) goCommandInvocation() gocommand.Invocation {
var vars []string
for k, v := range sb.GoEnv() {
vars = append(vars, fmt.Sprintf("%s=%s", k, v))
}
inv := gocommand.Invocation{
Env: vars,
}
// sb.Workdir may be nil if we exited the constructor with errors (we call
// Close to clean up any partial state from the constructor, which calls
// RunGoCommand).
if sb.Workdir != nil {
inv.WorkingDir = string(sb.Workdir.RelativeTo)
}
return inv
}
// RunGoCommand executes a go command in the sandbox. If checkForFileChanges is
// true, the sandbox scans the working directory and emits file change events
// for any file changes it finds.
func (sb *Sandbox) RunGoCommand(ctx context.Context, dir, verb string, args []string, checkForFileChanges bool) error {
inv := sb.goCommandInvocation()
inv.Verb = verb
inv.Args = args
if dir != "" {
inv.WorkingDir = sb.Workdir.AbsPath(dir)
}
stdout, stderr, _, err := sb.goCommandRunner.RunRaw(ctx, inv)
if err != nil {
return fmt.Errorf("go command failed (stdout: %s) (stderr: %s): %v", stdout.String(), stderr.String(), err)
}
// Since running a go command may result in changes to workspace files,
// check if we need to send any any "watched" file events.
//
// TODO(rFindley): this side-effect can impact the usability of the sandbox
// for benchmarks. Consider refactoring.
if sb.Workdir != nil && checkForFileChanges {
if err := sb.Workdir.CheckForFileChanges(ctx); err != nil {
return fmt.Errorf("checking for file changes: %w", err)
}
}
return nil
}
// GoVersion checks the version of the go command.
// It returns the X in Go 1.X.
func (sb *Sandbox) GoVersion(ctx context.Context) (int, error) {
inv := sb.goCommandInvocation()
return gocommand.GoVersion(ctx, inv, &sb.goCommandRunner)
}
// Close removes all state associated with the sandbox.
func (sb *Sandbox) Close() error {
var goCleanErr error
if sb.gopath != "" {
goCleanErr = sb.RunGoCommand(context.Background(), "", "clean", []string{"-modcache"}, false)
}
err := removeAll(sb.rootdir)
if err != nil || goCleanErr != nil {
return fmt.Errorf("error(s) cleaning sandbox: cleaning modcache: %v; removing files: %v", goCleanErr, err)
}
return nil
}
// removeAll is copied from GOROOT/src/testing/testing.go
//
// removeAll is like os.RemoveAll, but retries Windows "Access is denied."
// errors up to an arbitrary timeout.
//
// See https://go.dev/issue/50051 for additional context.
func removeAll(path string) error {
const arbitraryTimeout = 2 * time.Second
var (
start time.Time
nextSleep = 1 * time.Millisecond
)
for {
err := os.RemoveAll(path)
if !isWindowsRetryable(err) {
return err
}
if start.IsZero() {
start = time.Now()
} else if d := time.Since(start) + nextSleep; d >= arbitraryTimeout {
return err
}
time.Sleep(nextSleep)
nextSleep += time.Duration(rand.Int63n(int64(nextSleep)))
}
}