diff --git a/app/providers/docker_classic.go b/app/providers/docker_classic.go new file mode 100644 index 0000000..a98bb27 --- /dev/null +++ b/app/providers/docker_classic.go @@ -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 +} diff --git a/app/providers/docker_classic_test.go b/app/providers/docker_classic_test.go new file mode 100644 index 0000000..1ae8798 --- /dev/null +++ b/app/providers/docker_classic_test.go @@ -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) + } + }) + } +} diff --git a/app/providers/provider.go b/app/providers/provider.go new file mode 100644 index 0000000..1382b44 --- /dev/null +++ b/app/providers/provider.go @@ -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 +}