refactor(theme): themes are loaded at startup instead of every request

This commit is contained in:
Alexis Couvreur
2024-04-30 14:55:35 +00:00
parent dc935f534f
commit 9167e9c8c8
24 changed files with 558 additions and 683 deletions

View File

@@ -20,10 +20,11 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
FROM alpine:3.19.1
RUN mkdir -p /etc/sablier/themes
EXPOSE 10000
COPY --from=build /src/sablier* /etc/sablier/sablier
COPY docker/sablier.yaml /etc/sablier/sablier.yaml
EXPOSE 10000
ENTRYPOINT [ "/etc/sablier/sablier" ]
CMD [ "--configFile=/etc/sablier/sablier.yaml", "start" ]

View File

@@ -1,111 +0,0 @@
package pages
import (
"io"
"io/fs"
"fmt"
"html/template"
"math"
"time"
"embed"
)
//go:embed themes/*
var Themes embed.FS
type RenderOptionsInstanceState struct {
Name string
CurrentReplicas int
DesiredReplicas int
Status string
Error error
}
type RenderOptions struct {
DisplayName string
ShowDetails bool
InstanceStates []RenderOptionsInstanceState
SessionDuration time.Duration
RefreshFrequency time.Duration
Theme string
CustomThemes fs.FS
// If custom theme is loaded through os.DirFS, nothing prevents you
// from escaping the prefix with relative path such as ..
// The `AllowedCustomThemes` are the themes that were scanned during initilization
AllowedCustomThemes map[string]bool
Version string
}
type TemplateValues struct {
DisplayName string
InstanceStates []RenderOptionsInstanceState
SessionDuration string
RefreshFrequency string
Version string
}
func Render(options RenderOptions, writer io.Writer) error {
var tpl *template.Template
var err error
// Load custom theme if provided
if options.CustomThemes != nil && options.AllowedCustomThemes[options.Theme] {
tpl, err = template.ParseFS(options.CustomThemes, fmt.Sprintf("%s.html", options.Theme))
} else {
// Load from the embedded FS
tpl, err = template.ParseFS(Themes, fmt.Sprintf("themes/%s.html", options.Theme))
}
if err != nil {
return err
}
instanceStates := []RenderOptionsInstanceState{}
if options.ShowDetails {
instanceStates = options.InstanceStates
}
return tpl.Execute(writer, TemplateValues{
DisplayName: options.DisplayName,
InstanceStates: instanceStates,
SessionDuration: humanizeDuration(options.SessionDuration),
RefreshFrequency: fmt.Sprintf("%d", int64(options.RefreshFrequency.Seconds())),
Version: options.Version,
})
}
// humanizeDuration humanizes time.Duration output to a meaningful value,
// golang's default “time.Duration“ output is badly formatted and unreadable.
func humanizeDuration(duration time.Duration) string {
if duration.Seconds() < 60.0 {
return fmt.Sprintf("%d seconds", int64(duration.Seconds()))
}
if duration.Minutes() < 60.0 {
remainingSeconds := math.Mod(duration.Seconds(), 60)
if remainingSeconds > 0 {
return fmt.Sprintf("%d minutes %d seconds", int64(duration.Minutes()), int64(remainingSeconds))
}
return fmt.Sprintf("%d minutes", int64(duration.Minutes()))
}
if duration.Hours() < 24.0 {
remainingMinutes := math.Mod(duration.Minutes(), 60)
remainingSeconds := math.Mod(duration.Seconds(), 60)
if remainingMinutes > 0 {
if remainingSeconds > 0 {
return fmt.Sprintf("%d hours %d minutes %d seconds", int64(duration.Hours()), int64(remainingMinutes), int64(remainingSeconds))
}
return fmt.Sprintf("%d hours %d minutes", int64(duration.Hours()), int64(remainingMinutes))
}
return fmt.Sprintf("%d hours", int64(duration.Hours()))
}
remainingHours := math.Mod(duration.Hours(), 24)
remainingMinutes := math.Mod(duration.Minutes(), 60)
remainingSeconds := math.Mod(duration.Seconds(), 60)
return fmt.Sprintf("%d days %d hours %d minutes %d seconds",
int64(duration.Hours()/24), int64(remainingHours),
int64(remainingMinutes), int64(remainingSeconds))
}

