mirror of
https://github.com/sablierapp/sablier.git
synced 2025-12-27 23:46:36 +01:00
refactor: instance are in unrecoverable state instead of error
To avoid confusion between error from a call or network. Instance has an Unrecoverable state instead of Error.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
<p class="description">Your instance(s) will stop after {{ .SessionDuration }} of inactivity}</p>
|
||||
<div class="details">
|
||||
<table>
|
||||
{{- range $i, $request := .RequestStates }}
|
||||
{{- range $i, $instance := .InstanceStates }}
|
||||
<tr>
|
||||
<td class="name">{{ $request.Name }}</td>
|
||||
{{- if $request.Error }}
|
||||
<td class="value error">{{ $request.Error }}</td>
|
||||
<td class="name">{{ $instance.Name }}</td>
|
||||
{{- if $instance.Error }}
|
||||
<td class="value error">{{ $instance.Error }}</td>
|
||||
{{- else }}
|
||||
<td class="value success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</td>
|
||||
<td class="value success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
{{ end -}}
|
||||
|
||||
@@ -162,11 +162,11 @@
|
||||
<div class="terminal">
|
||||
<h1><span>Starting </span> <span class="error_code">{{ .DisplayName }}</span>...</h1>
|
||||
<p class="output"><span>Your instance(s) will stop after {{ .SessionDuration }} of inactivity</span>.</p>
|
||||
{{ range $i, $request := .RequestStates }}
|
||||
{{ range $i, $instance := .InstanceStates }}
|
||||
<div class="details">
|
||||
<p class="output small command"><span>sablier status <span class="error_code">{{ $request.Name }}</span></span></code></p>
|
||||
{{ if $request.Error }}<p class="output small error">An error occured</span>: <code>{{ $request.Error }}</code></p>
|
||||
{{ else }}<p class="output small success"><span>{{ $request.Name }}</span> is {{ $request.Status }} <code>({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</code></p>{{ end }}
|
||||
<p class="output small command"><span>sablier status <span class="error_code">{{ $instance.Name }}</span></span></code></p>
|
||||
{{ if $instance.Error }}<p class="output small error">An error occured</span>: <code>{{ $instance.Error }}</code></p>
|
||||
{{ else }}<p class="output small success"><span>{{ $instance.Name }}</span> is {{ $instance.Status }} <code>({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</code></p>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
|
||||
<div class="details">
|
||||
<ul>
|
||||
{{- range $i, $request := .RequestStates }}
|
||||
{{- range $i, $instance := .InstanceStates }}
|
||||
<li>
|
||||
<span><span>{{ $request.Name }}</span>:</span>
|
||||
{{- if $request.Error }}
|
||||
<span class="error">{{ $request.Error }}</span>
|
||||
<span><span>{{ $instance.Name }}</span>:</span>
|
||||
{{- if $instance.Error }}
|
||||
<span class="error">{{ $instance.Error }}</span>
|
||||
{{- else }}
|
||||
<span class="success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</span>
|
||||
<span class="success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</span>
|
||||
{{- end}}
|
||||
</li>
|
||||
{{ end -}}
|
||||
|
||||
@@ -81,13 +81,13 @@
|
||||
</div>
|
||||
<div class="hidden" id="details">
|
||||
<table>
|
||||
{{- range $i, $request := .RequestStates }}
|
||||
{{- range $i, $instance := .InstanceStates }}
|
||||
<tr>
|
||||
<td class="name">{{ $request.Name }}</td>
|
||||
{{- if $request.Error }}
|
||||
<td class="value error">{{ $request.Error }}</td>
|
||||
<td class="name">{{ $instance.Name }}</td>
|
||||
{{- if $instance.Error }}
|
||||
<td class="value error">{{ $instance.Error }}</td>
|
||||
{{- else }}
|
||||
<td class="value success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</td>
|
||||
<td class="value success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
{{ end -}}
|
||||
|
||||
9
app/http/routes/models/blocking_request.go
Normal file
9
app/http/routes/models/blocking_request.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type BlockingRequest struct {
|
||||
Names []string
|
||||
SessionDuration time.Duration
|
||||
Timeout time.Duration
|
||||
}
|
||||
12
app/http/routes/models/dynamic_request.go
Normal file
12
app/http/routes/models/dynamic_request.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DynamicRequest struct {
|
||||
Names []string
|
||||
DisplayName string
|
||||
Theme string
|
||||
SessionDuration time.Duration
|
||||
}
|
||||
92
app/http/routes/strategies.go
Normal file
92
app/http/routes/strategies.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
163
app/http/routes/strategies_test.go
Normal file
163
app/http/routes/strategies_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
126
app/sessions/sessions_manager.go
Normal file
126
app/sessions/sessions_manager.go
Normal file
@@ -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))
|
||||
}
|
||||
81
app/sessions/sessions_manager_test.go
Normal file
81
app/sessions/sessions_manager_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user