[gold] Add auth option to goldctl

- Adds a TokenSource implementation to auth that wraps around a token
- Implements the stubbed out 'auth' command in goldctl
- Adds the option to set a token source in the goldclient package

Bug: skia:
Change-Id: I21b626012daa95f356539979f77207113721f416
Reviewed-on: https://skia-review.googlesource.com/c/179743
Commit-Queue: Stephan Altmueller <stephana@google.com>
Reviewed-by: Ben Wagner <benjaminwagner@google.com>
diff --git a/go/auth/auth.go b/go/auth/auth.go
index b06451a..3908e26 100644
--- a/go/auth/auth.go
+++ b/go/auth/auth.go
@@ -240,7 +240,7 @@
 	}, nil
 }
 
-// cachingTokenSource implments the oauth2.TokenSource interface and
+// cachingTokenSource implements the oauth2.TokenSource interface and
 // caches the oauth token in a file.
 type cachingTokenSource struct {
 	cacheFilePath string
@@ -397,3 +397,15 @@
 	})
 	return authenticator.TokenSource()
 }
+
+// SimpleTokenSrc implements the oauth2.TokenSource interface and wraps around a token
+// that has been retrieved by other means
+func SimpleTokenSrc(token *oauth2.Token) oauth2.TokenSource {
+	return &simpleTokenSrc{token: token}
+}
+
+type simpleTokenSrc struct {
+	token *oauth2.Token
+}
+
+func (s *simpleTokenSrc) Token() (*oauth2.Token, error) { return s.token, nil }
diff --git a/gold-client/cmd/goldctl/cmd_auth.go b/gold-client/cmd/goldctl/cmd_auth.go
index 8f91044..011ccd5 100644
--- a/gold-client/cmd/goldctl/cmd_auth.go
+++ b/gold-client/cmd/goldctl/cmd_auth.go
@@ -1,27 +1,55 @@
 package main
 
 import (
+	gstorage "cloud.google.com/go/storage"
 	"github.com/spf13/cobra"
+	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/gold-client/go/goldclient"
 )
 
-// TODO(stephana): Implement the auth command that's currently stubbed out.
-
 // authEnv provides the environment for the auth command.
