feat(docker): add docker pause strategy (#755)

* add tmp test

* add strategy config

* strategy config

* pause / unpause strategy impl

* Fix tests

* fix compilation

* add pause / unpause tests

* add doc

* add config test

* start if not paused

* remove test files
This commit is contained in:
Yoann Lecuyer
2025-11-27 18:03:37 +01:00
committed by GitHub
parent d7685f04a1
commit 0d699effc3
23 changed files with 478 additions and 17 deletions

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
golang 1.25.4

View File

@@ -28,7 +28,10 @@ sablier --configFile=path/to/myconfigfile.yml
```yaml
provider:
# Provider to use to manage containers (docker, swarm, kubernetes)
name: docker
name: docker
docker:
# Strategy to use for stopping Docker containers: stop or pause (default: stop)
strategy: stop
server:
# The server port to use
port: 10000
@@ -113,6 +116,7 @@ sablier start --strategy.dynamic.custom-themes-path /my/path
```
-h, --help help for start
--provider.docker.strategy string Strategy to use to stop docker containers (stop or pause) (default "stop")
--provider.name string Provider to use to manage containers [docker swarm kubernetes] (default "docker")
--server.base-path string The base path for the API (default "/")
--server.port int The server port to use (default 10000)

View File

@@ -59,6 +59,66 @@ services:
- sablier.group=mygroup
```
## Strategies
The Docker provider supports two strategies for managing containers:
### Stop Strategy (default)
The `stop` strategy completely stops containers when they become idle and starts them again when needed.
<!-- tabs:start -->
#### **File (YAML)**
```yaml
provider:
docker:
strategy: stop
```
#### **CLI**
```bash
sablier start --provider.docker.strategy=stop
```
#### **Environment Variable**
```bash
PROVIDER_DOCKER_STRATEGY=stop
```
<!-- tabs:end -->
### Pause Strategy
The `pause` strategy pauses containers instead of stopping them. This is faster than stop/start as the container state remains in memory, but uses more system resources.
<!-- tabs:start -->
#### **File (YAML)**
```yaml
provider:
docker:
strategy: pause
```
#### **CLI**
```bash
sablier start --provider.docker.strategy=pause
```
#### **Environment Variable**
```bash
PROVIDER_DOCKER_STRATEGY=pause
```
<!-- tabs:end -->
## How does Sablier knows when a container is ready?
If the container defines a Healthcheck, then it will check for healthiness before stating the `ready` status.

View File

@@ -12,6 +12,7 @@ type Provider struct {
AutoStopOnStartup bool `yaml:"auto-stop-on-startup,omitempty" default:"true"`
Kubernetes Kubernetes
Podman Podman
Docker Docker
}
type Kubernetes struct {
@@ -34,7 +35,12 @@ type Podman struct {
Uri string `mapstructure:"URI" yaml:"uri,omitempty" default:"unix:///run/podman/podman.sock"`
}
type Docker struct {
Strategy string `mapstructure:"STRATEGY" yaml:"strategy,omitempty" default:"stop"`
}
var providers = []string{"docker", "docker_swarm", "swarm", "kubernetes", "podman"}
var dockerStrategies = []string{"stop", "pause"}
func NewProviderConfig() Provider {
return Provider{
@@ -48,18 +54,36 @@ func NewProviderConfig() Provider {
Podman: Podman{
Uri: "unix:///run/podman/podman.sock",
},
Docker: Docker{
Strategy: "stop",
},
}
}
func (provider Provider) IsValid() error {
for _, p := range providers {
if p == provider.Name {
// Validate Docker-specific settings when using Docker provider
if p == "docker" {
if err := provider.Docker.IsValid(); err != nil {
return err
}
}
return nil
}
}
return fmt.Errorf("unrecognized provider %s. providers available: %v", provider.Name, providers)
}
func (docker Docker) IsValid() error {
for _, s := range dockerStrategies {
if s == docker.Strategy {
return nil
}
}
return fmt.Errorf("unrecognized docker strategy %s. strategies available: %v", docker.Strategy, dockerStrategies)
}
func GetProviders() []string {
return providers
}

141
pkg/config/provider_test.go Normal file
View File

@@ -0,0 +1,141 @@
package config
import (
"fmt"
"testing"
"gotest.tools/v3/assert"
)
func TestProvider_IsValid(t *testing.T) {
tests := []struct {
name string
provider Provider
wantErr error
}{
{
name: "valid docker provider with stop strategy",
provider: Provider{
Name: "docker",
Docker: Docker{
Strategy: "stop",
},
},
wantErr: nil,
},
{
name: "valid docker provider with pause strategy",
provider: Provider{
Name: "docker",
Docker: Docker{
Strategy: "pause",
},
},
wantErr: nil,
},
{
name: "invalid docker strategy",
provider: Provider{
Name: "docker",
Docker: Docker{
Strategy: "invalid",
},
},
wantErr: fmt.Errorf("unrecognized docker strategy invalid. strategies available: [stop pause]"),
},
{
name: "valid kubernetes provider",
provider: Provider{
Name: "kubernetes",
},
wantErr: nil,
},
{
name: "valid swarm provider",
provider: Provider{
Name: "swarm",
},
wantErr: nil,
},
{
name: "valid docker_swarm provider",
provider: Provider{
Name: "docker_swarm",
},
wantErr: nil,
},
{
name: "valid podman provider",
provider: Provider{
Name: "podman",
},
wantErr: nil,
},
{
name: "invalid provider name",
provider: Provider{
Name: "invalid",
},
wantErr: fmt.Errorf("unrecognized provider invalid. providers available: [docker docker_swarm swarm kubernetes podman]"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.provider.IsValid()
if tt.wantErr != nil {
assert.Error(t, err, tt.wantErr.Error())
} else {
assert.NilError(t, err)
}
})
}
}
func TestDocker_IsValid(t *testing.T) {
tests := []struct {
name string
docker Docker
wantErr error
}{
{
name: "valid stop strategy",
docker: Docker{
Strategy: "stop",
},
wantErr: nil,
},
{
name: "valid pause strategy",
docker: Docker{
Strategy: "pause",
},
wantErr: nil,
},
{
name: "invalid strategy",
docker: Docker{
Strategy: "restart",
},
wantErr: fmt.Errorf("unrecognized docker strategy restart. strategies available: [stop pause]"),
},
{
name: "empty strategy",
docker: Docker{
Strategy: "",
},
wantErr: fmt.Errorf("unrecognized docker strategy . strategies available: [stop pause]"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.docker.IsValid()
if tt.wantErr != nil {
assert.Error(t, err, tt.wantErr.Error())
} else {
assert.NilError(t, err)
}
})
}
}

View File

@@ -264,7 +264,7 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := docker.New(ctx, c.client, slogt.New(t))
p, err := docker.New(ctx, c.client, slogt.New(t), "stop")
assert.NilError(t, err)
name, err := tt.args.do(c)

View File

@@ -1,14 +1,15 @@
package docker_test
import (
"sort"
"strings"
"testing"
"github.com/neilotoole/slogt"
"github.com/sablierapp/sablier/pkg/provider"
"github.com/sablierapp/sablier/pkg/provider/docker"
"github.com/sablierapp/sablier/pkg/sablier"
"gotest.tools/v3/assert"
"sort"
"strings"
"testing"
)
func TestDockerClassicProvider_InstanceList(t *testing.T) {
@@ -18,7 +19,7 @@ func TestDockerClassicProvider_InstanceList(t *testing.T) {
ctx := t.Context()
dind := setupDinD(t)
p, err := docker.New(ctx, dind.client, slogt.New(t))
p, err := docker.New(ctx, dind.client, slogt.New(t), "stop")
assert.NilError(t, err)
c1, err := dind.CreateMimic(ctx, MimicOptions{
@@ -77,7 +78,7 @@ func TestDockerClassicProvider_GetGroups(t *testing.T) {
ctx := t.Context()
dind := setupDinD(t)
p, err := docker.New(ctx, dind.client, slogt.New(t))
p, err := docker.New(ctx, dind.client, slogt.New(t), "stop")
assert.NilError(t, err)
c1, err := dind.CreateMimic(ctx, MimicOptions{

View File

@@ -9,6 +9,13 @@ import (
)
func (p *Provider) InstanceStart(ctx context.Context, name string) error {
if p.strategy == "pause" {
return p.dockerUnpause(ctx, name)
}
return p.dockerStart(ctx, name)
}
func (p *Provider) dockerStart(ctx context.Context, name string) error {
// TODO: InstanceStart should block until the container is ready.
p.l.DebugContext(ctx, "starting container", "name", name)
err := p.Client.ContainerStart(ctx, name, container.StartOptions{})
@@ -18,3 +25,26 @@ func (p *Provider) InstanceStart(ctx context.Context, name string) error {
}
return nil
}
func (p *Provider) dockerUnpause(ctx context.Context, name string) error {
container, inspectErr := p.Client.ContainerInspect(ctx, name)
if inspectErr != nil {
p.l.ErrorContext(ctx, "cannot inspect container before unpausing", slog.String("name", name), slog.Any("error", inspectErr))
return fmt.Errorf("cannot inspect container %s before unpausing: %w", name, inspectErr)
}
if !container.State.Paused {
p.l.DebugContext(ctx, "container is not paused, starting container", slog.String("name", name))
return p.dockerStart(ctx, name)
}
p.l.DebugContext(ctx, "unpausing container", slog.String("name", name))
err := p.Client.ContainerUnpause(ctx, name)
if err != nil {
p.l.ErrorContext(ctx, "cannot unpause container", slog.String("name", name), slog.Any("error", err))
return fmt.Errorf("cannot unpause container %s: %w", name, err)
}
p.l.DebugContext(ctx, "container unpaused", slog.String("name", name))
return nil
}

View File

@@ -3,10 +3,12 @@ package docker_test
import (
"context"
"fmt"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/neilotoole/slogt"
"github.com/sablierapp/sablier/pkg/provider/docker"
"gotest.tools/v3/assert"
"testing"
)
func TestDockerClassicProvider_Start(t *testing.T) {
@@ -47,7 +49,99 @@ func TestDockerClassicProvider_Start(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := docker.New(ctx, c.client, slogt.New(t))
p, err := docker.New(ctx, c.client, slogt.New(t), "stop")
assert.NilError(t, err)
name, err := tt.args.do(c)
assert.NilError(t, err)
err = p.InstanceStart(t.Context(), name)
if tt.err != nil {
assert.Error(t, err, tt.err.Error())
} else {
assert.NilError(t, err)
}
})
}
}
func TestDockerClassicProvider_Unpause(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
ctx := context.Background()
type args struct {
do func(dind *dindContainer) (string, error)
}
tests := []struct {
name string
args args
err error
}{
{
name: "non existing container unpause",
args: args{
do: func(dind *dindContainer) (string, error) {
return "non-existent", nil
},
},
err: fmt.Errorf("cannot inspect container non-existent before unpausing: Error response from daemon: No such container: non-existent"),
},
{
name: "container starts because was not paused",
args: args{
do: func(dind *dindContainer) (string, error) {
c, err := dind.CreateMimic(ctx, MimicOptions{})
if err != nil {
return "", err
}
err = dind.client.ContainerStart(ctx, c.ID, container.StartOptions{})
if err != nil {
return "", err
}
err = dind.client.ContainerStop(ctx, c.ID, container.StopOptions{})
if err != nil {
return "", err
}
return c.ID, nil
},
},
err: nil,
},
{
name: "container unpause as expected",
args: args{
do: func(dind *dindContainer) (string, error) {
c, err := dind.CreateMimic(ctx, MimicOptions{})
if err != nil {
return "", err
}
err = dind.client.ContainerStart(ctx, c.ID, container.StartOptions{})
if err != nil {
return "", err
}
err = dind.client.ContainerPause(ctx, c.ID)
if err != nil {
return "", err
}
return c.ID, nil
},
},
err: nil,
},
}
c := setupDinD(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := docker.New(ctx, c.client, slogt.New(t), "pause")
assert.NilError(t, err)
name, err := tt.args.do(c)

View File

@@ -9,6 +9,13 @@ import (
)
func (p *Provider) InstanceStop(ctx context.Context, name string) error {
if p.strategy == "pause" {
return p.dockerPause(ctx, name)
}
return p.dockerStop(ctx, name)
}
func (p *Provider) dockerStop(ctx context.Context, name string) error {
p.l.DebugContext(ctx, "stopping container", slog.String("name", name))
err := p.Client.ContainerStop(ctx, name, container.StopOptions{})
if err != nil {
@@ -30,3 +37,15 @@ func (p *Provider) InstanceStop(ctx context.Context, name string) error {
return ctx.Err()
}
}
func (p *Provider) dockerPause(ctx context.Context, name string) error {
p.l.DebugContext(ctx, "pausing container", slog.String("name", name))
err := p.Client.ContainerPause(ctx, name)
if err != nil {
p.l.ErrorContext(ctx, "cannot pause container", slog.String("name", name), slog.Any("error", err))
return fmt.Errorf("cannot pause container %s: %w", name, err)
}
p.l.DebugContext(ctx, "container paused", slog.String("name", name))
return nil
}

View File

@@ -58,7 +58,70 @@ func TestDockerClassicProvider_Stop(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := docker.New(ctx, c.client, slogt.New(t))
p, err := docker.New(ctx, c.client, slogt.New(t), "stop")
assert.NilError(t, err)
name, err := tt.args.do(c)
assert.NilError(t, err)
err = p.InstanceStop(t.Context(), name)
if tt.err != nil {
assert.Error(t, err, tt.err.Error())
} else {
assert.NilError(t, err)
}
})
}
}
func TestDockerClassicProvider_Pause(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
ctx := context.Background()
type args struct {
do func(dind *dindContainer) (string, error)
}
tests := []struct {
name string
args args
err error
}{
{
name: "non existing container pause",
args: args{
do: func(dind *dindContainer) (string, error) {
return "non-existent", nil
},
},
err: fmt.Errorf("cannot pause container non-existent: Error response from daemon: No such container: non-existent"),
},
{
name: "container pause as expected",
args: args{
do: func(dind *dindContainer) (string, error) {
c, err := dind.CreateMimic(ctx, MimicOptions{})
if err != nil {
return "", err
}
err = dind.client.ContainerStart(ctx, c.ID, container.StartOptions{})
if err != nil {
return "", err
}
return c.ID, nil
},
},
err: nil,
},
}
c := setupDinD(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p, err := docker.New(ctx, c.client, slogt.New(t), "pause")
assert.NilError(t, err)
name, err := tt.args.do(c)

View File

@@ -3,9 +3,10 @@ package docker
import (
"context"
"fmt"
"log/slog"
"github.com/docker/docker/client"
"github.com/sablierapp/sablier/pkg/sablier"
"log/slog"
)
// Interface guard
@@ -15,10 +16,11 @@ type Provider struct {
Client client.APIClient
desiredReplicas int32
l *slog.Logger
strategy string
}
func New(ctx context.Context, cli *client.Client, logger *slog.Logger) (*Provider, error) {
logger = logger.With(slog.String("provider", "docker"))
func New(ctx context.Context, cli *client.Client, logger *slog.Logger, strategy string) (*Provider, error) {
logger = logger.With(slog.String("provider", "docker"), slog.String("strategy", strategy))
serverVersion, err := cli.ServerVersion(ctx)
if err != nil {
@@ -33,5 +35,6 @@ func New(ctx context.Context, cli *client.Client, logger *slog.Logger) (*Provide
Client: cli,
desiredReplicas: 1,
l: logger,
strategy: strategy,
}, nil
}

View File

@@ -2,12 +2,13 @@ package docker_test
import (
"context"
"testing"
"time"
"github.com/docker/docker/api/types/container"
"github.com/neilotoole/slogt"
"github.com/sablierapp/sablier/pkg/provider/docker"
"gotest.tools/v3/assert"
"testing"
"time"
)
func TestDockerClassicProvider_NotifyInstanceStopped(t *testing.T) {
@@ -18,7 +19,7 @@ func TestDockerClassicProvider_NotifyInstanceStopped(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
dind := setupDinD(t)
p, err := docker.New(ctx, dind.client, slogt.New(t))
p, err := docker.New(ctx, dind.client, slogt.New(t), "stop")
assert.NilError(t, err)
c, err := dind.CreateMimic(ctx, MimicOptions{})

View File

@@ -111,6 +111,7 @@ func TestPrecedence(t *testing.T) {
"--provider.kubernetes.burst", "512",
"--provider.kubernetes.delimiter", "_",
"--provider.podman.uri", "unix:///run/podman/podman.sock.cli",
"--provider.docker.strategy", "pause",
"--server.port", "3333",
"--server.base-path", "/cli/",
"--storage.file", "/tmp/cli.json",

View File

@@ -34,7 +34,7 @@ func setupProvider(ctx context.Context, logger *slog.Logger, config config.Provi
if err != nil {
return nil, fmt.Errorf("cannot create docker client: %v", err)
}
return docker.New(ctx, cli, logger)
return docker.New(ctx, cli, logger, config.Docker.Strategy)
case "kubernetes":
kubeclientConfig, err := rest.InClusterConfig()
if err != nil {

View File

@@ -50,6 +50,8 @@ It provides integrations with multiple reverse proxies and different loading str
_ = viper.BindPFlag("provider.kubernetes.delimiter", startCmd.Flags().Lookup("provider.kubernetes.delimiter"))
startCmd.Flags().StringVar(&conf.Provider.Podman.Uri, "provider.podman.uri", "unix:///run/podman/podman.sock", "Uri is the URI to connect to the Podman service.")
_ = viper.BindPFlag("provider.podman.uri", startCmd.Flags().Lookup("provider.podman.uri"))
startCmd.Flags().StringVar(&conf.Provider.Docker.Strategy, "provider.docker.strategy", "stop", "Strategy to use to stop docker containers (stop or pause)")
_ = viper.BindPFlag("provider.docker.strategy", startCmd.Flags().Lookup("provider.docker.strategy"))
// Server flags
startCmd.Flags().IntVar(&conf.Server.Port, "server.port", 10000, "The server port to use")

View File

@@ -4,6 +4,7 @@ PROVIDER_KUBERNETES_QPS=16
PROVIDER_KUBERNETES_BURST=32
PROVIDER_KUBERNETES_DELIMITER=/
PROVIDER_PODMAN_URI=unix:///run/podman/podman.sock.env
PROVIDER_DOCKER_STRATEGY=pause
SERVER_PORT=2222
SERVER_BASE_PATH=/envvar/
STORAGE_FILE=/tmp/envvar.json

View File

@@ -7,6 +7,8 @@ provider:
delimiter: .
podman:
uri: unix:///run/podman/podman.sock.yml
docker:
strategy: pause
server:
port: 1111
base-path: /configfile/

View File

@@ -16,6 +16,9 @@
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.cli"
},
"Docker": {
"Strategy": "pause"
}
},
"Sessions": {

View File

@@ -16,6 +16,9 @@
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock"
},
"Docker": {
"Strategy": "stop"
}
},
"Sessions": {

View File

@@ -16,6 +16,9 @@
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.env"
},
"Docker": {
"Strategy": "pause"
}
},
"Sessions": {

View File

@@ -16,6 +16,9 @@
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.yml"
},
"Docker": {
"Strategy": "pause"
}
},
"Sessions": {

View File

@@ -1,5 +1,7 @@
provider:
name: docker
docker:
strategy: stop
server:
port: 10000
base-path: /