From 9167e9c8c8fb12046072cb5db226940f2d140e2e Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Tue, 30 Apr 2024 14:55:35 +0000 Subject: [PATCH] refactor(theme): themes are loaded at startup instead of every request --- Dockerfile | 5 +- app/http/pages/render.go | 111 ------- app/http/pages/render_test.go | 296 ------------------ app/http/routes/strategies.go | 82 +---- app/http/routes/strategies_test.go | 200 +----------- app/http/routes/theme_list.go | 13 + app/http/server.go | 6 +- app/sablier.go | 21 +- .../themes => theme/embedded}/ghost.html | 0 .../embedded}/hacker-terminal.html | 0 .../themes => theme/embedded}/matrix.html | 0 .../themes => theme/embedded}/shuffle.html | 0 app/theme/list.go | 16 + app/theme/list_test.go | 25 ++ app/theme/parse.go | 25 ++ app/theme/render.go | 34 ++ app/theme/render_test.go | 229 ++++++++++++++ app/theme/theme.go | 54 ++++ app/theme/types.go | 36 +++ e2e/e2e_test.go | 6 +- pkg/durations/duration.go | 34 ++ pkg/durations/humanize.go | 40 +++ plugins/traefik/e2e/kubernetes/run.sh | 4 +- plugins/traefik/e2e/kubernetes/values.yaml | 4 - 24 files changed, 558 insertions(+), 683 deletions(-) delete mode 100644 app/http/pages/render.go delete mode 100644 app/http/pages/render_test.go create mode 100644 app/http/routes/theme_list.go rename app/{http/pages/themes => theme/embedded}/ghost.html (100%) rename app/{http/pages/themes => theme/embedded}/hacker-terminal.html (100%) rename app/{http/pages/themes => theme/embedded}/matrix.html (100%) rename app/{http/pages/themes => theme/embedded}/shuffle.html (100%) create mode 100644 app/theme/list.go create mode 100644 app/theme/list_test.go create mode 100644 app/theme/parse.go create mode 100644 app/theme/render.go create mode 100644 app/theme/render_test.go create mode 100644 app/theme/theme.go create mode 100644 app/theme/types.go create mode 100644 pkg/durations/duration.go create mode 100644 pkg/durations/humanize.go diff --git a/Dockerfile b/Dockerfile index ae2d537..6074d5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] \ No newline at end of file diff --git a/app/http/pages/render.go b/app/http/pages/render.go deleted file mode 100644 index df98e6e..0000000 --- a/app/http/pages/render.go +++ /dev/null @@ -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)) -} diff --git a/app/http/pages/render_test.go b/app/http/pages/render_test.go deleted file mode 100644 index 9a42306..0000000 --- a/app/http/pages/render_test.go +++ /dev/null @@ -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: "", - }, - { - 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: "
", - }, - } - 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) - }) - } -} diff --git a/app/http/routes/strategies.go b/app/http/routes/strategies.go index 64c04ba..6a508fe 100644 --- a/app/http/routes/strategies.go +++ b/app/http/routes/strategies.go @@ -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 -} diff --git a/app/http/routes/strategies_test.go b/app/http/routes/strategies_test.go index 25f6285..34afa2d 100644 --- a/app/http/routes/strategies_test.go +++ b/app/http/routes/strategies_test.go @@ -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) - - }) - } -} diff --git a/app/http/routes/theme_list.go b/app/http/routes/theme_list.go new file mode 100644 index 0000000..adf1e1a --- /dev/null +++ b/app/http/routes/theme_list.go @@ -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(), + }) +} diff --git a/app/http/server.go b/app/http/server.go index 17b3975..bca1fca 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -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) diff --git a/app/sablier.go b/app/sablier.go index d3eb6f4..748e874 100644 --- a/app/sablier.go +++ b/app/sablier.go @@ -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 } diff --git a/app/http/pages/themes/ghost.html b/app/theme/embedded/ghost.html similarity index 100% rename from app/http/pages/themes/ghost.html rename to app/theme/embedded/ghost.html diff --git a/app/http/pages/themes/hacker-terminal.html b/app/theme/embedded/hacker-terminal.html similarity index 100% rename from app/http/pages/themes/hacker-terminal.html rename to app/theme/embedded/hacker-terminal.html diff --git a/app/http/pages/themes/matrix.html b/app/theme/embedded/matrix.html similarity index 100% rename from app/http/pages/themes/matrix.html rename to app/theme/embedded/matrix.html diff --git a/app/http/pages/themes/shuffle.html b/app/theme/embedded/shuffle.html similarity index 100% rename from app/http/pages/themes/shuffle.html rename to app/theme/embedded/shuffle.html diff --git a/app/theme/list.go b/app/theme/list.go new file mode 100644 index 0000000..34e1433 --- /dev/null +++ b/app/theme/list.go @@ -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 +} diff --git a/app/theme/list_test.go b/app/theme/list_test.go new file mode 100644 index 0000000..9292be3 --- /dev/null +++ b/app/theme/list_test.go @@ -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) +} diff --git a/app/theme/parse.go b/app/theme/parse.go new file mode 100644 index 0000000..f33cd98 --- /dev/null +++ b/app/theme/parse.go @@ -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 +} diff --git a/app/theme/render.go b/app/theme/render.go new file mode 100644 index 0000000..64b31da --- /dev/null +++ b/app/theme/render.go @@ -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) +} diff --git a/app/theme/render_test.go b/app/theme/render_test.go new file mode 100644 index 0000000..117080a --- /dev/null +++ b/app/theme/render_test.go @@ -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 = ` + + + + + + + Starting {{ .DisplayName }} + Your instance(s) will stop after {{ .SessionDuration }} of inactivity + + + {{- range $i, $instance := .InstanceStates }} + + + {{- if $instance.Error }} + + {{- else }} + + {{- end}} + + {{ end -}} +
{{ $instance.Name }}{{ $instance.Error }}{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})
+ Sablier version {{ .Version }} + + +` + 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 = ` + + + + + + Starting {{ .DisplayName }} + Your instances will stop after {{ .SessionDuration }} of inactivity + + {{- range $i, $instance := .InstanceStates }} + + + {{- if $instance.Error }} + + {{- else }} + + {{- end}} + + {{- end }} +
{{ $instance.Name }}{{ $instance.Error }}{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})
+ Sablier version {{ .Version }} + + +` + 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: + // + // + // + // + // + // Starting Test + // Your instances will stop after 10 minutes of inactivity + // + // + // + // + // + // + // + // + // + // + // + // + // + //
starting-instanceinstance is starting... (0/1)
started-instanceinstance is started. (1/1)
error-instanceinstance does not exist
+ // Sablier version 1.0.0 + // + // +} diff --git a/app/theme/theme.go b/app/theme/theme.go new file mode 100644 index 0000000..7f6f19e --- /dev/null +++ b/app/theme/theme.go @@ -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 +} diff --git a/app/theme/types.go b/app/theme/types.go new file mode 100644 index 0000000..f4697a2 --- /dev/null +++ b/app/theme/types.go @@ -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 +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 63cabbe..8fc4d35 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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). diff --git a/pkg/durations/duration.go b/pkg/durations/duration.go new file mode 100644 index 0000000..6697d00 --- /dev/null +++ b/pkg/durations/duration.go @@ -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 +} diff --git a/pkg/durations/humanize.go b/pkg/durations/humanize.go new file mode 100644 index 0000000..65efc1f --- /dev/null +++ b/pkg/durations/humanize.go @@ -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, " ") +} diff --git a/plugins/traefik/e2e/kubernetes/run.sh b/plugins/traefik/e2e/kubernetes/run.sh index 5b5a64f..57009f2 100644 --- a/plugins/traefik/e2e/kubernetes/run.sh +++ b/plugins/traefik/e2e/kubernetes/run.sh @@ -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() { diff --git a/plugins/traefik/e2e/kubernetes/values.yaml b/plugins/traefik/e2e/kubernetes/values.yaml index 3a63aaa..db78437 100644 --- a/plugins/traefik/e2e/kubernetes/values.yaml +++ b/plugins/traefik/e2e/kubernetes/values.yaml @@ -1,7 +1,3 @@ -# traefik helm values -image: - tag: "2.9.1" - additionalArguments: - "--experimental.localPlugins.sablier.moduleName=github.com/acouvreur/sablier"