sweet: add end-to-end integration test

This change adds an integration test for Sweet that 1) obtains assets,
2) generates a subset of assets (that don't take an obscenely long
time), 3) runs all the benchmarks exactly once under short mode.

Currently this test takes about 6 minutes to execute. It also uses just
over 4 GiB of memory and about 6 GiB of disk space.

Change-Id: Ia70efd92f0bd70f218b405aff7eb5a9e8ee4c912
Reviewed-on: https://go-review.googlesource.com/c/benchmarks/+/378276
Reviewed-by: Michael Pratt <mpratt@google.com>
Trust: Michael Knyszek <mknyszek@google.com>
Run-TryBot: Michael Knyszek <mknyszek@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/go.mod b/go.mod
index e568648..67892fa 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@
 
 require (
 	cloud.google.com/go/storage v1.18.2
-	github.com/BurntSushi/toml v0.3.1
+	github.com/BurntSushi/toml v1.0.0
 	github.com/biogo/biogo v1.0.4
 	github.com/biogo/graph v0.0.0-20150317020928-057c1989faed
 	github.com/biogo/store v0.0.0-20201120204734-aad293a2328f
diff --git a/go.sum b/go.sum
index 6a3614d..b0d9931 100644
--- a/go.sum
+++ b/go.sum
@@ -48,6 +48,8 @@
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
+github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
diff --git a/sweet/cmd/sweet/integration_test.go b/sweet/cmd/sweet/integration_test.go
new file mode 100644
index 0000000..1af2785
--- /dev/null
+++ b/sweet/cmd/sweet/integration_test.go
@@ -0,0 +1,198 @@
+// Copyright 2021 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 main_test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+	"testing"
+
+	"golang.org/x/benchmarks/sweet/common"
+	"golang.org/x/sync/semaphore"
+)
+
+func TestSweetEndToEnd(t *testing.T) {
+	if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
+		t.Skip("Sweet is currently only fully supported on linux/amd64")
+	}
+	if testing.Short() {
+		t.Skip("the full Sweet end-to-end experience takes several minutes")
+	}
+	goRoot := os.Getenv("GOROOT")
+	if goRoot == "" {
+		data, err := exec.Command("go", "env", "GOROOT").Output()
+		if err != nil {
+			t.Fatalf("failed to find a GOROOT: %v", err)
+		}
+		goRoot = strings.TrimSpace(string(data))
+	}
+	goTool := &common.Go{
+		Tool: filepath.Join(goRoot, "bin", "go"),
+		Env:  common.NewEnvFromEnviron(),
+	}
+
+	// Build sweet.
+	wd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	sweetRoot := filepath.Dir(filepath.Dir(wd))
+	sweetBin := filepath.Join(sweetRoot, "sweet")
+	if err := goTool.BuildPath(filepath.Join(sweetRoot, "cmd", "sweet"), sweetBin); err != nil {
+		t.Fatal(err)
+	}
+	// We're on a builder, so arrange all this a little differently.
+	// Let's do all our work in the work directory which has a lot
+	// more headroom, and put the compressed assets in /tmp.
+	var tmpDir, assetsCacheDir string
+	if os.Getenv("GO_BUILDER_NAME") != "" {
+		tmpDir = filepath.Join(sweetRoot, "tmp")
+		if err := os.Mkdir(tmpDir, 0777); err != nil {
+			t.Fatal(err)
+		}
+		// Be explicit that we want /tmp, because the builder is
+		// going to try and give us /workdir/tmp which will not
+		// have enough space for us.
+		assetsCacheDir = filepath.Join("/", "tmp", "go-sweet-assets")
+		defer func() {
+			if err := os.RemoveAll(assetsCacheDir); err != nil {
+				t.Errorf("clearing assets cache directory: %v", err)
+			}
+		}()
+	} else {
+		tmpDir, err = os.MkdirTemp("", "go-sweet-test")
+		if err != nil {
+			t.Fatal(err)
+		}
+		assetsCacheDir = filepath.Join(tmpDir, "assets")
+	}
+	defer func() {
+		if err := os.RemoveAll(tmpDir); err != nil {
+			t.Errorf("clearing tmp directory: %v", err)
+		}
+	}()
+
+	// Download assets.
+	getCmd := exec.Command(sweetBin, "get",
+		"-auth", "none",
+		"-cache", assetsCacheDir, // Make a full copy so we can mutate it.
+		"-assets-hash-file", filepath.Join(sweetRoot, "assets.hash"),
+	)
+	if output, err := getCmd.CombinedOutput(); err != nil {
+		t.Logf("command output:\n%s", string(output))
+		t.Fatal(err)
+	}
+
+	// TODO(mknyszek): Test regenerating assets. As it stands, the following
+	// parts of the test will fail if the source assets change, since they're
+	// prebuilt and baked into the assets archive. The only recourse is to
+	// first upload the new archive with the prebuilt assets (i.e. run sweet
+	// gen locally), bump the version, and then upload it (i.e. sweet put).
+
+	// Run each benchmark once.
+	benchDir := filepath.Join(sweetRoot, "benchmarks")
+	cfgPath := makeConfigFile(t, goRoot)
+
+	var outputMu sync.Mutex
+	runShard := func(shard, resultsDir, workDir string) {
+		runCmd := exec.Command(sweetBin, "run",
+			"-run", shard,
+			"-shell",
+			"-count", "1",
+			"-cache", assetsCacheDir,
+			"-bench-dir", benchDir,
+			"-results", resultsDir,
+			"-work-dir", workDir,
+			"-short",
+			cfgPath,
+		)
+		output, runErr := runCmd.CombinedOutput()
+
+		outputMu.Lock()
+		defer outputMu.Unlock()
+
+		// Poke at the results directory.
+		matches, err := filepath.Glob(filepath.Join(resultsDir, "*", "go.results"))
+		if err != nil {
+			t.Errorf("failed to search results directory for results: %v", err)
+		}
+		if len(matches) == 0 {
+			t.Log("no results produced.")
+		}
+
+		// Dump additional information in case of error, and
+		// check for reasonable results in the case of no error.
+		for _, match := range matches {
+			benchmark := filepath.Base(filepath.Dir(match))
+			if runErr != nil {
+				t.Logf("output for %s:", benchmark)
+			}
+			data, err := os.ReadFile(match)
+			if err != nil {
+				t.Errorf("failed to read results for %si: %v", benchmark, err)
+				continue
+			}
+			if runErr != nil {
+				t.Log(string(data))
+				continue
+			}
+			// TODO(mknyszek): Check to make sure the results look reasonable.
+		}
+		if runErr != nil {
+			t.Logf("command output:\n%s", string(output))
+			t.Error(runErr)
+		}
+	}
+	// Limit parallelism to conserve memory.
+	sema := semaphore.NewWeighted(2)
+	var wg sync.WaitGroup
+	for i, shard := range []string{
+		"tile38", "go-build", "biogo-igor", "biogo-krishna", "bleve-query",
+		"gvisor", "fogleman-pt", "bleve-index,fogleman-fauxgl,gopher-lua,markdown",
+	} {
+		sema.Acquire(context.Background(), 1)
+		wg.Add(1)
+		go func(i int, shard string) {
+			defer sema.Release(1)
+			defer wg.Done()
+			resultsDir := filepath.Join(tmpDir, fmt.Sprintf("results-%d", i))
+			workDir := filepath.Join(tmpDir, fmt.Sprintf("tmp-%d", i))
+			runShard(shard, resultsDir, workDir)
+		}(i, shard)
+	}
+	wg.Wait()
+}
+
+func makeConfigFile(t *testing.T, goRoot string) string {
+	t.Helper()
+
+	f, err := os.CreateTemp("", "config.toml")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	cfg := common.ConfigFile{
+		Configs: []*common.Config{
+			&common.Config{
+				Name:   "go",
+				GoRoot: goRoot,
+			},
+		},
+	}
+	b, err := common.ConfigFileMarshalTOML(&cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := f.Write(b); err != nil {
+		t.Fatal(err)
+	}
+	return f.Name()
+}
diff --git a/sweet/common/config.go b/sweet/common/config.go
index 63c88c8..3c000a1 100644
--- a/sweet/common/config.go
+++ b/sweet/common/config.go
@@ -5,8 +5,11 @@
 package common
 
 import (
+	"bytes"
 	"fmt"
 	"path/filepath"
+
+	"github.com/BurntSushi/toml"
 )
 
 const ConfigHelp = `
@@ -53,6 +56,40 @@
 	}
 }
 
+func ConfigFileMarshalTOML(c *ConfigFile) ([]byte, error) {
+	// Unfortunately because the github.com/BurntSushi/toml
+	// package at v1.0.0 doesn't correctly support Marshaler
+	// (see https://github.com/BurntSushi/toml/issues/341)
+	// we can't actually implement Marshaler for ConfigEnv.
+	// So instead we work around this by implementing MarshalTOML
+	// on Config and use dummy types that have a straightforward
+	// mapping that *does* work.
+	type config struct {
+		Name     string   `toml:"name"`
+		GoRoot   string   `toml:"goroot"`
+		BuildEnv []string `toml:"envbuild"`
+		ExecEnv  []string `toml:"envexec"`
+	}
+	type configFile struct {
+		Configs []*config `toml:"config"`
+	}
+	var cfgs configFile
+	for _, c := range c.Configs {
+		var cfg config
+		cfg.Name = c.Name
+		cfg.GoRoot = c.GoRoot
+		cfg.BuildEnv = c.BuildEnv.Collapse()
+		cfg.ExecEnv = c.ExecEnv.Collapse()
+
+		cfgs.Configs = append(cfgs.Configs, &cfg)
+	}
+	var b bytes.Buffer
+	if err := toml.NewEncoder(&b).Encode(&cfgs); err != nil {
+		return nil, err
+	}
+	return b.Bytes(), nil
+}
+
 type ConfigEnv struct {
 	*Env
 }
diff --git a/sweet/common/config_test.go b/sweet/common/config_test.go
new file mode 100644
index 0000000..22b4e06
--- /dev/null
+++ b/sweet/common/config_test.go
@@ -0,0 +1,83 @@
+// Copyright 2021 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 common_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/BurntSushi/toml"
+	"golang.org/x/benchmarks/sweet/common"
+)
+
+func TestConfigMarshalTOML(t *testing.T) {
+	cfgsBefore := common.ConfigFile{
+		Configs: []*common.Config{
+			&common.Config{
+				Name:   "go",
+				GoRoot: "/path/to/my/goroot",
+				// The unmarashaler propagates the environment,
+				// so to make sure this works, let's also seed
+				// from the environment.
+				BuildEnv: common.ConfigEnv{common.NewEnvFromEnviron()},
+				ExecEnv:  common.ConfigEnv{common.NewEnvFromEnviron()},
+			},
+		},
+	}
+	b, err := common.ConfigFileMarshalTOML(&cfgsBefore)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var cfgsAfter common.ConfigFile
+	if err := toml.Unmarshal(b, &cfgsAfter); err != nil {
+		t.Fatal(err)
+	}
+	if l := len(cfgsAfter.Configs); l != len(cfgsBefore.Configs) {
+		t.Fatalf("unexpected number of configs: got %d, want %d", l, len(cfgsBefore.Configs))
+	}
+	for i := range cfgsAfter.Configs {
+		cfgBefore := cfgsBefore.Configs[i]
+		cfgAfter := cfgsAfter.Configs[i]
+
+		if cfgBefore.Name != cfgAfter.Name {
+			t.Fatalf("unexpected name: got %s, want %s", cfgAfter.Name, cfgBefore.Name)
+		}
+		if cfgBefore.GoRoot != cfgAfter.GoRoot {
+			t.Fatalf("unexpected GOROOT: got %s, want %s", cfgAfter.GoRoot, cfgBefore.GoRoot)
+		}
+		compareEnvs(t, cfgBefore.BuildEnv.Env, cfgAfter.BuildEnv.Env)
+		compareEnvs(t, cfgBefore.ExecEnv.Env, cfgAfter.ExecEnv.Env)
+	}
+}
+
+func compareEnvs(t *testing.T, a, b *common.Env) {
+	t.Helper()
+
+	aIndex := makeEnvIndex(a)
+	bIndex := makeEnvIndex(b)
+	for aKey, aVal := range aIndex {
+		if bVal, ok := bIndex[aKey]; !ok {
+			t.Errorf("%s in A but not B", aKey)
+		} else if aVal != bVal {
+			t.Errorf("%s has value %s A but %s in B", aKey, aVal, bVal)
+		}
+	}
+	for bKey := range bIndex {
+		if _, ok := aIndex[bKey]; !ok {
+			t.Errorf("%s in B but not A", bKey)
+		}
+		// Don't check values that exist in both. We got that already
+		// in the first pass.
+	}
+}
+
+func makeEnvIndex(a *common.Env) map[string]string {
+	index := make(map[string]string)
+	for _, s := range a.Collapse() {
+		d := strings.IndexRune(s, '=')
+		index[s[:d]] = s[d+1:]
+	}
+	return index
+}