feat(provider): add docker provider

This commit is contained in:
Alexis Couvreur
2022-10-20 15:57:05 +00:00
parent c63bbee7dd
commit bbdddca107
3 changed files with 548 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
package providers
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
)
type DockerClassicProvider struct {
Client client.ContainerAPIClient
}
func NewDockerClassicProvider() (*DockerClassicProvider, 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 &DockerClassicProvider{
Client: cli,
}, nil
}
func (provider *DockerClassicProvider) Init() {
}
func (provider *DockerClassicProvider) Start(name string) (InstanceState, error) {
ctx := context.Background()
err := provider.Client.ContainerStart(ctx, name, types.ContainerStartOptions{})
if err != nil {
return errorInstanceState(name, err)
}
return InstanceState{
Name: name,
CurrentReplicas: 0,
Status: NotReady,
}, err
}
func (provider *DockerClassicProvider) Stop(name string) (InstanceState, error) {
ctx := context.Background()
// TODO: Allow to specify a termination timeout
err := provider.Client.ContainerStop(ctx, name, nil)
if err != nil {
return errorInstanceState(name, err)
}
return InstanceState{
Name: name,
CurrentReplicas: 0,
Status: NotReady,
}, nil
}
func (provider *DockerClassicProvider) GetState(name string) (InstanceState, error) {
ctx := context.Background()
spec, err := provider.Client.ContainerInspect(ctx, name)
if err != nil {
return errorInstanceState(name, err)
}
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch spec.State.Status {
case "created", "paused", "restarting", "removing":
return notReadyInstanceState(name)
case "running":
if spec.State.Health != nil {
// // "starting", "healthy" or "unhealthy"
if spec.State.Health.Status == "healthy" {
return readyInstanceState(name)
} else if spec.State.Health.Status == "unhealthy" {
if len(spec.State.Health.Log) >= 1 {
lastLog := spec.State.Health.Log[len(spec.State.Health.Log)-1]
return unrecoverableInstanceState(name, fmt.Sprintf("container is unhealthy: %s (%d)", lastLog.Output, lastLog.ExitCode))
} else {
return unrecoverableInstanceState(name, "container is unhealthy: no log available")
}
} else {
return notReadyInstanceState(name)
}
}
return readyInstanceState(name)
case "exited":
if spec.State.ExitCode != 0 {
return unrecoverableInstanceState(name, fmt.Sprintf("container exited with code \"%d\"", spec.State.ExitCode))
}
return notReadyInstanceState(name)
case "dead":
return unrecoverableInstanceState(name, "container in \"dead\" state cannot be restarted")
default:
return unrecoverableInstanceState(name, fmt.Sprintf("container status \"%s\" not handled", spec.State.Status))
}
}
func errorInstanceState(name string, err error) (InstanceState, error) {
log.Error(err.Error())
return InstanceState{
Name: name,
CurrentReplicas: 0,
Status: Error,
Error: err.Error(),
}, err
}
func unrecoverableInstanceState(name string, err string) (InstanceState, error) {
return InstanceState{
Name: name,
CurrentReplicas: 0,
Status: Error,
Error: err,
}, nil
}
func readyInstanceState(name string) (InstanceState, error) {
return InstanceState{
Name: name,
CurrentReplicas: 1,
Status: Ready,
}, nil
}
func notReadyInstanceState(name string) (InstanceState, error) {
return InstanceState{
Name: name,
CurrentReplicas: 0,
Status: NotReady,
}, nil
}

View File

