Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/content/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ SABLIER_LOGGING_LEVEL=info
| [`--provider.kubernetes.burst`](#opt-provider-kubernetes-burst) | Maximum burst for K8S API access client-side throttling |
| [`--provider.kubernetes.delimiter`](#opt-provider-kubernetes-delimiter) | Delimiter used for namespace/resource type/name resolution. |
| [`--provider.kubernetes.qps`](#opt-provider-kubernetes-qps) | QPS limit for K8S API access client-side throttling |
| [`--provider.kubernetes.ready-on-first-replica`](#opt-provider-kubernetes-ready-on-first-replica) | Consider a Deployment or StatefulSet ready as soon as at least one replica is ready, instead of requiring all desired replicas |
| [`--provider.name`](#opt-provider-name) | Provider to use to manage containers [docker docker_swarm swarm kubernetes podman proxmox_lxc] |
| [`--provider.podman.uri`](#opt-provider-podman-uri) | Uri is the URI to connect to the Podman service. |
| [`--provider.proxmox-lxc.tls-insecure`](#opt-provider-proxmox-lxc-tls-insecure) | Skip TLS certificate verification for Proxmox VE API |
Expand Down Expand Up @@ -292,6 +293,27 @@ SABLIER_PROVIDER_KUBERNETES_QPS=5
--provider.kubernetes.qps=5
```

### `--provider.kubernetes.ready-on-first-replica` {#opt-provider-kubernetes-ready-on-first-replica}

Consider a Deployment or StatefulSet ready as soon as at least one replica is ready, instead of requiring all desired replicas

{{< badge "boolean" >}} {{< badge content="Default: false" >}} {{< badge content="Next release" >}}

```yaml
# sablier.yaml
provider:
kubernetes:
ready-on-first-replica: false
```

```bash
# Environment variable
SABLIER_PROVIDER_KUBERNETES_READY_ON_FIRST_REPLICA=false

# Command-line flag
--provider.kubernetes.ready-on-first-replica=false
```

### `--provider.name` {#opt-provider-name}

Provider to use to manage containers [docker docker_swarm swarm kubernetes podman proxmox_lxc]
Expand Down
15 changes: 15 additions & 0 deletions pkg/config/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ type Kubernetes struct {
// Default: "_"
// Since: v1.7.0
Delimiter string

// ReadyOnFirstReplica reports a Deployment or StatefulSet as ready as soon as
// at least one replica is ready, instead of requiring all desired replicas to
// be ready. With the default behavior, a single restarting pod of a
// multi-replica workload flips the instance back to "starting" (and its
// sessions to the waiting page) even though the workload still serves traffic
// through its remaining ready replicas. Enabling this also lets traffic flow
// during scale-up as soon as the first replica of each workload is up.
// A workload scaled to zero is always reported as stopped, regardless of
// this option.
// Env: SABLIER_PROVIDER_KUBERNETES_READY_ON_FIRST_REPLICA
// CLI: --provider.kubernetes.ready-on-first-replica
// Default: false
// Since: NEXT_RELEASE
ReadyOnFirstReplica bool
}

type Podman struct {
Expand Down
11 changes: 8 additions & 3 deletions pkg/provider/kubernetes/deployment_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ func (p *Provider) DeploymentInspect(ctx context.Context, config ParsedName) (sa
p.l.DebugContext(ctx, "deployment inspected", "deployment", config.Name, "namespace", config.Namespace, "replicas", d.Status.Replicas, "readyReplicas", d.Status.ReadyReplicas, "availableReplicas", d.Status.AvailableReplicas)

var info sablier.InstanceInfo
// TODO: Should add option to set ready as soon as one replica is ready
if *d.Spec.Replicas != 0 && *d.Spec.Replicas == d.Status.ReadyReplicas {
ready := *d.Spec.Replicas != 0 && *d.Spec.Replicas == d.Status.ReadyReplicas
if p.readyOnFirstReplica {
// A workload scaled to zero must still be reported as stopped, even if
// terminating pods transiently keep readyReplicas above zero.
ready = *d.Spec.Replicas != 0 && d.Status.ReadyReplicas > 0
}
if ready {
info = sablier.InstanceInfo{
Name: config.Original,
CurrentReplicas: config.Replicas,
CurrentReplicas: d.Status.ReadyReplicas,
DesiredReplicas: config.Replicas,
Status: sablier.InstanceStatusReady,
}
Expand Down
65 changes: 63 additions & 2 deletions pkg/provider/kubernetes/deployment_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/sablierapp/sablier/pkg/sablier"
"gotest.tools/v3/assert"
autoscalingv1 "k8s.io/api/autoscaling/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -65,7 +64,7 @@ func TestKubernetesProvider_DeploymentInspect(t *testing.T) {
do: func(dind *kindContainer) (string, error) {
d, err := dind.CreateMimicDeployment(ctx, MimicOptions{
Cmd: []string{"/mimic", "-running-after=1ms", "-healthy=false", "-healthy-after=10s"},
Healthcheck: &corev1.Probe{},
Healthcheck: mimicHealthcheck(),
})
if err != nil {
return "", err
Expand Down Expand Up @@ -273,3 +272,65 @@ func TestKubernetesProvider_DeploymentInspect(t *testing.T) {
})
}
}

func TestKubernetesProvider_DeploymentInspect_ReadyOnFirstReplica(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
t.Parallel()

ctx := context.Background()
c := sharedKinD

// A pod only becomes healthy 20 seconds after its own start: the first
// replica gets ready, and the replica added by the scale-up below stays
// unready long enough to observe a stable 1/2-ready deployment.
d, err := c.CreateMimicDeployment(ctx, MimicOptions{
Cmd: []string{"/mimic", "-running-after=1ms", "-healthy=false", "-healthy-after=20s"},
Healthcheck: mimicHealthcheck(),
})
Comment thread
thisispr marked this conversation as resolved.
assert.NilError(t, err)
t.Cleanup(func() {
_ = c.client.AppsV1().Deployments(d.Namespace).Delete(context.Background(), d.Name, metav1.DeleteOptions{})
})

assert.NilError(t, WaitForDeploymentReady(ctx, c.client, "default", d.Name))

_, err = c.client.AppsV1().Deployments(d.Namespace).UpdateScale(ctx, d.Name, &autoscalingv1.Scale{
ObjectMeta: metav1.ObjectMeta{Name: d.Name},
Spec: autoscalingv1.ScaleSpec{Replicas: 2},
}, metav1.UpdateOptions{})
assert.NilError(t, err)
assert.NilError(t, WaitForDeploymentScale(ctx, c.client, "default", d.Name, 2))

name := kubernetes.DeploymentName(d, kubernetes.ParseOptions{Delimiter: "_"}).Original

// Default behavior: 1/2 ready replicas is still starting.
p, err := kubernetes.New(ctx, c.client, c.dynamic, slogt.New(t), config.NewProviderConfig().Kubernetes)
assert.NilError(t, err)
got, err := p.InstanceInspect(ctx, name)
assert.NilError(t, err)
assert.Equal(t, got.Status, sablier.InstanceStatusStarting)

// With ready-on-first-replica, one ready replica is enough.
cfg := config.NewProviderConfig().Kubernetes
cfg.ReadyOnFirstReplica = true
p, err = kubernetes.New(ctx, c.client, c.dynamic, slogt.New(t), cfg)
assert.NilError(t, err)
got, err = p.InstanceInspect(ctx, name)
assert.NilError(t, err)
assert.Equal(t, got.Status, sablier.InstanceStatusReady)
assert.Equal(t, got.CurrentReplicas, int32(1))

// Scaled to zero, the workload must still be reported as stopped.
_, err = c.client.AppsV1().Deployments(d.Namespace).UpdateScale(ctx, d.Name, &autoscalingv1.Scale{
ObjectMeta: metav1.ObjectMeta{Name: d.Name},
Spec: autoscalingv1.ScaleSpec{Replicas: 0},
}, metav1.UpdateOptions{})
assert.NilError(t, err)
assert.NilError(t, WaitForDeploymentScale(ctx, c.client, "default", d.Name, 0))

got, err = p.InstanceInspect(ctx, name)
assert.NilError(t, err)
assert.Equal(t, got.Status, sablier.InstanceStatusStopped)
}
20 changes: 11 additions & 9 deletions pkg/provider/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ type Provider struct {
// dynamic drives Custom Resources (e.g. CloudNativePG Clusters) that are not
// part of the typed clientset. It may be nil when the provider is constructed
// without CRD support; CRD-backed operations guard against that.
dynamic dynamic.Interface
delimiter string
l *slog.Logger
tracer trace.Tracer
dynamic dynamic.Interface
delimiter string
readyOnFirstReplica bool
l *slog.Logger
tracer trace.Tracer
}

func New(ctx context.Context, client *k8s.Clientset, dynamicClient dynamic.Interface, logger *slog.Logger, config providerConfig.Kubernetes) (*Provider, error) {
Expand All @@ -42,11 +43,12 @@ func New(ctx context.Context, client *k8s.Clientset, dynamicClient dynamic.Inter
)

return &Provider{
Client: client,
dynamic: dynamicClient,
delimiter: config.Delimiter,
l: logger,
tracer: otel.Tracer("github.com/sablierapp/sablier/pkg/provider/kubernetes"),
Client: client,
dynamic: dynamicClient,
delimiter: config.Delimiter,
readyOnFirstReplica: config.ReadyOnFirstReplica,
l: logger,
tracer: otel.Tracer("github.com/sablierapp/sablier/pkg/provider/kubernetes"),
}, nil

}
8 changes: 7 additions & 1 deletion pkg/provider/kubernetes/statefulset_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ func (p *Provider) StatefulSetInspect(ctx context.Context, config ParsedName) (s
}

var info sablier.InstanceInfo
if *ss.Spec.Replicas != 0 && *ss.Spec.Replicas == ss.Status.ReadyReplicas {
ready := *ss.Spec.Replicas != 0 && *ss.Spec.Replicas == ss.Status.ReadyReplicas
if p.readyOnFirstReplica {
// A workload scaled to zero must still be reported as stopped, even if
// terminating pods transiently keep readyReplicas above zero.
ready = *ss.Spec.Replicas != 0 && ss.Status.ReadyReplicas > 0
}
if ready {
info = sablier.InstanceInfo{
Name: config.Original,
CurrentReplicas: ss.Status.ReadyReplicas,
Expand Down
3 changes: 1 addition & 2 deletions pkg/provider/kubernetes/statefulset_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/sablierapp/sablier/pkg/sablier"
"gotest.tools/v3/assert"
autoscalingv1 "k8s.io/api/autoscaling/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -65,7 +64,7 @@ func TestKubernetesProvider_InspectStatefulSet(t *testing.T) {
do: func(dind *kindContainer) (string, error) {
d, err := dind.CreateMimicStatefulSet(ctx, MimicOptions{
Cmd: []string{"/mimic", "-running-after=1ms", "-healthy=false", "-healthy-after=10s"},
Healthcheck: &corev1.Probe{},
Healthcheck: mimicHealthcheck(),
})
if err != nil {
return "", err
Expand Down
28 changes: 22 additions & 6 deletions pkg/provider/kubernetes/testcontainers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ type MimicOptions struct {
Annotations map[string]string
}

// mimicHealthcheck returns a readiness probe wired to the mimic health
// endpoint, so pod readiness follows the -healthy/-healthy-after flags
// instead of flipping to Ready as soon as the container is running.
func mimicHealthcheck() *corev1.Probe {
return &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
Exec: &corev1.ExecAction{
Command: []string{"/mimic", "healthcheck"},
},
},
PeriodSeconds: 1,
}
}

func (d *kindContainer) CreateMimicDeployment(ctx context.Context, opts MimicOptions) (*v1.Deployment, error) {
if len(opts.Cmd) == 0 {
opts.Cmd = []string{"/mimic", "-running", "-running-after=1s", "-healthy=false"}
Expand Down Expand Up @@ -70,9 +84,10 @@ func (d *kindContainer) CreateMimicDeployment(ctx context.Context, opts MimicOpt
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "mimic",
Image: "sablierapp/mimic:v0.3.3",
Command: opts.Cmd,
Name: "mimic",
Image: "sablierapp/mimic:v0.3.3",
Command: opts.Cmd,
ReadinessProbe: opts.Healthcheck,
},
},
},
Expand Down Expand Up @@ -113,9 +128,10 @@ func (d *kindContainer) CreateMimicStatefulSet(ctx context.Context, opts MimicOp
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "mimic",
Image: "sablierapp/mimic:v0.3.3",
Command: opts.Cmd,
Name: "mimic",
Image: "sablierapp/mimic:v0.3.3",
Command: opts.Cmd,
ReadinessProbe: opts.Healthcheck,
},
},
},
Expand Down
1 change: 1 addition & 0 deletions pkg/sabliercmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestPrecedence(t *testing.T) {
"--provider.kubernetes.qps", "256",
"--provider.kubernetes.burst", "512",
"--provider.kubernetes.delimiter", "_",
"--provider.kubernetes.ready-on-first-replica=true",
"--provider.podman.uri", "unix:///run/podman/podman.sock.cli",
"--provider.docker.strategy", "pause",
"--server.port", "3333",
Expand Down
2 changes: 2 additions & 0 deletions pkg/sabliercmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ It provides integrations with multiple reverse proxies and different loading str
_ = viper.BindPFlag("provider.kubernetes.burst", startCmd.Flags().Lookup("provider.kubernetes.burst"))
startCmd.Flags().StringVar(&conf.Provider.Kubernetes.Delimiter, "provider.kubernetes.delimiter", "_", "Delimiter used for namespace/resource type/name resolution. Defaults to \"_\" for backward compatibility. But you should use \"/\" or \".\"")
_ = viper.BindPFlag("provider.kubernetes.delimiter", startCmd.Flags().Lookup("provider.kubernetes.delimiter"))
startCmd.Flags().BoolVar(&conf.Provider.Kubernetes.ReadyOnFirstReplica, "provider.kubernetes.ready-on-first-replica", false, "Consider a Deployment or StatefulSet ready as soon as at least one replica is ready, instead of requiring all desired replicas")
_ = viper.BindPFlag("provider.kubernetes.ready-on-first-replica", startCmd.Flags().Lookup("provider.kubernetes.ready-on-first-replica"))
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)")
Expand Down
1 change: 1 addition & 0 deletions pkg/sabliercmd/testdata/config.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ SABLIER_PROVIDER_AUTOSTOPONSTARTUP=false
SABLIER_PROVIDER_KUBERNETES_QPS=16
SABLIER_PROVIDER_KUBERNETES_BURST=32
SABLIER_PROVIDER_KUBERNETES_DELIMITER=/
SABLIER_PROVIDER_KUBERNETES_READY_ON_FIRST_REPLICA=true
SABLIER_PROVIDER_PODMAN_URI=unix:///run/podman/podman.sock.env
SABLIER_PROVIDER_DOCKER_STRATEGY=pause
SABLIER_SERVER_PORT=2222
Expand Down
1 change: 1 addition & 0 deletions pkg/sabliercmd/testdata/config.legacy.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PROVIDER_AUTOSTOPONSTARTUP=false
PROVIDER_KUBERNETES_QPS=16
PROVIDER_KUBERNETES_BURST=32
PROVIDER_KUBERNETES_DELIMITER=/
PROVIDER_KUBERNETES_READY_ON_FIRST_REPLICA=true
PROVIDER_PODMAN_URI=unix:///run/podman/podman.sock.env
PROVIDER_DOCKER_STRATEGY=pause
SERVER_PORT=2222
Expand Down
1 change: 1 addition & 0 deletions pkg/sabliercmd/testdata/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ provider:
qps: 64
burst: 128
delimiter: .
ready-on-first-replica: true
podman:
uri: unix:///run/podman/podman.sock.yml
docker:
Expand Down
3 changes: 2 additions & 1 deletion pkg/sabliercmd/testdata/config_cli_wanted.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"Kubernetes": {
"QPS": 256,
"Burst": 512,
"Delimiter": "_"
"Delimiter": "_",
"ReadyOnFirstReplica": true
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.cli"
Expand Down
3 changes: 2 additions & 1 deletion pkg/sabliercmd/testdata/config_default.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"Kubernetes": {
"QPS": 5,
"Burst": 10,
"Delimiter": "_"
"Delimiter": "_",
"ReadyOnFirstReplica": false
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock"
Expand Down
3 changes: 2 additions & 1 deletion pkg/sabliercmd/testdata/config_env_wanted.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"Kubernetes": {
"QPS": 16,
"Burst": 32,
"Delimiter": "/"
"Delimiter": "/",
"ReadyOnFirstReplica": true
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.env"
Expand Down
3 changes: 2 additions & 1 deletion pkg/sabliercmd/testdata/config_yaml_wanted.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"Kubernetes": {
"QPS": 64,
"Burst": 128,
"Delimiter": "."
"Delimiter": ".",
"ReadyOnFirstReplica": true
},
"Podman": {
"Uri": "unix:///run/podman/podman.sock.yml"
Expand Down