web: behavior: use go:embed, re-parse index.html

structural changes:

* switch from packr to go:embed!
* move many-tiny-handler-packages to single web package

behavior change:

* add --web-public-dir flag for specifying a live directory to serve
  instead of the embedded FS. this is set by the dev Dockerfile.

* we now parse the index.html template on every request, but there
  shouldn't be many requests in the first place since the web UI is a
  single-page app

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
This commit is contained in:
Alex Suraci 2021-02-17 18:01:18 -05:00
parent 8c05348336
commit f715c438ee
15 changed files with 146 additions and 236 deletions

View File

@ -14,13 +14,15 @@ RUN grep '^replace' go.mod || go mod download
COPY ./cmd/init/init.c /tmp/init.c
RUN gcc -O2 -static -o /usr/local/concourse/bin/init /tmp/init.c && rm /tmp/init.c
# build Concourse without using 'packr' so that the volume in the next stage
# can live-update
# copy the rest separately so we don't constantly rebuild init
COPY . .
# build 'concourse' binary
RUN go build -gcflags=all="-N -l" -o /usr/local/concourse/bin/concourse \
./cmd/concourse
RUN set -x && \
go build -ldflags '-extldflags "-static"' -o /tmp/fly ./fly && \
# build 'fly' binary and update web CLI asset
RUN go build -ldflags '-extldflags "-static"' -o /tmp/fly ./fly && \
tar -C /tmp -czf /usr/local/concourse/fly-assets/fly-$(go env GOOS)-$(go env GOARCH).tgz fly && \
rm /tmp/fly
@ -29,3 +31,4 @@ FROM base
# set up a volume so locally built web UI changes auto-propagate
VOLUME /src
ENV CONCOURSE_WEB_PUBLIC_DIR=/src/web/public

View File

