mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-28 19:03:24 +01:00
Merge pull request #46 from mindcrime-ilab/feature/context-root
Feature/context root
This commit is contained in:
commit
53e1012ca1
@ -139,6 +139,7 @@ Note that you can also add environment variables in the configuration file (i.e.
|
|||||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` |
|
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` |
|
||||||
| `web.address` | Address to listen on | `0.0.0.0` |
|
| `web.address` | Address to listen on | `0.0.0.0` |
|
||||||
| `web.port` | Port to listen on | `8080` |
|
| `web.port` | Port to listen on | `8080` |
|
||||||
|
| `web.context-root` | Context root which should be used by the web frontent | `/` |
|
||||||
|
|
||||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
|
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
|
||||||
|
|
||||||
|
@ -28,6 +28,9 @@ const (
|
|||||||
|
|
||||||
// DefaultPort is the default port the service will listen on
|
// DefaultPort is the default port the service will listen on
|
||||||
DefaultPort = 8080
|
DefaultPort = 8080
|
||||||
|
|
||||||
|
// DefaultContextRoot is the default context root of the web application
|
||||||
|
DefaultContextRoot = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -143,7 +146,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
|
|
||||||
func validateWebConfig(config *Config) {
|
func validateWebConfig(config *Config) {
|
||||||
if config.Web == nil {
|
if config.Web == nil {
|
||||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort}
|
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
|
||||||
} else {
|
} else {
|
||||||
config.Web.validateAndSetDefaults()
|
config.Web.validateAndSetDefaults()
|
||||||
}
|
}
|
||||||
|
@ -109,6 +109,9 @@ services:
|
|||||||
if config.Web.Port != DefaultPort {
|
if config.Web.Port != DefaultPort {
|
||||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||||
}
|
}
|
||||||
|
if config.Web.ContextRoot != DefaultContextRoot {
|
||||||
|
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
|
||||||
@ -215,6 +218,44 @@ services:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) {
|
||||||
|
config, err := parseAndValidateConfigBytes([]byte(`
|
||||||
|
web:
|
||||||
|
port: 12345
|
||||||
|
address: 127.0.0.1
|
||||||
|
context-root: /deeply/nested/down=/their
|
||||||
|
services:
|
||||||
|
- name: twinnation
|
||||||
|
url: https://twinnation.org/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Error("No error should've been returned")
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
t.Fatal("Config shouldn't have been nil")
|
||||||
|
}
|
||||||
|
if config.Metrics {
|
||||||
|
t.Error("Metrics should've been false by default")
|
||||||
|
}
|
||||||
|
if config.Services[0].URL != "https://twinnation.org/health" {
|
||||||
|
t.Errorf("URL should have been %s", "https://twinnation.org/health")
|
||||||
|
}
|
||||||
|
if config.Services[0].Interval != 60*time.Second {
|
||||||
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
|
}
|
||||||
|
if config.Web.Address != "127.0.0.1" {
|
||||||
|
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
|
||||||
|
}
|
||||||
|
if config.Web.Port != 12345 {
|
||||||
|
t.Errorf("Port should have been %d, because it is specified in config", 12345)
|
||||||
|
}
|
||||||
|
if config.Web.ContextRoot != "/deeply/nested/down=/their/" {
|
||||||
|
t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
|
||||||
defer func() { recover() }()
|
defer func() { recover() }()
|
||||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||||
@ -260,6 +301,9 @@ services:
|
|||||||
if config.Web.Port != DefaultPort {
|
if config.Web.Port != DefaultPort {
|
||||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||||
}
|
}
|
||||||
|
if config.Web.ContextRoot != DefaultContextRoot {
|
||||||
|
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
|
||||||
|
@ -3,6 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// webConfig is the structure which supports the configuration of the endpoint
|
// webConfig is the structure which supports the configuration of the endpoint
|
||||||
@ -13,10 +15,13 @@ type webConfig struct {
|
|||||||
|
|
||||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
|
|
||||||
|
// ContextRoot set the root context for the web application
|
||||||
|
ContextRoot string `yaml:"context-root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAndSetDefaults checks and sets missing values based on the defaults
|
// validateAndSetDefaults checks and sets missing values based on the defaults
|
||||||
// in given in DefaultAddress and DefaultPort if necessary
|
// in given in DefaultWebContext, DefaultAddress and DefaultPort if necessary
|
||||||
func (web *webConfig) validateAndSetDefaults() {
|
func (web *webConfig) validateAndSetDefaults() {
|
||||||
if len(web.Address) == 0 {
|
if len(web.Address) == 0 {
|
||||||
web.Address = DefaultAddress
|
web.Address = DefaultAddress
|
||||||
@ -26,9 +31,38 @@ func (web *webConfig) validateAndSetDefaults() {
|
|||||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||||
panic(fmt.Sprintf("port has an invalid: value should be between %d and %d", 0, math.MaxUint16))
|
panic(fmt.Sprintf("port has an invalid: value should be between %d and %d", 0, math.MaxUint16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
web.ContextRoot = validateAndBuild(web.ContextRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAndBuild validates and builds a checked
|
||||||
|
// path for the context root
|
||||||
|
func validateAndBuild(contextRoot string) string {
|
||||||
|
trimedContextRoot := strings.Trim(contextRoot, "/")
|
||||||
|
|
||||||
|
if len(trimedContextRoot) == 0 {
|
||||||
|
return DefaultContextRoot
|
||||||
|
} else {
|
||||||
|
url, err := url.Parse(trimedContextRoot)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Invalid context root %s - error: %s.", contextRoot, err))
|
||||||
|
}
|
||||||
|
if url.Path != trimedContextRoot {
|
||||||
|
panic(fmt.Sprintf("Invalid context root %s, simple path required.", contextRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + strings.Trim(url.Path, "/") + "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SocketAddress returns the combination of the Address and the Port
|
// SocketAddress returns the combination of the Address and the Port
|
||||||
func (web *webConfig) SocketAddress() string {
|
func (web *webConfig) SocketAddress() string {
|
||||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrependWithContextRoot appends the given string to the context root
|
||||||
|
// PrependWithContextRoot takes care of having only one "/" character at
|
||||||
|
// the join point and exactly on "/" at the end
|
||||||
|
func (web *webConfig) PrependWithContextRoot(fragment string) string {
|
||||||
|
return web.ContextRoot + strings.Trim(fragment, "/") + "/"
|
||||||
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||||
web := &webConfig{
|
web := &webConfig{
|
||||||
@ -11,3 +14,75 @@ func TestWebConfig_SocketAddress(t *testing.T) {
|
|||||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validContextRootTest specifies all test case which should end up in
|
||||||
|
// a valid context root used to bind the web interface to
|
||||||
|
var validContextRootTests = []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{"Empty", "", "/"},
|
||||||
|
{"/", "/", "/"},
|
||||||
|
{"///", "///", "/"},
|
||||||
|
{"Single character 'a'", "a", "/a/"},
|
||||||
|
{"Slash at the beginning", "/status", "/status/"},
|
||||||
|
{"Slashes at start and end", "/status/", "/status/"},
|
||||||
|
{"Multiple slashes at start", "//status", "/status/"},
|
||||||
|
{"Mutliple slashes at start and end", "///status////", "/status/"},
|
||||||
|
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
|
||||||
|
{"nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
|
||||||
|
{"nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||||
|
for idx, test := range validContextRootTests {
|
||||||
|
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||||
|
expectValidResultForContextRoot(t, test.path, test.expectedPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidContextRootTests contains all tests for context root which are
|
||||||
|
// expected to fail and stop program execution
|
||||||
|
var invalidContextRootTests = []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"Only a fragment identifier", "#"},
|
||||||
|
{"Invalid character in path", "/invalid" + string([]byte{0x7F}) + "/"},
|
||||||
|
{"Starts with protocol", "http://status/gatus"},
|
||||||
|
{"Path with fragment", "/status/gatus#here"},
|
||||||
|
{"starts with '://'", "://status"},
|
||||||
|
{"contains query parameter", "/status/h?ello=world"},
|
||||||
|
{"contains '?'", "/status?/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebConfig_InvalidContextRoots(t *testing.T) {
|
||||||
|
for idx, test := range invalidContextRootTests {
|
||||||
|
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||||
|
expectInvalidResultForContextRoot(t, test.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectInvalidResultForContextRoot(t *testing.T, path string) {
|
||||||
|
defer func() { recover() }()
|
||||||
|
|
||||||
|
web := &webConfig{ContextRoot: path}
|
||||||
|
web.validateAndSetDefaults()
|
||||||
|
|
||||||
|
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
|
||||||
|
web := &webConfig{
|
||||||
|
ContextRoot: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
web.validateAndSetDefaults()
|
||||||
|
|
||||||
|
if web.ContextRoot != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
18
main.go
18
main.go
@ -29,13 +29,16 @@ func main() {
|
|||||||
if cfg.Security != nil && cfg.Security.IsValid() {
|
if cfg.Security != nil && cfg.Security.IsValid() {
|
||||||
resultsHandler = security.Handler(serviceResultsHandler, cfg.Security)
|
resultsHandler = security.Handler(serviceResultsHandler, cfg.Security)
|
||||||
}
|
}
|
||||||
http.HandleFunc("/api/v1/results", resultsHandler)
|
// favicon needs to be always served from the root
|
||||||
http.HandleFunc("/health", healthHandler)
|
http.HandleFunc("/favicon.ico", favIconHandler)
|
||||||
http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static"))))
|
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/results"), resultsHandler)
|
||||||
|
http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler)
|
||||||
|
http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
|
||||||
|
|
||||||
if cfg.Metrics {
|
if cfg.Metrics {
|
||||||
http.Handle("/metrics", promhttp.Handler())
|
http.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler())
|
||||||
}
|
}
|
||||||
log.Printf("[main][main] Listening on %s\n", cfg.Web.SocketAddress())
|
log.Printf("[main][main] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
|
||||||
go watchdog.Monitor(cfg)
|
go watchdog.Monitor(cfg)
|
||||||
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
||||||
}
|
}
|
||||||
@ -88,3 +91,8 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
|||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// favIconHanlder responds to /favicon.ico requests
|
||||||
|
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
http.ServeFile(writer, request, "./static/favicon.ico")
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Health Dashboard</title>
|
<title>Health Dashboard</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="stylesheet" href="/bootstrap.min.css" />
|
<link rel="stylesheet" href="./bootstrap.min.css" />
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
background-color: #f7f9fb;
|
background-color: #f7f9fb;
|
||||||
@ -129,11 +129,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/jquery.min.js"></script>
|
<script src="./jquery.min.js"></script>
|
||||||
|
|
||||||
<div id="social">
|
<div id="social">
|
||||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
||||||
<img src="/github.png" alt="GitHub" width="32" height="auto" />
|
<img src="./github.png" alt="GitHub" width="32" height="auto" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -220,7 +220,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshResults() {
|
function refreshResults() {
|
||||||
$.getJSON("/api/v1/results", function (data) {
|
$.getJSON("./api/v1/results", function (data) {
|
||||||
// Update the table only if there's a change
|
// Update the table only if there's a change
|
||||||
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
||||||
serviceStatuses = data;
|
serviceStatuses = data;
|
||||||
|
Loading…
Reference in New Issue
Block a user