internal/relui: support uploading to CDN and publishing to go.dev

Port over the -upload mode of cmd/release. We'll need a website change
to allow the "relui" user to publish.

For golang/go#51797.

Change-Id: Ic9e059ef14253f972a1709ea6c37d2a73f8622fd
Reviewed-on: https://go-review.googlesource.com/c/build/+/410234
Run-TryBot: Heschi Kreinick <heschi@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Alex Rakoczy <alex@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Auto-Submit: Heschi Kreinick <heschi@google.com>
diff --git a/cmd/relui/deployment-prod.yaml b/cmd/relui/deployment-prod.yaml
index 9d052a4..09c8bef 100644
--- a/cmd/relui/deployment-prod.yaml
+++ b/cmd/relui/deployment-prod.yaml
@@ -37,6 +37,9 @@
             - "--builder-master-key=secret:symbolic-datum-552/builder-master-key"
             - "--scratch-files-base=gs://golang-release-staging/relui-scratch"
             - "--staging-files-base=gs://golang-release-staging"
+            - "--serving-files-base=gs://golang"
+            - "--edge-cache-url=https://dl.google.com/go"
+            - "--website-upload-url=https://go.dev/dl/upload"
           ports:
             - containerPort: 444
           env:
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index 9380c96..671dc67 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -6,14 +6,18 @@
 package main
 
 import (
+	"bytes"
 	"context"
 	"crypto/hmac"
 	"crypto/md5"
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"log"
 	"math/rand"
+	"net/http"
 	"net/url"
 	"time"
 
@@ -39,6 +43,9 @@
 
 	scratchFilesBase = flag.String("scratch-files-base", "", "Storage for scratch files. gs://bucket/path or file:///path/to/scratch.")
 	stagingFilesBase = flag.String("staging-files-base", "", "Storage for staging files. gs://bucket/path or file:///path/to/staging.")
+	servingFilesBase = flag.String("serving-files-base", "", "Storage for serving files. gs://bucket/path or file:///path/to/serving.")
+	edgeCacheURL     = flag.String("edge-cache-url", "", "URL release files appear at when published to the CDN, e.g. https://dl.google.com/go.")
+	websiteUploadURL = flag.String("website-upload-url", "", "URL to POST website file data to, e.g. https://go.dev/dl/upload.")
 )
 
 func main() {
@@ -89,11 +96,12 @@
 	dh := relui.NewDefinitionHolder()
 	relui.RegisterMailDLCLDefinition(dh, extCfg)
 	relui.RegisterTweetDefinitions(dh, extCfg)
+	userPassAuth := buildlet.UserPass{
+		Username: "user-relui",
+		Password: key(*masterKey, "user-relui"),
+	}
 	coordinator := &buildlet.CoordinatorClient{
-		Auth: buildlet.UserPass{
-			Username: "user-relui",
-			Password: key(*masterKey, "user-relui"),
-		},
+		Auth:     userPassAuth,
 		Instance: build.ProdCoordinator,
 	}
 	if _, err := coordinator.RemoteBuildlets(); err != nil {
@@ -109,6 +117,11 @@
 		GCSClient:      gcsClient,
 		ScratchURL:     *scratchFilesBase,
 		StagingURL:     *stagingFilesBase,
+		ServingURL:     *servingFilesBase,
+		DownloadURL:    *edgeCacheURL,
+		PublishFile: func(f *relui.WebsiteFile) error {
+			return publishFile(*websiteUploadURL, userPassAuth, f)
+		},
 	}
 	releaseTasks.RegisterBuildReleaseWorkflows(dh)
 	db, err := pgxpool.Connect(ctx, *pgConnect)
@@ -140,3 +153,26 @@
 	io.WriteString(h, principal)
 	return fmt.Sprintf("%x", h.Sum(nil))
 }