@ -160,6 +160,7 @@ type RunCommand struct {
GardenRequestTimeout time.Duration `long:"garden-request-timeout" default:"5m" description:"How long to wait for requests to Garden to complete. 0 means no timeout."`
CLIArtifactsDir flag.Dir `long:"cli-artifacts-dir" description:"Directory containing downloadable CLI binaries."`
WebPublicDir flag.Dir `long:"web-public-dir" description:"Web public/ directory to serve live for local development."`
Metrics struct {
HostName string `long:"metrics-host-name" description:"Host string to attach to emitted metrics."`
@ -1356,7 +1357,7 @@ func (cmd *RunCommand) oldKey() *encryption.Key {
}
func (cmd *RunCommand) constructWebHandler(logger lager.Logger) (http.Handler, error) {
webHandler, err := web.NewHandler(logger)
webHandler, err := web.NewHandler(logger, cmd.WebPublicDir.Path())
if err != nil {
return nil, err
}

View File

@ -1,4 +1,4 @@
package publichandler
package web
import (
"fmt"

View File

@ -1,4 +1,4 @@
package publichandler_test
package web_test
import (
"compress/gzip"
@ -11,7 +11,7 @@ import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/concourse/concourse/web/publichandler"
"github.com/concourse/concourse/web"
)
var _ = Describe("CacheNearlyForever", func() {
@ -20,7 +20,7 @@ var _ = Describe("CacheNearlyForever", func() {
fmt.Fprint(w, "The wrapped handler was called!")
})
wrappedHandler := publichandler.CacheNearlyForever(insideHandler)
wrappedHandler := web.CacheNearlyForever(insideHandler)
recorder := httptest.NewRecorder()
request, err := http.NewRequest("GET", "/", nil)
Expect(err).ToNot(HaveOccurred())
@ -39,7 +39,7 @@ var _ = Describe("CacheNearlyForever", func() {
fmt.Fprint(w, strings.Repeat("abc123", 1000))
})
wrappedHandler := publichandler.CacheNearlyForever(insideHandler)
wrappedHandler := web.CacheNearlyForever(insideHandler)
recorder := httptest.NewRecorder()
request, err := http.NewRequest("GET", "/", nil)
Expect(err).ToNot(HaveOccurred())

View File

@ -1,40 +0,0 @@
package main
import (
"net/http"
"os"
"code.cloudfoundry.org/lager"
"github.com/concourse/concourse/web"
"github.com/concourse/concourse/web/proxyhandler"
)
func NewLogger() lager.Logger {
logger := lager.NewLogger("web")
logger.RegisterSink(lager.NewReconfigurableSink(lager.NewPrettySink(os.Stdout, lager.DEBUG), lager.DEBUG))
return logger
}
func main() {
logger := NewLogger()
proxyHandler, err := proxyhandler.NewHandler(logger, "http://localhost:8080")
if err != nil {
panic(err)
}
webHandler, err := web.NewHandler(logger)
if err != nil {
panic(err)
}
http.Handle("/api/", proxyHandler)
http.Handle("/auth/", proxyHandler)
http.Handle("/oauth/", proxyHandler)
http.Handle("/", webHandler)
if err = http.ListenAndServe(":8081", nil); err != nil {
logger.Error("server-error", err)
}
}

View File

@ -1,30 +1,35 @@
package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"code.cloudfoundry.org/lager"
"github.com/concourse/concourse/web/indexhandler"
"github.com/concourse/concourse/web/publichandler"
"github.com/concourse/concourse/web/robotshandler"
)
func NewHandler(logger lager.Logger) (http.Handler, error) {
indexHandler, err := indexhandler.NewHandler(logger)
if err != nil {
return nil, err
}
//go:embed public
var publicEmbed embed.FS
publicHandler, err := publichandler.NewHandler()
if err != nil {
return nil, err
func NewHandler(logger lager.Logger, livePublicDir string) (http.Handler, error) {
var publicFS fs.FS
if livePublicDir != "" {
publicFS = os.DirFS(livePublicDir)
} else {
var err error
publicFS, err = fs.Sub(publicEmbed, "public")
if err != nil {
return nil, fmt.Errorf("public fs sub: %w", err)
}
}
robotsHandler := robotshandler.NewHandler()
webMux := http.NewServeMux()
webMux.Handle("/public/", publicHandler)
webMux.Handle("/robots.txt", robotsHandler)
webMux.Handle("/", indexHandler)
webMux.Handle("/public/", PublicHandler(publicFS))
webMux.Handle("/robots.txt", RobotsHandler)
webMux.Handle("/", IndexHandler(logger, publicFS))
return webMux, nil
}

80
web/index_handler.go Normal file
View File

@ -0,0 +1,80 @@
package web
import (
"crypto/md5"
"fmt"
"html/template"
"io/fs"
"net/http"
"sync"
"code.cloudfoundry.org/lager"
)
func IndexHandler(logger lager.Logger, publicFS fs.FS) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := logger.Session("index")
tfuncs := &indexTemplateFuncs{
publicFS: publicFS,
assetIDs: map[string]string{},
}
funcs := template.FuncMap{
"asset": tfuncs.asset,
}
t, err := template.New("web").Funcs(funcs).ParseFS(publicFS, "index.html")
if err != nil {
log.Error("failed-to-parse-templates", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = t.ExecuteTemplate(w, "index.html", indexTemplateData{
CSRFToken: r.FormValue("csrf_token"),
AuthToken: r.Header.Get("Authorization"),
})
if err != nil {
log.Error("failed-to-build-template", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
})
}
type indexTemplateData struct {
CSRFToken string
AuthToken string
}
type indexTemplateFuncs struct {
publicFS fs.FS
assetIDs map[string]string
assetsL sync.Mutex
}
func (funcs *indexTemplateFuncs) asset(asset string) (string, error) {
funcs.assetsL.Lock()
defer funcs.assetsL.Unlock()
id, found := funcs.assetIDs[asset]
if !found {
hash := md5.New()
contents, err := fs.ReadFile(funcs.publicFS, asset)
if err != nil {
return "", err
}
_, err = hash.Write(contents)
if err != nil {
return "", err
}
id = fmt.Sprintf("%x", hash.Sum(nil))
}
return fmt.Sprintf("/public/%s?id=%s", asset, id), nil
}

View File

@ -1,61 +0,0 @@
package indexhandler
import (
"html/template"
"net/http"
"code.cloudfoundry.org/lager"
"github.com/gobuffalo/packr"
)
type templateData struct {
CSRFToken string
AuthToken string
}
type handler struct {
logger lager.Logger
template *template.Template
}
func NewHandler(logger lager.Logger) (http.Handler, error) {
tfuncs := &templateFuncs{
assetIDs: map[string]string{},
}
funcs := template.FuncMap{
"asset": tfuncs.asset,
}
box := packr.NewBox("../public")
src, err := box.MustBytes("index.html")
if err != nil {
return nil, err
}
t, err := template.New("index").Funcs(funcs).Parse(string(src))
if err != nil {
return nil, err
}
return &handler{
logger: logger,
template: t,
}, nil
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := h.logger.Session("index")
err := h.template.Execute(w, templateData{
CSRFToken: r.FormValue("csrf_token"),
AuthToken: r.Header.Get("Authorization"),
})
if err != nil {
log.Fatal("failed-to-build-template", err, lager.Data{})
w.WriteHeader(http.StatusInternalServerError)
}
}

View File

@ -1,40 +0,0 @@
package indexhandler
import (
"crypto/md5"
"fmt"
"sync"
"github.com/gobuffalo/packr"
)
type templateFuncs struct {
assetIDs map[string]string
assetsL sync.Mutex
}
func (funcs *templateFuncs) asset(asset string) (string, error) {
funcs.assetsL.Lock()
defer funcs.assetsL.Unlock()
box := packr.NewBox("../public")
id, found := funcs.assetIDs[asset]
if !found {
hash := md5.New()
contents, err := box.MustBytes(asset)
if err != nil {
return "", err
}
_, err = hash.Write(contents)
if err != nil {
return "", err
}
id = fmt.Sprintf("%x", hash.Sum(nil))
}
return fmt.Sprintf("/public/%s?id=%s", asset, id), nil
}

View File

@ -1,36 +0,0 @@
package proxyhandler
import (
"net"
"net/http"
"net/http/httputil"
"net/url"
"time"
"code.cloudfoundry.org/lager"
)
func NewHandler(logger lager.Logger, host string) (http.Handler, error) {
targetUrl, err := url.Parse(host)
if err != nil {
return nil, err
}
dialer := &net.Dialer{
Timeout: 24 * time.Hour,
KeepAlive: 24 * time.Hour,
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
Dial: dialer.Dial,
TLSHandshakeTimeout: 60 * time.Second,
}
handler := httputil.NewSingleHostReverseProxy(targetUrl)
handler.FlushInterval = 100 * time.Millisecond
handler.Transport = transport
return handler, nil
}

15
web/public_handler.go Normal file
View File

@ -0,0 +1,15 @@
package web
import (
"io/fs"
"net/http"
)
func PublicHandler(publicFS fs.FS) http.Handler {
return CacheNearlyForever(
http.StripPrefix(
"/public/",
http.FileServer(http.FS(publicFS)),
),
)
}

View File

@ -1,11 +0,0 @@
package publichandler
import (
"net/http"
"github.com/gobuffalo/packr"
)
func NewHandler() (http.Handler, error) {
return CacheNearlyForever(http.StripPrefix("/public/", http.FileServer(packr.NewBox("../public")))), nil
}

11
web/robots_handler.go Normal file
View File

@ -0,0 +1,11 @@
package web
import (
"fmt"
"net/http"
)
var RobotsHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "User-agent: *")
fmt.Fprintln(w, "Disallow: /")
})

View File

@ -1,17 +0,0 @@
package robotshandler
import (
"fmt"
"net/http"
)
func NewHandler() http.Handler {
return &handler{}
}
type handler struct{}
func (self *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "User-agent: *")
fmt.Fprintln(w, "Disallow: /")
}

View File

@ -1,13 +1,13 @@
package publichandler_test
package web_test
import (
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"testing"
)
func TestPublichandler(t *testing.T) {
func TestWebHandler(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Public Handler Suite")
RunSpecs(t, "Web Handler Suite")
}