Skip to content

Commit

Permalink
feat(providers): add provider.auto-stop-on-startup argument (#346)
Browse files Browse the repository at this point in the history
This feature adds the capability to stop unregistered running instances upon startup.

Previously, you had to stop running instances manually or issue an initial request that will shut down instances afterwards.

With this change, all discovered instances will be shutdown. They need to be registered using labels. E.g.: sablier.enable=true

Fixes #153
  • Loading branch information
acouvreur authored Jul 4, 2024
1 parent 7ebfdd5 commit 52a7d21
Show file tree
Hide file tree
Showing 68 changed files with 1,116 additions and 106 deletions.
59 changes: 59 additions & 0 deletions app/discovery/autostop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package discovery

import (
"context"
"github.com/acouvreur/sablier/app/providers"
"github.com/acouvreur/sablier/pkg/arrays"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
)

// StopAllUnregisteredInstances stops all auto-discovered running instances that are not yet registered
// as running instances by Sablier.
// By default, Sablier does not stop all already running instances. Meaning that you need to make an
// initial request in order to trigger the scaling to zero.
func StopAllUnregisteredInstances(ctx context.Context, provider providers.Provider, registered []string) error {
log.Info("Stopping all unregistered running instances")

log.Tracef("Retrieving all instances with label [%v=true]", LabelEnable)
instances, err := provider.InstanceList(ctx, providers.InstanceListOptions{
All: false, // Only running containers
Labels: []string{LabelEnable},
})
if err != nil {
return err
}

log.Tracef("Found %v instances with label [%v=true]", len(instances), LabelEnable)
names := make([]string, 0, len(instances))
for _, instance := range instances {
names = append(names, instance.Name)
}

unregistered := arrays.RemoveElements(names, registered)
log.Tracef("Found %v unregistered instances ", len(instances))

waitGroup := errgroup.Group{}

// Previously, the variables declared by a “for” loop were created once and updated by each iteration.
// In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.
// The transition support tooling described in the proposal continues to work in the same way it did in Go 1.21.
for _, name := range unregistered {
waitGroup.Go(stopFunc(ctx, name, provider))
}

return waitGroup.Wait()
}

func stopFunc(ctx context.Context, name string, provider providers.Provider) func() error {
return func() error {
log.Tracef("Stopping %v...", name)
_, err := provider.Stop(ctx, name)
if err != nil {
log.Errorf("Could not stop %v: %v", name, err)
return err
}
log.Tracef("Successfully stopped %v", name)
return nil
}
}
76 changes: 76 additions & 0 deletions app/discovery/autostop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package discovery_test

import (
"context"
"errors"
"github.com/acouvreur/sablier/app/discovery"
"github.com/acouvreur/sablier/app/instance"
"github.com/acouvreur/sablier/app/providers"
"github.com/acouvreur/sablier/app/providers/mock"
"github.com/acouvreur/sablier/app/types"
"testing"
)

func TestStopAllUnregisteredInstances(t *testing.T) {
mockProvider := new(mock.ProviderMock)
ctx := context.TODO()

// Define instances and registered instances
instances := []types.Instance{
{Name: "instance1"},
{Name: "instance2"},
{Name: "instance3"},
}
registered := []string{"instance1"}

// Set up expectations for InstanceList
mockProvider.On("InstanceList", ctx, providers.InstanceListOptions{
All: false,
Labels: []string{discovery.LabelEnable},
}).Return(instances, nil)

// Set up expectations for Stop
mockProvider.On("Stop", ctx, "instance2").Return(instance.State{}, nil)
mockProvider.On("Stop", ctx, "instance3").Return(instance.State{}, nil)

// Call the function under test
err := discovery.StopAllUnregisteredInstances(ctx, mockProvider, registered)
if err != nil {
t.Fatalf("Expected no error, but got %v", err)
}

// Check expectations
mockProvider.AssertExpectations(t)
}

func TestStopAllUnregisteredInstances_WithError(t *testing.T) {
mockProvider := new(mock.ProviderMock)
ctx := context.TODO()

// Define instances and registered instances
instances := []types.Instance{
{Name: "instance1"},
{Name: "instance2"},
{Name: "instance3"},
}
registered := []string{"instance1"}

// Set up expectations for InstanceList
mockProvider.On("InstanceList", ctx, providers.InstanceListOptions{
All: false,
Labels: []string{discovery.LabelEnable},
}).Return(instances, nil)

// Set up expectations for Stop with error
mockProvider.On("Stop", ctx, "instance2").Return(instance.State{}, errors.New("stop error"))
mockProvider.On("Stop", ctx, "instance3").Return(instance.State{}, nil)

// Call the function under test
err := discovery.StopAllUnregisteredInstances(ctx, mockProvider, registered)
if err == nil {
t.Fatalf("Expected error, but got nil")
}

// Check expectations
mockProvider.AssertExpectations(t)
}
18 changes: 18 additions & 0 deletions app/discovery/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package discovery

const (
LabelEnable = "sablier.enable"
LabelGroup = "sablier.group"
LabelGroupDefaultValue = "default"
LabelReplicas = "sablier.replicas"
LabelReplicasDefaultValue uint64 = 1
)

type Group struct {
Name string
Instances []Instance
}

type Instance struct {
Name string
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package providers
package docker

import (
"context"
"errors"
"fmt"
"github.com/acouvreur/sablier/app/discovery"
"io"
"strings"

Expand Down Expand Up @@ -33,7 +34,7 @@ func NewDockerClassicProvider() (*DockerClassicProvider, error) {
return nil, fmt.Errorf("cannot connect to docker host: %v", err)
}

log.Trace(fmt.Sprintf("connection established with docker %s (API %s)", serverVersion.Version, serverVersion.APIVersion))
log.Tracef("connection established with docker %s (API %s)", serverVersion.Version, serverVersion.APIVersion)

return &DockerClassicProvider{
Client: cli,
Expand All @@ -43,7 +44,7 @@ func NewDockerClassicProvider() (*DockerClassicProvider, error) {

func (provider *DockerClassicProvider) GetGroups(ctx context.Context) (map[string][]string, error) {
args := filters.NewArgs()
args.Add("label", fmt.Sprintf("%s=true", enableLabel))
args.Add("label", fmt.Sprintf("%s=true", discovery.LabelEnable))

containers, err := provider.Client.ContainerList(ctx, container.ListOptions{
All: true,
Expand All @@ -56,9 +57,9 @@ func (provider *DockerClassicProvider) GetGroups(ctx context.Context) (map[strin

groups := make(map[string][]string)
for _, c := range containers {
groupName := c.Labels[groupLabel]
groupName := c.Labels[discovery.LabelGroup]
if len(groupName) == 0 {
groupName = defaultGroupValue
groupName = discovery.LabelGroupDefaultValue
}
group := groups[groupName]
group = append(group, strings.TrimPrefix(c.Names[0], "/"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package providers
package docker

import (
"context"
Expand Down
60 changes: 60 additions & 0 deletions app/providers/docker/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package docker

import (
"context"
"fmt"
"github.com/acouvreur/sablier/app/discovery"
"github.com/acouvreur/sablier/app/providers"
"github.com/acouvreur/sablier/app/types"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"strings"
)

func (provider *DockerClassicProvider) InstanceList(ctx context.Context, options providers.InstanceListOptions) ([]types.Instance, error) {
args := filters.NewArgs()
for _, label := range options.Labels {
args.Add("label", label)
args.Add("label", fmt.Sprintf("%s=true", label))
}

containers, err := provider.Client.ContainerList(ctx, container.ListOptions{
All: options.All,
Filters: args,
})

if err != nil {
return nil, err
}

instances := make([]types.Instance, 0, len(containers))
for _, c := range containers {
instance := containerToInstance(c)
instances = append(instances, instance)
}

return instances, nil
}

func containerToInstance(c dockertypes.Container) types.Instance {
var group string

if _, ok := c.Labels[discovery.LabelEnable]; ok {
if g, ok := c.Labels[discovery.LabelGroup]; ok {
group = g
} else {
group = discovery.LabelGroupDefaultValue
}
}

return types.Instance{
Name: strings.TrimPrefix(c.Names[0], "/"), // Containers name are reported with a leading slash
Kind: "container",
Status: c.Status,
// Replicas: c.Status,
// DesiredReplicas: 1,
ScalingReplicas: 1,
Group: group,
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package providers
package dockerswarm

import (
"context"
"errors"
"fmt"
"github.com/acouvreur/sablier/app/discovery"
"io"
"strings"

Expand Down Expand Up @@ -78,7 +79,7 @@ func (provider *DockerSwarmProvider) scale(ctx context.Context, name string, rep

func (provider *DockerSwarmProvider) GetGroups(ctx context.Context) (map[string][]string, error) {
filters := filters.NewArgs()
filters.Add("label", fmt.Sprintf("%s=true", enableLabel))
filters.Add("label", fmt.Sprintf("%s=true", discovery.LabelEnable))

services, err := provider.Client.ServiceList(ctx, types.ServiceListOptions{
Filters: filters,
Expand All @@ -90,9 +91,9 @@ func (provider *DockerSwarmProvider) GetGroups(ctx context.Context) (map[string]

groups := make(map[string][]string)
for _, service := range services {
groupName := service.Spec.Labels[groupLabel]
groupName := service.Spec.Labels[discovery.LabelGroup]
if len(groupName) == 0 {
groupName = defaultGroupValue
groupName = discovery.LabelGroupDefaultValue
}

group := groups[groupName]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package providers
package dockerswarm

import (
"context"
Expand Down
74 changes: 74 additions & 0 deletions app/providers/dockerswarm/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dockerswarm

import (
"context"
"fmt"
"github.com/acouvreur/sablier/app/discovery"
"github.com/acouvreur/sablier/app/providers"
"github.com/acouvreur/sablier/app/types"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
log "github.com/sirupsen/logrus"
"strconv"
)

func (provider *DockerSwarmProvider) InstanceList(ctx context.Context, options providers.InstanceListOptions) ([]types.Instance, error) {
args := filters.NewArgs()
for _, label := range options.Labels {
args.Add("label", label)
args.Add("label", fmt.Sprintf("%s=true", label))
}

services, err := provider.Client.ServiceList(ctx, dockertypes.ServiceListOptions{
Filters: args,
})

if err != nil {
return nil, err
}

instances := make([]types.Instance, 0, len(services))
for _, s := range services {
instance := serviceToInstance(s)
instances = append(instances, instance)
}

return instances, nil
}

func serviceToInstance(s swarm.Service) (i types.Instance) {
var group string
var replicas uint64

if _, ok := s.Spec.Labels[discovery.LabelEnable]; ok {
if g, ok := s.Spec.Labels[discovery.LabelGroup]; ok {
group = g
} else {
group = discovery.LabelGroupDefaultValue
}

if r, ok := s.Spec.Labels[discovery.LabelReplicas]; ok {
atoi, err := strconv.Atoi(r)
if err != nil {
log.Warnf("Defaulting to default replicas value, could not convert value \"%v\" to int: %v", r, err)
replicas = discovery.LabelReplicasDefaultValue
} else {
replicas = uint64(atoi)
}
} else {
replicas = discovery.LabelReplicasDefaultValue
}
}

return types.Instance{
Name: s.Spec.Name,
Kind: "service",
// TODO
// Status: string(s.UpdateStatus.State),
// Replicas: s.ServiceStatus.RunningTasks,
// DesiredReplicas: s.ServiceStatus.DesiredTasks,
ScalingReplicas: replicas,
Group: group,
}
}
Loading

0 comments on commit 52a7d21

Please sign in to comment.