+
+func publishFile(uploadURL string, auth buildlet.UserPass, f *relui.WebsiteFile) error {
+	req, err := json.Marshal(f)
+	if err != nil {
+		return err
+	}
+	u, err := url.Parse(uploadURL)
+	if err != nil {
+		return fmt.Errorf("invalid website upload URL %q: %v", *websiteUploadURL, err)
+	}
+	u.Query().Set("user", auth.Username)
+	u.Query().Set("key", auth.Password)
+	resp, err := http.Post(u.String(), "application/json", bytes.NewReader(req))
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		b, _ := ioutil.ReadAll(resp.Body)
+		return fmt.Errorf("upload failed to %q: %v\n%s", uploadURL, resp.Status, b)
+	}
+	return nil
+}
diff --git a/internal/relui/buildrelease_test.go b/internal/relui/buildrelease_test.go
index 333251e..669ce14 100644
--- a/internal/relui/buildrelease_test.go
+++ b/internal/relui/buildrelease_test.go
@@ -27,8 +27,10 @@
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/google/uuid"
 	"golang.org/x/build/buildlet"
+	"golang.org/x/build/internal"
 	"golang.org/x/build/internal/untar"
 	"golang.org/x/build/internal/workflow"
 )
@@ -39,23 +41,49 @@
 	if runtime.GOOS != "linux" {
 		t.Skip("Requires bash shell scripting support.")
 	}
-	s := httptest.NewServer(http.HandlerFunc(serveTarballs))
-	defer s.Close()
+
+	// Set up a server that will be used to serve inputs to the build.
+	tarballServer := httptest.NewServer(http.HandlerFunc(serveTarballs))
+	defer tarballServer.Close()
 	fakeBuildlets := &fakeBuildlets{
 		t:       t,
 		dir:     t.TempDir(),
-		httpURL: s.URL,
+		httpURL: tarballServer.URL,
 		logs:    map[string][]*[]string{},
 	}
+
+	// Set up the fake signing process.
 	stagingDir := t.TempDir()
 	go fakeSign(ctx, t, filepath.Join(stagingDir, "go1.18releasetest1"))
 	signingPollDuration = 100 * time.Millisecond
