mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
fix(elbv2): build lb-agent data-plane config on LB-ready heartbeat (#347)
CreateLoadBalancer is async: InstanceID is only set when the LB VM's QEMU process starts, ~600ms after the create burst. The reactive updateStoredConfig calls from CreateListener/RegisterTargets all no-op during that window (InstanceID empty), and nothing rebuilds config once the VM launches. The lb-agent then heartbeats, gets an empty ConfigHash, never sees it change, and probes zero backends -> e2e 0/2 healthy. Build the data-plane config on the provisioning->active heartbeat transition instead: that is the one point where InstanceID is guaranteed set and the agent is alive to consume config. The InstanceID guard in updateStoredConfig stays as a cheap no-data-plane skip, no longer the primary gate.
This commit is contained in:
@@ -895,12 +895,26 @@ func (s *ELBv2ServiceImpl) LBAgentHeartbeat(input *LBAgentHeartbeatInput, accoun
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
heartbeatStale := now.Sub(lb.LastHeartbeat) >= heartbeatPersistInterval
|
||||
lb.LastHeartbeat = now
|
||||
|
||||
// Only persist to KV on state transitions or when the stored heartbeat
|
||||
// timestamp is stale. This avoids writing the full record (including
|
||||
// ConfigText) every 5 seconds when nothing changed.
|
||||
if stateChanged || now.Sub(lb.LastHeartbeat) >= heartbeatPersistInterval {
|
||||
lb.LastHeartbeat = now
|
||||
switch {
|
||||
case stateChanged:
|
||||
// Activation is the LB's readiness signal: InstanceID is set and the
|
||||
// agent is alive to consume config. The reactive updateStoredConfig
|
||||
// calls during the create burst no-op while the VM is still launching
|
||||
// (InstanceID empty), so build the data-plane config now from the
|
||||
// listeners/targets created during provisioning. updateStoredConfig
|
||||
// persists the record (state, config, hash, health targets) in one
|
||||
// write, so the ConfigHash returned below is fresh.
|
||||
if err := s.updateStoredConfig(lb); err != nil {
|
||||
slog.Error("LBAgentHeartbeat: failed to build config on activation", "lbId", lbID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
case heartbeatStale:
|
||||
// Steady state: refresh the heartbeat timestamp only. Avoids writing
|
||||
// the full record (including ConfigText) every 5 seconds when nothing
|
||||
// changed.
|
||||
if err := s.store.PutLoadBalancer(lb); err != nil {
|
||||
slog.Error("LBAgentHeartbeat: failed to persist LB", "lbId", lbID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
|
||||
@@ -2008,6 +2008,71 @@ func TestLBAgentHeartbeat_ProcessesHealthReport(t *testing.T) {
|
||||
assert.Equal(t, TargetHealthHealthy, stored.Targets[0].HealthState)
|
||||
}
|
||||
|
||||
// TestLBAgentHeartbeat_BuildsConfigOnActivation covers the create-burst race:
|
||||
// the reactive updateStoredConfig calls during CreateListener/RegisterTargets
|
||||
// no-op while the LB VM is still launching (InstanceID empty). The agent's
|
||||
// first heartbeat (provisioning→active) is the readiness signal that must build
|
||||
// the data-plane config from the listeners/targets created during provisioning,
|
||||
// so the agent receives a non-empty ConfigHash + backend list on its first poll.
|
||||
func TestLBAgentHeartbeat_BuildsConfigOnActivation(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/act-lb/lb-act1",
|
||||
LoadBalancerID: "lb-act1",
|
||||
Name: "act-lb",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
Scheme: SchemeInternal,
|
||||
State: StateProvisioning,
|
||||
InstanceID: "i-sys-act1",
|
||||
VPCIP: "10.0.1.100",
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
require.NoError(t, svc.store.PutLoadBalancer(lb))
|
||||
|
||||
tg := &TargetGroupRecord{
|
||||
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/act-tg/tg-act1",
|
||||
TargetGroupID: "tg-act1",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 80,
|
||||
HealthCheck: DefaultHealthCheck(),
|
||||
Targets: []Target{
|
||||
{Id: "i-target1", Port: 80, HealthState: TargetHealthInitial, PrivateIP: "10.0.1.20"},
|
||||
},
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
require.NoError(t, svc.store.PutTargetGroup(tg))
|
||||
|
||||
require.NoError(t, svc.store.PutListener(&ListenerRecord{
|
||||
ListenerArn: lb.LoadBalancerArn + "/listener-1",
|
||||
ListenerID: "lst-act1",
|
||||
LoadBalancerArn: lb.LoadBalancerArn,
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tg.TargetGroupArn}},
|
||||
AccountID: testAccountID,
|
||||
}))
|
||||
|
||||
// Pre-condition: config was never built during provisioning.
|
||||
pre, err := svc.store.GetLoadBalancer("lb-act1")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pre.ConfigHash, "config must be empty before activation")
|
||||
|
||||
out, err := svc.LBAgentHeartbeat(&LBAgentHeartbeatInput{
|
||||
LBID: aws.String("lb-act1"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StateActive, *out.Status)
|
||||
assert.NotEmpty(t, aws.StringValue(out.ConfigHash), "first heartbeat must return a built ConfigHash")
|
||||
|
||||
stored, err := svc.store.GetLoadBalancer("lb-act1")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, stored.ConfigText, "data-plane config must be built on activation")
|
||||
assert.NotEmpty(t, stored.ConfigHash)
|
||||
require.Len(t, stored.HealthTargets, 1, "NLB health target must be populated for the registered backend")
|
||||
assert.Equal(t, "10.0.1.20:80", stored.HealthTargets[0].Address)
|
||||
}
|
||||
|
||||
func TestLBAgentHeartbeat_MissingLBID(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user