internal/configstore: add a package for upload config download

Telemetry upload config is a Go module (golang.org/x/telemetry/config).
that can be downloaded with `go mod download`. This allows telemetry
configs to be cacheable, and verifiable like other Go modules.
Moreover, 'go mod download' can download the config directly from
the source repository, so we don't need a separate config serving
infra.

internal/proxy is a helper that builds a file-system based Go
module proxy used for testing.

Change-Id: I299946943fce05561879dfb05addec47404d6a32
Reviewed-on: https://go-review.googlesource.com/c/telemetry/+/499255
Reviewed-by: Jamal Carvalho <jamal@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Auto-Submit: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/go.mod b/go.mod
index f427f76..b0876d2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
 module golang.org/x/telemetry
 
 go 1.20
+
+require golang.org/x/mod v0.10.0
diff --git a/go.sum b/go.sum
index e69de29..0b96b7e 100644
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
+golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
diff --git a/internal/configstore/download.go b/internal/configstore/download.go
new file mode 100644
index 0000000..f8c657c
--- /dev/null
+++ b/internal/configstore/download.go
@@ -0,0 +1,70 @@
+// Copyright 2023 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 configstore abstracts interaction with the telemetry config server.
+// Telemetry config (golang.org/x/telemetry/config) is distributed as a go
+// module containing go.mod and config.json. Programs that upload collected
+// counters download the latest config using `go mod download`. This provides
+// verification of downloaded configuration and cacheability.
+package configstore
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"golang.org/x/telemetry"
+)
+
+const (
+	configModulePath = "golang.org/x/telemetry/config"
+	configFileName   = "config.json"
+)
+
+// DownloadOption is an option for Download.
+type DownloadOption struct {
+	// Env holds the environment variables used when downloading the configuration.
+	// If nil, the process's environment variables are used.
+	Env []string
+}
+
+// Download fetches the requested telemetry UploadConfig using "go mod download".
+func Download(version string, opts *DownloadOption) (telemetry.UploadConfig, error) {
+	if version == "" {
+		version = "latest"
+	}
+	if opts == nil {
+		opts = &DownloadOption{}
+	}
+	modVer := configModulePath + "@" + version
+	var stdout, stderr bytes.Buffer
+	cmd := exec.Command("go", "mod", "download", "-json", modVer)
+	cmd.Env = opts.Env
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return telemetry.UploadConfig{}, fmt.Errorf("failed to download config module: %w\n%s", err, &stderr)
+	}
+
+	var info struct {
+		Dir     string
+		Version string
+	}
+	if err := json.Unmarshal(stdout.Bytes(), &info); err != nil || info.Dir == "" {
+		return telemetry.UploadConfig{}, fmt.Errorf("failed to download config module (invalid JSON): %w", err)
+	}
+	data, err := os.ReadFile(filepath.Join(info.Dir, configFileName))
+	if err != nil {
+		return telemetry.UploadConfig{}, fmt.Errorf("invalid config module: %w", err)
+	}
+	var cfg telemetry.UploadConfig
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return telemetry.UploadConfig{}, fmt.Errorf("invalid config: %w", err)
+	}
+	cfg.Version = info.Version
+	return cfg, nil
+}
diff --git a/internal/configstore/download_test.go b/internal/configstore/download_test.go
new file mode 100644
index 0000000..ec18880
--- /dev/null
+++ b/internal/configstore/download_test.go
@@ -0,0 +1,102 @@
+// Copyright 2023 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 configstore
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"reflect"
+	"testing"
+
+	"golang.org/x/telemetry"
+	"golang.org/x/telemetry/internal/proxy"
+	"golang.org/x/telemetry/internal/testenv"
+)
+
+func TestDownload(t *testing.T) {
+	testenv.NeedsGo(t)
+	tmpdir := t.TempDir()
+	defer cleanModuleCache(t, tmpdir)
+
+	configVersion := "v0.0.0-20230526221463-e8d11ddaba41"
+	in := telemetry.UploadConfig{
+		GOOS:      []string{"darwin"},
+		GOARCH:    []string{"amd64", "arm64"},
+		GoVersion: []string{"1.20.3", "1.20.4"},
+		Programs: []*telemetry.ProgramConfig{{
+			Name:     "gopls",
+			Versions: []string{"v0.11.0"},
+			Counters: []telemetry.CounterConfig{{
+				Name: "foobar",
+				Rate: 2,
+			}},
+		}},
+	}
+
+	proxyURI, err := writeConfig(tmpdir, in, configVersion)
+	if err != nil {
+		t.Fatal("failed to prepare proxy:", err)
+	}
+
+	opts := testDownloadOption(proxyURI, tmpdir)
+
+	got, err := Download("latest", opts)
+	if err != nil {
+		t.Fatal("failed to download the latest version:", err)
+	}
+
+	want := in
+	want.Version = configVersion // want the Version field to be populated with the module version.
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("Download(latest, _) = %v\nwant %v", stringify(got), stringify(want))
+	}
+}
+
+func stringify(x any) string {
+	ret, err := json.MarshalIndent(x, "", " ")
+	if err != nil {
+		return fmt.Sprintf("json.Marshal failed - %v", err)
+	}
+	return string(ret)
+}
+
+// writeConfig adds cfg to the module proxy used for testing.
+func writeConfig(dir string, cfg telemetry.UploadConfig, version string) (proxyURI string, _ error) {
+	encoded, err := json.Marshal(cfg)
+	if err != nil {
+		return "", err
+	}
+	dirPath := fmt.Sprintf("%v@%v/", configModulePath, version)
+	files := map[string][]byte{
+		dirPath + "go.mod":      []byte("module " + configModulePath + "\n\ngo 1.20\n"),
+		dirPath + "config.json": encoded,
+	}
+	return proxy.WriteProxy(dir, files)
+}
+
+func testDownloadOption(proxyURI, tmpDir string) *DownloadOption {
+	var env []string
+	env = append(env, os.Environ()...)
+	env = append(env,
+		"GOPROXY="+proxyURI,  // Use the fake proxy.
+		"GONOSUMDB=*",        // Skip verifying checksum against sum.golang.org.
+		"GOMODCACHE="+tmpDir, // Don't pollute system module cache.
+	)
+	return &DownloadOption{
+		Env: env,
+	}
+}
+
+func cleanModuleCache(t *testing.T, tmpDir string) {
+	t.Helper()
+	cmd := exec.Command("go", "clean", "-modcache")
+	cmd.Env = append(cmd.Environ(), "GOMODCACHE="+tmpDir)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Errorf("go clean -modcache failed: %v\n%s", err, out)
+	}
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
new file mode 100644
index 0000000..9f5b677
--- /dev/null
+++ b/internal/proxy/proxy.go
@@ -0,0 +1,144 @@
+// Copyright 2023 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 proxy provides functions for writing module data to a directory
+// in proxy format, so that it can be used as a module proxy by setting
+// GOPROXY="file://<dir>".
+// This is copied from golang.org/x/tools/gopls/internal/{proxydir,proxy}.
+package proxy
+
+import (
+	"archive/zip"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/mod/module"
+)
+
+// WriteProxy creates a new proxy file tree using the txtar-encoded content,
+// and returns its URL.
+func WriteProxy(tmpdir string, files map[string][]byte) (string, error) {
+	type moduleVersion struct {
+		modulePath, version string
+	}
+	// Transform into the format expected by the proxydir package.
+	filesByModule := make(map[moduleVersion]map[string][]byte)
+	for name, data := range files {
+		modulePath, version, suffix := splitModuleVersionPath(name)
+		mv := moduleVersion{modulePath, version}
+		if _, ok := filesByModule[mv]; !ok {
+			filesByModule[mv] = make(map[string][]byte)
+		}
+		filesByModule[mv][suffix] = data
+	}
+	for mv, files := range filesByModule {
+		if err := writeModuleVersion(tmpdir, mv.modulePath, mv.version, files); err != nil {
+			return "", fmt.Errorf("error writing %s@%s: %v", mv.modulePath, mv.version, err)
+		}
+	}
+	return toURL(tmpdir), 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, "", ""
+}
+
+// writeModuleVersion creates a directory in the proxy dir for a module.
+func writeModuleVersion(rootDir, mod, ver string, files map[string][]byte) (rerr error) {
+	dir := filepath.Join(rootDir, mod, "@v")
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	// The go command checks for versions by looking at the "list" file.  Since
+	// we are supporting multiple versions, create this file if it does not exist
+	// or append the version number to the preexisting file.
+
+	f, err := os.OpenFile(filepath.Join(dir, "list"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+	if err != nil {
+		return err
+	}
+	defer checkClose("list file", f, &rerr)
+	if _, err := f.WriteString(ver + "\n"); err != nil {
+		return err
+	}
+
+	// Serve the go.mod file on the <version>.mod url, if it exists. Otherwise,
+	// serve a stub.
+	modContents, ok := files["go.mod"]
+	if !ok {
+		modContents = []byte("module " + mod)
+	}
+	if err := os.WriteFile(filepath.Join(dir, ver+".mod"), modContents, 0644); err != nil {
+		return err
+	}
+
+	// info file, just the bare bones.
+	infoContents := []byte(fmt.Sprintf(`{"Version": "%v", "Time":"2017-12-14T13:08:43Z"}`, ver))
+	if err := os.WriteFile(filepath.Join(dir, ver+".info"), infoContents, 0644); err != nil {
+		return err
+	}
+
+	// zip of all the source files.
+	f, err = os.OpenFile(filepath.Join(dir, ver+".zip"), os.O_CREATE|os.O_WRONLY, 0644)
+	if err != nil {
+		return err
+	}
+	defer checkClose("zip file", f, &rerr)
+	z := zip.NewWriter(f)
+	defer checkClose("zip writer", z, &rerr)
+	for name, contents := range files {
+		zf, err := z.Create(mod + "@" + ver + "/" + name)
+		if err != nil {
+			return err
+		}
+		if _, err := zf.Write(contents); err != nil {
+			return err
+		}
+	}
+
+	// Populate the /module/path/@latest that is used by @latest query.
+	if module.IsPseudoVersion(ver) {
+		latestFile := filepath.Join(rootDir, mod, "@latest")
+		if err := os.WriteFile(latestFile, infoContents, 0644); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func checkClose(name string, closer io.Closer, err *error) {
+	if cerr := closer.Close(); cerr != nil && *err == nil {
+		*err = fmt.Errorf("closing %s: %v", name, cerr)
+	}
+}
+
+// toURL returns the file uri for a proxy directory.
+func toURL(dir string) string {
+	// file URLs on Windows must start with file:///. See golang.org/issue/6027.
+	path := filepath.ToSlash(dir)
+	if !strings.HasPrefix(path, "/") {
+		path = "/" + path
+	}
+	return "file://" + path
+}
diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go
new file mode 100644
index 0000000..79ded1d
--- /dev/null
+++ b/internal/proxy/proxy_test.go
@@ -0,0 +1,109 @@
+// Copyright 2023 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 proxy
+
+import (
+	"archive/zip"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestWriteModuleVersion(t *testing.T) {
+	tests := []struct {
+		modulePath, version string
+		files               map[string][]byte
+	}{
+		{
+			modulePath: "mod.test/module",
+			version:    "v1.2.3",
+			files: map[string][]byte{
+				"go.mod":   []byte("module mod.com\n\ngo 1.12"),
+				"const.go": []byte("package module\n\nconst Answer = 42"),
+			},
+		},
+		{
+			modulePath: "mod.test/module",
+			version:    "v1.2.4",
+			files: map[string][]byte{
+				"go.mod":   []byte("module mod.com\n\ngo 1.12"),
+				"const.go": []byte("package module\n\nconst Answer = 43"),
+			},
+		},
+		{
+			modulePath: "mod.test/nogomod",
+			version:    "v0.9.0",
+			files: map[string][]byte{
+				"const.go": []byte("package module\n\nconst Other = \"Other\""),
+			},
+		},
+	}
+	dir := t.TempDir()
+	defer os.RemoveAll(dir)
+	for _, test := range tests {
+		// Since we later assert on the contents of /list, don't use subtests.
+		if err := writeModuleVersion(dir, test.modulePath, test.version, test.files); err != nil {
+			t.Fatal(err)
+		}
+		rootDir := filepath.Join(dir, filepath.FromSlash(test.modulePath), "@v")
+		gomod, err := os.ReadFile(filepath.Join(rootDir, test.version+".mod"))
+		if err != nil {
+			t.Fatal(err)
+		}
+		wantMod, ok := test.files["go.mod"]
+		if !ok {
+			wantMod = []byte("module " + test.modulePath)
+		}
+		if got, want := string(gomod), string(wantMod); got != want {
+			t.Errorf("reading %s/@v/%s.mod: got %q, want %q", test.modulePath, test.version, got, want)
+		}
+		zr, err := zip.OpenReader(filepath.Join(rootDir, test.version+".zip"))
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer zr.Close()
+
+		for _, zf := range zr.File {
+			r, err := zf.Open()
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer r.Close()
+			content, err := io.ReadAll(r)
+			if err != nil {
+				t.Fatal(err)
+			}
+			name := strings.TrimPrefix(zf.Name, fmt.Sprintf("%s@%s/", test.modulePath, test.version))
+			if got, want := string(content), string(test.files[name]); got != want {
+				t.Errorf("unzipping %q: got %q, want %q", zf.Name, got, want)
+			}
+			delete(test.files, name)
+		}
+		for name := range test.files {
+			t.Errorf("file %q not present in the module zip", name)
+		}
+	}
+
+	lists := []struct {
+		modulePath, want string
+	}{
+		{"mod.test/module", "v1.2.3\nv1.2.4\n"},
+		{"mod.test/nogomod", "v0.9.0\n"},
+	}
+
+	for _, test := range lists {
+		fp := filepath.Join(dir, filepath.FromSlash(test.modulePath), "@v", "list")
+		list, err := os.ReadFile(fp)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if got := string(list); got != test.want {
+			t.Errorf("%q/@v/list: got %q, want %q", test.modulePath, got, test.want)
+		}
+	}
+}