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)
+ }
+ }
+ })
+ }
+}