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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ This branch contains the compatibility runtime used by PastureStack Server to op

## Runtime image

PastureStack Server creates this image automatically when a user adds a load-balancer service:
The currently published runtime image used when PastureStack Server creates a load-balancer service is:

```text
ghcr.io/pasturestack/load-balancer-service:v0.9.27
```

Release deployments pin the image by digest. The image is not intended to be started by itself because the control plane supplies metadata, API credentials, service links, port rules, and health-check configuration.

This branch prepares the `v0.9.28` source candidate for drain weight recovery.
The candidate has passed the focused local race tests and static checks described
below. Protected pull-request CI and native PastureStack load-balancer QA remain
release qualification gates; `v0.9.28` has not been tagged or published.

## Compatibility

The controller accepts role-specific credentials when the orchestration engine supplies them:
Expand All @@ -27,6 +32,36 @@ It also accepts the generic `CATTLE_ACCESS_KEY` and `CATTLE_SECRET_KEY` pair use

The HAProxy logging helper keeps its configuration under the standard `/etc/rsyslog.d` path and does not require a daemon PID file. Logging initialization is validated independently before HAProxy configuration is applied.

### Drain weight recovery (v0.9.28 source candidate)

The controller records each backend's runtime weight before setting it to zero
for `target.drain`, including drains that already have zero active sessions.
After drain completion or timeout, a healthy endpoint still present in desired
metadata can recover its original weight even when its ID, IP, and generated
configuration have not changed. No reload is forced solely for this recovery.

Only weights changed by this controller are eligible. Existing zero weights,
manual MAINT/DRAIN, configured `disabled` or weight zero, and an operator's later
nonzero weight are preserved. Active drains follow a HAProxy reload; a completed
drain does not adopt an unexplained zero in a different HAProxy process. Recovery
ownership is process-local and is not reconstructed by guessing after a controller
restart.

Pending recovery retries every five seconds and after successful configuration
application. Runtime socket operations have a two-second deadline. A failed or
unverified weight change retains recovery ownership and produces an ERROR with
the endpoint, backend, and intended weight. Successful restoration is logged only
after runtime readback. A metadata target in `stopping` keeps desired weight zero
even when port rules are merged. This lifecycle zero retains recovery ownership
until the same endpoint returns to an eligible running state; an explicit user
weight zero or `disabled` remains protected. Across a HAProxy PID change, an
active drain is reapplied only to an unchanged configured initial weight, never
to a runtime custom weight observed in the replacement process.
If the desired configured weight changes while draining, recovery honors that
new configured weight instead of undoing the user's edit with the old snapshot.

The backend state fields follow the [HAProxy Runtime API management contract](https://docs.haproxy.org/3.4/management.html#show%20servers%20state).

Protocol-critical `CATTLE_*` variables, `io.rancher.*` labels, metadata paths, API types, and provider identifiers remain compatibility interfaces. They do not represent PastureStack branding or affiliation.

## Build and test
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type Endpoint struct {
IsCname bool
DrainTimeout string
Weight int
Stopping bool
}

type FrontendService struct {
Expand Down
52 changes: 52 additions & 0 deletions controller/rancher/drain_weight_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package rancher

import (
"testing"

"github.com/PastureStack/load-balancer-controller/internal/metadata"
)

type drainWeightMetadata struct {
tMetaFetcher
state string
}

func (mf drainWeightMetadata) GetService(link string) (*metadata.Service, error) {
ip := "10.42.0.1"
if link == "default/two" {
ip = "10.42.0.2"
}
return &metadata.Service{Kind: "service", State: "active", Containers: []metadata.Container{{Name: link, UUID: link, PrimaryIp: ip, State: mf.state}}}, nil
}

func TestStoppingAndRestartedTargetsPreserveDesiredWeightsAcrossRuleMerges(t *testing.T) {
var oldNames []string
for _, state := range []string{"running", "stopping", "running", "starting"} {
c := &LoadBalancerController{MetaFetcher: drainWeightMetadata{state: state}, CertFetcher: tCertFetcher{}, LBProvider: &tProvider{}}
meta := &LBMetadata{PortRules: []metadata.PortRule{
{Protocol: "http", Service: "default/one", SourcePort: 80, TargetPort: 8080, Weight: 17},
{Protocol: "http", Service: "default/two", SourcePort: 80, TargetPort: 8080, Weight: 17},
}}
configs, err := c.BuildConfigFromMetadata("lb", "env", "", "any", meta)
if err != nil {
t.Fatal(err)
}
endpoints := configs[0].FrontendServices[0].BackendServices[0].Endpoints
if len(endpoints) != 2 {
t.Fatalf("merged endpoint count=%d", len(endpoints))
}
for i, ep := range endpoints {
want := 17
if state == "stopping" {
want = 0
}
if ep.Weight != want {
t.Fatalf("state=%s endpoint=%s weight=%d want=%d", state, ep.Name, ep.Weight, want)
}
if oldNames != nil && ep.Name != oldNames[i] {
t.Fatalf("state transition changed endpoint identity: %s", state)
}
}
oldNames = []string{endpoints[0].Name, endpoints[1].Name}
}
}
9 changes: 7 additions & 2 deletions controller/rancher/rancher.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ func (lbc *LoadBalancerController) BuildConfigFromMetadata(lbName, envUUID, self
for _, ep := range eps {
if _, ok := epMap[ep.IP]; !ok {
epMap[ep.IP] = ep.IP
ep.Weight = rule.Weight
if !ep.Stopping {
ep.Weight = rule.Weight
}
backend.Endpoints = append(backend.Endpoints, ep)
}
}
Expand All @@ -408,7 +410,9 @@ func (lbc *LoadBalancerController) BuildConfigFromMetadata(lbName, envUUID, self
epMap := make(map[string]string)
for _, ep := range eps {
epMap[ep.IP] = ep.IP
ep.Weight = rule.Weight
if !ep.Stopping {
ep.Weight = rule.Weight
}
}
allEps[pathUUID] = epMap
}
Expand Down Expand Up @@ -765,6 +769,7 @@ func getContainerEndpoint(c *metadata.Container, targetPort int, selfHostUUID st
}
if strings.EqualFold(c.State, "stopping") {
ep.Weight = 0
ep.Stopping = true
}

if localServicePreference != "any" && !strings.EqualFold(c.HostUUID, selfHostUUID) {
Expand Down
Loading
Loading