cmd/relui: add relui webserver for releases

This commit introduces relui, a prototype for running releases in a
persistent server environment. The primary goals of using a long-running
service for release management is to improve observability, scheduling,
and testing of our release process.

This change introduces a very basic webserver, and minimal styling.

For golang/go#40279

Change-Id: I5958e5dc19e62df92b36c64583058cbcef8dd75c
Reviewed-on: https://go-review.googlesource.com/c/build/+/243338
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Andrew Bonventre <andybons@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/cmd/relui/README.md b/cmd/relui/README.md
new file mode 100644
index 0000000..eb1b511
--- /dev/null
+++ b/cmd/relui/README.md
@@ -0,0 +1,12 @@
+#golang.org/x/build/cmd/relui
+
+```
+               ▀▀█             ▀
+  ▄ ▄▄   ▄▄▄     █    ▄   ▄  ▄▄▄
+  █▀  ▀ █▀  █    █    █   █    █
+  █     █▀▀▀▀    █    █   █    █
+  █     ▀█▄▄▀    ▀▄▄  ▀▄▄▀█  ▄▄█▄▄
+```
+
+relui is a web interface for managing the release process of Go.
+
diff --git a/cmd/relui/index.html b/cmd/relui/index.html
new file mode 100644
index 0000000..0c7de90
--- /dev/null
+++ b/cmd/relui/index.html
@@ -0,0 +1,35 @@
+<!--
+    Copyright 2020 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.
+-->
+<!DOCTYPE html>
+<html lang="en">
+  <title>Go Releases</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <link rel="stylesheet" href="/styles.css" />
+  <body class="Site">
+    <header class="Site-header">
+      <div class="Header">
+        <h1 class="Header-title">Go Releases</h1>
+      </div>
+    </header>
+    <main class="Site-content">
+      <section class="Workflows">
+        <h2>Workflows</h2>
+        <ul class="WorkflowList">
+          <li class="WorkflowList-item">
+            <h3>Local Release - 20a838ab</h3>
+            <form> </form>
+            <ul class="TaskList">
+              <li class="TaskList-item">
+                <h4>Fetch blob - 20a838ab</h4>
+                Status: created
+              </li>
+            </ul>
+          </li>
+        </ul>
+      </section>
+    </main>
+  </body>
+</html>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
new file mode 100644
index 0000000..57e52bb
--- /dev/null
+++ b/cmd/relui/main.go
@@ -0,0 +1,22 @@
+// Copyright 2020 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
+
+import (
+	"log"
+	"net/http"
+	"os"
+)
+
+func main() {
+	http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(homeHandler)))
+	port := os.Getenv("PORT")
+	if port == "" {
+		port = "8080"
+	}
+
+	log.Printf("Listening on :" + port)
+	log.Fatal(http.ListenAndServe(":"+port, http.DefaultServeMux))
+}
diff --git a/cmd/relui/static/styles.css b/cmd/relui/static/styles.css
new file mode 100644
index 0000000..3d6217a
--- /dev/null
+++ b/cmd/relui/static/styles.css
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020 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.
+ */
+*,
+:before,
+:after {
+  box-sizing: border-box;
+}
+html,
+.Site {
+  height: 100%;
+}
+.Site {
+  display: flex;
+  flex-direction: column;
+  font-family: sans-serif;
+  margin: 0;
+}
+h1,
+h2 {
+  font-weight: 600;
+  letter-spacing: 0.03rem;
+}
+
+h3,
+h4 {
+  font-weight: 600;
+  letter-spacing: 0.08rem;
+}
+h5,
+h6 {
+  font-weight: 500;
+  letter-spacing: 0.08rem;
+}
+.Site-content {
+  flex: 1 0 auto;
+  padding: 0.625rem;
+  width: 100%;
+}
+.Site-header {
+  flex: none;
+}
+.Header {
+  background: #e0ebf5;
+  color: #375eab;
+  padding: 0.625rem;
+}
+.Header-title {
+  font-size: 1.5rem;
+  margin: 0;
+}
diff --git a/cmd/relui/testing/test.css b/cmd/relui/testing/test.css
new file mode 100644
index 0000000..ecd36b5
--- /dev/null
+++ b/cmd/relui/testing/test.css
@@ -0,0 +1 @@
+.Header { font-size: 10rem; }
diff --git a/cmd/relui/web.go b/cmd/relui/web.go
new file mode 100644
index 0000000..40cd773
--- /dev/null
+++ b/cmd/relui/web.go
@@ -0,0 +1,54 @@
+// Copyright 2020 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
+
+import (
+	"html/template"
+	"log"
+	"mime"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+)
+
+func fileServerHandler(root string, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/" {
+			next.ServeHTTP(w, r)
+			return
+		}
+		if _, err := os.Stat(path.Join(root, r.URL.Path)); os.IsNotExist(err) {
+			http.NotFound(w, r)
+			return
+		}
+		w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
+		w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
+
+		fs := http.FileServer(http.Dir(root))
+		fs.ServeHTTP(w, r)
+	})
+}
+
+var homeTemplate = template.Must(template.ParseFiles(relativeFile("index.html")))
+
+func homeHandler(w http.ResponseWriter, _ *http.Request) {
+	if err := homeTemplate.Execute(w, nil); err != nil {
+		log.Printf("homeHandlerFunc: %v", err)
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+	}
+}
+
+// relativeFile returns the path to the provided file or directory,
+// conditionally prepending a relative path depending on the environment.
+//
+// In tests the current directory is ".", but the command may be running from the module root.
+func relativeFile(base string) string {
+	// Check to see if it is in "." first.
+	if _, err := os.Stat(base); err == nil {
+		return base
+	}
+	return filepath.Join("cmd/relui", base)
+}
diff --git a/cmd/relui/web_test.go b/cmd/relui/web_test.go
new file mode 100644
index 0000000..1649e2d
--- /dev/null
+++ b/cmd/relui/web_test.go
@@ -0,0 +1,87 @@
+// Copyright 2020 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
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestHomeHandler(t *testing.T) {
+	req := httptest.NewRequest("GET", "/", nil)
+	w := httptest.NewRecorder()
+
+	homeHandler(w, req)
+	resp := w.Result()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+	}
+}
+
+func TestFileServerHandler(t *testing.T) {
+	h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("Home"))
+	}))
+
+	cases := []struct {
+		desc        string
+		path        string
+		wantCode    int
+		wantBody    string
+		wantHeaders map[string]string
+	}{
+		{
+			desc:     "fallback to next handler",
+			path:     "/",
+			wantCode: http.StatusOK,
+			wantBody: "Home",
+		},
+		{
+			desc:     "sets headers and returns file",
+			path:     "/test.css",
+			wantCode: http.StatusOK,
+			wantBody: ".Header { font-size: 10rem; }\n",
+			wantHeaders: map[string]string{
+				"Content-Type":  "text/css; charset=utf-8",
+				"Cache-Control": "no-cache, private, max-age=0",
+			},
+		},
+		{
+			desc:     "handles missing file",
+			path:     "/foo.js",
+			wantCode: http.StatusNotFound,
+			wantBody: "404 page not found\n",
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			req := httptest.NewRequest("GET", c.path, nil)
+			w := httptest.NewRecorder()
+
+			h.ServeHTTP(w, req)
+			resp := w.Result()
+			defer resp.Body.Close()
+
+			if resp.StatusCode != c.wantCode {
+				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
+			}
+			b, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				t.Errorf("resp.Body = _, %v, wanted no error", err)
+			}
+			if string(b) != c.wantBody {
+				t.Errorf("resp.Body = %q, %v, wanted %q, %v", b, err, c.wantBody, nil)
+			}
+			for k, v := range c.wantHeaders {
+				if resp.Header.Get(k) != v {
+					t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
+				}
+			}
+		})
+	}
+}