diff --git a/app/http/pages/render.go b/app/http/pages/render.go index fa69505..e4a807a 100644 --- a/app/http/pages/render.go +++ b/app/http/pages/render.go @@ -15,7 +15,7 @@ import ( //go:embed themes/* var themes embed.FS -type RenderOptionsRequestState struct { +type RenderOptionsInstanceState struct { Name string CurrentReplicas int DesiredReplicas int @@ -25,7 +25,7 @@ type RenderOptionsRequestState struct { type RenderOptions struct { DisplayName string - RequestStates []RenderOptionsRequestState + InstanceStates []RenderOptionsInstanceState SessionDuration time.Duration RefreshFrequency time.Duration Theme string @@ -35,7 +35,7 @@ type RenderOptions struct { type TemplateValues struct { DisplayName string - RequestStates []RenderOptionsRequestState + InstanceStates []RenderOptionsInstanceState SessionDuration string RefreshFrequency time.Duration Version string @@ -59,7 +59,7 @@ func Render(options RenderOptions, writer io.Writer) error { return tpl.Execute(writer, TemplateValues{ DisplayName: options.DisplayName, - RequestStates: options.RequestStates, + InstanceStates: options.InstanceStates, SessionDuration: humanizeDuration(options.SessionDuration), RefreshFrequency: options.RefreshFrequency, Version: options.Version, diff --git a/app/http/pages/render_test.go b/app/http/pages/render_test.go index c131ea3..ecce8d5 100644 --- a/app/http/pages/render_test.go +++ b/app/http/pages/render_test.go @@ -8,7 +8,7 @@ import ( "time" ) -var requestsStates []RenderOptionsRequestState = []RenderOptionsRequestState{ +var instanceStates []RenderOptionsInstanceState = []RenderOptionsInstanceState{ { Name: "nginx", CurrentReplicas: 0, @@ -46,7 +46,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "ghost", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -61,7 +61,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "hacker-terminal", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -76,7 +76,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "matrix", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -91,7 +91,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "shuffle", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -106,7 +106,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "nonexistant", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -121,7 +121,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "dc-comics.html", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, @@ -139,7 +139,7 @@ func TestRender(t *testing.T) { args: args{ options: RenderOptions{ DisplayName: "Test", - RequestStates: requestsStates, + InstanceStates: instanceStates, Theme: "nonexistant", SessionDuration: 10 * time.Minute, RefreshFrequency: 5 * time.Second, diff --git a/app/http/pages/themes/ghost.html b/app/http/pages/themes/ghost.html index 96e21c3..db6adae 100644 --- a/app/http/pages/themes/ghost.html +++ b/app/http/pages/themes/ghost.html @@ -53,13 +53,13 @@

Your instance(s) will stop after {{ .SessionDuration }} of inactivity}

- {{- range $i, $request := .RequestStates }} + {{- range $i, $instance := .InstanceStates }} - - {{- if $request.Error }} - + + {{- if $instance.Error }} + {{- else }} - + {{- end}} {{ end -}} diff --git a/app/http/pages/themes/hacker-terminal.html b/app/http/pages/themes/hacker-terminal.html index d3df01d..97684e5 100644 --- a/app/http/pages/themes/hacker-terminal.html +++ b/app/http/pages/themes/hacker-terminal.html @@ -162,11 +162,11 @@

Starting {{ .DisplayName }}...

Your instance(s) will stop after {{ .SessionDuration }} of inactivity.

- {{ range $i, $request := .RequestStates }} + {{ range $i, $instance := .InstanceStates }}
-

sablier status {{ $request.Name }}

- {{ if $request.Error }}

An error occured: {{ $request.Error }}

- {{ else }}

{{ $request.Name }} is {{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})

{{ end }} +

sablier status {{ $instance.Name }}

+ {{ if $instance.Error }}

An error occured: {{ $instance.Error }}

+ {{ else }}

{{ $instance.Name }} is {{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})

