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 }}
- | {{ $request.Name }} |
- {{- if $request.Error }}
- {{ $request.Error }} |
+ {{ $instance.Name }} |
+ {{- if $instance.Error }}
+ {{ $instance.Error }} |
{{- else }}
- {{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}) |
+ {{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }}) |
{{- 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 @@
- {{- range $i, $request := .RequestStates }}
+ {{- range $i, $instance := .InstanceStates }}
-
- {{ $request.Name }}:
- {{- if $request.Error }}
- {{ $request.Error }}
+ {{ $instance.Name }}:
+ {{- if $instance.Error }}
+ {{ $instance.Error }}
{{- else }}
- {{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})
+ {{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})
{{- end}}
{{ end -}}
diff --git a/app/http/pages/themes/shuffle.html b/app/http/pages/themes/shuffle.html
index 317943f..0750e52 100644
--- a/app/http/pages/themes/shuffle.html
+++ b/app/http/pages/themes/shuffle.html
@@ -81,13 +81,13 @@
- {{- range $i, $request := .RequestStates }}
+ {{- range $i, $instance := .InstanceStates }}
- | {{ $request.Name }} |
- {{- if $request.Error }}
- {{ $request.Error }} |
+ {{ $instance.Name }} |
+ {{- if $instance.Error }}
+ {{ $instance.Error }} |
{{- else }}
- {{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}) |
+ {{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }}) |
{{- 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
+}