app/appengine: update docs, add --dev and --fake-results flags

The dev_appserver.py is no longer available in Go 1.12+, so update
instructions so others know how to hack on this codebase.

The new --fake-results flag means anybody can hack on it locally
without any special access.

Change-Id: I3e395814bf7275bd3b24615d2a37f9c6ac186508
Reviewed-on: https://go-review.googlesource.com/c/build/+/211097
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/app/appengine/README.md b/app/appengine/README.md
index eaae5a9..bdff720 100644
--- a/app/appengine/README.md
+++ b/app/appengine/README.md
@@ -4,20 +4,48 @@
 
 ## Local development
 
-On a machine with a browser:
+To use production maintner data (for the GetDashboard RPC containing
+the list of commits, etc) and production active builds (from the
+coordinator), both of which are open to anybody, use:
 
 ```
-dev_appserver.py --port=8080 .
+go run . --dev --fake-results
 ```
 
-With a remote VM with a port open to the Internet:
+If you also want to use the production datastore for real commit data,
+or you want to work on the handlers that mutate data in the datastore,
+use:
 
 ```
-dev_appserver.py --enable_host_checking=false --host=0.0.0.0 --port=8080 .
+go run . --dev
 ```
 
-## Deploying
+That requires access to the "golang-org" GCP project's datastore.
+
+Environment variables you can change:
+
+* `PORT`: plain port number or Go-style listen address
+* `DATASTORE_PROJECT_ID`: defaults to `"golang-org"` in dev mode
+* `MAINTNER_ADDR`: defaults to "maintner.golang.org"
+
+## Deploying a test version
+
+To deploy to the production project but to a version that's not promoted to the default URL:
 
 ```sh
-make deploy
+make deploy-test
 ```
+
+It will tell you what URL it deployed to. You can then check it and
+either delete it or promote it with either the gcloud or web UIs. Or
+just ignore it. They'll scale to zero and are only visual clutter
+until somebody deletes a batch of old ones.
+
+## Deploying to production
+
+To deploy to https://build.golang.org:
+
+```sh
+make deploy-prod
+```
+
diff --git a/app/appengine/build.go b/app/appengine/build.go
index 6dbcebc..c645299 100644
--- a/app/appengine/build.go
+++ b/app/appengine/build.go
@@ -12,6 +12,7 @@
 	"fmt"
 	"io"
 	"io/ioutil"
+	"math/rand"
 	pathpkg "path"
 	"strings"
 
@@ -243,6 +244,26 @@
 			GoHash:      goHash,
 		}
 	}
+	if *fakeResults {
+		switch rand.Intn(3) {
+		default:
+			return nil
+		case 1:
+			return &Result{
+				Builder: builder,
+				Hash:    c.Hash,
+				GoHash:  goHash,
+				OK:      true,
+			}
+		case 2:
+			return &Result{
+				Builder: builder,
+				Hash:    c.Hash,
+				GoHash:  goHash,
+				LogHash: "fakefailureurl",
+			}
+		}
+	}
 	return nil
 }
 
diff --git a/app/appengine/dash.go b/app/appengine/dash.go
index f9f8245..98aad7b 100644
--- a/app/appengine/dash.go
+++ b/app/appengine/dash.go
@@ -6,7 +6,10 @@
 
 import (
 	"context"
+	crand "crypto/rand"
 	"crypto/tls"
+	"flag"
+	"fmt"
 	"log"
 	"net/http"
 	"os"
@@ -25,9 +28,43 @@
 	datastoreClient *datastore.Client // not done at init as createDatastoreClient fails under test environments
 )
 