View File

@@ -1,296 +0,0 @@
package pages
import (
"bytes"
"fmt"
"io"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
)
var instanceStates []RenderOptionsInstanceState = []RenderOptionsInstanceState{
{
Name: "nginx",
CurrentReplicas: 0,
DesiredReplicas: 4,
Status: "starting",
Error: nil,
},
{
Name: "whoami",
CurrentReplicas: 4,
DesiredReplicas: 4,
Status: "started",
Error: nil,
},
{
Name: "devil",
CurrentReplicas: 0,
DesiredReplicas: 4,
Status: "error",
Error: fmt.Errorf("devil service does not exist"),
},
}
func TestRender(t *testing.T) {
type args struct {
options RenderOptions
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "Load ghost theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "ghost",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Load hacker-terminal theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "hacker-terminal",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Load matrix theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "matrix",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Load shiffle theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "shuffle",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Load non existent theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "nonexistent",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantErr: true,
},
{
name: "Load custom theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "dc-comics",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: fstest.MapFS{
"marvel.html": {Data: []byte("{{ .DisplayName }}")},
"dc-comics.html": {Data: []byte("batman")},
},
AllowedCustomThemes: map[string]bool{
"marvel": true,
"dc-comics": true,
},
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Load non existent custom theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "nonexistent",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: fstest.MapFS{
"marvel.html": {Data: []byte("thor")},
"dc-comics.html": {Data: []byte("batman")},
},
AllowedCustomThemes: map[string]bool{
"marvel": true,
"dc-comics": true,
},
Version: "v0.0.0",
},
},
wantErr: true,
},
{
name: "Load embedded theme with custom theme provided",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "hacker-terminal",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: fstest.MapFS{
"marvel.html": {Data: []byte("thor")},
"dc-comics.html": {Data: []byte("batman")},
},
AllowedCustomThemes: map[string]bool{
"marvel": true,
"dc-comics": true,
},
Version: "v0.0.0",
},
},
wantErr: false,
},
{
name: "Error loading non allowed custom theme",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "dc-comics",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
CustomThemes: fstest.MapFS{
"marvel.html": {Data: []byte("thor")},
"dc-comics.html": {Data: []byte("batman")},
},
AllowedCustomThemes: map[string]bool{
"marvel": true,
},
Version: "v0.0.0",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &bytes.Buffer{}
if err := Render(tt.args.options, writer); (err != nil) != tt.wantErr {
t.Errorf("Render() error = %v, wantErr %v", err, tt.wantErr)
return
}
})
}
}
func TestRenderContent(t *testing.T) {
type args struct {
options RenderOptions
}
tests := []struct {
name string
args args
wantContent string
}{
{
name: "refresh frequency is 10 seconds",
args: args{
options: RenderOptions{
DisplayName: "Test",
InstanceStates: instanceStates,
Theme: "ghost",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 10 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantContent: "<meta http-equiv=\"refresh\" content=\"10\" />",
},
{
name: "details is rendered",
args: args{
options: RenderOptions{
DisplayName: "Test",
ShowDetails: true,
InstanceStates: instanceStates,
Theme: "ghost",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 10 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantContent: "started (4/4)",
},
{
name: "details is not rendered",
args: args{
options: RenderOptions{
DisplayName: "Test",
ShowDetails: false,
InstanceStates: instanceStates,
Theme: "ghost",
SessionDuration: 10 * time.Minute,
RefreshFrequency: 10 * time.Second,
CustomThemes: nil,
Version: "v0.0.0",
},
},
wantContent: "<table></table>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &bytes.Buffer{}
if err := Render(tt.args.options, writer); err != nil {
t.Errorf("Render() error = %v", err)
return
}
content, err := io.ReadAll(writer)
if err != nil {
t.Errorf("ReadAll() error = %v", err)
return
}
assert.Contains(t, string(content), tt.wantContent)
})
}
}

View File

@@ -2,7 +2,6 @@ package routes
import (
"fmt"
"io/fs"
"net/http"
"os"
"sort"
@@ -10,40 +9,33 @@ import (
log "github.com/sirupsen/logrus"
"github.com/acouvreur/sablier/app/http/pages"
"github.com/acouvreur/sablier/app/http/routes/models"
"github.com/acouvreur/sablier/app/instance"
"github.com/acouvreur/sablier/app/sessions"
"github.com/acouvreur/sablier/app/theme"
"github.com/acouvreur/sablier/config"
"github.com/acouvreur/sablier/version"
"github.com/gin-gonic/gin"
)
var osDirFS = os.DirFS
type ServeStrategy struct {
customThemesFS fs.FS
customThemes map[string]bool
Theme *theme.Themes
SessionsManager sessions.Manager
StrategyConfig config.Strategy
SessionsConfig config.Sessions
}
func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions) *ServeStrategy {
func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions, themes *theme.Themes) *ServeStrategy {
serveStrategy := &ServeStrategy{
Theme: themes,
SessionsManager: sessionsManager,
StrategyConfig: strategyConf,
SessionsConfig: sessionsConf,
}
if strategyConf.Dynamic.CustomThemesPath != "" {
customThemesFs := osDirFS(strategyConf.Dynamic.CustomThemesPath)
serveStrategy.customThemesFS = customThemesFs
serveStrategy.customThemes = listThemes(customThemesFs)
}
return serveStrategy
}
@@ -78,46 +70,22 @@ func (s *ServeStrategy) ServeDynamic(c *gin.Context) {
c.Header("X-Sablier-Session-Status", "not-ready")
}
renderOptions := pages.RenderOptions{
DisplayName: request.DisplayName,
ShowDetails: request.ShowDetails,
SessionDuration: request.SessionDuration,
Theme: request.Theme,
CustomThemes: s.customThemesFS,
AllowedCustomThemes: s.customThemes,
Version: version.Version,
RefreshFrequency: request.RefreshFrequency,
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
renderOptions := theme.Options{
DisplayName: request.DisplayName,
ShowDetails: request.ShowDetails,
SessionDuration: request.SessionDuration,
RefreshFrequency: request.RefreshFrequency,
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
}
c.Header("Content-Type", "text/html")
if err := pages.Render(renderOptions, c.Writer); err != nil {
if err := s.Theme.Render(request.Theme, renderOptions, c.Writer); err != nil {
log.Error(err)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) {
customThemes := []string{}
for theme := range s.customThemes {
customThemes = append(customThemes, theme)
}
sort.Strings(customThemes)
embeddedThemes := []string{}
for theme := range listThemes(pages.Themes) {
embeddedThemes = append(embeddedThemes, strings.TrimPrefix(theme, "themes/"))
}
sort.Strings(embeddedThemes)
c.JSON(http.StatusOK, map[string]interface{}{
"custom": customThemes,
"embedded": embeddedThemes,
})
}
func (s *ServeStrategy) ServeBlocking(c *gin.Context) {
request := models.BlockingRequest{
Timeout: s.StrategyConfig.Blocking.DefaultTimeout,
@@ -161,7 +129,7 @@ func (s *ServeStrategy) ServeBlocking(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState})
}
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []pages.RenderOptionsInstanceState) {
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) {
sessionState.Instances.Range(func(key, value any) bool {
instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance))
return true
@@ -174,7 +142,7 @@ func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionStat
return
}
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pages.RenderOptionsInstanceState {
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance {
var err error
if instanceState.Message == "" {
@@ -183,7 +151,7 @@ func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pag
err = fmt.Errorf(instanceState.Message)
}
return pages.RenderOptionsInstanceState{
return theme.Instance{
Name: instanceState.Name,
Status: instanceState.Status,
CurrentReplicas: instanceState.CurrentReplicas,
@@ -191,25 +159,3 @@ func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pag
Error: err,
}
}
func listThemes(dir fs.FS) (themes map[string]bool) {
themes = make(map[string]bool)
fs.WalkDir(dir, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), ".html") {
log.Debugf("found theme at \"%s\" can be loaded using \"%s\"", path, strings.TrimSuffix(path, ".html"))
themes[strings.TrimSuffix(path, ".html")] = true
} else {
log.Tracef("ignoring file \"%s\" because it has no .html suffix", path)
}
return nil
})
return
}

View File

@@ -5,11 +5,9 @@ import (
"context"
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"sync"
"testing"
"testing/fstest"
@@ -18,6 +16,7 @@ import (
"github.com/acouvreur/sablier/app/http/routes/models"
"github.com/acouvreur/sablier/app/instance"
"github.com/acouvreur/sablier/app/sessions"
"github.com/acouvreur/sablier/app/theme"
"github.com/acouvreur/sablier/config"
"github.com/gin-gonic/gin"
"gotest.tools/v3/assert"
@@ -96,11 +95,16 @@ func TestServeStrategy_ServeDynamic(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
theme, err := theme.NewWithCustomThemes(fstest.MapFS{})
if err != nil {
panic(err)
}
s := &ServeStrategy{
SessionsManager: &SessionsManagerMock{
SessionState: tt.arg.session,
},
StrategyConfig: config.NewStrategyConfig(),
Theme: theme,
}
recorder := httptest.NewRecorder()
c := GetTestGinContext(recorder)
@@ -261,195 +265,3 @@ func createMap(instances []*instance.State) (store *sync.Map) {
return
}
func TestNewServeStrategy(t *testing.T) {
type args struct {
sessionsManager sessions.Manager
strategyConf config.Strategy
sessionsConf config.Sessions
}
tests := []struct {
name string
args args
osDirFS fs.FS
want map[string]bool
}{
{
name: "load custom themes",
args: args{
sessionsManager: &SessionsManagerMock{},
strategyConf: config.Strategy{
Dynamic: config.DynamicStrategy{
CustomThemesPath: "my/path/to/themes",
},
},
},
osDirFS: fstest.MapFS{
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
},
want: map[string]bool{
"marvel": true,
"dc-comics": true,
},
},
{
name: "load custom themes recursively",
args: args{
sessionsManager: &SessionsManagerMock{},
strategyConf: config.Strategy{
Dynamic: config.DynamicStrategy{
CustomThemesPath: "my/path/to/themes",
},
},
},
osDirFS: fstest.MapFS{
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
},
want: map[string]bool{
"marvel": true,
"dc-comics": true,
"inner/dc-comics": true,
},
},
{
name: "do not load custom themes outside of path",
args: args{
sessionsManager: &SessionsManagerMock{},
strategyConf: config.Strategy{
Dynamic: config.DynamicStrategy{
CustomThemesPath: "my/path/to/themes",
},
},
},
osDirFS: fstest.MapFS{
"my/path/to/superman.html": {Data: []byte("superman")},
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
},
want: map[string]bool{
"marvel": true,
"dc-comics": true,
"inner/dc-comics": true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldosDirFS := osDirFS
defer func() { osDirFS = oldosDirFS }()
myOsDirFS := func(dir string) fs.FS {
fs, err := fs.Sub(tt.osDirFS, dir)
if err != nil {
panic(err)
}
return fs
}
osDirFS = myOsDirFS
if got := NewServeStrategy(tt.args.sessionsManager, tt.args.strategyConf, tt.args.sessionsConf); !reflect.DeepEqual(got.customThemes, tt.want) {
t.Errorf("NewServeStrategy() = %v, want %v", got.customThemes, tt.want)
}
})
}
}
func TestServeStrategy_ServeDynamicThemes(t *testing.T) {
type fields struct {
StrategyConfig config.Strategy
SessionsConfig config.Sessions
}
tests := []struct {
name string
fields fields
osDirFS fs.FS
expected map[string]any
}{
{
name: "load custom themes",
fields: fields{StrategyConfig: config.Strategy{
Dynamic: config.DynamicStrategy{
CustomThemesPath: "my/path/to/themes",
},
}},
osDirFS: fstest.MapFS{
"my/path/to/superman.html": {Data: []byte("superman")},
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
},
expected: map[string]any{
"custom": []any{
"dc-comics",
"inner/dc-comics",
"marvel",
},
"embedded": []any{
"ghost",
"hacker-terminal",
"matrix",
"shuffle",
},
},
},
{
name: "load without custom themes",
expected: map[string]any{
"custom": []any{},
"embedded": []any{
"ghost",
"hacker-terminal",
"matrix",
"shuffle",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldosDirFS := osDirFS
defer func() { osDirFS = oldosDirFS }()
myOsDirFS := func(dir string) fs.FS {
fs, err := fs.Sub(tt.osDirFS, dir)
if err != nil {
panic(err)
}
return fs
}
osDirFS = myOsDirFS
s := NewServeStrategy(nil, tt.fields.StrategyConfig, tt.fields.SessionsConfig)
recorder := httptest.NewRecorder()
c := GetTestGinContext(recorder)
s.ServeDynamicThemes(c)
res := recorder.Result()
defer res.Body.Close()
jsonRes := make(map[string]interface{})
err := json.NewDecoder(res.Body).Decode(&jsonRes)
if err != nil {
panic(err)
}
assert.DeepEqual(t, jsonRes, tt.expected)
})
}
}

View File

@@ -0,0 +1,13 @@
package routes
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
"themes": s.Theme.List(),
})
}

View File

@@ -13,11 +13,13 @@ import (
"github.com/acouvreur/sablier/app/http/middleware"
"github.com/acouvreur/sablier/app/http/routes"
"github.com/acouvreur/sablier/app/sessions"
"github.com/acouvreur/sablier/app/theme"
"github.com/acouvreur/sablier/config"
"github.com/gin-gonic/gin"
)
func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager) {
func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager, t *theme.Themes) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -29,7 +31,7 @@ func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf
{
api := base.Group("/api")
{
strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf)
strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf, t)
api.GET("/strategies/dynamic", strategy.ServeDynamic)
api.GET("/strategies/dynamic/themes", strategy.ServeDynamicThemes)
api.GET("/strategies/blocking", strategy.ServeBlocking)

View File

@@ -2,12 +2,14 @@ package app
import (
"context"
"os"
"github.com/acouvreur/sablier/app/http"
"github.com/acouvreur/sablier/app/instance"
"github.com/acouvreur/sablier/app/providers"
"github.com/acouvreur/sablier/app/sessions"
"github.com/acouvreur/sablier/app/storage"
"github.com/acouvreur/sablier/app/theme"
"github.com/acouvreur/sablier/config"
"github.com/acouvreur/sablier/pkg/tinykv"
"github.com/acouvreur/sablier/version"
@@ -49,7 +51,24 @@ func Start(conf config.Config) error {
loadSessions(storage, sessionsManager)
}
http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager)
var t *theme.Themes
if conf.Strategy.Dynamic.CustomThemesPath != "" {
log.Tracef("loading themes with custom theme path: %s", conf.Strategy.Dynamic.CustomThemesPath)
custom := os.DirFS(conf.Strategy.Dynamic.CustomThemesPath)
t, err = theme.NewWithCustomThemes(custom)
if err != nil {
return err
}
} else {
log.Trace("loading themes without custom themes")
t, err = theme.New()
if err != nil {
return err
}
}
http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager, t)
return nil
}

16
app/theme/list.go Normal file
View File

@@ -0,0 +1,16 @@
package theme
import "strings"
// List all the loaded themes
func (t *Themes) List() []string {
themes := make([]string, 0)
for _, template := range t.themes.Templates() {
if strings.HasSuffix(template.Name(), ".html") {
themes = append(themes, strings.TrimSuffix(template.Name(), ".html"))
}
}
return themes
}

25
app/theme/list_test.go Normal file
View File

@@ -0,0 +1,25 @@
package theme_test
import (
"testing"
"testing/fstest"
"github.com/acouvreur/sablier/app/theme"
"github.com/stretchr/testify/assert"
)
func TestList(t *testing.T) {
themes, err := theme.NewWithCustomThemes(
fstest.MapFS{
"theme1.html": &fstest.MapFile{},
"inner/theme2.html": &fstest.MapFile{},
})
if err != nil {
t.Error(err)
return
}
list := themes.List()
assert.ElementsMatch(t, []string{"theme1", "theme2", "ghost", "hacker-terminal", "matrix", "shuffle"}, list)
}

25
app/theme/parse.go Normal file
View File

@@ -0,0 +1,25 @@
package theme
import (
"html/template"
"io/fs"
"strings"
log "github.com/sirupsen/logrus"
)
func ParseTemplatesFS(f fs.FS, t *template.Template) error {
err := fs.WalkDir(f, ".", func(path string, d fs.DirEntry, err error) error {
if strings.Contains(path, ".html") {
log.Tracef("found template %s", path)
_, err = t.ParseFS(f, path)
if err != nil {
return err
}
log.Tracef("successfully added template %s", path)
}
return err
})
return err
}

34
app/theme/render.go Normal file
View File

@@ -0,0 +1,34 @@
package theme
import (
"fmt"
"io"
"github.com/acouvreur/sablier/pkg/durations"
"github.com/acouvreur/sablier/version"
)
func (t *Themes) Render(name string, opts Options, writer io.Writer) error {
var instances []Instance
if opts.ShowDetails {
instances = opts.InstanceStates
} else {
instances = []Instance{}
}
options := templateOptions{
DisplayName: opts.DisplayName,
InstanceStates: instances,
SessionDuration: durations.Humanize(opts.SessionDuration),
RefreshFrequency: fmt.Sprintf("%d", int64(opts.RefreshFrequency.Seconds())),
Version: version.Version,
}
tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name))
if tpl == nil {
return fmt.Errorf("theme %s does not exist", name)
}
return tpl.Execute(writer, options)
}

