From 00b56ecefd79be8a5b49602f7472f71dea05b99b Mon Sep 17 00:00:00 2001 From: TwiN Date: Sun, 9 Oct 2022 21:33:31 -0400 Subject: [PATCH] feat: Bundle assets in binary using go:embed (#340) Fixes #47 --- Dockerfile | 1 - config/config_test.go | 5 -- config/ui/ui.go | 8 +-- config/ui/ui_test.go | 4 -- controller/controller.go | 3 +- controller/handler/badge_test.go | 2 +- controller/handler/chart_test.go | 2 +- controller/handler/endpoint_status_test.go | 4 +- controller/handler/favicon.go | 12 ---- controller/handler/favicon_test.go | 35 ---------- controller/handler/handler.go | 15 +++-- controller/handler/handler_test.go | 22 ++++++- controller/handler/spa.go | 16 +++-- controller/handler/spa_test.go | 2 +- web/static.go | 13 ++++ web/static_test.go | 74 ++++++++++++++++++++++ 16 files changed, 135 insertions(+), 83 deletions(-) delete mode 100644 controller/handler/favicon.go delete mode 100644 controller/handler/favicon_test.go create mode 100644 web/static.go create mode 100644 web/static_test.go diff --git a/Dockerfile b/Dockerfile index 277a15ff..1fae4952 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus FROM scratch COPY --from=builder /app/gatus . COPY --from=builder /app/config.yaml ./config/config.yaml -COPY --from=builder /app/web/static ./web/static COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt ENV PORT=8080 EXPOSE ${PORT} diff --git a/config/config_test.go b/config/config_test.go index 1273eb54..d3a5ea1b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -17,7 +17,6 @@ import ( "github.com/TwiN/gatus/v4/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/twilio" "github.com/TwiN/gatus/v4/client" - "github.com/TwiN/gatus/v4/config/ui" "github.com/TwiN/gatus/v4/config/web" "github.com/TwiN/gatus/v4/core" "github.com/TwiN/gatus/v4/storage" @@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) { func TestParseAndValidateConfigBytes(t *testing.T) { file := t.TempDir() + "/test.db" - ui.StaticFolder = "../web/static" - defer func() { - ui.StaticFolder = "./web/static" - }() config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` storage: type: sqlite diff --git a/config/ui/ui.go b/config/ui/ui.go index 51a71a4e..5a337180 100644 --- a/config/ui/ui.go +++ b/config/ui/ui.go @@ -4,6 +4,8 @@ import ( "bytes" "errors" "html/template" + + "github.com/TwiN/gatus/v4/web" ) const ( @@ -14,10 +16,6 @@ const ( ) var ( - // StaticFolder is the path to the location of the static folder from the root path of the project - // The only reason this is exposed is to allow running tests from a different path than the root path of the project - StaticFolder = "./web/static" - ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link") ) @@ -71,7 +69,7 @@ func (cfg *Config) ValidateAndSetDefaults() error { } } // Validate that the template works - t, err := template.ParseFiles(StaticFolder + "/index.html") + t, err := template.ParseFS(static.FileSystem, static.IndexPath) if err != nil { return err } diff --git a/config/ui/ui_test.go b/config/ui/ui_test.go index 1c493bc2..885081f3 100644 --- a/config/ui/ui_test.go +++ b/config/ui/ui_test.go @@ -6,10 +6,6 @@ import ( ) func TestConfig_ValidateAndSetDefaults(t *testing.T) { - StaticFolder = "../../web/static" - defer func() { - StaticFolder = "./web/static" - }() cfg := &Config{ Title: "", Header: "", diff --git a/controller/controller.go b/controller/controller.go index 29f398d3..d28c3604 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -9,7 +9,6 @@ import ( "time" "github.com/TwiN/gatus/v4/config" - "github.com/TwiN/gatus/v4/config/ui" "github.com/TwiN/gatus/v4/controller/handler" ) @@ -21,7 +20,7 @@ var ( // Handle creates the router and starts the server func Handle(cfg *config.Config) { - var router http.Handler = handler.CreateRouter(ui.StaticFolder, cfg) + var router http.Handler = handler.CreateRouter(cfg) if os.Getenv("ENVIRONMENT") == "dev" { router = handler.DevelopmentCORS(router) } diff --git a/controller/handler/badge_test.go b/controller/handler/badge_test.go index 459ea910..632989c4 100644 --- a/controller/handler/badge_test.go +++ b/controller/handler/badge_test.go @@ -62,7 +62,7 @@ func TestBadge(t *testing.T) { watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) - router := CreateRouter("../../web/static", cfg) + router := CreateRouter(cfg) type Scenario struct { Name string Path string diff --git a/controller/handler/chart_test.go b/controller/handler/chart_test.go index 2ed6ba12..4869831b 100644 --- a/controller/handler/chart_test.go +++ b/controller/handler/chart_test.go @@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) { } watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) - router := CreateRouter("../../web/static", cfg) + router := CreateRouter(cfg) type Scenario struct { Name string Path string diff --git a/controller/handler/endpoint_status_test.go b/controller/handler/endpoint_status_test.go index 523a16e8..94319949 100644 --- a/controller/handler/endpoint_status_test.go +++ b/controller/handler/endpoint_status_test.go @@ -97,7 +97,7 @@ func TestEndpointStatus(t *testing.T) { } watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) - router := CreateRouter("../../web/static", cfg) + router := CreateRouter(cfg) type Scenario struct { Name string @@ -153,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) { // Can't be bothered dealing with timezone issues on the worker that runs the automated tests firstResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{} - router := CreateRouter("../../web/static", &config.Config{Metrics: true}) + router := CreateRouter(&config.Config{Metrics: true}) type Scenario struct { Name string diff --git a/controller/handler/favicon.go b/controller/handler/favicon.go deleted file mode 100644 index ee642c64..00000000 --- a/controller/handler/favicon.go +++ /dev/null @@ -1,12 +0,0 @@ -package handler - -import ( - "net/http" -) - -// FavIcon handles requests for /favicon.ico -func FavIcon(staticFolder string) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - http.ServeFile(writer, request, staticFolder+"/favicon.ico") - } -} diff --git a/controller/handler/favicon_test.go b/controller/handler/favicon_test.go deleted file mode 100644 index 1a9c21ea..00000000 --- a/controller/handler/favicon_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/TwiN/gatus/v4/config" -) - -func TestFavIcon(t *testing.T) { - router := CreateRouter("../../web/static", &config.Config{}) - type Scenario struct { - Name string - Path string - ExpectedCode int - } - scenarios := []Scenario{ - { - Name: "favicon", - Path: "/favicon.ico", - ExpectedCode: http.StatusOK, - }, - } - for _, scenario := range scenarios { - t.Run(scenario.Name, func(t *testing.T) { - request, _ := http.NewRequest("GET", scenario.Path, http.NoBody) - responseRecorder := httptest.NewRecorder() - router.ServeHTTP(responseRecorder, request) - if responseRecorder.Code != scenario.ExpectedCode { - t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) - } - }) - } -} diff --git a/controller/handler/handler.go b/controller/handler/handler.go index c75c4250..83df2e99 100644 --- a/controller/handler/handler.go +++ b/controller/handler/handler.go @@ -1,15 +1,17 @@ package handler import ( + "io/fs" "net/http" "github.com/TwiN/gatus/v4/config" + "github.com/TwiN/gatus/v4/web" "github.com/TwiN/health" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" ) -func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router { +func CreateRouter(cfg *config.Config) *mux.Router { router := mux.NewRouter() if cfg.Metrics { router.Handle("/metrics", promhttp.Handler()).Methods("GET") @@ -35,11 +37,14 @@ func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router { unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET") // Misc router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") - router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET") // SPA - router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET") - router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET") + router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET") + router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET") // Everything else falls back on static content - router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) + staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath) + if err != nil { + panic(err) + } + router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.FS(staticFileSystem)))) return router } diff --git a/controller/handler/handler_test.go b/controller/handler/handler_test.go index decf9667..ad2b9b96 100644 --- a/controller/handler/handler_test.go +++ b/controller/handler/handler_test.go @@ -9,7 +9,7 @@ import ( ) func TestCreateRouter(t *testing.T) { - router := CreateRouter("../../web/static", &config.Config{Metrics: true}) + router := CreateRouter(&config.Config{Metrics: true}) type Scenario struct { Name string Path string @@ -28,16 +28,32 @@ func TestCreateRouter(t *testing.T) { ExpectedCode: http.StatusOK, }, { - Name: "scripts", + Name: "favicon.ico", + Path: "/favicon.ico", + ExpectedCode: http.StatusOK, + }, + { + Name: "app.js", Path: "/js/app.js", ExpectedCode: http.StatusOK, }, { - Name: "scripts-gzipped", + Name: "app.js-gzipped", Path: "/js/app.js", ExpectedCode: http.StatusOK, Gzip: true, }, + { + Name: "chunk-vendors.js", + Path: "/js/chunk-vendors.js", + ExpectedCode: http.StatusOK, + }, + { + Name: "chunk-vendors.js-gzipped", + Path: "/js/chunk-vendors.js", + ExpectedCode: http.StatusOK, + Gzip: true, + }, { Name: "index-redirect", Path: "/index.html", diff --git a/controller/handler/spa.go b/controller/handler/spa.go index 6c17d1bd..1b9fa817 100644 --- a/controller/handler/spa.go +++ b/controller/handler/spa.go @@ -1,26 +1,30 @@ package handler import ( + _ "embed" "html/template" "log" "net/http" "github.com/TwiN/gatus/v4/config/ui" + "github.com/TwiN/gatus/v4/web" ) -func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc { +func SinglePageApplication(ui *ui.Config) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { - t, err := template.ParseFiles(staticFolder + "/index.html") + t, err := template.ParseFS(static.FileSystem, static.IndexPath) if err != nil { - log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) - http.ServeFile(writer, request, staticFolder+"/index.html") + // This should never happen, because ui.ValidateAndSetDefaults validates that the template works. + log.Println("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error()) + http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError) return } writer.Header().Set("Content-Type", "text/html") err = t.Execute(writer, ui) if err != nil { - log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) - http.ServeFile(writer, request, staticFolder+"/index.html") + // This should never happen, because ui.ValidateAndSetDefaults validates that the template works. + log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error()) + http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError) return } } diff --git a/controller/handler/spa_test.go b/controller/handler/spa_test.go index e1678150..826990dd 100644 --- a/controller/handler/spa_test.go +++ b/controller/handler/spa_test.go @@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) { } watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) - router := CreateRouter("../../web/static", cfg) + router := CreateRouter(cfg) type Scenario struct { Name string Path string diff --git a/web/static.go b/web/static.go new file mode 100644 index 00000000..97a34f8f --- /dev/null +++ b/web/static.go @@ -0,0 +1,13 @@ +package static + +import "embed" + +var ( + //go:embed static + FileSystem embed.FS +) + +const ( + RootPath = "static" + IndexPath = RootPath + "/index.html" +) diff --git a/web/static_test.go b/web/static_test.go new file mode 100644 index 00000000..a8b79ade --- /dev/null +++ b/web/static_test.go @@ -0,0 +1,74 @@ +package static + +import ( + "io/fs" + "strings" + "testing" +) + +func TestEmbed(t *testing.T) { + scenarios := []struct { + path string + shouldExist bool + expectedContainString string + }{ + { + path: "index.html", + shouldExist: true, + expectedContainString: "", + }, + { + path: "favicon.ico", + shouldExist: true, + expectedContainString: "", // not checking because it's an image + }, + { + path: "img/logo.svg", + shouldExist: true, + expectedContainString: "", + }, + { + path: "css/app.css", + shouldExist: true, + expectedContainString: "background-color", + }, + { + path: "js/app.js", + shouldExist: true, + expectedContainString: "function", + }, + { + path: "js/chunk-vendors.js", + shouldExist: true, + expectedContainString: "function", + }, + { + path: "file-that-does-not-exist.html", + shouldExist: false, + }, + } + staticFileSystem, err := fs.Sub(FileSystem, RootPath) + if err != nil { + t.Fatal(err) + } + for _, scenario := range scenarios { + t.Run(scenario.path, func(t *testing.T) { + content, err := fs.ReadFile(staticFileSystem, scenario.path) + if !scenario.shouldExist { + if err == nil { + t.Errorf("%s should not have existed", scenario.path) + } + } else { + if err != nil { + t.Errorf("opening %s should not have returned an error, got %s", scenario.path, err.Error()) + } + if len(content) == 0 { + t.Errorf("%s should have existed in the static FileSystem, but was empty", scenario.path) + } + if !strings.Contains(string(content), scenario.expectedContainString) { + t.Errorf("%s should have contained %s, but did not", scenario.path, scenario.expectedContainString) + } + } + }) + } +}