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:
Alexis Couvreur
2022-10-26 14:45:43 +00:00
parent 67bf03780c
commit c827154506
19 changed files with 572 additions and 78 deletions

View File

@@ -15,7 +15,7 @@ import (
//go:embed themes/* //go:embed themes/*
var themes embed.FS var themes embed.FS
type RenderOptionsRequestState struct { type RenderOptionsInstanceState struct {
Name string Name string
CurrentReplicas int CurrentReplicas int
DesiredReplicas int DesiredReplicas int
@@ -25,7 +25,7 @@ type RenderOptionsRequestState struct {
type RenderOptions struct { type RenderOptions struct {
DisplayName string DisplayName string
RequestStates []RenderOptionsRequestState InstanceStates []RenderOptionsInstanceState
SessionDuration time.Duration SessionDuration time.Duration
RefreshFrequency time.Duration RefreshFrequency time.Duration
Theme string Theme string
@@ -35,7 +35,7 @@ type RenderOptions struct {
type TemplateValues struct { type TemplateValues struct {
DisplayName string DisplayName string
RequestStates []RenderOptionsRequestState InstanceStates []RenderOptionsInstanceState
SessionDuration string SessionDuration string
RefreshFrequency time.Duration RefreshFrequency time.Duration
Version string Version string
@@ -59,7 +59,7 @@ func Render(options RenderOptions, writer io.Writer) error {
return tpl.Execute(writer, TemplateValues{ return tpl.Execute(writer, TemplateValues{
DisplayName: options.DisplayName, DisplayName: options.DisplayName,
RequestStates: options.RequestStates, InstanceStates: options.InstanceStates,
SessionDuration: humanizeDuration(options.SessionDuration), SessionDuration: humanizeDuration(options.SessionDuration),
RefreshFrequency: options.RefreshFrequency, RefreshFrequency: options.RefreshFrequency,
Version: options.Version, Version: options.Version,

View File

@@ -8,7 +8,7 @@ import (
"time" "time"
) )
var requestsStates []RenderOptionsRequestState = []RenderOptionsRequestState{ var instanceStates []RenderOptionsInstanceState = []RenderOptionsInstanceState{
{ {
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
@@ -46,7 +46,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "ghost", Theme: "ghost",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -61,7 +61,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "hacker-terminal", Theme: "hacker-terminal",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -76,7 +76,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "matrix", Theme: "matrix",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -91,7 +91,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "shuffle", Theme: "shuffle",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -106,7 +106,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "nonexistant", Theme: "nonexistant",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -121,7 +121,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "dc-comics.html", Theme: "dc-comics.html",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,
@@ -139,7 +139,7 @@ func TestRender(t *testing.T) {
args: args{ args: args{
options: RenderOptions{ options: RenderOptions{
DisplayName: "Test", DisplayName: "Test",
RequestStates: requestsStates, InstanceStates: instanceStates,
Theme: "nonexistant", Theme: "nonexistant",
SessionDuration: 10 * time.Minute, SessionDuration: 10 * time.Minute,
RefreshFrequency: 5 * time.Second, RefreshFrequency: 5 * time.Second,

View File

@@ -53,13 +53,13 @@
<p class="description">Your instance(s) will stop after {{ .SessionDuration }} of inactivity}</p> <p class="description">Your instance(s) will stop after {{ .SessionDuration }} of inactivity}</p>
<div class="details"> <div class="details">
<table> <table>
{{- range $i, $request := .RequestStates }} {{- range $i, $instance := .InstanceStates }}
<tr> <tr>
<td class="name">{{ $request.Name }}</td> <td class="name">{{ $instance.Name }}</td>
{{- if $request.Error }} {{- if $instance.Error }}
<td class="value error">{{ $request.Error }}</td> <td class="value error">{{ $instance.Error }}</td>
{{- else }} {{- else }}
<td class="value success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</td> <td class="value success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
{{- end}} {{- end}}
</tr> </tr>
{{ end -}} {{ end -}}

View File

@@ -162,11 +162,11 @@
<div class="terminal"> <div class="terminal">
<h1><span>Starting </span> <span class="error_code">{{ .DisplayName }}</span>...</h1> <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> <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"> <div class="details">
<p class="output small command"><span>sablier status <span class="error_code">{{ $request.Name }}</span></span></code></p> <p class="output small command"><span>sablier status <span class="error_code">{{ $instance.Name }}</span></span></code></p>
{{ if $request.Error }}<p class="output small error">An error occured</span>: <code>{{ $request.Error }}</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>{{ $request.Name }}</span> is {{ $request.Status }} <code>({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</code></p>{{ end }} {{ else }}<p class="output small success"><span>{{ $instance.Name }}</span> is {{ $instance.Status }} <code>({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</code></p>{{ end }}
</div> </div>
{{ end }} {{ end }}
</div> </div>

View File

@@ -48,13 +48,13 @@
<div class="details"> <div class="details">
<ul> <ul>
{{- range $i, $request := .RequestStates }} {{- range $i, $instance := .InstanceStates }}
<li> <li>
<span><span>{{ $request.Name }}</span>:</span> <span><span>{{ $instance.Name }}</span>:</span>
{{- if $request.Error }} {{- if $instance.Error }}
<span class="error">{{ $request.Error }}</span> <span class="error">{{ $instance.Error }}</span>
{{- else }} {{- else }}
<span class="success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</span> <span class="success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</span>
{{- end}} {{- end}}
</li> </li>
{{ end -}} {{ end -}}

View File

@@ -81,13 +81,13 @@
</div> </div>
<div class="hidden" id="details"> <div class="hidden" id="details">
<table> <table>
{{- range $i, $request := .RequestStates }} {{- range $i, $instance := .InstanceStates }}
<tr> <tr>
<td class="name">{{ $request.Name }}</td> <td class="name">{{ $instance.Name }}</td>
{{- if $request.Error }} {{- if $instance.Error }}
<td class="value error">{{ $request.Error }}</td> <td class="value error">{{ $instance.Error }}</td>
{{- else }} {{- else }}
<td class="value success">{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})</td> <td class="value success">{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
{{- end}} {{- end}}
</tr> </tr>
{{ end -}} {{ end -}}

View File

@@ -0,0 +1,9 @@
package models
import "time"
type BlockingRequest struct {
Names []string
SessionDuration time.Duration
Timeout time.Duration
}

View File

@@ -0,0 +1,12 @@
package models
import (
"time"
)
type DynamicRequest struct {
Names []string
DisplayName string
Theme string
SessionDuration time.Duration
}

View 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),
}
}

View 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
}

View File

@@ -4,40 +4,36 @@ import log "github.com/sirupsen/logrus"
var Ready = "ready" var Ready = "ready"
var NotReady = "not-ready" var NotReady = "not-ready"
var Error = "error" var Unrecoverable = "unrecoverable"
type State struct { type State struct {
Name string Name string
CurrentReplicas int CurrentReplicas int
Status string Status string
Error string Message string
} }
func (instance State) IsReady() bool { func (instance State) IsReady() bool {
return instance.Status == Ready return instance.Status == Ready
} }
func (instance State) HasError() bool {
return instance.Status == Error
}
func ErrorInstanceState(name string, err error) (State, error) { func ErrorInstanceState(name string, err error) (State, error) {
log.Error(err.Error()) log.Error(err.Error())
return State{ return State{
Name: name, Name: name,
CurrentReplicas: 0, CurrentReplicas: 0,
Status: Error, Status: Unrecoverable,
Error: err.Error(), Message: err.Error(),
}, err }, err
} }
func UnrecoverableInstanceState(name string, err string) (State, error) { func UnrecoverableInstanceState(name string, message string) (State, error) {
log.Warn(err) log.Warn(message)
return State{ return State{
Name: name, Name: name,
CurrentReplicas: 0, CurrentReplicas: 0,
Status: Error, Status: Unrecoverable,
Error: err, Message: message,
}, nil }, nil
} }

View File

@@ -86,8 +86,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container is unhealthy: curl http://localhost failed (1)", Message: "container is unhealthy: curl http://localhost failed (1)",
}, },
wantErr: false, wantErr: false,
containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "unhealthy"), containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "unhealthy"),
@@ -183,8 +183,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container exited with code \"137\"", Message: "container exited with code \"137\"",
}, },
wantErr: false, wantErr: false,
containerSpec: mocks.ExitedContainerSpec("nginx", 137), containerSpec: mocks.ExitedContainerSpec("nginx", 137),
@@ -200,8 +200,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container in \"dead\" state cannot be restarted", Message: "container in \"dead\" state cannot be restarted",
}, },
wantErr: false, wantErr: false,
containerSpec: mocks.DeadContainerSpec("nginx"), containerSpec: mocks.DeadContainerSpec("nginx"),
@@ -217,8 +217,8 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container with name \"nginx\" was not found", Message: "container with name \"nginx\" was not found",
}, },
wantErr: true, wantErr: true,
containerSpec: types.ContainerJSON{}, containerSpec: types.ContainerJSON{},
@@ -271,8 +271,8 @@ func TestDockerClassicProvider_Stop(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container with name \"nginx\" was not found", Message: "container with name \"nginx\" was not found",
}, },
wantErr: true, wantErr: true,
err: fmt.Errorf("container with name \"nginx\" was not found"), err: fmt.Errorf("container with name \"nginx\" was not found"),
@@ -340,8 +340,8 @@ func TestDockerClassicProvider_Start(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "container with name \"nginx\" was not found", Message: "container with name \"nginx\" was not found",
}, },
wantErr: true, wantErr: true,
err: fmt.Errorf("container with name \"nginx\" was not found"), err: fmt.Errorf("container with name \"nginx\" was not found"),

View File

@@ -20,7 +20,6 @@ type DockerSwarmProvider struct {
func NewDockerSwarmProvider() (*DockerSwarmProvider, error) { func NewDockerSwarmProvider() (*DockerSwarmProvider, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil { if err != nil {
log.Fatal(fmt.Errorf("%+v", "Could not connect to docker API"))
return nil, err return nil, err
} }
return &DockerSwarmProvider{ return &DockerSwarmProvider{

View File

@@ -68,8 +68,8 @@ func TestDockerSwarmProvider_Start(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", Message: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)",
}, },
wantService: mocks.ServiceReplicated("nginx", 1), wantService: mocks.ServiceReplicated("nginx", 1),
wantErr: true, wantErr: true,
@@ -138,8 +138,8 @@ func TestDockerSwarmProvider_Start(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "swarm service is not in \"replicated\" mode", Message: "swarm service is not in \"replicated\" mode",
}, },
wantService: mocks.ServiceReplicated("nginx", 1), wantService: mocks.ServiceReplicated("nginx", 1),
wantErr: false, wantErr: false,
@@ -223,8 +223,8 @@ func TestDockerSwarmProvider_Stop(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)", Message: "ambiguous service names found for \"nginx\" (STACK1_nginx, STACK2_nginx)",
}, },
wantService: mocks.ServiceReplicated("nginx", 1), wantService: mocks.ServiceReplicated("nginx", 1),
wantErr: true, wantErr: true,
@@ -293,8 +293,8 @@ func TestDockerSwarmProvider_Stop(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "swarm service is not in \"replicated\" mode", Message: "swarm service is not in \"replicated\" mode",
}, },
wantService: mocks.ServiceReplicated("nginx", 1), wantService: mocks.ServiceReplicated("nginx", 1),
wantErr: false, wantErr: false,
@@ -404,8 +404,8 @@ func TestDockerSwarmProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "nginx", Name: "nginx",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "swarm service is not in \"replicated\" mode", Message: "swarm service is not in \"replicated\" mode",
}, },
wantErr: false, wantErr: false,
}, },

View File

@@ -8,7 +8,6 @@ import (
"strings" "strings"
"github.com/acouvreur/sablier/app/instance" "github.com/acouvreur/sablier/app/instance"
log "github.com/sirupsen/logrus"
autoscalingv1 "k8s.io/api/autoscaling/v1" autoscalingv1 "k8s.io/api/autoscaling/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -55,18 +54,18 @@ type KubernetesProvider struct {
Client kubernetes.Interface Client kubernetes.Interface
} }
func NewKubernetesProvider() *KubernetesProvider { func NewKubernetesProvider() (*KubernetesProvider, error) {
config, err := rest.InClusterConfig() config, err := rest.InClusterConfig()
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
client, err := kubernetes.NewForConfig(config) client, err := kubernetes.NewForConfig(config)
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
return &KubernetesProvider{ return &KubernetesProvider{
Client: client, Client: client,
} }, nil
} }
func (provider *KubernetesProvider) Start(name string) (instance.State, error) { func (provider *KubernetesProvider) Start(name string) (instance.State, error) {

View File

@@ -70,8 +70,8 @@ func TestKubernetesProvider_Start(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "gateway_default_nginx_2", Name: "gateway_default_nginx_2",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"",
}, },
data: data{ data: data{
name: "nginx", name: "nginx",
@@ -165,8 +165,8 @@ func TestKubernetesProvider_Stop(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "gateway_default_nginx_2", Name: "gateway_default_nginx_2",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"",
}, },
data: data{ data: data{
name: "nginx", name: "nginx",
@@ -290,8 +290,8 @@ func TestKubernetesProvider_GetState(t *testing.T) {
want: instance.State{ want: instance.State{
Name: "gateway_default_nginx_2", Name: "gateway_default_nginx_2",
CurrentReplicas: 0, CurrentReplicas: 0,
Status: instance.Error, Status: instance.Unrecoverable,
Error: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"", Message: "unsupported kind \"gateway\" must be one of \"deployment\", \"statefulset\"",
}, },
data: data{ data: data{
name: "nginx", name: "nginx",

View File

@@ -1,9 +1,26 @@
package providers 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 { type Provider interface {
Start(name string) (instance.State, error) Start(name string) (instance.State, error)
Stop(name string) (instance.State, error) Stop(name string) (instance.State, error)
GetState(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)
}

View 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))
}

View 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
}