-type authEnv struct{}
+type authEnv struct {
+	flagServiceAccount string
+	flagWorkDir        string
+}
 
 // getAuthCmd returns the definition of the auth command.
 func getAuthCmd() *cobra.Command {
 	env := &authEnv{}
-	authCmd := &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "auth",
-		Short: "Authenticate against GCP",
+		Short: "Authenticate against GCP and Gold instances",
 		Long: `
-Authenticate against GCP - TODO: How to specify the service account file ? `,
+Authenticate against GCP and the Gold instance.
+Currently only service accounts are supported. `,
 		Run: env.runAuthCmd,
 	}
 
-	return authCmd
+	// add the --service-account flag and make it required
+	cmd.Flags().StringVarP(&env.flagServiceAccount, "service-account", "", "", "Service account file to be used to authenticate against GCP and Gold")
+	_ = cmd.MarkFlagRequired("service-account")
+
+	// add the --work-dir flag and make it required
+	cmd.Flags().StringVarP(&env.flagWorkDir, "workdir", "", "", "Temporary work directory")
+	_ = cmd.MarkFlagRequired("work-dir")
+
+	return cmd
 }
 
 // runAuthCommand
-func (a *authEnv) runAuthCmd(cmd *cobra.Command, args []string) { notImplemented(cmd) }
+func (a *authEnv) runAuthCmd(cmd *cobra.Command, args []string) {
+	config := &goldclient.GoldClientConfig{
+		WorkDir: a.flagWorkDir,
+	}
+
+	// Create a cloud based Gold client and authenticate.
+	goldClient, err := goldclient.NewCloudClient(config, nil)
+	ifErrLogExit(cmd, err)
+
+	tokenSrc, err := auth.NewJWTServiceAccountTokenSource("#bogus", a.flagServiceAccount, gstorage.ScopeFullControl)
+	ifErrLogExit(cmd, err)
+
+	err = goldClient.ServiceAccount(tokenSrc)
+	ifErrLogExit(cmd, err)
+
+}
diff --git a/gold-client/go/goldclient/goldclient.go b/gold-client/go/goldclient/goldclient.go
index 05b2fb3..0c3421f 100644
--- a/gold-client/go/goldclient/goldclient.go
+++ b/gold-client/go/goldclient/goldclient.go
@@ -3,10 +3,12 @@
 import (
 	"bufio"
 	"bytes"
+	"context"
 	"crypto/md5"
 	"encoding/json"
 	"fmt"
 	"image/png"
+	"io"
 	"io/ioutil"
 	"net/http"
 	"os"
@@ -16,6 +18,7 @@
 	"strings"
 	"time"
 
+	"go.skia.org/infra/go/auth"
 	"go.skia.org/infra/go/fileutil"
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/skerr"
@@ -25,6 +28,7 @@
 	"go.skia.org/infra/golden/go/jsonio"
 	"go.skia.org/infra/golden/go/shared"
 	"go.skia.org/infra/golden/go/types"
+	"golang.org/x/oauth2"
 )
 
 const (
@@ -48,7 +52,10 @@
 	resultStateFile = "result-state.json"
 
 	// jsonTempFileName is the temporary file that is created to upload results via gsutil.
-	jsonTempFileName = "gsutil_dm.json"
+	jsonTempFile = "gsutil_dm.json"
+
+	// oauthTokenFile is the file in the work directory where the oauth token is cached.
+	oauthTokenFile = "oauth_token.json"
 )
 
 // md5Regexp is used to check whether strings are MD5 hashes.
@@ -61,6 +68,21 @@
 	// comparison with the expectations. An error is only returned if there was a technical problem
 	// in processing the test.
 	Test(name string, imgFileName string) (bool, error)
+
+	// ServiceAccount uses the given oauth.TokenSource to authenticate requests to GCS and the
+	// Gold backend. The token retrieved through the token source will be cached on disk.
+	ServiceAccount(tkSrc oauth2.TokenSource) error
+}
+
+// cloudUploader implementations provide functions to upload to GCS.
+type cloudUploader interface {
+	// copy copies from a local file to GCS. The dst string is assumed to have a gs:// prefix.
+	// Currently only uploading from a local file to GCS is supported.
+	uploadBytesOrFile(data []byte, fileName, dst string) error
+
+	// uploadJson serializes the given data to JSON and uploads the result to GCS.
+	// An implementation can use tempFileName for temporary storage of JSON data.
+	uploadJson(data interface{}, tempFileName, gcsObjectPath string) error
 }
 
 // cloudClient implements the GoldClient interface for the remote Gold service.
@@ -75,7 +97,9 @@
 	freshState bool
 
 	// ready caches the result of the isReady call so we avoid duplicate work.
-	ready      bool
+	ready bool
+
+	oauthToken *oauth2.Token
 	httpClient *http.Client
 }
 
@@ -106,7 +130,8 @@
 	if config.WorkDir == "" {
 		return nil, skerr.Fmt("No 'workDir' provided to NewCloudClient")
 	}
-	workDir, err := filepath.Abs(config.WorkDir)
+
+	workDir, err := fileutil.EnsureDirExists(config.WorkDir)
 	if err != nil {
 		return nil, err
 	}
@@ -114,19 +139,10 @@
 	// TODO(stephana): When we add authentication via a service account this needs to be
 	// be triggered by an argument to this function or a config flag of some sort.
 
-	// Make sure 'gsutil' is on the PATH.
-	if !gsutilAvailable() {
-		return nil, skerr.Fmt("Unable to find 'gsutil' on the PATH")
-	}
-
-	if !fileutil.FileExists(workDir) {
-		return nil, fmt.Errorf("Workdir path %q does not exist", workDir)
-	}
-
 	ret := &cloudClient{
-		workDir:    workDir,
-		httpClient: httputils.DefaultClientConfig().Client(),
+		workDir: workDir,
 	}
+	ret.setHttpClient()
 
 	if err := ret.initResultState(config, goldResult); err != nil {
 		return nil, skerr.Fmt("Error initializing result in cloud GoldClient: %s", err)
@@ -141,21 +157,36 @@
 
 	// If there was no error and this is new instance then save the resultState for the next call.
 	if err == nil && c.freshState {
-		if err := c.resultState.save(c.getResultStateFile()); err != nil {
+		if err := saveJSONFile(c.getResultStateFile(), c.resultState); err != nil {
 			return false, err
 		}
 	}
 	return passed, err
 }
 
+// getUploader returns a cloudUploader instance. It either uses oauth/http if available or
+// shells out to gsutil if no authentication is available.
+func (c *cloudClient) getUploader() (cloudUploader, error) {
+	if c.oauthToken != nil {
+		return newHttpUploader(context.TODO(), c.httpClient)
+	}
+	return gsutilUploader{}, nil
+}
+
 // addTest adds a test to results. If perTestPassFail is true it will also upload the result.
 func (c *cloudClient) addTest(name string, imgFileName string) (bool, error) {
 	if err := c.isReady(); err != nil {
 		return false, skerr.Fmt("Unable to process test result. Cloud Gold Client not ready: %s", err)
 	}
 
+	// Get an uploader. This is either based on an authenticated client or on gsutils.
+	uploader, err := c.getUploader()
+	if err != nil {
+		return false, skerr.Fmt("Error retrieving uploader: %s", err)
+	}
+
 	// Load the PNG from disk and hash it.
-	_, imgHash, err := loadAndHashImage(imgFileName)
+	imgBytes, imgHash, err := loadAndHashImage(imgFileName)
 	if err != nil {
 		return false, err
 	}
@@ -163,7 +194,7 @@
 	// Check against known hashes and upload if needed.
 	if !c.resultState.KnownHashes[imgHash] {
 		gcsImagePath := c.resultState.getGCSImagePath(imgHash)
-		if err := gsutilCopy(imgFileName, prefixGCS(gcsImagePath)); err != nil {
+		if err := uploader.uploadBytesOrFile(imgBytes, imgFileName, prefixGCS(gcsImagePath)); err != nil {
 			return false, skerr.Fmt("Error uploading image: %s", err)
 		}
 	}
@@ -178,8 +209,8 @@
 
 	// If we do per test pass/fail then upload the result and compare it to the baseline.
 	if c.resultState.PerTestPassFail {
-		localFileName := filepath.Join(c.workDir, jsonTempFileName)
-		if err := gsUtilUploadJson(c.resultState.GoldResults, localFileName, c.resultState.getResultFilePath()); err != nil {
+		localFileName := filepath.Join(c.workDir, jsonTempFile)
+		if err := uploader.uploadJson(c.resultState.GoldResults, localFileName, c.resultState.getResultFilePath()); err != nil {
 			return false, err
 		}
 		return c.resultState.Expectations[name][imgHash] == types.POSITIVE, nil
@@ -199,22 +230,77 @@
 		return err
 	}
 
+	if err := c.loadOAuthToken(); err != nil {
+		return skerr.Fmt("Error loading auth information: %s", err)
+	}
+
 	// If we are ready that means we have loaded the resultState from the temporary directory.
 	if err := c.isReady(); err == nil {
 		return nil
 	}
 
-	// Create a new instance of result state. Setting freshState to true indicates that this needs
-	// to be stored to disk once a test has been added successfully.
-	c.resultState, err = newResultState(goldResult, config, c.workDir, c.httpClient)
+	// If we have enough information we create an instance of the result state. Sometimes we
+	// might create an instance with minimal information to, e.g. add auth information.
+	if config != nil && config.InstanceID != "" {
+		c.resultState, err = newResultState(goldResult, config, c.workDir, c.httpClient)
+		if err != nil {
+			return err
+		}
+
+		// Setting freshState to true indicates that this needs
+		// to be stored to disk once a test has been added successfully.
+		c.freshState = true
+	}
+	return nil
+}
+
+// ServiceAccount loads a service account from the given file and uses it for requests to
+// GCP and the Gold backend.
+func (c *cloudClient) ServiceAccount(tokenSrc oauth2.TokenSource) error {
+	var err error
+	c.oauthToken, err = tokenSrc.Token()
+	if err != nil {
+		return skerr.Fmt("Error retrieving token: %s", err)
+	}
+
+	if err := c.saveOAuthToken(); err != nil {
+		return err
+	}
+	c.setHttpClient()
+	return nil
+}
+
+// loadOauthToken loads the oauth token that has been saved earlier.
+func (c *cloudClient) loadOAuthToken() error {
+	inFile := filepath.Join(c.workDir, oauthTokenFile)
+	ret := &oauth2.Token{}
+	found, err := loadJSONFile(inFile, &ret)
 	if err != nil {
 		return err
 	}
 
-	c.freshState = true
+	if found {
+		c.oauthToken = ret
+	}
+	c.setHttpClient()
 	return nil
 }
 
+// setHttpClient sets httpClient with an oauth token if available, otherwise it is unauthenticated.
+func (c *cloudClient) setHttpClient() {
+	if c.oauthToken == nil {
+		c.httpClient = httputils.DefaultClientConfig().Client()
+	} else {
+		c.httpClient = httputils.DefaultClientConfig().WithTokenSource(auth.SimpleTokenSrc(c.oauthToken)).Client()
+	}
+}
+
+// savesOAuthToken assumes that oauthToken has been set. It saves it to the work directory.
+func (c *cloudClient) saveOAuthToken() error {
+	outFile := filepath.Join(c.workDir, oauthTokenFile)
+	return saveJSONFile(outFile, c.oauthToken)
+}
+
 // isReady returns true if the instance is ready to accept test results (all necessary info has been
 // configured)
 func (c *cloudClient) isReady() error {
@@ -227,6 +313,11 @@
 		return skerr.Fmt("No result state object available")
 	}
 
+	// Check whether we have some means of uploading results
+	if c.oauthToken == nil && !gsutilAvailable() {
+		return skerr.Fmt("Unable to find 'gsutil' on the PATH and no authentication information provided")
+	}
+
 	// Check if the GoldResults instance is complete once results are added.
 	if _, err := c.resultState.GoldResults.Validate(true); err != nil {
 		return skerr.Fmt("Gold results fields invalid: %s", err)
@@ -335,36 +426,17 @@
 // loadStateFromJson loads a serialization of a resultState instance that was previously written
 // via the save method.
 func loadStateFromJson(fileName string) (*resultState, error) {
-	// If the state is not on disk, we return nil, indicating that a new resultState has to be created
-	if !fileutil.FileExists(fileName) {
+	ret := &resultState{}
+	exists, err := loadJSONFile(fileName, ret)
+	if err != nil {
+		return nil, err
+	}
+	if !exists {
 		return nil, nil
 	}
-
-	jsonBytes, err := ioutil.ReadFile(fileName)
-	if err != nil {
-		return nil, err
-	}
-
-	ret := &resultState{}
-	if err := json.Unmarshal(jsonBytes, ret); err != nil {
-		return nil, err
-	}
 	return ret, nil
 }
 
-// save serializes this instance to JSON and writes it to the given file.
-func (r *resultState) save(fileName string) error {
-	jsonBytes, err := json.Marshal(r)
-	if err != nil {
-		return skerr.Fmt("Error serializing resultState to JSON: %s", err)
-	}
-
-	if err := ioutil.WriteFile(fileName, jsonBytes, 0644); err != nil {
-		return skerr.Fmt("Error writing resultState to %s: %s", fileName, err)
-	}
-	return nil
-}
-
 // loadKnownHashes loads the list of known hashes from the Gold instance.
 func (r *resultState) loadKnownHashes() error {
 	r.KnownHashes = util.StringSet{}
@@ -476,3 +548,31 @@
 func (r *resultState) getGCSImagePath(imgHash string) string {
 	return fmt.Sprintf("%s/%s/%s.png", r.Bucket, imagePrefix, imgHash)
 }
+
+// loadJSONFile loads and parses the JSON in 'fileName'. If the file doesn't exist it returns
+// (false, nil). If the first return value is true, 'data' contains the parse JSON data.
+func loadJSONFile(fileName string, data interface{}) (bool, error) {
+	if !fileutil.FileExists(fileName) {
+		return false, nil
+	}
+
+	err := util.WithReadFile(fileName, func(r io.Reader) error {
+		return json.NewDecoder(r).Decode(data)
+	})
+	if err != nil {
+		return false, skerr.Fmt("Error reading/parsing JSON file: %s", err)
+	}
+
+	return true, nil
+}
+
+// saveJSONFile stores the given 'data' in a file with the given name
+func saveJSONFile(fileName string, data interface{}) error {
+	err := util.WithWriteFile(fileName, func(w io.Writer) error {
+		return json.NewEncoder(w).Encode(data)
+	})
+	if err != nil {
+		return skerr.Fmt("Error writing/serializing to JSON: %s", err)
+	}
+	return nil
+}
diff --git a/gold-client/go/goldclient/gsutil_util.go b/gold-client/go/goldclient/gsutil_util.go
index 31e32ed..8e01c68 100644
--- a/gold-client/go/goldclient/gsutil_util.go
+++ b/gold-client/go/goldclient/gsutil_util.go
@@ -1,14 +1,28 @@
 package goldclient
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"net/http"
 	"os/exec"
+	"strings"
 
+	gstorage "cloud.google.com/go/storage"
+	"go.skia.org/infra/go/gcs"
 	"go.skia.org/infra/go/skerr"
+	"google.golang.org/api/option"
 )
 
+const (
+	// gsPrefix is the expected prefix for a GCS URL.
+	gsPrefix = "gs://"
+)
+
+// gsutilUploader implements the cloudUploader interface.
+type gsutilUploader struct{}
+
 // gsutilAvailable returns true if the 'gsutil' command could be found on the PATH
 func gsutilAvailable() bool {
 	_, err := exec.LookPath("gsutil")
@@ -18,7 +32,7 @@
 // gsUtilUploadJson serializes the given data to JSON and writes the result to the given
 // tempFileName, then it copies the file to the given path in GCS. gcsObjPath is assumed
 // to have the form: <bucket_name>/path/to/object
-func gsUtilUploadJson(data interface{}, tempFileName, gcsObjPath string) error {
+func (g gsutilUploader) uploadJson(data interface{}, tempFileName, gcsObjPath string) error {
 	jsonBytes, err := json.Marshal(data)
 	if err != nil {
 		return err
@@ -29,21 +43,76 @@
 	}
 
 	// Upload the written file.
-	return gsutilCopy(tempFileName, prefixGCS(gcsObjPath))
+	return g.uploadBytesOrFile(nil, tempFileName, prefixGCS(gcsObjPath))
 }
 
 // prefixGCS adds the "gs://" prefix to the given GCS path.
 func prefixGCS(gcsPath string) string {
-	return fmt.Sprintf("gs://%s", gcsPath)
+	return fmt.Sprintf(gsPrefix+"%s", gcsPath)
 }
 
 // gsutilCopy shells out to gsutil to copy the given src to the given target. A path
 // starting with "gs://" is assumed to be in GCS.
-func gsutilCopy(src, dst string) error {
-	runCmd := exec.Command("gsutil", "cp", src, dst)
+func (g gsutilUploader) uploadBytesOrFile(data []byte, fileName, dst string) error {
+	runCmd := exec.Command("gsutil", "cp", fileName, dst)
 	outBytes, err := runCmd.CombinedOutput()
 	if err != nil {
 		return skerr.Fmt("Error running gsutil. Got output \n%s\n and error: %s", outBytes, err)
 	}
 	return nil
 }
+
+// httpUploader implements the cloudUploader interface using an authenticated (via an OAuth service
+// account) http client.
+type httpUploader struct {
+	client *gstorage.Client
+}
+
+func newHttpUploader(ctx context.Context, httpClient *http.Client) (cloudUploader, error) {
+	ret := &httpUploader{}
+	var err error
+	ret.client, err = gstorage.NewClient(ctx, option.WithHTTPClient(httpClient))
+	if err != nil {
+		return nil, skerr.Fmt("Error instantiating storage client: %s", err)
+	}
+	return ret, nil
+}
+
+func (h *httpUploader) uploadBytesOrFile(data []byte, fallbackSrc, dst string) error {
+	if len(data) == 0 {
+		if strings.HasPrefix(fallbackSrc, gsPrefix) {
+			return skerr.Fmt("Copying from a remote file is not supported")
+		}
+
+		var err error
+		data, err = ioutil.ReadFile(fallbackSrc)
+		if err != nil {
+			return skerr.Fmt("Error reading file %s: %s", fallbackSrc, err)
+		}
+	}
+
+	return h.copyBytes(data, dst)
+}
+
+func (h *httpUploader) uploadJson(data interface{}, tempFileName, gcsObjectPath string) error {
+	jsonBytes, err := json.Marshal(data)
+	if err != nil {
+		return err
+	}
+	return h.copyBytes(jsonBytes, gcsObjectPath)
+}
+
+func (h *httpUploader) copyBytes(data []byte, dst string) error {
+	// Trim the prefix and upload the content to the cloud.
+	dst = strings.TrimPrefix(dst, gsPrefix)
+	bucket, objPath := gcs.SplitGSPath(dst)
+	handle := h.client.Bucket(bucket).Object(objPath)
+
+	w := handle.NewWriter(context.Background())
+	_, err := w.Write(data)
+	if err != nil {
+		_ = w.CloseWithError(err) // Always returns nil, according to docs.
+		return err
+	}
+	return w.Close()
+}