229
app/theme/render_test.go Normal file
View File

@@ -0,0 +1,229 @@
package theme_test
import (
"bytes"
"fmt"
"os"
"testing"
"testing/fstest"
"time"
"github.com/acouvreur/sablier/app/theme"
"github.com/acouvreur/sablier/version"
)
var (
StartingInstanceInfo = theme.Instance{
Name: "starting-instance",
Status: "instance is starting...",
Error: nil,
CurrentReplicas: 0,
DesiredReplicas: 1,
}
StartedInstanceInfo = theme.Instance{
Name: "started-instance",
Status: "instance is started.",
Error: nil,
CurrentReplicas: 1,
DesiredReplicas: 1,
}
ErrorInstanceInfo = theme.Instance{
Name: "error-instance",
Error: fmt.Errorf("instance does not exist"),
CurrentReplicas: 0,
DesiredReplicas: 1,
}
)
func TestThemes_Render(t *testing.T) {
const customTheme = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="{{ .RefreshFrequency }}" />
</head>
<body>
Starting</span> {{ .DisplayName }}
Your instance(s) will stop after {{ .SessionDuration }} of inactivity
<table>
{{- range $i, $instance := .InstanceStates }}
<tr>
<td>{{ $instance.Name }}</td>
{{- if $instance.Error }}
<td>{{ $instance.Error }}</td>
{{- else }}
<td>{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
{{- end}}
</tr>
{{ end -}}
</table>
Sablier version {{ .Version }}
</body>
</html>
`
version.Version = "1.0.0"
themes, err := theme.NewWithCustomThemes(fstest.MapFS{
"inner/custom-theme.html": &fstest.MapFile{Data: []byte(customTheme)},
})
if err != nil {
t.Error(err)
return
}
instances := []theme.Instance{
StartingInstanceInfo,
StartedInstanceInfo,
ErrorInstanceInfo,
}
options := theme.Options{
DisplayName: "Test",
InstanceStates: instances,
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
}
type args struct {
name string
opts theme.Options
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "Load ghost theme",
args: args{
name: "ghost",
opts: options,
},
wantErr: false,
},
{
name: "Load hacker-terminal theme",
args: args{
name: "hacker-terminal",
opts: options,
},
wantErr: false,
},
{
name: "Load matrix theme",
args: args{
name: "matrix",
opts: options,
},
wantErr: false,
},
{
name: "Load shuffle theme",
args: args{
name: "shuffle",
opts: options,
},
wantErr: false,
},
{
name: "Load non existent theme",
args: args{
name: "non-existent",
opts: options,
},
wantErr: true,
},
{
name: "Load custom theme",
args: args{
name: "custom-theme",
opts: options,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &bytes.Buffer{}
if err := themes.Render(tt.args.name, tt.args.opts, writer); (err != nil) != tt.wantErr {
t.Errorf("Themes.Render() error = %v, wantErr %v", err, tt.wantErr)
return
}
})
}
}
func ExampleThemes_Render() {
const customTheme = `
<html lang="en">
<head>
<meta http-equiv="refresh" content="{{ .RefreshFrequency }}" />
</head>
<body>
Starting {{ .DisplayName }}
Your instances will stop after {{ .SessionDuration }} of inactivity
<table>
{{- range $i, $instance := .InstanceStates }}
<tr>
<td>{{ $instance.Name }}</td>
{{- if $instance.Error }}
<td>{{ $instance.Error }}</td>
{{- else }}
<td>{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
{{- end}}
</tr>
{{- end }}
</table>
Sablier version {{ .Version }}
</body>
</html>
`
version.Version = "1.0.0"
themes, err := theme.NewWithCustomThemes(fstest.MapFS{
"inner/custom-theme.html": &fstest.MapFile{Data: []byte(customTheme)},
})
if err != nil {
panic(err)
}
instances := []theme.Instance{
StartingInstanceInfo,
StartedInstanceInfo,
ErrorInstanceInfo,
}
err = themes.Render("custom-theme", theme.Options{
DisplayName: "Test",
InstanceStates: instances,
ShowDetails: true,
SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second,
}, os.Stdout)
if err != nil {
panic(err)
}
// Output:
//<html lang="en">
// <head>
// <meta http-equiv="refresh" content="5" />
// </head>
// <body>
// Starting Test
// Your instances will stop after 10 minutes of inactivity
// <table>
// <tr>
// <td>starting-instance</td>
// <td>instance is starting... (0/1)</td>
// </tr>
// <tr>
// <td>started-instance</td>
// <td>instance is started. (1/1)</td>
// </tr>
// <tr>
// <td>error-instance</td>
// <td>instance does not exist</td>
// </tr>
// </table>
// Sablier version 1.0.0
// </body>
//</html>
}

54
app/theme/theme.go Normal file
View File

@@ -0,0 +1,54 @@
package theme
import (
"embed"
"html/template"
"io/fs"
log "github.com/sirupsen/logrus"
)
// List of built-it themes
//
//go:embed embedded/*.html
var embeddedThemesFS embed.FS
type Themes struct {
themes *template.Template
}
func New() (*Themes, error) {
themes := &Themes{
themes: template.New("root"),
}
err := ParseTemplatesFS(embeddedThemesFS, themes.themes)
if err != nil {
// Should never happen
log.Errorf("could not parse embedded templates: %v", err)
return nil, err
}
return themes, nil
}
func NewWithCustomThemes(custom fs.FS) (*Themes, error) {
themes := &Themes{
themes: template.New("root"),
}
err := ParseTemplatesFS(embeddedThemesFS, themes.themes)
if err != nil {
// Should never happen
log.Errorf("could not parse embedded templates: %v", err)
return nil, err
}
err = ParseTemplatesFS(custom, themes.themes)
if err != nil {
log.Errorf("could not parse custom templates: %v", err)
return nil, err
}
return themes, nil
}

36
app/theme/types.go Normal file
View File

@@ -0,0 +1,36 @@
package theme
import "time"
// Theme represents an available theme
type Theme struct {
Name string
Embedded bool
}
// Instance holds the current state about an instance
type Instance struct {
Name string
Status string
Error error
CurrentReplicas int
DesiredReplicas int
}
// Options holds the customizable input to template
type Options struct {
DisplayName string
ShowDetails bool
InstanceStates []Instance
SessionDuration time.Duration
RefreshFrequency time.Duration
}
// templateOptions holds the internal options used to template
type templateOptions struct {
DisplayName string
InstanceStates []Instance
SessionDuration string
RefreshFrequency string
Version string
}

View File

@@ -21,7 +21,7 @@ func Test_Dynamic(t *testing.T) {
Status(http.StatusOK).
Body().
Contains(`Dynamic Whoami`).
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
e.GET("/whoami").
WithMaxRetries(10).
@@ -62,7 +62,7 @@ func Test_Multiple(t *testing.T) {
Status(http.StatusOK).
Body().
Contains(`Multiple Whoami`).
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
e.GET("/whoami").
WithMaxRetries(10).
@@ -114,7 +114,7 @@ func Test_Healthy(t *testing.T) {
Status(http.StatusOK).
Body().
Contains(`Healthy Nginx`).
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
e.GET("/nginx").
WithMaxRetries(10).

34
pkg/durations/duration.go Normal file
View File

@@ -0,0 +1,34 @@
package durations
import (
"encoding/json"
"fmt"
"time"
)
type Duration struct {
time.Duration
}
func (duration *Duration) UnmarshalJSON(b []byte) error {
var unmarshalledJson interface{}
err := json.Unmarshal(b, &unmarshalledJson)
if err != nil {
return err
}
switch value := unmarshalledJson.(type) {
case float64:
duration.Duration = time.Duration(value)
case string:
duration.Duration, err = time.ParseDuration(value)
if err != nil {
return err
}
default:
return fmt.Errorf("invalid duration: %#v", unmarshalledJson)
}
return nil
}

40
pkg/durations/humanize.go Normal file
View File

@@ -0,0 +1,40 @@
package durations
import (
"fmt"
"math"
"strings"
"time"
)
func Humanize(d time.Duration) string {
days := int64(d.Hours() / 24)
hours := int64(math.Mod(d.Hours(), 24))
minutes := int64(math.Mod(d.Minutes(), 60))
seconds := int64(math.Mod(d.Seconds(), 60))
chunks := []struct {
singularName string
amount int64
}{
{"day", days},
{"hour", hours},
{"minute", minutes},
{"second", seconds},
}
var parts []string
for _, chunk := range chunks {
switch chunk.amount {
case 0:
continue
case 1:
parts = append(parts, fmt.Sprintf("%d %s", chunk.amount, chunk.singularName))
default:
parts = append(parts, fmt.Sprintf("%d %ss", chunk.amount, chunk.singularName))
}
}
return strings.Join(parts, " ")
}

View File

@@ -23,9 +23,9 @@ destroy_kubernetes() {
}
prepare_traefik() {
helm repo add traefik https://helm.traefik.io/traefik
helm repo add traefik https://traefik.github.io/charts
helm repo update
helm install traefik traefik/traefik -f values.yaml --namespace kube-system
helm install traefik --version 27.0.2 traefik/traefik -f values.yaml --namespace kube-system
}
prepare_deployment() {

View File

@@ -1,7 +1,3 @@
# traefik helm values
image:
tag: "2.9.1"
additionalArguments:
- "--experimental.localPlugins.sablier.moduleName=github.com/acouvreur/sablier"