+var (
+	dev         = flag.Bool("dev", false, "whether to run in local development mode")
+	fakeResults = flag.Bool("fake-results", false, "dev mode option: whether to make up fake random results. If true, datastore is not used.")
+)
+
 func main() {
+	flag.Parse()
+	if *fakeResults && !*dev {
+		log.Fatalf("--fake-results requires --dev mode")
+	}
+	if *dev {
+		randBytes := make([]byte, 20)
+		if _, err := crand.Read(randBytes[:]); err != nil {
+			panic(err)
+		}
+		devModeMasterKey = fmt.Sprintf("%x", randBytes)
+		if !*fakeResults {
+			log.Printf("Running in dev mode. Temporary master key is %v", devModeMasterKey)
+			if os.Getenv("DATASTORE_PROJECT_ID") == "" {
+				log.Printf("DATASTORE_PROJECT_ID not set; defaulting to production golang-org")
+				os.Setenv("DATASTORE_PROJECT_ID", "golang-org")
+			}
+		}
+	}
+
 	datastoreClient = createDatastoreClient()
 
+	if *dev && !*fakeResults {
+		// Test early whether user has datastore access.
+		key := dsKey("Log", "bogus-want-no-such-entity", nil)
+		if err := datastoreClient.Get(context.Background(), key, new(Log)); err != datastore.ErrNoSuchEntity {
+			log.Printf("Failed to access datastore: %v", err)
+			log.Printf("Run with --fake-results to avoid hitting a real datastore.")
+			os.Exit(1)
+		}
+	}
+
 	// authenticated handlers
 	handleFunc("/clear-results", AuthHandler(clearResultsHandler)) // called by x/build/cmd/retrybuilds
 	handleFunc("/result", AuthHandler(resultHandler))              // called by coordinator after build
@@ -45,14 +82,16 @@
 		http.Redirect(w, r, "https://golang.org/favicon.ico", http.StatusFound)
 	})
 
-	port := os.Getenv("PORT")
-	if port == "" {
-		port = "8080"
-		log.Printf("Defaulting to port %s", port)
+	listen := os.Getenv("PORT")
+	if listen == "" {
+		listen = "8080"
+	}
+	if !strings.Contains(listen, ":") {
+		listen = ":" + listen
 	}
 
-	log.Printf("Listening on port %s", port)
-	if err := http.ListenAndServe(":"+port, nil); err != nil {
+	log.Printf("Serving dashboard on %s", listen)
+	if err := http.ListenAndServe(listen, nil); err != nil {
 		log.Fatal(err)
 	}
 }
@@ -65,6 +104,9 @@
 }
 
 func createDatastoreClient() *datastore.Client {
+	if *fakeResults {
+		return nil
+	}
 	// First try with an empty project ID, so $DATASTORE_PROJECT_ID will be respected
 	// if set.
 	c, err := datastore.NewClient(context.Background(), "")
diff --git a/app/appengine/handler.go b/app/appengine/handler.go
index 33514c4..0283bd6 100644
--- a/app/appengine/handler.go
+++ b/app/appengine/handler.go
@@ -239,12 +239,21 @@
 	return key == builderKey(c, builder)
 }
 
-func isMasterKey(c context.Context, k string) bool {
-	return k == key.Secret(datastoreClient, c)
+var devModeMasterKey string
+
+func masterKey(ctx context.Context) string {
+	if *dev {
+		return devModeMasterKey
+	}
+	return key.Secret(ctx, datastoreClient)
 }
 
-func builderKey(c context.Context, builder string) string {
-	h := hmac.New(md5.New, []byte(key.Secret(datastoreClient, c)))
+func isMasterKey(ctx context.Context, k string) bool {
+	return k == masterKey(ctx)
+}
+
+func builderKey(ctx context.Context, builder string) string {
+	h := hmac.New(md5.New, []byte(masterKey(ctx)))
 	h.Write([]byte(builder))
 	return fmt.Sprintf("%x", h.Sum(nil))
 }
diff --git a/app/appengine/ui.go b/app/appengine/ui.go
index 4f8ea00..0a1fe0a 100644
--- a/app/appengine/ui.go
+++ b/app/appengine/ui.go
@@ -123,6 +123,9 @@
 // getCommitsToLoad returns a set (all values are true) of which commits to load from
 // the datastore.
 func (tb *uiTemplateDataBuilder) getCommitsToLoad() map[commitInPackage]bool {
+	if *fakeResults {
+		return nil
+	}
 	m := make(map[commitInPackage]bool)
 	add := func(packagePath, commit string) {
 		m[commitInPackage{packagePath: packagePath, commit: commit}] = true
diff --git a/app/key/key.go b/app/key/key.go
index 06bfb68..c903e24 100644
--- a/app/key/key.go
+++ b/app/key/key.go
@@ -22,7 +22,7 @@
 
 var dsKey = datastore.NameKey("BuilderKey", "root", nil)
 
-func Secret(c *datastore.Client, ctx context.Context) string {
+func Secret(ctx context.Context, c *datastore.Client) string {
 	// check with rlock
 	theKey.RLock()
 	k := theKey.Secret