Skip to content
Merged
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
59 changes: 59 additions & 0 deletions docs/static/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,57 @@
},
"type": "object"
},
"sablier.InstanceConfig": {
"properties": {
"antiAffinity": {
"description": "AntiAffinity lists the groups this instance backs off from\n(sablier.anti-affinity).",
"items": {
"type": "string"
},
"type": "array"
},
"enabled": {
"description": "Enabled reports whether the instance opted into Sablier management\n(sablier.enable set to exactly \"true\").",
"type": "boolean"
},
"groups": {
"description": "Groups the instance belongs to (sablier.group). Only populated for\nenabled instances; defaults to [\"default\"] when the label is absent.",
"items": {
"type": "string"
},
"type": "array"
},
"readyAfter": {
"allOf": [
{
"$ref": "#/components/schemas/time.Duration"
}
],
"description": "ReadyAfter is the settling delay after the instance first reports ready\n(sablier.ready-after). Zero means no extra wait."
},
"readyOnStart": {
"description": "ReadyOnStart marks the instance ready as soon as its start is\ndispatched, skipping the health check (sablier.ready-on-start).",
"type": "boolean"
},
"runningDays": {
"description": "RunningDays restricts RunningHours to specific weekdays\n(sablier.running-days). Empty means every day.",
"type": "string"
},
"runningHours": {
"description": "RunningHours is the validated daily keep-warm window\n(sablier.running-hours, \"HH:MM-HH:MM\"). It is kept in its validated\nstring form because that is the serializable canonical representation;\nparse it with ParseRunningHours where the window is evaluated.",
"type": "string"
},
"scale": {
"allOf": [
{
"$ref": "#/components/schemas/sablier.ScaleConfig"
}
],
"description": "Scale holds the idle/active resource profiles when any non-default\nscale-mode label is present (sablier.idle.* / sablier.active.*)."
}
},
"type": "object"
},
"sablier.InstanceInfo": {
"properties": {
"antiAffinity": {
Expand All @@ -89,6 +140,14 @@
},
"type": "array"
},
"config": {
"allOf": [
{
"$ref": "#/components/schemas/sablier.InstanceConfig"
}
],
"description": "Config is the typed, parsed form of the instance's sablier.* labels,\nproduced by InstanceConfigFromLabels at the provider boundary. Prefer it\n(and the accessor methods such as IsEnabled) over the flat fields above,\nwhich are kept for API and store compatibility and will move behind a\ndedicated API DTO in a later stage. Nil on records that predate this\nfield (e.g. sessions persisted by an older version); accessors fall back\nto the flat fields in that case."
},
"currentReplicas": {
"type": "integer"
},
Expand Down
13 changes: 13 additions & 0 deletions pkg/provider/docker/container_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,19 @@ func TestDockerClassicProvider_GetState(t *testing.T) {
ID: name,
Image: "sablierapp/mimic:v0.3.3",
}
// The provider mirrors the parsed label config into Config with the
// same values as the flat fields each case already declares, so
// derive the expectation instead of repeating it per case.
tt.want.Config = &sablier.InstanceConfig{
Enabled: tt.want.Enabled == "true",
Groups: tt.want.Groups,
ReadyAfter: tt.want.ReadyAfter,
ReadyOnStart: tt.want.ReadyOnStart,
RunningHours: tt.want.RunningHours,
RunningDays: tt.want.RunningDays,
AntiAffinity: tt.want.AntiAffinity,
Scale: tt.want.ScaleConfig,
}
got, err := p.InstanceInspect(ctx, name)
if !cmp.Equal(err, tt.wantErr) {
t.Errorf("DockerClassicProvider.InstanceInspect() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
13 changes: 13 additions & 0 deletions pkg/provider/dockerswarm/service_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ func TestDockerSwarmProvider_GetState(t *testing.T) {
tt.want.Name = name
tt.want.Provider = "swarm"
tt.want.Swarm = &sablier.SwarmServiceInfo{}
// The provider mirrors the parsed label config into Config with the
// same values as the flat fields each case already declares, so
// derive the expectation instead of repeating it per case.
tt.want.Config = &sablier.InstanceConfig{
Enabled: tt.want.Enabled == "true",
Groups: tt.want.Groups,
ReadyAfter: tt.want.ReadyAfter,
ReadyOnStart: tt.want.ReadyOnStart,
RunningHours: tt.want.RunningHours,
RunningDays: tt.want.RunningDays,
AntiAffinity: tt.want.AntiAffinity,
Scale: tt.want.ScaleConfig,
}
got, err := p.InstanceInspect(ctx, name)
if !cmp.Equal(err, tt.wantErr) {
t.Errorf("Provider.InstanceInspect() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
13 changes: 13 additions & 0 deletions pkg/provider/kubernetes/deployment_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ func TestKubernetesProvider_DeploymentInspect(t *testing.T) {
Image: "sablierapp/mimic:v0.3.3",
Labels: labels,
}
// The provider mirrors the parsed label config into Config with the
// same values as the flat fields each case already declares, so
// derive the expectation instead of repeating it per case.
tt.want.Config = &sablier.InstanceConfig{
Enabled: tt.want.Enabled == "true",
Groups: tt.want.Groups,
ReadyAfter: tt.want.ReadyAfter,
ReadyOnStart: tt.want.ReadyOnStart,
RunningHours: tt.want.RunningHours,
RunningDays: tt.want.RunningDays,
AntiAffinity: tt.want.AntiAffinity,
Scale: tt.want.ScaleConfig,
}
got, err := p.InstanceInspect(ctx, name)
if !cmp.Equal(err, tt.wantErr) {
t.Errorf("Provider.InstanceInspect() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
13 changes: 13 additions & 0 deletions pkg/provider/kubernetes/statefulset_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,19 @@ func TestKubernetesProvider_InspectStatefulSet(t *testing.T) {
Image: "sablierapp/mimic:v0.3.3",
Labels: labels,
}
// The provider mirrors the parsed label config into Config with the
// same values as the flat fields each case already declares, so
// derive the expectation instead of repeating it per case.
tt.want.Config = &sablier.InstanceConfig{
Enabled: tt.want.Enabled == "true",
Groups: tt.want.Groups,
ReadyAfter: tt.want.ReadyAfter,
ReadyOnStart: tt.want.ReadyOnStart,
RunningHours: tt.want.RunningHours,
RunningDays: tt.want.RunningDays,
AntiAffinity: tt.want.AntiAffinity,
Scale: tt.want.ScaleConfig,
}
got, err := p.InstanceInspect(ctx, name)
if !cmp.Equal(err, tt.wantErr) {
t.Errorf("DockerSwarmProvider.InstanceInspect() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
13 changes: 13 additions & 0 deletions pkg/provider/podman/container_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,19 @@ func TestPodmanProvider_GetState(t *testing.T) {
ID: name,
Image: "docker.io/sablierapp/mimic:v0.3.3",
}
// The provider mirrors the parsed label config into Config with the
// same values as the flat fields each case already declares, so
// derive the expectation instead of repeating it per case.
tt.want.Config = &sablier.InstanceConfig{
Enabled: tt.want.Enabled == "true",
Groups: tt.want.Groups,
ReadyAfter: tt.want.ReadyAfter,
ReadyOnStart: tt.want.ReadyOnStart,
RunningHours: tt.want.RunningHours,
RunningDays: tt.want.RunningDays,
AntiAffinity: tt.want.AntiAffinity,
Scale: tt.want.ScaleConfig,
}
got, err := p.InstanceInspect(ctx, name)
if !cmp.Equal(err, tt.wantErr) {
t.Errorf("PodmanProvider.InstanceInspect() error = %v, wantErr %v", err, tt.wantErr)
Expand Down
2 changes: 1 addition & 1 deletion pkg/sablier/autostop.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (s *Sablier) WatchAndStopExternallyStarted(ctx context.Context) {
continue
}
// Only act on Sablier-managed instances.
if info.Info.Enabled != "true" {
if !info.Info.IsEnabled() {
continue
}
if s.isStartedByUs(ctx, info.Info.Name) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/sablier/autowarm.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func (s *Sablier) WatchAndWarmExternallyStarted(ctx context.Context) {
continue
}
// Only act on Sablier-managed instances.
if info.Info.Enabled != "true" {
if !info.Info.IsEnabled() {
continue
}
if s.isStartedByUs(ctx, info.Info.Name) {
Expand Down
104 changes: 42 additions & 62 deletions pkg/sablier/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ type InstanceInfo struct {
// ScaleConfig configures resource-based scale mode for this instance.
// When present, Sablier throttles CPU/memory instead of stopping the container.
ScaleConfig *ScaleConfig `json:"scaleConfig,omitempty"`

// Config is the typed, parsed form of the instance's sablier.* labels,
// produced by InstanceConfigFromLabels at the provider boundary. Prefer it
// (and the accessor methods such as IsEnabled) over the flat fields above,
// which are kept for API and store compatibility and will move behind a
// dedicated API DTO in a later stage. Nil on records that predate this
// field (e.g. sessions persisted by an older version); accessors fall back
// to the flat fields in that case.
Config *InstanceConfig `json:"config,omitempty"`
}

// IsEnabled reports whether the instance opted into Sablier management
// (sablier.enable set to exactly "true"). It is the single source of that
// semantics; do not compare the flat Enabled string directly.
func (instance InstanceInfo) IsEnabled() bool {
if instance.Config != nil {
return instance.Config.Enabled
}
return instance.Enabled == "true"
}

// BlkioWeightDevice holds a per-device I/O scheduling weight override.
Expand Down Expand Up @@ -163,6 +182,12 @@ type InstanceConfiguration struct {
Enabled string
}

// IsEnabled reports whether the listed instance opted into Sablier management
// (sablier.enable set to exactly "true"), mirroring InstanceInfo.IsEnabled.
func (c InstanceConfiguration) IsEnabled() bool {
return c.Enabled == "true"
}

func (instance InstanceInfo) IsReady() bool {
if instance.ReadyOnStart {
return true
Expand Down Expand Up @@ -366,68 +391,23 @@ func ParseAntiAffinity(label string) []string {
return out
}

// PopulateEnabledAndGroup reads the sablier.enable and sablier.group labels from
// labels and writes the results into info. Centralising this logic avoids
// duplicating the same map lookups in every provider's Inspect implementation.
// PopulateEnabledAndGroup parses the sablier.* labels into info.Config and
// mirrors the result into the flat legacy fields, which stay byte-identical
// on the wire (API responses and persisted session records). All parsing
// lives in InstanceConfigFromLabels; this is only the compatibility adapter
// providers call from their Inspect implementations.
func PopulateEnabledAndGroup(info *InstanceInfo, labels map[string]string) {
cfg := InstanceConfigFromLabels(labels, slog.Default().With(slog.String("instance", info.Name)))
info.Config = &cfg

// Legacy flat fields. Enabled keeps the raw label value (not the parsed
// boolean) because the API has always exposed it verbatim.
info.Enabled = labels[LabelEnable]
if info.Enabled == "true" {
info.Groups = ParseGroups(labels[LabelGroup])
}
if v := labels[LabelReadyAfter]; v != "" {
if d, err := time.ParseDuration(v); err == nil {
info.ReadyAfter = d
} else {
slog.Warn("invalid sablier.ready-after label value, ignoring",
slog.String("instance", info.Name),
slog.String("value", v),
slog.Any("error", err),
)
}
}
if v := labels[LabelRunningHours]; v != "" {
if _, err := ParseRunningHours(v); err == nil {
info.RunningHours = v
} else {
slog.Warn("invalid sablier.running-hours label value, ignoring",
slog.String("instance", info.Name),
slog.String("value", v),
slog.Any("error", err),
)
}
}
if v := labels[LabelRunningDays]; v != "" {
if _, err := ParseRunningDays(v); err == nil {
info.RunningDays = v
} else {
slog.Warn("invalid sablier.running-days label value, ignoring",
slog.String("instance", info.Name),
slog.String("value", v),
slog.Any("error", err),
)
}
}
if v := labels[LabelReadyOnStart]; v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
slog.Warn("invalid sablier.ready-on-start label value, ignoring",
slog.String("instance", info.Name),
slog.String("value", v),
slog.Any("error", err),
)
} else {
info.ReadyOnStart = b
}
}
if v := labels[LabelAntiAffinity]; v != "" {
info.AntiAffinity = ParseAntiAffinity(v)
}
// Only expose ScaleConfig in the response when at least one non-default
// scale label is present. Detects configuration by checking for values
// that differ from the zero-value defaults (Idle.Replicas=0, Active.Replicas=1).
sc := ScaleConfigFromLabels(labels)
if sc.Idle.Replicas > 0 || sc.Idle.HasResources() ||
sc.Active.Replicas > 1 || sc.Active.HasResources() {
info.ScaleConfig = &sc
}
info.Groups = cfg.Groups
info.ReadyAfter = cfg.ReadyAfter
info.RunningHours = cfg.RunningHours
info.RunningDays = cfg.RunningDays
info.ReadyOnStart = cfg.ReadyOnStart
info.AntiAffinity = cfg.AntiAffinity
info.ScaleConfig = cfg.Scale
}
Loading
Loading