@@ -0,0 +1,383 @@
package providers
import (
"fmt"
"reflect"
"testing"
"github.com/acouvreur/sablier/app/providers/mocks"
"github.com/docker/docker/api/types"
"github.com/stretchr/testify/mock"
)
func TestDockerClassicProvider_GetState(t *testing.T) {
type fields struct {
Client *mocks.ContainerAPIClientMock
}
type args struct {
name string
}
tests := []struct {
name string
fields fields
args args
want InstanceState
wantErr bool
containerSpec types.ContainerJSON
err error
}{
{
name: "nginx created container state",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.CreatedContainerSpec("nginx"),
},
{
name: "nginx running container state without healthcheck",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 1,
Status: Ready,
},
wantErr: false,
containerSpec: mocks.RunningWithoutHealthcheckContainerSpec("nginx"),
},
{
name: "nginx running container state with \"starting\" health",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "starting"),
},
{
name: "nginx running container state with \"unhealthy\" health",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container is unhealthy: curl http://localhost failed (1)",
},
wantErr: false,
containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "unhealthy"),
},
{
name: "nginx running container state with \"healthy\" health",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 1,
Status: Ready,
},
wantErr: false,
containerSpec: mocks.RunningWithHealthcheckContainerSpec("nginx", "healthy"),
},
{
name: "nginx paused container state",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.PausedContainerSpec("nginx"),
},
{
name: "nginx restarting container state",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.RestartingContainerSpec("nginx"),
},
{
name: "nginx removing container state",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.RemovingContainerSpec("nginx"),
},
{
name: "nginx exited container state with status code 0",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
containerSpec: mocks.ExitedContainerSpec("nginx", 0),
},
{
name: "nginx exited container state with status code 137",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container exited with code \"137\"",
},
wantErr: false,
containerSpec: mocks.ExitedContainerSpec("nginx", 137),
},
{
name: "nginx dead container state",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container in \"dead\" state cannot be restarted",
},
wantErr: false,
containerSpec: mocks.DeadContainerSpec("nginx"),
},
{
name: "container inspect has an error",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container with name \"nginx\" was not found",
},
wantErr: true,
containerSpec: types.ContainerJSON{},
err: fmt.Errorf("container with name \"nginx\" was not found"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := &DockerClassicProvider{
Client: tt.fields.Client,
}
tt.fields.Client.On("ContainerInspect", mock.Anything, mock.Anything).Return(tt.containerSpec, tt.err)
got, err := provider.GetState(tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("DockerClassicProvider.GetState() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DockerClassicProvider.GetState() = %v, want %v", got, tt.want)
}
})
}
}
func TestDockerClassicProvider_Stop(t *testing.T) {
type fields struct {
Client *mocks.ContainerAPIClientMock
}
type args struct {
name string
}
tests := []struct {
name string
fields fields
args args
want InstanceState
wantErr bool
err error
}{
{
name: "container stop has an error",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container with name \"nginx\" was not found",
},
wantErr: true,
err: fmt.Errorf("container with name \"nginx\" was not found"),
},
{
name: "container stop as expected",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := &DockerClassicProvider{
Client: tt.fields.Client,
}
tt.fields.Client.On("ContainerStop", mock.Anything, mock.Anything, mock.Anything).Return(tt.err)
got, err := provider.Stop(tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("DockerClassicProvider.Stop() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DockerClassicProvider.Stop() = %v, want %v", got, tt.want)
}
})
}
}
func TestDockerClassicProvider_Start(t *testing.T) {
type fields struct {
Client *mocks.ContainerAPIClientMock
}
type args struct {
name string
}
tests := []struct {
name string
fields fields
args args
want InstanceState
wantErr bool
err error
}{
{
name: "container start has an error",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: Error,
Error: "container with name \"nginx\" was not found",
},
wantErr: true,
err: fmt.Errorf("container with name \"nginx\" was not found"),
},
{
name: "container start as expected",
fields: fields{
Client: mocks.NewContainerAPIClientMock(),
},
args: args{
name: "nginx",
},
want: InstanceState{
Name: "nginx",
CurrentReplicas: 0,
Status: NotReady,
},
wantErr: false,
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := &DockerClassicProvider{
Client: tt.fields.Client,
}
tt.fields.Client.On("ContainerStart", mock.Anything, mock.Anything, mock.Anything).Return(tt.err)
got, err := provider.Start(tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("DockerClassicProvider.Start() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DockerClassicProvider.Start() = %v, want %v", got, tt.want)
}
})
}
}

26
app/providers/provider.go Normal file
View File

@@ -0,0 +1,26 @@
package providers
type InstanceState struct {
Name string
CurrentReplicas int
Status string
Error string
}
var Ready = "ready"
var NotReady = "not-ready"
var Error = "error"
type Provider interface {
Start(name string) (InstanceState, error)
Stop(name string) (InstanceState, error)
GetState(name string) (InstanceState, error)
}
func (instance InstanceState) IsReady() bool {
return instance.Status == Ready
}
func (instance InstanceState) HasError() bool {
return instance.Status == Error
}