{{ end }}
{{ end }}
diff --git a/app/http/pages/themes/matrix.html b/app/http/pages/themes/matrix.html index 74fc2fb..9ff2c4c 100644 --- a/app/http/pages/themes/matrix.html +++ b/app/http/pages/themes/matrix.html @@ -48,13 +48,13 @@
{{ $request.Name }}{{ $request.Error }}{{ $instance.Name }}{{ $instance.Error }}{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}){{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})
- {{- range $i, $request := .RequestStates }} + {{- range $i, $instance := .InstanceStates }} - - {{- if $request.Error }} - + + {{- if $instance.Error }} + {{- else }} - + {{- end}} {{ end -}} diff --git a/app/http/routes/models/blocking_request.go b/app/http/routes/models/blocking_request.go new file mode 100644 index 0000000..319fa7f --- /dev/null +++ b/app/http/routes/models/blocking_request.go @@ -0,0 +1,9 @@ +package models + +import "time" + +type BlockingRequest struct { + Names []string + SessionDuration time.Duration + Timeout time.Duration +} diff --git a/app/http/routes/models/dynamic_request.go b/app/http/routes/models/dynamic_request.go new file mode 100644 index 0000000..9954e89 --- /dev/null +++ b/app/http/routes/models/dynamic_request.go @@ -0,0 +1,12 @@ +package models + +import ( + "time" +) + +type DynamicRequest struct { + Names []string + DisplayName string + Theme string + SessionDuration time.Duration +} diff --git a/app/http/routes/strategies.go b/app/http/routes/strategies.go new file mode 100644 index 0000000..981d652 --- /dev/null +++ b/app/http/routes/strategies.go @@ -0,0 +1,92 @@ +package routes + +import ( + "fmt" + "net/http" + "time" + + 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/version" + "github.com/gin-gonic/gin" +) + +type ServeStrategy struct { + SessionsManager sessions.Manager +} + +// ServeDynamic returns a waiting page displaying the session request if the session is not ready +// If the session is ready, returns a redirect 307 with an arbitrary location +func (s *ServeStrategy) ServeDynamic(c *gin.Context) { + request := models.DynamicRequest{} + + if err := c.BindJSON(&request); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + sessionState := s.SessionsManager.RequestSession(request.Names, request.SessionDuration) + + if sessionState.IsReady() { + // All requests are fulfilled, redirect to + c.Redirect(http.StatusTemporaryRedirect, "origin") + return + } + + renderOptions := pages.RenderOptions{ + DisplayName: request.DisplayName, + SessionDuration: request.SessionDuration, + Theme: request.Theme, + Version: version.Version, + RefreshFrequency: 5 * time.Second, + InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState), + } + + c.Header("Content-Type", "text/html") + if err := pages.Render(renderOptions, c.Writer); err != nil { + log.Error(err) + c.AbortWithError(http.StatusInternalServerError, err) + return + } +} + +func (s *ServeStrategy) ServeBlocking(c *gin.Context) { + request := models.BlockingRequest{} + + if err := c.BindJSON(&request); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + sessionState := s.SessionsManager.RequestReadySession(request.Names, request.SessionDuration, request.Timeout) + + if sessionState.IsReady() { + // All requests are fulfilled, redirect to + c.Redirect(http.StatusTemporaryRedirect, "origin") + return + } + +} + +func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []pages.RenderOptionsInstanceState) { + sessionState.Instances.Range(func(key, value any) bool { + instances = append(instances, instanceStateToRenderOptionsRequestState(value.(*instance.State))) + return true + }) + + return +} + +func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pages.RenderOptionsInstanceState { + return pages.RenderOptionsInstanceState{ + Name: instanceState.Name, + Status: instanceState.Status, + CurrentReplicas: instanceState.CurrentReplicas, + DesiredReplicas: 1, //instanceState.DesiredReplicas, + Error: fmt.Errorf(instanceState.Message), + } +} diff --git a/app/http/routes/strategies_test.go b/app/http/routes/strategies_test.go new file mode 100644 index 0000000..718d746 --- /dev/null +++ b/app/http/routes/strategies_test.go @@ -0,0 +1,163 @@ +package routes + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/acouvreur/sablier/app/http/routes/models" + "github.com/acouvreur/sablier/app/instance" + "github.com/acouvreur/sablier/app/sessions" + "github.com/gin-gonic/gin" + "gotest.tools/v3/assert" +) + +type SessionsManagerMock struct { + SessionState sessions.SessionState +} + +func (s *SessionsManagerMock) RequestSession(names []string, duration time.Duration) *sessions.SessionState { + return &s.SessionState +} + +func (s *SessionsManagerMock) RequestReadySession(names []string, duration time.Duration, timeout time.Duration) *sessions.SessionState { + return &s.SessionState +} + +func TestServeStrategy_ServeDynamic(t *testing.T) { + type arg struct { + body models.DynamicRequest + session sessions.SessionState + } + tests := []struct { + name string + arg arg + expectedCode int + }{ + { + name: "return HTML Theme", + arg: arg{ + body: models.DynamicRequest{ + Names: []string{"nginx"}, + DisplayName: "Test", + Theme: "hacker-terminal", + SessionDuration: 1 * time.Minute, + }, + session: sessions.SessionState{ + Instances: createMap([]instance.State{ + {Name: "nginx", Status: instance.NotReady}, + }), + }, + }, + expectedCode: http.StatusOK, + }, + { + name: "temporary redirect when session is ready", + arg: arg{ + body: models.DynamicRequest{ + Names: []string{"nginx"}, + DisplayName: "Test", + Theme: "hacker-terminal", + SessionDuration: 1 * time.Minute, + }, + session: sessions.SessionState{ + Instances: createMap([]instance.State{ + {Name: "nginx", Status: instance.Ready}, + }), + }, + }, + expectedCode: http.StatusTemporaryRedirect, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + s := &ServeStrategy{ + SessionsManager: &SessionsManagerMock{ + SessionState: tt.arg.session, + }, + } + recorder := httptest.NewRecorder() + c := GetTestGinContext(recorder) + MockJsonPost(c, tt.arg.body) + + s.ServeDynamic(c) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, c.Writer.Status(), tt.expectedCode) + }) + } +} + +// mock gin context +func GetTestGinContext(w *httptest.ResponseRecorder) *gin.Context { + gin.SetMode(gin.TestMode) + + ctx, _ := gin.CreateTestContext(w) + ctx.Request = &http.Request{ + Header: make(http.Header), + URL: &url.URL{}, + } + + return ctx +} + +// mock getrequest +func MockJsonGet(c *gin.Context, params gin.Params, u url.Values) { + c.Request.Method = "GET" + c.Request.Header.Set("Content-Type", "application/json") + c.Params = params + c.Request.URL.RawQuery = u.Encode() +} + +func MockJsonPost(c *gin.Context, content interface{}) { + c.Request.Method = "POST" + c.Request.Header.Set("Content-Type", "application/json") + + jsonbytes, err := json.Marshal(content) + if err != nil { + panic(err) + } + + // the request body must be an io.ReadCloser + // the bytes buffer though doesn't implement io.Closer, + // so you wrap it in a no-op closer + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes)) +} + +func MockJsonPut(c *gin.Context, content interface{}, params gin.Params) { + c.Request.Method = "PUT" + c.Request.Header.Set("Content-Type", "application/json") + c.Params = params + + jsonbytes, err := json.Marshal(content) + if err != nil { + panic(err) + } + + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes)) +} + +func MockJsonDelete(c *gin.Context, params gin.Params) { + c.Request.Method = "DELETE" + c.Request.Header.Set("Content-Type", "application/json") + c.Params = params +} + +func createMap(instances []instance.State) (store *sync.Map) { + store = &sync.Map{} + + for _, v := range instances { + store.Store(v.Name, &v) + } + + return +} diff --git a/app/instance/instance.go b/app/instance/instance.go index 65e154c..2ab15c1 100644 --- a/app/instance/instance.go +++ b/app/instance/instance.go @@ -4,40 +4,36 @@ import log "github.com/sirupsen/logrus" var Ready = "ready" var NotReady = "not-ready" -var Error = "error" +var Unrecoverable = "unrecoverable" type State struct { Name string CurrentReplicas int Status string - Error string + Message string } func (instance State) IsReady() bool { return instance.Status == Ready } -func (instance State) HasError() bool { - return instance.Status == Error -} - func ErrorInstanceState(name string, err error) (State, error) { log.Error(err.Error()) return State{ Name: name, CurrentReplicas: 0, - Status: Error, - Error: err.Error(), + Status: Unrecoverable, + Message: err.Error(), }, err } -func UnrecoverableInstanceState(name string, err string) (State, error) { - log.Warn(err) +func UnrecoverableInstanceState(name string, message string) (State, error) { + log.Warn(message) return State{ Name: name, CurrentReplicas: 0, - Status: Error, - Error: err, + Status: Unrecoverable, + Message: message, }, nil } diff --git a/app/providers/docker_classic_test.go b/app/providers/docker_classic_test.go index e4041ff..8b7e1d0 100644 --- a/app/providers/docker_classic_test.go +++ b/app/providers/docker_classic_test.go @@ -86,8 +86,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container is unhealthy: curl http://localhost failed (1)", + Status: instance.Unrecoverable, + Message: "container is unhealthy: curl http://localhost failed (1)", }, wantErr: false, containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "unhealthy"), @@ -183,8 +183,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container exited with code \"137\"", + Status: instance.Unrecoverable, + Message: "container exited with code \"137\"", }, wantErr: false, containerSpec: mocks.ExitedContainerSpec("nginx", 137), @@ -200,8 +200,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container in \"dead\" state cannot be restarted", + Status: instance.Unrecoverable, + Message: "container in \"dead\" state cannot be restarted", }, wantErr: false, containerSpec: mocks.DeadContainerSpec("nginx"), @@ -217,8 +217,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container with name \"nginx\" was not found", + Status: instance.Unrecoverable, + Message: "container with name \"nginx\" was not found", }, wantErr: true, containerSpec: types.ContainerJSON{}, @@ -271,8 +271,8 @@ func TestDockerClassicProvider_Stop(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container with name \"nginx\" was not found", + Status: instance.Unrecoverable, + Message: "container with name \"nginx\" was not found", }, wantErr: true, err: fmt.Errorf("container with name \"nginx\" was not found"), @@ -340,8 +340,8 @@ func TestDockerClassicProvider_Start(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "container with name \"nginx\" was not found", + Status: instance.Unrecoverable, + Message: "container with name \"nginx\" was not found", }, wantErr: true, err: fmt.Errorf("container with name \"nginx\" was not found"), diff --git a/app/providers/docker_swarm.go b/app/providers/docker_swarm.go index 71e48a3..3e94f0e 100644 --- a/app/providers/docker_swarm.go +++ b/app/providers/docker_swarm.go @@ -20,7 +20,6 @@ type DockerSwarmProvider struct { func NewDockerSwarmProvider() (*DockerSwarmProvider, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { - log.Fatal(fmt.Errorf("%+v", "Could not connect to docker API")) return nil, err } return &DockerSwarmProvider{ diff --git a/app/providers/docker_swarm_test.go b/app/providers/docker_swarm_test.go index c8f4667..a049c41 100644 --- a/app/providers/docker_swarm_test.go +++ b/app/providers/docker_swarm_test.go @@ -68,8 +68,8 @@ func TestDockerSwarmProvider_Start(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", + Status: instance.Unrecoverable, + Message: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", }, wantService: mocks.ServiceReplicated("nginx", 1), wantErr: true, @@ -138,8 +138,8 @@ func TestDockerSwarmProvider_Start(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "swarm service is not in \"replicated\" mode", + Status: instance.Unrecoverable, + Message: "swarm service is not in \"replicated\" mode", }, wantService: mocks.ServiceReplicated("nginx", 1), wantErr: false, @@ -223,8 +223,8 @@ func TestDockerSwarmProvider_Stop(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", + Status: instance.Unrecoverable, + Message: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", }, wantService: mocks.ServiceReplicated("nginx", 1), wantErr: true, @@ -293,8 +293,8 @@ func TestDockerSwarmProvider_Stop(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "swarm service is not in \"replicated\" mode", + Status: instance.Unrecoverable, + Message: "swarm service is not in \"replicated\" mode", }, wantService: mocks.ServiceReplicated("nginx", 1), wantErr: false, @@ -404,8 +404,8 @@ func TestDockerSwarmProvider_GetState(t *testing.T) { want: instance.State{ Name: "nginx", CurrentReplicas: 0, - Status: instance.Error, - Error: "swarm service is not in \"replicated\" mode", + Status: instance.Unrecoverable, + Message: "swarm service is not in \"replicated\" mode", }, wantErr: false, }, diff --git a/app/providers/kubernetes.go b/app/providers/kubernetes.go index 406ed09..847b8b9 100644 --- a/app/providers/kubernetes.go +++ b/app/providers/kubernetes.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/acouvreur/sablier/app/instance" - log "github.com/sirupsen/logrus" autoscalingv1 "k8s.io/api/autoscaling/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -55,18 +54,18 @@ type KubernetesProvider struct { Client kubernetes.Interface } -func NewKubernetesProvider() *KubernetesProvider { +func NewKubernetesProvider() (*KubernetesProvider, error) { config, err := rest.InClusterConfig() if err != nil { - log.Fatal(err) + return nil, err } client, err := kubernetes.NewForConfig(config) if err != nil { - log.Fatal(err) + return nil, err } return &KubernetesProvider{ Client: client, - } + }, nil } func (provider *KubernetesProvider) Start(name string) (instance.State, error) { diff --git a/app/providers/kubernetes_test.go b/app/providers/kubernetes_test.go index f126015..9481a28 100644 --- a/app/providers/kubernetes_test.go +++ b/app/providers/kubernetes_test.go @@ -70,8 +70,8 @@ func TestKubernetesProvider_Start(t *testing.T) { want: instance.State{ Name: "gateway_default_nginx_2", CurrentReplicas: 0, - Status: instance.Error, - Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", + Status: instance.Unrecoverable, + Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", }, data: data{ name: "nginx", @@ -165,8 +165,8 @@ func TestKubernetesProvider_Stop(t *testing.T) { want: instance.State{ Name: "gateway_default_nginx_2", CurrentReplicas: 0, - Status: instance.Error, - Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", + Status: instance.Unrecoverable, + Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", }, data: data{ name: "nginx", @@ -290,8 +290,8 @@ func TestKubernetesProvider_GetState(t *testing.T) { want: instance.State{ Name: "gateway_default_nginx_2", CurrentReplicas: 0, - Status: instance.Error, - Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", + Status: instance.Unrecoverable, + Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", }, data: data{ name: "nginx", diff --git a/app/providers/provider.go b/app/providers/provider.go index 8a5cdb5..07e63e2 100644 --- a/app/providers/provider.go +++ b/app/providers/provider.go @@ -1,9 +1,26 @@ package providers -import "github.com/acouvreur/sablier/app/instance" +import ( + "fmt" + + "github.com/acouvreur/sablier/app/instance" + "github.com/acouvreur/sablier/config" +) type Provider interface { Start(name string) (instance.State, error) Stop(name string) (instance.State, error) GetState(name string) (instance.State, error) } + +func NewProvider(config config.Provider) (Provider, error) { + switch { + case config.Name == "swarm": + return NewDockerSwarmProvider() + case config.Name == "docker": + return NewDockerClassicProvider() + case config.Name == "kubernetes": + return NewKubernetesProvider() + } + return nil, fmt.Errorf("unimplemented provider %s", config.Name) +} diff --git a/app/sessions/sessions_manager.go b/app/sessions/sessions_manager.go new file mode 100644 index 0000000..1ea0566 --- /dev/null +++ b/app/sessions/sessions_manager.go @@ -0,0 +1,126 @@ +package sessions + +import ( + "sync" + "time" + + "github.com/acouvreur/sablier/app/instance" + "github.com/acouvreur/sablier/app/providers" + "github.com/acouvreur/sablier/config" + "github.com/acouvreur/sablier/pkg/tinykv" + log "github.com/sirupsen/logrus" +) + +type Manager interface { + RequestSession(names []string, duration time.Duration) *SessionState + RequestReadySession(names []string, duration time.Duration, timeout time.Duration) *SessionState +} + +type SessionsManager struct { + store tinykv.KV[instance.State] + provider providers.Provider +} + +func NewSessionsManager(conf config.Sessions, provider providers.Provider) (Manager, error) { + + store := tinykv.New(conf.ExpirationInterval, onSessionExpires(provider)) + + return &SessionsManager{ + store: store, + provider: provider, + }, nil +} + +type InstanceState struct { + Instance *instance.State + Error error +} + +type SessionState struct { + Instances *sync.Map +} + +func onSessionExpires(provider providers.Provider) func(key string, instance instance.State) { + return func(key string, instance instance.State) { + log.Debugf("stopping %s...", key) + _, err := provider.Stop(key) + + if err != nil { + log.Warnf("error stopping %s: %s", key, err.Error()) + } else { + log.Debugf("stopped %s", key) + } + } +} + +func (s *SessionState) IsReady() bool { + ready := true + + s.Instances.Range(func(key, value interface{}) bool { + state := value.(InstanceState) + if state.Error != nil || state.Instance.Status != instance.Ready { + ready = false + return false + } + return true + }) + + return ready +} + +func (s *SessionsManager) RequestSession(names []string, duration time.Duration) (sessionState *SessionState) { + + var wg sync.WaitGroup + + wg.Add(len(names)) + + for i := 0; i < len(names); i++ { + name := names[i] + go func() { + defer wg.Done() + state, err := s.requestSessionInstance(name, duration) + + sessionState.Instances.Store(name, InstanceState{ + Instance: state, + Error: err, + }) + }() + } + + wg.Wait() + + return sessionState +} + +func (s *SessionsManager) requestSessionInstance(name string, duration time.Duration) (*instance.State, error) { + + requestState, exists := s.store.Get(name) + + // Trust the stored value + // TODO: Provider background check on the store + // Via polling or whatever + if !exists || requestState.Status != instance.Ready { + state, err := s.provider.Start(name) + + if err != nil { + return nil, err + } + + requestState.Name = state.Name + requestState.CurrentReplicas = state.CurrentReplicas + requestState.Status = state.Status + requestState.Error = state.Error + } + + // Refresh the duration + s.ExpiresAfter(&requestState, duration) + return &requestState, nil +} + +func (s *SessionsManager) RequestReadySession(names []string, duration time.Duration, timeout time.Duration) *SessionState { + return nil +} + +func (s *SessionsManager) ExpiresAfter(request *instance.State, duration time.Duration) { + s.store.Put(request.Name, *request, tinykv.ExpiresAfter(duration)) +} diff --git a/app/sessions/sessions_manager_test.go b/app/sessions/sessions_manager_test.go new file mode 100644 index 0000000..0aa9cac --- /dev/null +++ b/app/sessions/sessions_manager_test.go @@ -0,0 +1,81 @@ +package sessions + +import ( + "sync" + "testing" + + "github.com/acouvreur/sablier/app/instance" +) + +func TestSessionState_IsReady(t *testing.T) { + type fields struct { + Instances *sync.Map + Error error + } + tests := []struct { + name string + fields fields + want bool + }{ + { + name: "all instances are ready", + fields: fields{ + Instances: createMap([]instance.State{ + {Name: "nginx", Status: instance.Ready}, + {Name: "apache", Status: instance.Ready}, + }), + }, + want: true, + }, + { + name: "one instance is not ready", + fields: fields{ + Instances: createMap([]instance.State{ + {Name: "nginx", Status: instance.Ready}, + {Name: "apache", Status: instance.NotReady}, + }), + }, + want: false, + }, + { + name: "no instances specified", + fields: fields{ + Instances: createMap([]instance.State{}), + }, + want: true, + }, + { + name: "one instance has an error", + fields: fields{ + Instances: createMap([]instance.State{ + {Name: "nginx", Status: instance.Error, Error: "connection timeout"}, + {Name: "apache", Status: instance.Ready}, + }), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &SessionState{ + Instances: tt.fields.Instances, + } + if got := s.IsReady(); got != tt.want { + t.Errorf("SessionState.IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func createMap(instances []instance.State) (store *sync.Map) { + store = &sync.Map{} + + for _, v := range instances { + store.Store(v.Name, InstanceState{ + Instance: &v, + Error: nil, + }) + } + + return +}
{{ $request.Name }}{{ $request.Error }}{{ $instance.Name }}{{ $instance.Error }}{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}){{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})