info takes health into account

This commit is contained in:
Alexis Couvreur
2024-12-07 12:11:31 -05:00
parent 1db83b4c82
commit 672939d73d
7 changed files with 362 additions and 120 deletions

View File

@@ -10,7 +10,6 @@ import (
"github.com/sablierapp/sablier/pkg/provider"
"github.com/sablierapp/sablier/pkg/sablier"
"os"
"time"
)
var _ sablier.Provider = (*DockerProvider)(nil)
@@ -62,16 +61,6 @@ func (d *DockerProvider) Stop(ctx context.Context, name string) error {
return d.Client.ContainerStop(ctx, name, container.StopOptions{})
}
func (d *DockerProvider) Info(ctx context.Context, name string) (sablier.InstanceInfo, error) {
return sablier.InstanceInfo{
Name: name,
CurrentReplicas: 0,
DesiredReplicas: 0,
Status: sablier.InstanceStarting,
StartedAt: time.Now(),
}, nil
}
func (d *DockerProvider) List(ctx context.Context, opts provider.ListOptions) ([]sablier.InstanceConfig, error) {
//TODO implement me
panic("implement me")

View File

@@ -11,10 +11,13 @@ import (
func (d *DockerProvider) AfterReady(ctx context.Context, name string) <-chan error {
ch := make(chan error, 1)
started := make(chan struct{})
go func() {
defer close(ch)
c, err := d.Client.ContainerInspect(ctx, name)
if err != nil {
close(started)
ch <- err
return
}
@@ -30,6 +33,7 @@ func (d *DockerProvider) AfterReady(ctx context.Context, name string) <-chan err
ready := d.afterAction(ctx, name, action)
ticker := time.NewTicker(5 * time.Second)
close(started)
for {
select {
case <-ctx.Done():
@@ -51,23 +55,27 @@ func (d *DockerProvider) AfterReady(ctx context.Context, name string) <-chan err
}
}
}()
<-started
return ch
}
func (d *DockerProvider) afterAction(ctx context.Context, name string, action events.Action) <-chan error {
ch := make(chan error, 1)
msgs, errs := d.Client.Events(ctx, events.ListOptions{
Filters: filters.NewArgs(
filters.Arg("scope", "local"),
filters.Arg("type", string(events.ContainerEventType)),
filters.Arg("container", name),
filters.Arg("event", string(action)),
),
})
started := make(chan struct{})
go func() {
defer close(ch)
msgs, errs := d.Client.Events(ctx, events.ListOptions{
Filters: filters.NewArgs(
filters.Arg("scope", "local"),
filters.Arg("type", string(events.ContainerEventType)),
filters.Arg("container", name),
filters.Arg("event", string(action)),
),
})
close(started)
for {
select {
case <-ctx.Done():
@@ -90,6 +98,7 @@ func (d *DockerProvider) afterAction(ctx context.Context, name string, action ev
}
}
}()
<-started
return ch
}

View File

@@ -0,0 +1,8 @@
package docker
import "strings"
// FormatName removes the container name `/` prefix returned by the Docker API
func FormatName(name string) string {
return strings.TrimPrefix(name, "/")
}

View File

@@ -0,0 +1,53 @@
package docker
import (
"context"
"fmt"
"github.com/sablierapp/sablier/pkg/sablier"
"time"
)
func (d *DockerProvider) Info(ctx context.Context, name string) (sablier.InstanceInfo, error) {
spec, err := d.Client.ContainerInspect(ctx, name)
if err != nil {
return sablier.InstanceInfo{}, err
}
// String representation of the container state.
// Can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch spec.State.Status {
case "created", "paused", "exited", "dead":
return sablier.InstanceInfo{
Name: FormatName(spec.Name),
CurrentReplicas: 0,
DesiredReplicas: 1,
Status: sablier.InstanceDown,
StartedAt: time.Time{},
}, nil
case "running":
startedAt, err := time.Parse(time.RFC3339Nano, spec.State.StartedAt)
if err != nil {
return sablier.InstanceInfo{}, err
}
if spec.State.Health != nil && spec.State.Health.Status != "healthy" {
return sablier.InstanceInfo{
Name: FormatName(spec.Name),
CurrentReplicas: 0,
DesiredReplicas: 1,
Status: sablier.InstanceStarting,
StartedAt: startedAt,
}, nil
}
return sablier.InstanceInfo{
Name: FormatName(spec.Name),
CurrentReplicas: 1,
DesiredReplicas: 1,
Status: sablier.InstanceReady,
StartedAt: startedAt,
}, nil
default:
return sablier.InstanceInfo{}, fmt.Errorf("unknown container status: %s", spec.State.Status)
}
}

View File

@@ -0,0 +1,151 @@
package docker_test
import (
"context"
"fmt"
"github.com/docker/docker/api/types/container"
"github.com/sablierapp/sablier/pkg/provider/docker"
"github.com/sablierapp/sablier/pkg/sablier"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestDockerProvider_Info(t *testing.T) {
ctx := context.Background()
type args struct {
do func(dind *dindContainer) error
name string
}
tests := []struct {
name string
args args
want sablier.InstanceInfo
wantErr assert.ErrorAssertionFunc
}{
{
name: "container is created",
args: args{
do: func(dind *dindContainer) error {
_, err := dind.CreateMimic(ctx, MimicOptions{
Name: "test-info-created",
})
if err != nil {
return err
}
return nil
},
name: "test-info-created",
},
want: sablier.InstanceInfo{
Name: "test-info-created",
CurrentReplicas: 0,
DesiredReplicas: 1,
Status: sablier.InstanceDown,
StartedAt: time.Time{},
},
wantErr: assert.NoError,
},
{
name: "container is paused",
args: args{
do: func(dind *dindContainer) error {
mimic, err := dind.CreateMimic(ctx, MimicOptions{
Name: "test-info-paused",
})
if err != nil {
return err
}
err = dind.client.ContainerStart(ctx, mimic.ID, container.StartOptions{})
if err != nil {
return err
}
return dind.client.ContainerPause(ctx, mimic.ID)
},
name: "test-info-paused",
},
want: sablier.InstanceInfo{
Name: "test-info-paused",
CurrentReplicas: 0,
DesiredReplicas: 1,
Status: sablier.InstanceDown,
StartedAt: time.Time{},
},
wantErr: assert.NoError,
},
{
name: "container is exited",
args: args{
do: func(dind *dindContainer) error {
mimic, err := dind.CreateMimic(ctx, MimicOptions{
Name: "test-info-exited",
})
if err != nil {
return err
}
err = dind.client.ContainerStart(ctx, mimic.ID, container.StartOptions{})
if err != nil {
return err
}
return dind.client.ContainerStop(ctx, mimic.ID, container.StopOptions{})
},
name: "test-info-exited",
},
want: sablier.InstanceInfo{
Name: "test-info-exited",
CurrentReplicas: 0,
DesiredReplicas: 1,
Status: sablier.InstanceDown,
StartedAt: time.Time{},
},
wantErr: assert.NoError,
},
{
name: "container is running (no healthcheck)",
args: args{
do: func(dind *dindContainer) error {
mimic, err := dind.CreateMimic(ctx, MimicOptions{
Name: "test-info-running-no-healthcheck",
})
if err != nil {
return err
}
return dind.client.ContainerStart(ctx, mimic.ID, container.StartOptions{})
},
name: "test-info-running-no-healthcheck",
},
want: sablier.InstanceInfo{
Name: "test-info-running-no-healthcheck",
CurrentReplicas: 1,
DesiredReplicas: 1,
Status: sablier.InstanceReady,
StartedAt: time.Now(),
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dinD, err := setupDinD(t, ctx)
if err != nil {
t.Fatal(err)
}
d, err := docker.NewDockerProvider(dinD.client)
if err != nil {
t.Fatal(err)
}
err = tt.args.do(dinD)
if err != nil {
t.Fatal(err)
}
got, err := d.Info(ctx, tt.args.name)
if !tt.wantErr(t, err, fmt.Sprintf("Info(ctx, %v)", tt.args.name)) {
return
}
assert.NotNil(t, got.StartedAt)
// assert.NotEqual(t, time.Time{}, got.StartedAt) // When instance is not started, this is not set
got.StartedAt = tt.want.StartedAt // Cannot assert equal on that field otherwise
assert.Equalf(t, tt.want, got, "Info(ctx, %v)", tt.args.name)
})
}
}

View File

@@ -4,113 +4,16 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/sablierapp/sablier/pkg/provider"
"github.com/sablierapp/sablier/pkg/provider/docker"
"github.com/stretchr/testify/assert"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"testing"
"time"
)
type dindContainer struct {
testcontainers.Container
client *client.Client
}
type MimicOptions struct {
WithHealth bool
HealthyAfter time.Duration
RunningAfter time.Duration
SablierGroup string
}
func (d *dindContainer) CreateMimic(ctx context.Context, opts MimicOptions) (container.CreateResponse, error) {
i, err := d.client.ImagePull(ctx, "docker.io/sablierapp/mimic:v0.3.1", image.PullOptions{})
if err != nil {
return container.CreateResponse{}, err
}
_, err = d.client.ImageLoad(ctx, i, false)
if err != nil {
return container.CreateResponse{}, err
}
if opts.WithHealth == false {
return d.client.ContainerCreate(ctx, &container.Config{
Cmd: []string{"/mimic", "-running", "-running-after", opts.RunningAfter.String(), "-healthy=false"},
Image: "docker.io/sablierapp/mimic:v0.3.1",
Labels: map[string]string{
"sablier.enable": "true",
"sablier.group": opts.SablierGroup,
},
}, nil, nil, nil, "")
}
return d.client.ContainerCreate(ctx, &container.Config{
Cmd: []string{"/mimic", "-running", "-running-after", opts.RunningAfter.String(), "-healthy", "--healthy-after", opts.HealthyAfter.String()},
Healthcheck: &container.HealthConfig{
Test: []string{"CMD", "/mimic", "healthcheck"},
Interval: time.Second,
Timeout: 3 * time.Second,
StartPeriod: opts.RunningAfter,
StartInterval: time.Second,
Retries: 15,
},
Image: "docker.io/sablierapp/mimic:v0.3.1",
Labels: map[string]string{
"sablier.enable": "true",
"sablier.group": opts.SablierGroup,
},
}, nil, nil, nil, "")
}
func setupDinD(t *testing.T, ctx context.Context) (*dindContainer, error) {
req := testcontainers.ContainerRequest{
Image: "docker:dind",
ExposedPorts: []string{"2375/tcp"},
WaitingFor: wait.ForLog("API listen on [::]:2375"),
Cmd: []string{
"dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false",
},
Privileged: true,
}
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
Logger: testcontainers.TestLogger(t),
})
if err != nil {
return nil, err
}
ip, err := c.Host(ctx)
if err != nil {
return nil, err
}
mappedPort, err := c.MappedPort(ctx, "2375")
if err != nil {
return nil, err
}
// DOCKER_HOST
host := fmt.Sprintf("http://%s:%s", ip, mappedPort.Port())
fmt.Println("DOCKER_HOST: ", host)
cli, err := client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &dindContainer{
Container: c,
client: cli,
}, nil
}
func TestDockerProvider_StartWithHealthcheck(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
dind, err := setupDinD(t, ctx)
if err != nil {
t.Fatal(err)
@@ -155,7 +58,8 @@ func TestDockerProvider_StartWithHealthcheck(t *testing.T) {
}
func TestDockerProvider_StartWithoutHealthcheck(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
dind, err := setupDinD(t, ctx)
if err != nil {
t.Fatal(err)
@@ -196,3 +100,24 @@ func TestDockerProvider_StartWithoutHealthcheck(t *testing.T) {
t.Logf("inspect: %+v\n", string(resp))
assert.Equal(t, inspect.State.Status, "running")
}
func TestDockerProvider_StartNonExistingContainer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
dind, err := setupDinD(t, ctx)
if err != nil {
t.Fatal(err)
}
p, err := docker.NewDockerProvider(dind.client)
if err != nil {
t.Fatal(err)
}
err = p.Start(ctx, "non-existent", provider.StartOptions{
DesiredReplicas: 1,
ConsiderReadyAfter: 0,
})
fmt.Printf("error: %v", err)
assert.Error(t, err)
}

View File

@@ -0,0 +1,107 @@
package docker_test
import (
"context"
"fmt"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"testing"
"time"
)
type dindContainer struct {
testcontainers.Container
client *client.Client
}
type MimicOptions struct {
Name string
WithHealth bool
HealthyAfter time.Duration
RunningAfter time.Duration
SablierGroup string
}
func (d *dindContainer) CreateMimic(ctx context.Context, opts MimicOptions) (container.CreateResponse, error) {
i, err := d.client.ImagePull(ctx, "docker.io/sablierapp/mimic:v0.3.1", image.PullOptions{})
if err != nil {
return container.CreateResponse{}, err
}
_, err = d.client.ImageLoad(ctx, i, false)
if err != nil {
return container.CreateResponse{}, err
}
if opts.WithHealth == false {
return d.client.ContainerCreate(ctx, &container.Config{
Cmd: []string{"/mimic", "-running", "-running-after", opts.RunningAfter.String(), "-healthy=false"},
Image: "docker.io/sablierapp/mimic:v0.3.1",
Labels: map[string]string{
"sablier.enable": "true",
"sablier.group": opts.SablierGroup,
},
}, nil, nil, nil, opts.Name)
}
return d.client.ContainerCreate(ctx, &container.Config{
Cmd: []string{"/mimic", "-running", "-running-after", opts.RunningAfter.String(), "-healthy", "--healthy-after", opts.HealthyAfter.String()},
Healthcheck: &container.HealthConfig{
Test: []string{"CMD", "/mimic", "healthcheck"},
Interval: 100 * time.Millisecond,
Timeout: time.Second,
StartPeriod: opts.RunningAfter,
StartInterval: time.Second,
Retries: 50,
},
Image: "docker.io/sablierapp/mimic:v0.3.1",
Labels: map[string]string{
"sablier.enable": "true",
"sablier.group": opts.SablierGroup,
},
}, nil, nil, nil, opts.Name)
}
func setupDinD(t *testing.T, ctx context.Context) (*dindContainer, error) {
req := testcontainers.ContainerRequest{
Image: "docker:dind",
ExposedPorts: []string{"2375/tcp"},
WaitingFor: wait.ForLog("API listen on [::]:2375"),
Cmd: []string{
"dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false",
},
Privileged: true,
}
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
Logger: testcontainers.TestLogger(t),
})
if err != nil {
return nil, err
}
ip, err := c.Host(ctx)
if err != nil {
return nil, err
}
mappedPort, err := c.MappedPort(ctx, "2375")
if err != nil {
return nil, err
}
// DOCKER_HOST
host := fmt.Sprintf("http://%s:%s", ip, mappedPort.Port())
fmt.Println("DOCKER_HOST: ", host)
cli, err := client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &dindContainer{
Container: c,
client: cli,
}, nil
}