Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,13 @@ LABEL description="Service status monitor for Rotational applications and system
# Copy the uptime binary
COPY --from=builder /go/bin/uptime /usr/local/bin/uptime

# Copy the static assets
COPY --from=builder /go/src/go.rtnl.ai/uptime/pkg/web/static /var/www/html

ENV UPTIME_STATIC_SERVE=true
ENV UPTIME_STATIC_ROOT=/var/www/html
ENV UPTIME_STATIC_URL=/static

EXPOSE 8000

CMD [ "/usr/local/bin/uptime", "serve" ]
53 changes: 51 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package config

import (
"errors"
"net/url"
"os"
"strings"
"sync"

"github.com/gin-gonic/gin"
Expand All @@ -22,9 +26,17 @@ type Config struct {
ConsoleLog bool `default:"false" split_words:"true" desc:"if true the server will log to the console in text format"`
BindAddr string `default:":8000" split_words:"true" desc:"the ip address and port to bind the server to"`
AllowedOrigins []string `split_words:"true" default:"http://localhost:8000" desc:"a list of allowed origins for CORS"`
Static StaticConfig
Telemetry TelemetryConfig
}

// StaticConfig contains the configuration for the static file server.
type StaticConfig struct {
Serve bool `default:"true" desc:"if true the static file server will be enabled"`
Root string `default:"pkg/web/static" desc:"the root directory of the static file server"`
URL string `default:"/static" desc:"the URL that static files are served from, either a relative URL or a URL to a CDN"`
}

// Telemetry is primarily configured via the open telemetry sdk environment variables.
// As such there is no need to specify OTel specific configuration here. This config
// is used primarily to enable/disable telemetry and to set values for custom telemetry.
Expand Down Expand Up @@ -56,9 +68,46 @@ func New() (conf *Config, err error) {

func (c Config) Validate() (err error) {
if c.Mode != gin.ReleaseMode && c.Mode != gin.DebugMode && c.Mode != gin.TestMode {
return confire.Invalid("", "mode", "gin mode must be one of: release, debug, test")
err = confire.Join(err, confire.Invalid("", "mode", "gin mode must be one of: release, debug, test"))
}
return nil
return err
}

func (c StaticConfig) Validate() (err error) {
if c.Serve {
if c.Root == "" {
err = confire.Join(err, confire.Required("static", "root"))
} else {
if _, serr := os.Stat(c.Root); errors.Is(serr, os.ErrNotExist) {
err = confire.Join(err, confire.Invalid("static", "root", "directory does not exist"))
}
}
}

if c.URL == "" {
err = confire.Join(err, confire.Required("static", "url"))
} else {
if strings.Contains(c.URL, "://") {
if u, perr := url.Parse(c.URL); perr != nil || u.Scheme == "" || u.Host == "" || u.Path == "" {
err = confire.Join(err, confire.Invalid("static", "url", "must be a valid URL with scheme and host"))
}

if c.Serve {
err = confire.Join(err, confire.Invalid("static", "url", "cannot use a remote URL if static files are served from the filesystem"))
}

} else {
if !strings.HasPrefix(c.URL, "/") {
err = confire.Join(err, confire.Invalid("static", "url", "must be a valid URL or an absolute path starting with a slash"))
}

if !c.Serve {
err = confire.Join(err, confire.Invalid("static", "url", "must be a remote url if static files are not served from the filesystem"))
}
}
}

return err
}

//============================================================================
Expand Down
90 changes: 90 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ var testEnv = contest.Env{
"UPTIME_CONSOLE_LOG": "true",
"UPTIME_BIND_ADDR": ":8888",
"UPTIME_ALLOWED_ORIGINS": "http://localhost:8888",
"UPTIME_STATIC_SERVE": "true",
"UPTIME_STATIC_ROOT": "/tmp",
"UPTIME_STATIC_URL": "/assets",
"UPTIME_TELEMETRY_ENABLED": "false",
"OTEL_SERVICE_NAME": "uptime",
"GIMLET_OTEL_SERVICE_ADDR": "localhost:8888",
Expand All @@ -29,6 +32,11 @@ var validConfig = config.Config{
ConsoleLog: true,
BindAddr: ":8888",
AllowedOrigins: []string{"http://localhost:8888"},
Static: config.StaticConfig{
Serve: true,
Root: "/tmp",
URL: "/assets",
},
Telemetry: config.TelemetryConfig{
Enabled: false,
ServiceName: "uptime",
Expand Down Expand Up @@ -67,3 +75,85 @@ func TestConfig(t *testing.T) {

})
}

func TestStaticConfigValidate(t *testing.T) {

t.Run("Valid", func(t *testing.T) {
require.NoError(t, validConfig.Static.Validate(), "valid static config should pass validation")
})

t.Run("Invalid", func(t *testing.T) {
tests := []struct {
name string
conf config.StaticConfig
err string
}{
{
"root is required",
config.StaticConfig{
Serve: true,
URL: "/static",
},
"invalid configuration: static.root is required but not set",
},
{
"root does not exist",
config.StaticConfig{
Serve: true,
Root: "/var/lib/www/not-a-directory",
URL: "/static",
},
"invalid configuration: static.root directory does not exist",
},
{
"url is required",
config.StaticConfig{
Serve: true,
Root: "../web/static",
},
"invalid configuration: static.url is required but not set",
},
{
"url is not a valid URL",
config.StaticConfig{
Serve: false,
Root: "../web/static",
URL: "://not-a-url",
},
"invalid configuration: static.url must be a valid URL with scheme and host",
},
{
"url is not an absolute path starting with a slash",
config.StaticConfig{
Serve: true,
Root: "../web/static",
URL: "not-a-slash",
},
"invalid configuration: static.url must be a valid URL or an absolute path starting with a slash",
},
{
"url is a remote URL and serve is true",
config.StaticConfig{
Serve: true,
Root: "../web/static",
URL: "https://example.com/static",
},
"invalid configuration: static.url cannot use a remote URL if static files are served from the filesystem",
},
{
"url is a absolute URL and serve is false",
config.StaticConfig{
Serve: false,
Root: "../web/static",
URL: "/static",
},
"invalid configuration: static.url must be a remote url if static files are not served from the filesystem",
},
}

for _, test := range tests {
err := test.conf.Validate()
require.EqualError(t, err, test.err, "expected static config validation error on test case %q", test.name)
}
})
}
11 changes: 9 additions & 2 deletions pkg/server/routes.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package server

import (
"net/http"
"os"
"path/filepath"

"github.com/gin-gonic/gin"
"go.rtnl.ai/gimlet/logger"
"go.rtnl.ai/uptime/pkg"
Expand Down Expand Up @@ -58,8 +62,11 @@ func (s *Server) setupRoutes() (err error) {
s.router.GET("/error", s.InternalError)

// Static Assets
s.router.StaticFS("/static", web.Static())
// TODO: add routes for favicon.ico, robots.txt, etc. when they are served from the fs
if s.conf.Static.Serve {
s.router.StaticFS(s.conf.Static.URL, http.FS(os.DirFS(s.conf.Static.Root)))
s.router.StaticFile("/favicon.ico", filepath.Join(s.conf.Static.Root, "favicon.ico"))
s.router.StaticFile("/robots.txt", filepath.Join(s.conf.Static.Root, "robots.txt"))
}

// Unauthenticated API Routes
v1o := s.router.Group("/v1")
Expand Down
38 changes: 22 additions & 16 deletions pkg/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import (
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"

"github.com/gin-gonic/gin/render"
"go.rtnl.ai/uptime/pkg/config"
"go.rtnl.ai/x/rlog"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

//go:embed all:static
//go:embed all:templates
var content embed.FS

Expand All @@ -35,15 +35,6 @@ var (
partials = []string{"partials/*.html", "partials/*/*.html"}
)

// Static creates the StaticFS that contains our static assets to be served.
func Static() http.FileSystem {
staticFiles, err := fs.Sub(content, "static")
if err != nil {
panic(fmt.Errorf("failed to create static file system: %w", err))
}
return http.FS(staticFiles)
}

Comment thread
bbengfort marked this conversation as resolved.
// Templates returns the FileSystem that contains our HTML templates to be rendered.
func Templates() fs.FS {
templatesFiles, err := fs.Sub(content, "templates")
Expand Down Expand Up @@ -130,7 +121,7 @@ func (r *Render) AddPattern(fsys fs.FS, pattern string, includes ...string) (err
func (r *Render) FuncMap() template.FuncMap {
if r.funcs == nil {
r.funcs = template.FuncMap{
"static": static,
"static": static(),
"titlecase": titlecase,
"lowercase": lowercase,
"uppercase": uppercase,
Expand Down Expand Up @@ -161,14 +152,29 @@ func datetime(t time.Time) string {
return t.Format("January 2, 2006 3:04 PM")
}

func static(path string) string {
return filepath.Join("/static", path)
}

func currentYear() int {
return time.Now().Year()
}

func static() func(path string) string {
// Load the configuration to get the static URL.
conf := config.MustGet()

if !conf.Static.Serve {
// Treat the bae URL as a remote URL and use URL resolution
baseURL, _ := url.Parse(conf.Static.URL)
return func(path string) string {
return baseURL.ResolveReference(&url.URL{Path: path}).String()
}
}

// Otherwise treat the URL as a prefix and returna prepended path.
baseURL := conf.Static.URL
return func(path string) string {
return filepath.Join(baseURL, path)
}
}

// ===========================================================================
// Helper Functions
// ===========================================================================
Expand Down
56 changes: 56 additions & 0 deletions pkg/web/web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,67 @@ import (
"time"

"github.com/stretchr/testify/require"
"go.rtnl.ai/confire/contest"
"go.rtnl.ai/uptime/pkg/config"
"go.rtnl.ai/uptime/pkg/web"
)

var env = contest.Env{
"UPTIME_STATIC_SERVE": "true",
"UPTIME_STATIC_ROOT": "./static",
}

func TestRender(t *testing.T) {
t.Cleanup(env.Set())
t.Cleanup(config.Reset)

renderer, err := web.NewRender(web.Templates())
require.NoError(t, err)
require.NotNil(t, renderer)
}

func TestStaticServe(t *testing.T) {
staticEnv := contest.Env{
"UPTIME_STATIC_SERVE": "true",
"UPTIME_STATIC_ROOT": "./static",
}
t.Cleanup(staticEnv.Set())
t.Cleanup(config.Reset)

render := &web.Render{}
funcMap := render.FuncMap()
f := funcMap["static"].(func(string) string)
require.Equal(t, "/static/favicon.ico", f("favicon.ico"))
require.Equal(t, "/static/robots.txt", f("robots.txt"))
require.Equal(t, "/static/css/style.css", f("css/style.css"))
require.Equal(t, "/static/js/script.js", f("js/script.js"))
require.Equal(t, "/static/images/logo.png", f("images/logo.png"))
require.Equal(t, "/static/fonts/font.woff", f("fonts/font.woff"))
}

func TestStaticCDN(t *testing.T) {
cdnEnv := contest.Env{
"UPTIME_STATIC_SERVE": "false",
"UPTIME_STATIC_URL": "https://cdn.example.com/",
}
t.Cleanup(cdnEnv.Set())
t.Cleanup(config.Reset)

render := &web.Render{}
funcMap := render.FuncMap()
f := funcMap["static"].(func(string) string)
require.Equal(t, "https://cdn.example.com/favicon.ico", f("favicon.ico"))
require.Equal(t, "https://cdn.example.com/robots.txt", f("robots.txt"))
require.Equal(t, "https://cdn.example.com/css/style.css", f("css/style.css"))
require.Equal(t, "https://cdn.example.com/js/script.js", f("js/script.js"))
require.Equal(t, "https://cdn.example.com/images/logo.png", f("images/logo.png"))
require.Equal(t, "https://cdn.example.com/fonts/font.woff", f("fonts/font.woff"))
}

func TestFuncMap(t *testing.T) {
t.Cleanup(env.Set())
t.Cleanup(config.Reset)

renderer := &web.Render{}
funcMap := renderer.FuncMap()

Expand Down Expand Up @@ -83,6 +134,11 @@ func TestFuncMap(t *testing.T) {
}
})

t.Run("currentYear", func(t *testing.T) {
f := funcMap["currentYear"].(func() int)
require.Equal(t, time.Now().Year(), f())
})

t.Run("static", func(t *testing.T) {
testCases := []struct {
input string
Expand Down
Loading