+
+	// Set up the fake CDN publishing process.
+	servingDir := t.TempDir()
+	dlDir := t.TempDir()
+	dlServer := httptest.NewServer(http.FileServer(http.FS(os.DirFS(dlDir))))
+	defer dlServer.Close()
+	go fakeCDNLoad(ctx, t, servingDir, dlDir)
+	uploadPollDuration = 100 * time.Millisecond
+
+	// Set up the fake website to publish to.
+	var filesMu sync.Mutex
+	files := map[string]*WebsiteFile{}
+	publishFile := func(f *WebsiteFile) error {
+		filesMu.Lock()
+		defer filesMu.Unlock()
+		files[f.Filename] = f
+		return nil
+	}
+
 	tasks := BuildReleaseTasks{
-		GerritURL:      s.URL,
+		GerritURL:      tarballServer.URL,
 		GCSClient:      nil,
 		ScratchURL:     "file://" + filepath.ToSlash(t.TempDir()),
 		StagingURL:     "file://" + filepath.ToSlash(stagingDir),
+		ServingURL:     "file://" + filepath.ToSlash(servingDir),
 		CreateBuildlet: fakeBuildlets.createBuildlet,
+		DownloadURL:    dlServer.URL,
+		PublishFile:    publishFile,
 	}
 	wd, err := tasks.newBuildReleaseWorkflow("go1.18")
 	if err != nil {
@@ -69,38 +97,59 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	out, err := w.Run(ctx, &verboseListener{t})
+	_, err = w.Run(ctx, &verboseListener{t})
 	if err != nil {
 		t.Fatal(err)
 	}
-	artifacts := out["Signed artifacts"].([]artifact)
-	byName := map[string]artifact{}
-	for _, a := range artifacts {
-		if a.sha256 == "" || a.size < 1 || a.suffix == "" || a.filename == "" {
-			t.Errorf("release process produced an invalid artifact: %#v", a)
+	for _, f := range files {
+		if f.ChecksumSHA256 == "" || f.Size < 1 || f.Filename == "" || f.Kind == "" {
+			t.Errorf("release process produced an invalid artifact: %#v", f)
 		}
-		byName[a.filename] = a
 	}
 
-	checkTGZ(t, stagingDir, byName["go1.18releasetest1.src.tar.gz"], map[string]string{
+	checkTGZ(t, dlDir, files, "go1.18releasetest1.src.tar.gz", &WebsiteFile{
+		OS:   "",
+		Arch: "",
+		Kind: "source",
+	}, map[string]string{
 		"go/VERSION":       "go1.18releasetest1",
 		"go/src/make.bash": makeScript,
 	})
-	checkContents(t, stagingDir, byName["go1.18releasetest1.windows-amd64.msi"], "I'm an MSI!\n")
-	checkTGZ(t, stagingDir, byName["go1.18releasetest1.linux-amd64.tar.gz"], map[string]string{
+	checkContents(t, dlDir, files, "go1.18releasetest1.windows-amd64.msi", &WebsiteFile{
+		OS:   "windows",
+		Arch: "amd64",
+		Kind: "installer",
+	}, "I'm an MSI!\n")
+	checkTGZ(t, dlDir, files, "go1.18releasetest1.linux-amd64.tar.gz", &WebsiteFile{
+		OS:   "linux",
+		Arch: "amd64",
+		Kind: "archive",
+	}, map[string]string{
 		"go/VERSION":                        "go1.18releasetest1",
 		"go/tool/something_orother/compile": "",
 		"go/pkg/something_orother/race.a":   "",
 	})
-	checkZip(t, stagingDir, byName["go1.18releasetest1.windows-arm64.zip"], map[string]string{
+	checkZip(t, dlDir, files, "go1.18releasetest1.windows-arm64.zip", &WebsiteFile{
+		OS:   "windows",
+		Arch: "arm64",
+		Kind: "archive",
+	}, map[string]string{
 		"go/VERSION":                        "go1.18releasetest1",
 		"go/tool/something_orother/compile": "",
 	})
-	checkZip(t, stagingDir, byName["go1.18releasetest1.windows-386.zip"], map[string]string{
+	checkTGZ(t, dlDir, files, "go1.18releasetest1.linux-armv6l.tar.gz", &WebsiteFile{
+		OS:   "linux",
+		Arch: "armv6l",
+		Kind: "archive",
+	}, map[string]string{
 		"go/VERSION":                        "go1.18releasetest1",
 		"go/tool/something_orother/compile": "",
 	})
-	checkContents(t, stagingDir, byName["go1.18releasetest1.darwin-amd64.pkg"], "I'm a .pkg!\n")
+	checkContents(t, dlDir, files, "go1.18releasetest1.darwin-amd64.pkg", &WebsiteFile{
+		OS:   "darwin",
+		Arch: "amd64",
+		Kind: "installer",
+	}, "I'm a .pkg!\n")
 
 	// TODO: consider logging this to golden files?
 	for name, logs := range fakeBuildlets.logs {
@@ -195,27 +244,36 @@
 	}
 }
 
-func checkContents(t *testing.T, stagingDir string, a artifact, contents string) {
-	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
-		if err != nil {
-			t.Fatalf("reading %v: %v", a.filename, err)
+func checkFile(t *testing.T, dlDir string, files map[string]*WebsiteFile, filename string, meta *WebsiteFile, check func([]byte)) {
+	t.Run(filename, func(t *testing.T) {
+		f, ok := files[filename]
+		if !ok {
+			t.Fatalf("file %q not published", filename)
 		}
+		if diff := cmp.Diff(meta, f, cmpopts.IgnoreFields(WebsiteFile{}, "Filename", "Version", "ChecksumSHA256", "Size")); diff != "" {
+			t.Errorf("file metadata mismatch (-want +got):\n%v", diff)
+		}
+		b, err := ioutil.ReadFile(filepath.Join(dlDir, f.Filename))
+		if err != nil {
+			t.Fatalf("reading %v: %v", f.Filename, err)
+		}
+		check(b)
+	})
+}
+
+func checkContents(t *testing.T, dlDir string, files map[string]*WebsiteFile, filename string, meta *WebsiteFile, contents string) {
+	checkFile(t, dlDir, files, filename, meta, func(b []byte) {
 		if got, want := string(b), contents; got != want {
-			t.Errorf("%v contains %q, want %q", a.filename, got, want)
+			t.Errorf("%v contains %q, want %q", filename, got, want)
 		}
 	})
 }
 
-func checkTGZ(t *testing.T, stagingDir string, a artifact, contents map[string]string) {
-	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
-		if err != nil {
-			t.Fatalf("reading %v: %v", a.filename, err)
-		}
+func checkTGZ(t *testing.T, dlDir string, files map[string]*WebsiteFile, filename string, meta *WebsiteFile, contents map[string]string) {
+	checkFile(t, dlDir, files, filename, meta, func(b []byte) {
 		gzr, err := gzip.NewReader(bytes.NewReader(b))
 		if err != nil {
-			t.Fatalf("unzipping %v: %v", a.filename, err)
+			t.Fatal(err)
 		}
 		tr := tar.NewReader(gzr)
 		for {
@@ -245,12 +303,8 @@
 	})
 }
 
-func checkZip(t *testing.T, stagingDir string, a artifact, contents map[string]string) {
-	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
-		if err != nil {
-			t.Fatalf("reading %v: %v", a.filename, err)
-		}
+func checkZip(t *testing.T, dlDir string, files map[string]*WebsiteFile, filename string, meta *WebsiteFile, contents map[string]string) {
+	checkFile(t, dlDir, files, filename, meta, func(b []byte) {
 		zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
 		if err != nil {
 			t.Fatal(err)
@@ -489,15 +543,9 @@
 // fakeSign acts like a human running the signbinaries job periodically.
 func fakeSign(ctx context.Context, t *testing.T, dir string) {
 	seen := map[string]bool{}
-	for {
-		select {
-		case <-ctx.Done():
-			return
-		case <-time.After(500 * time.Millisecond):
-		}
-
+	internal.PeriodicallyDo(ctx, 100*time.Millisecond, func(_ context.Context, _ time.Time) {
 		fakeSignOnce(t, dir, seen)
-	}
+	})
 }
 
 func fakeSignOnce(t *testing.T, dir string, seen map[string]bool) {
@@ -645,3 +693,26 @@
 		t.Errorf("signed outputs mismatch (-want +got):\n%v", diff)
 	}
 }
+
+func fakeCDNLoad(ctx context.Context, t *testing.T, from, to string) {
+	seen := map[string]bool{}
+	internal.PeriodicallyDo(ctx, 100*time.Millisecond, func(_ context.Context, _ time.Time) {
+		files, err := os.ReadDir(from)
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, f := range files {
+			if seen[f.Name()] {
+				continue
+			}
+			seen[f.Name()] = true
+			contents, err := os.ReadFile(filepath.Join(from, f.Name()))
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := os.WriteFile(filepath.Join(to, f.Name()), contents, 0777); err != nil {
+				t.Fatal(err)
+			}
+		}
+	})
+}
diff --git a/internal/relui/workflows.go b/internal/relui/workflows.go
index 620cb1a..deec414 100644
--- a/internal/relui/workflows.go
+++ b/internal/relui/workflows.go
@@ -10,6 +10,7 @@
 	"io"
 	"io/fs"
 	"math/rand"
+	"net/http"
 	"path"
 	"strings"
 	"sync"
@@ -244,17 +245,21 @@
 	stagedArtifacts := wd.Task("Stage artifacts for signing", tasks.copyToStaging, version, wd.Slice(artifacts))
 	signedArtifacts := wd.Task("Wait for signed artifacts", tasks.awaitSigned, version, wd.Constant(darwinTargets), stagedArtifacts)
 	wd.Output("Signed artifacts", signedArtifacts)
-	results := wd.Task("Combine results", combineResults, stagedArtifacts, wd.Slice(testResults))
-	wd.Output("Build results", results)
+	wait := wd.Task("Wait for signing and tests", combineResults, signedArtifacts, wd.Slice(testResults))
+	uploaded := wd.Task("Upload artifacts to CDN", tasks.uploadArtifacts, signedArtifacts, wait)
+	published := wd.Task("Publish to website", tasks.publishArtifacts, version, signedArtifacts, uploaded)
+	wd.Output("Published", published)
 	return wd, nil
 }
 
 // BuildReleaseTasks serves as an adapter to the various build tasks in the task package.
 type BuildReleaseTasks struct {
-	GerritURL              string
-	GCSClient              *storage.Client
-	ScratchURL, StagingURL string
-	CreateBuildlet         func(string) (buildlet.Client, error)
+	GerritURL                          string
+	GCSClient                          *storage.Client
+	ScratchURL, StagingURL, ServingURL string
+	DownloadURL                        string
+	PublishFile                        func(*WebsiteFile) error
+	CreateBuildlet                     func(string) (buildlet.Client, error)
 }
 
 func (b *BuildReleaseTasks) buildSource(ctx *workflow.TaskContext, revision, version string) (artifact, error) {
@@ -584,3 +589,136 @@
 	}
 	return signed, true, nil
 }
+
+var uploadPollDuration = 30 * time.Second
+
+func (tasks *BuildReleaseTasks) uploadArtifacts(ctx *workflow.TaskContext, artifacts []artifact, _ string) (string, error) {
+	stagingFS, err := gcsfs.FromURL(ctx, tasks.GCSClient, tasks.StagingURL)
+	if err != nil {
+		return "", err
+	}
+	servingFS, err := gcsfs.FromURL(ctx, tasks.GCSClient, tasks.ServingURL)
+	if err != nil {
+		return "", err
+	}
+
+	todo := map[artifact]bool{}
+	for _, a := range artifacts {
+		if err := uploadArtifact(stagingFS, servingFS, a); err != nil {
+			return "", err
+		}
+		todo[a] = true
+	}
+
+	for {
+		for _, a := range artifacts {
+			resp, err := http.Head(tasks.DownloadURL + "/" + a.filename)
+			if err != nil {
+				return "", err
+			}
+			resp.Body.Close()
+			if resp.StatusCode == http.StatusOK {
+				delete(todo, a)
+			}
+		}
+
+		if len(todo) == 0 {
+			return "", nil
+		}
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-time.After(uploadPollDuration):
+			ctx.Printf("Still waiting for %v artifacts to be published", len(todo))
+		}
+	}
+}
+
+func uploadArtifact(stagingFS, servingFS fs.FS, a artifact) error {
+	in, err := stagingFS.Open(a.signedPath)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := gcsfs.Create(servingFS, a.filename)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	if _, err := io.Copy(out, in); err != nil {
+		return err
+	}
+	if err := out.Close(); err != nil {
+		return err
+	}
+
+	sha256, err := gcsfs.Create(servingFS, a.filename+".sha256")
+	if err != nil {
+		return err
+	}
+	defer sha256.Close()
+	if _, err := sha256.Write([]byte(a.sha256)); err != nil {
+		return err
+	}
+	if err := sha256.Close(); err != nil {
+		return err
+	}
+
+	if a.gpgSignature != "" {
+		asc, err := gcsfs.Create(servingFS, a.filename+".asc")
+		if err != nil {
+			return err
+		}
+		defer asc.Close()
+		if _, err := asc.Write([]byte(a.gpgSignature)); err != nil {
+			return err
+		}
+		if err := asc.Close(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (tasks *BuildReleaseTasks) publishArtifacts(ctx *workflow.TaskContext, version string, artifacts []artifact, _ string) (string, error) {
+	for _, a := range artifacts {
+		f := &WebsiteFile{
+			Filename:       a.filename,
+			Version:        version,
+			ChecksumSHA256: a.sha256,
+			Size:           int64(a.size),
+		}
+		if a.target != nil {
+			f.OS = a.target.GOOS
+			f.Arch = a.target.GOARCH
+			if a.target.GOARCH == "arm" {
+				f.Arch = "armv6l"
+			}
+		}
+		switch a.suffix {
+		case "src.tar.gz":
+			f.Kind = "source"
+		case "tar.gz", "zip":
+			f.Kind = "archive"
+		case "msi", "pkg":
+			f.Kind = "installer"
+		}
+		if err := tasks.PublishFile(f); err != nil {
+			return "", err
+		}
+	}
+	return "", nil
+}
+
+// WebsiteFile represents a file on the go.dev downloads page.
+// It should be kept in sync with the download code in x/website/internal/dl.
+type WebsiteFile struct {
+	Filename       string `json:"filename"`
+	OS             string `json:"os"`
+	Arch           string `json:"arch"`
+	Version        string `json:"version"`
+	ChecksumSHA256 string `json:"sha256"`
+	Size           int64  `json:"size"`
+	Kind           string `json:"kind"` // "archive", "installer", "source"
+}