mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat(elbv2): NLB L4 data plane on nginx stream + UI parity (#295)
* feat(elbv2): NLB L4 data plane on nginx stream (mulga-siv-952) Network load balancers render an nginx `stream` config (TCP/UDP/TLS/ TCP_UDP) instead of HAProxy, which cannot load-balance UDP in any release. The gateway tags GetLBConfig with Engine=nginx; the LB agent dispatches to the nginx engine (config/pid/cert paths + reload) and skips HAProxy stats polling. ALB stays on HAProxy. nginx is added to the microvm image. * docs(elbv2): note NLB runs nginx stream, ALB runs HAProxy (mulga-siv-952) * fix(elbv2): load nginx stream module for NLB data plane Alpine's `nginx` apk is built without the stream module — it ships separately as `nginx-mod-stream`. Without it, the generated NLB config fails `nginx -t` with [emerg] unknown directive "stream", so the agent never applies it. Add the package to the microvm image and emit a `load_module .../ngx_stream_module.so;` directive at the top of the generated config. * fix(elbv2-ui): allow single-subnet LB + full NLB protocols in listener edit Create-LB form required min 2 subnets while the CLI/backend accept 1; relax to min(1) to match. Listener add/edit only offered HTTP/HTTPS — add the NLB protocol set (TCP, UDP, TLS, TCP_UDP), driven by LB type, with cert/ssl-policy fields gated on secure protocols (HTTPS/TLS). * feat(elbv2): active target health checks for nginx NLBs nginx stream has no active upstream probing, so NLB targets never advanced past initial — DescribeTargetHealth stayed initial forever and WaitForTargetsHealthy timed out for every NLB. GetLBConfig now delivers per-target HealthTarget specs (daemon-computed server name, probe address, protocol/path) for network LBs; the lb-agent probes each target every tick (TCP dial or HTTP/HTTPS GET) and reports UP/DOWN via the existing heartbeat path, so the daemon's threshold hysteresis advances targets to healthy. ALBs are unchanged (HAProxy stats). Adds an Internal_NLB_UDP e2e suite gating on WaitForTargetsHealthy plus a UDP datagram round-trip through the VIP.
This commit is contained in:
+1
-1
@@ -602,7 +602,7 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
|
||||
|
||||
## ELBv2 (Application & Network Load Balancer)
|
||||
|
||||
The data plane uses a system-managed LB VM running HAProxy, launched automatically during `create-load-balancer`.
|
||||
The data plane uses a system-managed LB VM, launched automatically during `create-load-balancer`. Application Load Balancers run **HAProxy** (L7: rules, fixed-response, redirect, HTTP/HTTPS). Network Load Balancers run **nginx `stream`** (L4: TCP, UDP, TLS, TCP_UDP) — HAProxy cannot load-balance UDP. The agent selects the engine from the `Engine` field on the config-delivery response.
|
||||
|
||||
### ELBv2 — Load Balancers
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ INITTAB="$BUILD_DIR/inittab"
|
||||
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
|
||||
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
|
||||
ALPINE_PACKAGES="busybox linux-virt haproxy iproute2 ca-certificates"
|
||||
# haproxy: ALB (L7) data plane. nginx + nginx-mod-stream: NLB (L4) data plane —
|
||||
# the `stream` module (a separate Alpine package) load-balances TCP/UDP/TLS,
|
||||
# which haproxy cannot do for UDP.
|
||||
ALPINE_PACKAGES="busybox linux-virt haproxy nginx nginx-mod-stream iproute2 ca-certificates"
|
||||
|
||||
echo "[build-microvm-image] repo root: $REPO_ROOT"
|
||||
echo "[build-microvm-image] build dir: $BUILD_DIR"
|
||||
|
||||
@@ -32,12 +32,30 @@ type GetLBConfigInput struct {
|
||||
LBID *string `locationName:"LBID" type:"string"`
|
||||
}
|
||||
|
||||
// GetLBConfigOutput returns the pre-computed HAProxy config and its hash, plus
|
||||
// any TLS certificate files the config's `ssl crt` directives reference.
|
||||
// GetLBConfigOutput returns the pre-computed data-plane config and its hash,
|
||||
// plus any TLS certificate files the config references. Engine selects the
|
||||
// data plane the agent runs: "haproxy" (ALB) or "nginx" (NLB). HealthTargets
|
||||
// is populated for NLBs only: nginx `stream` has no active upstream probing,
|
||||
// so the agent probes these and reports the results via heartbeat.
|
||||
type GetLBConfigOutput struct {
|
||||
ConfigText *string `type:"string"`
|
||||
ConfigHash *string `type:"string"`
|
||||
CertFiles []*CertFile `locationName:"CertFiles" type:"list"`
|
||||
ConfigText *string `type:"string"`
|
||||
ConfigHash *string `type:"string"`
|
||||
CertFiles []*CertFile `locationName:"CertFiles" type:"list"`
|
||||
Engine *string `type:"string"`
|
||||
HealthTargets []*HealthTarget `locationName:"HealthTargets" type:"list"`
|
||||
}
|
||||
|
||||
// HealthTarget is one backend the nginx agent must actively health-check.
|
||||
// ServerName is daemon-computed (sanitizeName("srv", target.Id)) so it matches
|
||||
// the name the health checker keys on — the agent echoes it back verbatim and
|
||||
// needs no ELBv2 naming logic. Address is the ip:port to probe; Protocol is
|
||||
// the target group's health-check protocol (TCP/HTTP/HTTPS); Path is the
|
||||
// HTTP(S) health-check path.
|
||||
type HealthTarget struct {
|
||||
ServerName *string `locationName:"ServerName" type:"string"`
|
||||
Address *string `locationName:"Address" type:"string"`
|
||||
Protocol *string `locationName:"Protocol" type:"string"`
|
||||
Path *string `locationName:"Path" type:"string"`
|
||||
}
|
||||
|
||||
// CertFile is one TLS certificate PEM delivered to the LB agent. Path is the
|
||||
|
||||
@@ -62,43 +62,7 @@ backend {{.Name}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
const haproxyTCPConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
|
||||
global
|
||||
log stdout format short daemon
|
||||
stats socket {{.SocketPath}} mode 660 level admin
|
||||
|
||||
defaults
|
||||
mode tcp
|
||||
log global
|
||||
option tcplog
|
||||
timeout connect 5s
|
||||
timeout client 60s
|
||||
timeout server 60s
|
||||
{{range .Frontends}}
|
||||
frontend {{.Name}}
|
||||
bind *:{{.Port}}{{if .CertPath}} ssl crt {{.CertPath}}{{end}}
|
||||
default_backend {{.DefaultBackend}}
|
||||
{{end}}
|
||||
{{range .Backends}}
|
||||
backend {{.Name}}
|
||||
balance roundrobin
|
||||
{{- if .TCPCheck}}
|
||||
option tcp-check
|
||||
{{- end}}
|
||||
{{- if .HTTPCheck}}
|
||||
option httpchk GET {{.HealthCheckPath}}
|
||||
http-check expect status {{.HealthCheckMatcher}}
|
||||
{{- end}}
|
||||
{{- range .Servers}}
|
||||
server {{.Name}} {{.Addr}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.Fall}} rise {{.Rise}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
var (
|
||||
haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
|
||||
haproxyTCPTmpl = template.Must(template.New("haproxy-tcp").Parse(haproxyTCPConfigTemplate))
|
||||
)
|
||||
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
|
||||
|
||||
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
|
||||
type HAProxyConfig struct {
|
||||
@@ -145,8 +109,6 @@ type HAProxyBackend struct {
|
||||
Name string
|
||||
HealthCheckPath string
|
||||
HealthCheckMatcher string
|
||||
TCPCheck bool
|
||||
HTTPCheck bool
|
||||
Servers []HAProxyServer
|
||||
FixedResponse *HAProxyFixedResponse
|
||||
Redirect *HAProxyRedirect
|
||||
@@ -196,15 +158,16 @@ func GenerateHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord,
|
||||
// ARN is absent from certPEMByArn renders without `ssl crt` (HAProxy would fail
|
||||
// to bind, surfacing the missing material).
|
||||
func GenerateHAProxyConfigWithCerts(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
|
||||
// NLBs (L4) render an nginx `stream` config — HAProxy load-balances no UDP.
|
||||
if lb.Type == LoadBalancerTypeNetwork {
|
||||
return generateNLBConfig(lb, listeners, tgByArn, bindAddr, certPEMByArn)
|
||||
return generateNLBStreamConfig(lb, listeners, tgByArn, bindAddr, certPEMByArn)
|
||||
}
|
||||
return generateALBConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
|
||||
}
|
||||
|
||||
// generateALBConfig builds an HTTP-mode HAProxy config for ALBs.
|
||||
func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
|
||||
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, false, certPEMByArn)
|
||||
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -217,25 +180,10 @@ func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgBy
|
||||
return buf.String(), cfg.CertFiles, nil
|
||||
}
|
||||
|
||||
// generateNLBConfig builds a TCP-mode HAProxy config for NLBs.
|
||||
func generateNLBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
|
||||
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, nil, bindAddr, true, certPEMByArn)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := haproxyTCPTmpl.Execute(&buf, cfg); err != nil {
|
||||
return "", nil, fmt.Errorf("execute haproxy tcp template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), cfg.CertFiles, nil
|
||||
}
|
||||
|
||||
// buildHAProxyConfig constructs the HAProxyConfig struct shared by ALB and NLB
|
||||
// generation. For ALBs, rulesByListener provides per-listener rules sorted by
|
||||
// priority; nil/empty for NLBs.
|
||||
func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, isTCP bool, certPEMByArn map[string]string) (HAProxyConfig, error) {
|
||||
// buildHAProxyConfig constructs the HAProxyConfig struct for an ALB (L7).
|
||||
// rulesByListener provides per-listener rules sorted by priority. NLBs use the
|
||||
// nginx data plane (generateNLBStreamConfig), not this builder.
|
||||
func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (HAProxyConfig, error) {
|
||||
socketDir := filepath.Join(os.TempDir(), "spinifex-haproxy")
|
||||
// Use "lb" prefix for both ALB and NLB — must match the agent's socket path.
|
||||
socketPath := filepath.Join(socketDir, fmt.Sprintf("lb-%s.sock", lb.LoadBalancerID))
|
||||
@@ -255,19 +203,10 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
|
||||
return
|
||||
}
|
||||
|
||||
backend := HAProxyBackend{Name: backendName}
|
||||
if isTCP {
|
||||
switch tg.HealthCheck.Protocol {
|
||||
case ProtocolHTTP, ProtocolHTTPS:
|
||||
backend.HTTPCheck = true
|
||||
backend.HealthCheckPath = tg.HealthCheck.Path
|
||||
backend.HealthCheckMatcher = tg.HealthCheck.Matcher
|
||||
default:
|
||||
backend.TCPCheck = true
|
||||
}
|
||||
} else {
|
||||
backend.HealthCheckPath = tg.HealthCheck.Path
|
||||
backend.HealthCheckMatcher = tg.HealthCheck.Matcher
|
||||
backend := HAProxyBackend{
|
||||
Name: backendName,
|
||||
HealthCheckPath: tg.HealthCheck.Path,
|
||||
HealthCheckMatcher: tg.HealthCheck.Matcher,
|
||||
}
|
||||
|
||||
for _, target := range tg.Targets {
|
||||
@@ -355,18 +294,16 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
|
||||
}
|
||||
}
|
||||
|
||||
if !isTCP {
|
||||
for _, r := range rulesByListener[l.ListenerArn] {
|
||||
backendName, err := registerRuleBackend(r, l, addBackend, addFixedResponseBackend, addRedirectBackend)
|
||||
if err != nil {
|
||||
return HAProxyConfig{}, err
|
||||
}
|
||||
rule, err := buildHAProxyRule(r, backendName)
|
||||
if err != nil {
|
||||
return HAProxyConfig{}, err
|
||||
}
|
||||
frontend.Rules = append(frontend.Rules, rule)
|
||||
for _, r := range rulesByListener[l.ListenerArn] {
|
||||
backendName, err := registerRuleBackend(r, l, addBackend, addFixedResponseBackend, addRedirectBackend)
|
||||
if err != nil {
|
||||
return HAProxyConfig{}, err
|
||||
}
|
||||
rule, err := buildHAProxyRule(r, backendName)
|
||||
if err != nil {
|
||||
return HAProxyConfig{}, err
|
||||
}
|
||||
frontend.Rules = append(frontend.Rules, rule)
|
||||
}
|
||||
|
||||
cfg.Frontends = append(cfg.Frontends, frontend)
|
||||
|
||||
@@ -585,250 +585,6 @@ func TestHAProxyManager_RemoveConfig_Idempotent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- NLB TCP Config Generation Tests ---
|
||||
|
||||
func TestGenerateNLBConfig_TCPHealthCheck(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-nlb001",
|
||||
Name: "my-nlb",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
}
|
||||
|
||||
tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-tg/tg-tcp001"
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/net/my-nlb/lb-nlb001/lst-tcp001",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 5432,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArn},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: {
|
||||
TargetGroupArn: tgArn,
|
||||
Name: "tcp-tg",
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: HealthCheckConfig{
|
||||
Protocol: ProtocolTCP,
|
||||
Port: "traffic-port",
|
||||
IntervalSeconds: 30,
|
||||
TimeoutSeconds: 10,
|
||||
HealthyThreshold: 3,
|
||||
UnhealthyThreshold: 3,
|
||||
},
|
||||
Targets: []Target{
|
||||
{Id: "i-tcp001", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
|
||||
{Id: "i-tcp002", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.1.5")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify TCP mode
|
||||
assert.Contains(t, config, "mode tcp")
|
||||
assert.Contains(t, config, "option tcplog")
|
||||
assert.NotContains(t, config, "mode http")
|
||||
assert.NotContains(t, config, "option httplog")
|
||||
|
||||
// Verify socket name uses generic lb- prefix (matches agent expectation)
|
||||
assert.Contains(t, config, "lb-lb-nlb001.sock")
|
||||
|
||||
// Verify TCP health check
|
||||
assert.Contains(t, config, "option tcp-check")
|
||||
assert.NotContains(t, config, "option httpchk")
|
||||
|
||||
// Verify frontend/backend
|
||||
assert.Contains(t, config, "bind *:5432")
|
||||
assert.Contains(t, config, "10.0.1.10:5432")
|
||||
assert.Contains(t, config, "10.0.1.11:5432")
|
||||
assert.Contains(t, config, "check inter 30s fall 3 rise 3")
|
||||
}
|
||||
|
||||
func TestGenerateNLBConfig_HTTPHealthCheckOnTCPTargetGroup(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-nlb002",
|
||||
Name: "nlb-http-hc",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
}
|
||||
|
||||
tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-http-hc/tg-tcp002"
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-nlb-http",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 8080,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArn},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: {
|
||||
TargetGroupArn: tgArn,
|
||||
Name: "tcp-http-hc",
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: HealthCheckConfig{
|
||||
Protocol: ProtocolHTTP,
|
||||
Port: "traffic-port",
|
||||
Path: "/healthz",
|
||||
Matcher: "200-299",
|
||||
IntervalSeconds: 10,
|
||||
TimeoutSeconds: 5,
|
||||
HealthyThreshold: 2,
|
||||
UnhealthyThreshold: 3,
|
||||
},
|
||||
Targets: []Target{
|
||||
{Id: "i-http-hc1", Port: 8080, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.2.1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// TCP mode but HTTP health check
|
||||
assert.Contains(t, config, "mode tcp")
|
||||
assert.Contains(t, config, "option httpchk GET /healthz")
|
||||
assert.Contains(t, config, "http-check expect status 200-299")
|
||||
assert.NotContains(t, config, "option tcp-check")
|
||||
|
||||
assert.Contains(t, config, "10.0.2.10:8080")
|
||||
assert.Contains(t, config, "check inter 10s fall 3 rise 2")
|
||||
}
|
||||
|
||||
func TestGenerateNLBConfig_MixedBackends(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-nlb003",
|
||||
Name: "nlb-mixed",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
}
|
||||
|
||||
tgArnTCP := "arn:tg-tcp-mixed"
|
||||
tgArnHTTP := "arn:tg-http-mixed"
|
||||
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-tcp-mixed",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 5432,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArnTCP},
|
||||
},
|
||||
},
|
||||
{
|
||||
ListenerArn: "arn:lst-http-mixed",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 8080,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArnHTTP},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArnTCP: {
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: HealthCheckConfig{
|
||||
Protocol: ProtocolTCP,
|
||||
IntervalSeconds: 30,
|
||||
HealthyThreshold: 3,
|
||||
UnhealthyThreshold: 3,
|
||||
},
|
||||
Targets: []Target{
|
||||
{Id: "i-db1", Port: 5432, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy},
|
||||
},
|
||||
},
|
||||
tgArnHTTP: {
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: HealthCheckConfig{
|
||||
Protocol: ProtocolHTTP,
|
||||
Path: "/ready",
|
||||
Matcher: "200",
|
||||
IntervalSeconds: 15,
|
||||
HealthyThreshold: 2,
|
||||
UnhealthyThreshold: 2,
|
||||
},
|
||||
Targets: []Target{
|
||||
{Id: "i-app1", Port: 8080, PrivateIP: "10.0.3.20", HealthState: TargetHealthHealthy},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.3.1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Overall TCP mode
|
||||
assert.Contains(t, config, "mode tcp")
|
||||
|
||||
// First backend: TCP health check
|
||||
assert.Contains(t, config, "option tcp-check")
|
||||
|
||||
// Second backend: HTTP health check
|
||||
assert.Contains(t, config, "option httpchk GET /ready")
|
||||
assert.Contains(t, config, "http-check expect status 200")
|
||||
|
||||
// Both servers present
|
||||
assert.Contains(t, config, "10.0.3.10:5432")
|
||||
assert.Contains(t, config, "10.0.3.20:8080")
|
||||
}
|
||||
|
||||
func TestGenerateNLBConfig_NoListeners(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-nlb-empty",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, nil, nil, nil, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "mode tcp")
|
||||
assert.Contains(t, config, "option tcplog")
|
||||
assert.NotContains(t, config, "frontend")
|
||||
assert.NotContains(t, config, "backend")
|
||||
}
|
||||
|
||||
func TestGenerateNLBConfig_SkipsDrainingTargets(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-nlb-drain",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
}
|
||||
|
||||
tgArn := "arn:tg-nlb-drain"
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-nlb-drain",
|
||||
Protocol: ProtocolTCP,
|
||||
Port: 3306,
|
||||
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: {
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: DefaultNLBHealthCheck(),
|
||||
Targets: []Target{
|
||||
{Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
|
||||
{Id: "i-draining", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
|
||||
{Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "10.0.0.1:3306")
|
||||
assert.NotContains(t, config, "10.0.0.2") // draining
|
||||
assert.NotContains(t, config, "i-no-ip") // no IP
|
||||
}
|
||||
|
||||
func TestGenerateALBConfig_StillWorks(t *testing.T) {
|
||||
// Regression: ALB (no Type set) should still generate HTTP-mode config
|
||||
lb := &LoadBalancerRecord{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/lbagent"
|
||||
)
|
||||
|
||||
// NLB (L4) data plane. HAProxy load-balances no UDP in any release, so NLB
|
||||
// listeners (TCP/UDP/TLS/TCP_UDP) render an nginx `stream` config instead;
|
||||
// ALBs stay on HAProxy. See docs/development/feature/nlb-nginx-l4-dataplane.md.
|
||||
//
|
||||
// Health checks are passive only (max_fails / fail_timeout) — OSS nginx stream
|
||||
// has no active upstream probing.
|
||||
|
||||
const nginxStreamConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
|
||||
load_module /usr/lib/nginx/modules/ngx_stream_module.so;
|
||||
user root;
|
||||
worker_processes auto;
|
||||
error_log stderr;
|
||||
pid {{.PIDPath}};
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
stream {
|
||||
{{- range .Upstreams}}
|
||||
upstream {{.Name}} {
|
||||
{{- range .Servers}}
|
||||
server {{.Addr}}:{{.Port}} max_fails={{.MaxFails}} fail_timeout={{.FailTimeout}}s;
|
||||
{{- else}}
|
||||
server 127.0.0.1:1 down;
|
||||
{{- end}}
|
||||
}
|
||||
{{end}}
|
||||
{{- range .Servers}}
|
||||
server {
|
||||
listen {{.Port}}{{if .UDP}} udp{{end}}{{if .SSL}} ssl{{end}};
|
||||
{{- if .SSL}}
|
||||
ssl_certificate {{.CertPath}};
|
||||
ssl_certificate_key {{.CertPath}};
|
||||
{{- end}}
|
||||
{{- if .UDP}}
|
||||
proxy_responses 1;
|
||||
proxy_timeout 10s;
|
||||
{{- end}}
|
||||
proxy_pass {{.Upstream}};
|
||||
}
|
||||
{{end}}}
|
||||
`
|
||||
|
||||
var nginxStreamTmpl = template.Must(
|
||||
template.New("nginx-stream").Parse(nginxStreamConfigTemplate),
|
||||
)
|
||||
|
||||
// nginxConfig is the render model for the NLB nginx `stream` config.
|
||||
type nginxConfig struct {
|
||||
PIDPath string
|
||||
Upstreams []nginxUpstream
|
||||
Servers []nginxServer
|
||||
}
|
||||
|
||||
// nginxUpstream is one target group's backend pool. An empty pool renders a
|
||||
// single permanently-down placeholder so nginx (which requires >=1 server)
|
||||
// still validates.
|
||||
type nginxUpstream struct {
|
||||
Name string
|
||||
Servers []nginxUpstreamServer
|
||||
}
|
||||
|
||||
type nginxUpstreamServer struct {
|
||||
Addr string
|
||||
Port int64
|
||||
MaxFails int64
|
||||
FailTimeout int64
|
||||
}
|
||||
|
||||
// nginxServer is one listener's `server` block. TCP_UDP listeners expand to two
|
||||
// blocks on the same port (one TCP, one UDP).
|
||||
type nginxServer struct {
|
||||
Port int64
|
||||
UDP bool
|
||||
SSL bool
|
||||
CertPath string
|
||||
Upstream string
|
||||
}
|
||||
|
||||
// engineForType reports the data-plane engine for a load balancer type: nginx
|
||||
// for NLBs (L4), HAProxy for everything else (ALB / L7).
|
||||
func engineForType(lbType string) string {
|
||||
if lbType == LoadBalancerTypeNetwork {
|
||||
return EngineNginx
|
||||
}
|
||||
return EngineHAProxy
|
||||
}
|
||||
|
||||
// generateNLBStreamConfig builds an nginx `stream` config for an NLB. Secure
|
||||
// (TLS) listeners terminate TLS at nginx and forward plaintext to the target;
|
||||
// the returned certFiles map carries each cert path → combined PEM for delivery
|
||||
// to the LB agent. bindAddr is unused (nginx listens on all addresses, matching
|
||||
// the HAProxy `bind *` behaviour).
|
||||
func generateNLBStreamConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, _ string, certPEMByArn map[string]string) (string, map[string]string, error) {
|
||||
cfg := nginxConfig{PIDPath: lbagent.NginxPIDPath}
|
||||
var certFiles map[string]string
|
||||
upstreamAdded := make(map[string]struct{})
|
||||
|
||||
addUpstream := func(tgArn string) string {
|
||||
name := sanitizeName("us", tgArn)
|
||||
if _, ok := upstreamAdded[name]; ok {
|
||||
return name
|
||||
}
|
||||
us := nginxUpstream{Name: name}
|
||||
if tg, ok := tgByArn[tgArn]; ok {
|
||||
for _, t := range tg.Targets {
|
||||
if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
|
||||
continue
|
||||
}
|
||||
us.Servers = append(us.Servers, nginxUpstreamServer{
|
||||
Addr: t.PrivateIP,
|
||||
Port: t.Port,
|
||||
MaxFails: tg.HealthCheck.UnhealthyThreshold,
|
||||
FailTimeout: tg.HealthCheck.IntervalSeconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
cfg.Upstreams = append(cfg.Upstreams, us)
|
||||
upstreamAdded[name] = struct{}{}
|
||||
return name
|
||||
}
|
||||
|
||||
for _, l := range listeners {
|
||||
if len(l.DefaultActions) == 0 {
|
||||
continue
|
||||
}
|
||||
da := l.DefaultActions[0]
|
||||
// NLB default actions are forward-only (no redirect / fixed-response).
|
||||
if da.Type != ActionTypeForward || da.TargetGroupArn == "" {
|
||||
continue
|
||||
}
|
||||
upstream := addUpstream(da.TargetGroupArn)
|
||||
|
||||
var certPath string
|
||||
if protocolRequiresCert(l.Protocol) {
|
||||
pem, _ := resolveFrontendCert(lb, l, certPEMByArn)
|
||||
if pem != "" {
|
||||
certPath = nginxCertPath(lb, l)
|
||||
if certFiles == nil {
|
||||
certFiles = make(map[string]string)
|
||||
}
|
||||
certFiles[certPath] = pem
|
||||
}
|
||||
}
|
||||
|
||||
switch l.Protocol {
|
||||
case ProtocolUDP:
|
||||
cfg.Servers = append(cfg.Servers, nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
|
||||
case ProtocolTCPUDP:
|
||||
cfg.Servers = append(cfg.Servers,
|
||||
nginxServer{Port: l.Port, Upstream: upstream},
|
||||
nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
|
||||
default: // TCP, TLS
|
||||
cfg.Servers = append(cfg.Servers, nginxServer{
|
||||
Port: l.Port,
|
||||
SSL: certPath != "",
|
||||
CertPath: certPath,
|
||||
Upstream: upstream,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := nginxStreamTmpl.Execute(&buf, cfg); err != nil {
|
||||
return "", nil, fmt.Errorf("execute nginx stream template: %w", err)
|
||||
}
|
||||
return buf.String(), certFiles, nil
|
||||
}
|
||||
|
||||
// nginxCertPath is the stable absolute PEM path for a TLS listener under the
|
||||
// agent's nginx cert dir. Daemon-rendered and agent-written paths must match.
|
||||
func nginxCertPath(lb *LoadBalancerRecord, l *ListenerRecord) string {
|
||||
return filepath.Join(lbagent.NginxCertDir, fmt.Sprintf("lb-%s-%s.pem", lb.LoadBalancerID, l.ListenerID))
|
||||
}
|
||||
|
||||
// buildNLBHealthTargets computes the active health-check targets for an NLB's
|
||||
// backends. nginx `stream` has no active upstream probing, so the agent probes
|
||||
// these and reports the results via heartbeat. ServerName matches the health
|
||||
// checker's key (sanitizeName("srv", target.Id)); the probe port resolves
|
||||
// "traffic-port" to the target's port. Sorted by server name for deterministic
|
||||
// delivery. The tgByArn map is the same set used to render the upstreams.
|
||||
func buildNLBHealthTargets(tgByArn map[string]*TargetGroupRecord) []HealthTargetSpec {
|
||||
var out []HealthTargetSpec
|
||||
for _, tg := range tgByArn {
|
||||
for _, t := range tg.Targets {
|
||||
if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
|
||||
continue
|
||||
}
|
||||
out = append(out, HealthTargetSpec{
|
||||
ServerName: sanitizeName("srv", t.Id),
|
||||
Address: fmt.Sprintf("%s:%d", t.PrivateIP, nlbHealthCheckPort(tg, t)),
|
||||
Protocol: nlbHealthCheckProtocol(tg),
|
||||
Path: tg.HealthCheck.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ServerName < out[j].ServerName })
|
||||
return out
|
||||
}
|
||||
|
||||
// nlbHealthCheckProtocol returns the target group's health-check protocol,
|
||||
// defaulting to the NLB default (TCP) when unset.
|
||||
func nlbHealthCheckProtocol(tg *TargetGroupRecord) string {
|
||||
if tg.HealthCheck.Protocol != "" {
|
||||
return tg.HealthCheck.Protocol
|
||||
}
|
||||
return DefaultNLBHealthCheckProtocol
|
||||
}
|
||||
|
||||
// nlbHealthCheckPort resolves the probe port: an explicit numeric health-check
|
||||
// port wins, otherwise "traffic-port" maps to the target's port (or the target
|
||||
// group's default port when the target has none).
|
||||
func nlbHealthCheckPort(tg *TargetGroupRecord, t Target) int64 {
|
||||
if p := tg.HealthCheck.Port; p != "" && p != DefaultHealthCheckPort {
|
||||
if n, err := strconv.ParseInt(p, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if t.Port != 0 {
|
||||
return t.Port
|
||||
}
|
||||
return tg.Port
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func nlbHealthCheck() HealthCheckConfig {
|
||||
return HealthCheckConfig{
|
||||
Protocol: ProtocolTCP,
|
||||
Port: "traffic-port",
|
||||
IntervalSeconds: 30,
|
||||
TimeoutSeconds: 10,
|
||||
HealthyThreshold: 3,
|
||||
UnhealthyThreshold: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func tcpListener(arn, listenerID, protocol string, port int64, tgArn string) *ListenerRecord {
|
||||
return &ListenerRecord{
|
||||
ListenerArn: arn,
|
||||
ListenerID: listenerID,
|
||||
Protocol: protocol,
|
||||
Port: port,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArn},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nlbTG(arn string, targets ...Target) *TargetGroupRecord {
|
||||
return &TargetGroupRecord{
|
||||
TargetGroupArn: arn,
|
||||
Protocol: ProtocolTCP,
|
||||
HealthCheck: nlbHealthCheck(),
|
||||
Targets: targets,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_TCP(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-tcp", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
|
||||
listeners := []*ListenerRecord{tcpListener("arn:lst-tcp", "lst-tcp", ProtocolTCP, 5432, tgArn)}
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn,
|
||||
Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
|
||||
Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
|
||||
),
|
||||
}
|
||||
|
||||
config, certs, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.1.5", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, certs)
|
||||
|
||||
assert.Contains(t, config, "load_module /usr/lib/nginx/modules/ngx_stream_module.so;") // stream is a separate Alpine module
|
||||
assert.Contains(t, config, "stream {")
|
||||
assert.NotContains(t, config, "mode tcp") // not HAProxy
|
||||
assert.Contains(t, config, "listen 5432;")
|
||||
assert.NotContains(t, config, "listen 5432 udp")
|
||||
assert.Contains(t, config, "server 10.0.1.10:5432 max_fails=3 fail_timeout=30s;")
|
||||
assert.Contains(t, config, "server 10.0.1.11:5432 max_fails=3 fail_timeout=30s;")
|
||||
assert.Contains(t, config, "proxy_pass us_tg-db;")
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_UDP(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-udp", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:tg-dns"
|
||||
listeners := []*ListenerRecord{tcpListener("arn:lst-udp", "lst-udp", ProtocolUDP, 53, tgArn)}
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn, Target{Id: "i-dns", Port: 53, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy}),
|
||||
}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.2.1", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "listen 53 udp;")
|
||||
assert.Contains(t, config, "proxy_responses 1;")
|
||||
assert.Contains(t, config, "10.0.2.10:53")
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_TLS(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-tls", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:tg-tls"
|
||||
lst := tcpListener("arn:lst-tls", "lst-tls", ProtocolTLS, 443, tgArn)
|
||||
lst.Certificates = []ListenerCertificate{{CertificateArn: "arn:cert-1", IsDefault: true}}
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn, Target{Id: "i-tls", Port: 8443, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy}),
|
||||
}
|
||||
certPEM := map[string]string{"arn:cert-1": "LEAF\nCHAIN\nKEY\n"}
|
||||
|
||||
config, certs, err := GenerateHAProxyConfigWithCerts(lb, []*ListenerRecord{lst}, tgByArn, nil, "10.0.3.1", certPEM)
|
||||
require.NoError(t, err)
|
||||
|
||||
wantPath := "/etc/nginx/certs/lb-lb-tls-lst-tls.pem"
|
||||
assert.Contains(t, config, "listen 443 ssl;")
|
||||
assert.Contains(t, config, "ssl_certificate "+wantPath+";")
|
||||
assert.Contains(t, config, "ssl_certificate_key "+wantPath+";")
|
||||
require.Contains(t, certs, wantPath)
|
||||
assert.Equal(t, "LEAF\nCHAIN\nKEY\n", certs[wantPath])
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_TCPUDP(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-both", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:tg-both"
|
||||
listeners := []*ListenerRecord{tcpListener("arn:lst-both", "lst-both", ProtocolTCPUDP, 5000, tgArn)}
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn, Target{Id: "i-both", Port: 5000, PrivateIP: "10.0.4.10", HealthState: TargetHealthHealthy}),
|
||||
}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.4.1", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two server blocks on the same port: one TCP, one UDP.
|
||||
assert.Contains(t, config, "listen 5000;")
|
||||
assert.Contains(t, config, "listen 5000 udp;")
|
||||
assert.Equal(t, 2, strings.Count(config, "listen 5000"))
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_SkipsDrainingAndNoIP(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-drain", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:tg-drain"
|
||||
listeners := []*ListenerRecord{tcpListener("arn:lst-drain", "lst-drain", ProtocolTCP, 3306, tgArn)}
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn,
|
||||
Target{Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
|
||||
Target{Id: "i-drain", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
|
||||
Target{Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
|
||||
),
|
||||
}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "10.0.0.1:3306")
|
||||
assert.NotContains(t, config, "10.0.0.2")
|
||||
}
|
||||
|
||||
func TestGenerateNLBStream_EmptyUpstreamPlaceholder(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty", Type: LoadBalancerTypeNetwork}
|
||||
tgArn := "arn:tg-empty"
|
||||
listeners := []*ListenerRecord{tcpListener("arn:lst-empty", "lst-empty", ProtocolTCP, 80, tgArn)}
|
||||
tgByArn := map[string]*TargetGroupRecord{tgArn: nlbTG(tgArn)}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// nginx requires >=1 server per upstream; an empty TG renders a down placeholder.
|
||||
assert.Contains(t, config, "server 127.0.0.1:1 down;")
|
||||
}
|
||||
|
||||
func TestBuildNLBHealthTargets_Resolution(t *testing.T) {
|
||||
tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: nlbTG(tgArn,
|
||||
Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
|
||||
Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
|
||||
Target{Id: "i-drain", Port: 5432, PrivateIP: "10.0.1.12", HealthState: TargetHealthDraining},
|
||||
Target{Id: "i-no-ip", Port: 5432, PrivateIP: "", HealthState: TargetHealthInitial},
|
||||
),
|
||||
}
|
||||
|
||||
got := buildNLBHealthTargets(tgByArn)
|
||||
require.Len(t, got, 2) // draining + no-IP excluded
|
||||
|
||||
// Sorted by server name for deterministic delivery.
|
||||
assert.Equal(t, sanitizeName("srv", "i-1"), got[0].ServerName)
|
||||
assert.Equal(t, sanitizeName("srv", "i-2"), got[1].ServerName)
|
||||
assert.Equal(t, "10.0.1.10:5432", got[0].Address) // traffic-port -> target port
|
||||
assert.Equal(t, ProtocolTCP, got[0].Protocol)
|
||||
}
|
||||
|
||||
func TestBuildNLBHealthTargets_ExplicitHCPortAndHTTP(t *testing.T) {
|
||||
tgArn := "arn:tg-http"
|
||||
tg := nlbTG(tgArn, Target{Id: "i-1", Port: 8080, PrivateIP: "10.0.0.9", HealthState: TargetHealthHealthy})
|
||||
tg.HealthCheck.Protocol = ProtocolHTTP
|
||||
tg.HealthCheck.Port = "9000"
|
||||
tg.HealthCheck.Path = "/healthz"
|
||||
tgByArn := map[string]*TargetGroupRecord{tgArn: tg}
|
||||
|
||||
got := buildNLBHealthTargets(tgByArn)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "10.0.0.9:9000", got[0].Address) // explicit numeric HC port wins
|
||||
assert.Equal(t, ProtocolHTTP, got[0].Protocol)
|
||||
assert.Equal(t, "/healthz", got[0].Path)
|
||||
}
|
||||
@@ -608,6 +608,13 @@ func (s *ELBv2ServiceImpl) updateStoredConfig(lb *LoadBalancerRecord) error {
|
||||
lb.ConfigText = configContent
|
||||
lb.ConfigHash = hash
|
||||
lb.CertFiles = certFiles
|
||||
// NLBs run nginx, which has no active upstream probing — the agent
|
||||
// probes these targets and reports health. ALBs report via HAProxy stats.
|
||||
if lb.Type == LoadBalancerTypeNetwork {
|
||||
lb.HealthTargets = buildNLBHealthTargets(tgByArn)
|
||||
} else {
|
||||
lb.HealthTargets = nil
|
||||
}
|
||||
|
||||
if err := s.store.PutLoadBalancer(lb); err != nil {
|
||||
slog.Error("updateStoredConfig: failed to persist LB", "lbId", lb.LoadBalancerID, "err", err)
|
||||
@@ -828,12 +835,32 @@ func (s *ELBv2ServiceImpl) GetLBConfig(input *GetLBConfigInput, accountID string
|
||||
}
|
||||
|
||||
return &GetLBConfigOutput{
|
||||
ConfigText: aws.String(lb.ConfigText),
|
||||
ConfigHash: aws.String(lb.ConfigHash),
|
||||
CertFiles: certFilesToSDK(lb.CertFiles),
|
||||
ConfigText: aws.String(lb.ConfigText),
|
||||
ConfigHash: aws.String(lb.ConfigHash),
|
||||
CertFiles: certFilesToSDK(lb.CertFiles),
|
||||
Engine: aws.String(engineForType(lb.Type)),
|
||||
HealthTargets: healthTargetsToSDK(lb.HealthTargets),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// healthTargetsToSDK converts the stored health-target specs into the SDK
|
||||
// HealthTarget slice delivered to the nginx agent.
|
||||
func healthTargetsToSDK(specs []HealthTargetSpec) []*HealthTarget {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*HealthTarget, 0, len(specs))
|
||||
for i := range specs {
|
||||
out = append(out, &HealthTarget{
|
||||
ServerName: aws.String(specs[i].ServerName),
|
||||
Address: aws.String(specs[i].Address),
|
||||
Protocol: aws.String(specs[i].Protocol),
|
||||
Path: aws.String(specs[i].Path),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// certFilesToSDK converts the stored path→PEM map into the sorted CertFile
|
||||
// slice delivered to the agent. Sorted for deterministic output.
|
||||
func certFilesToSDK(certFiles map[string]string) []*CertFile {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -2048,6 +2049,91 @@ func TestGetLBConfig_ReturnsStoredConfig(t *testing.T) {
|
||||
assert.Equal(t, "deadbeef", *out.ConfigHash)
|
||||
}
|
||||
|
||||
func TestGetLBConfig_DeliversHealthTargetsForNLB(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb-hc/lb-nlbhc",
|
||||
LoadBalancerID: "lb-nlbhc",
|
||||
Name: "nlb-hc",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
State: StateActive,
|
||||
ConfigText: "stream {}\n",
|
||||
ConfigHash: "hc1",
|
||||
AccountID: testAccountID,
|
||||
HealthTargets: []HealthTargetSpec{
|
||||
{ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolTCP},
|
||||
},
|
||||
}
|
||||
require.NoError(t, svc.store.PutLoadBalancer(lb))
|
||||
|
||||
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-nlbhc")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, EngineNginx, *out.Engine)
|
||||
require.Len(t, out.HealthTargets, 1)
|
||||
assert.Equal(t, "srv_i-1", *out.HealthTargets[0].ServerName)
|
||||
assert.Equal(t, "10.0.0.1:80", *out.HealthTargets[0].Address)
|
||||
assert.Equal(t, ProtocolTCP, *out.HealthTargets[0].Protocol)
|
||||
}
|
||||
|
||||
func TestGetLBConfig_HealthTargetsWireShape(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
require.NoError(t, svc.store.PutLoadBalancer(&LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-wire",
|
||||
Type: LoadBalancerTypeNetwork,
|
||||
AccountID: testAccountID,
|
||||
ConfigText: "stream {}\n",
|
||||
ConfigHash: "w1",
|
||||
HealthTargets: []HealthTargetSpec{
|
||||
{ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolHTTP, Path: "/healthz"},
|
||||
},
|
||||
}))
|
||||
|
||||
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-wire")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Confirm the marshalled member shape matches what the lb-agent parses
|
||||
// (GetLBConfigResult>HealthTargets>member>{ServerName,Address,Protocol,Path}).
|
||||
payload := utils.GenerateIAMXMLPayload("GetLBConfig", *out)
|
||||
xmlBytes, err := utils.MarshalToXML(payload)
|
||||
require.NoError(t, err)
|
||||
var parsed struct {
|
||||
Members []struct {
|
||||
ServerName string `xml:"ServerName"`
|
||||
Address string `xml:"Address"`
|
||||
Protocol string `xml:"Protocol"`
|
||||
Path string `xml:"Path"`
|
||||
} `xml:"GetLBConfigResult>HealthTargets>member"`
|
||||
}
|
||||
require.NoError(t, xml.Unmarshal(xmlBytes, &parsed))
|
||||
require.Len(t, parsed.Members, 1)
|
||||
assert.Equal(t, "srv_i-1", parsed.Members[0].ServerName)
|
||||
assert.Equal(t, "10.0.0.1:80", parsed.Members[0].Address)
|
||||
assert.Equal(t, ProtocolHTTP, parsed.Members[0].Protocol)
|
||||
assert.Equal(t, "/healthz", parsed.Members[0].Path)
|
||||
}
|
||||
|
||||
func TestGetLBConfig_NoHealthTargetsForALB(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb-hc/lb-albhc",
|
||||
LoadBalancerID: "lb-albhc",
|
||||
Name: "alb-hc",
|
||||
Type: LoadBalancerTypeApplication,
|
||||
State: StateActive,
|
||||
ConfigText: "global\n",
|
||||
ConfigHash: "hc2",
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
require.NoError(t, svc.store.PutLoadBalancer(lb))
|
||||
|
||||
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-albhc")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, EngineHAProxy, *out.Engine)
|
||||
assert.Empty(t, out.HealthTargets) // ALBs report health via HAProxy stats
|
||||
}
|
||||
|
||||
func TestGetLBConfig_MissingLBID(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ const (
|
||||
ProtocolTLS = "TLS"
|
||||
ProtocolTCPUDP = "TCP_UDP"
|
||||
|
||||
// Data-plane engines. ALBs (L7) run HAProxy; NLBs (L4) run nginx `stream`
|
||||
// because HAProxy cannot load-balance UDP. The agent learns which engine to
|
||||
// run from the GetLBConfig response.
|
||||
EngineHAProxy = "haproxy"
|
||||
EngineNginx = "nginx"
|
||||
|
||||
// DefaultSslPolicy is applied to a secure listener when the caller does
|
||||
// not specify an SslPolicy.
|
||||
DefaultSslPolicy = "ELBSecurityPolicy-2016-08"
|
||||
@@ -94,31 +100,43 @@ const (
|
||||
|
||||
// LoadBalancerRecord represents a stored load balancer (ALB or NLB).
|
||||
type LoadBalancerRecord struct {
|
||||
LoadBalancerArn string `json:"load_balancer_arn"`
|
||||
LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
|
||||
DNSName string `json:"dns_name"`
|
||||
Name string `json:"name"`
|
||||
Scheme string `json:"scheme"` // "internet-facing" or "internal"
|
||||
Type string `json:"type"` // Always "application"
|
||||
State string `json:"state"` // "provisioning", "active", "failed"
|
||||
VpcId string `json:"vpc_id"`
|
||||
SecurityGroups []string `json:"security_groups"`
|
||||
Subnets []string `json:"subnets"`
|
||||
AvailZones []AvailZoneInfo `json:"availability_zones"`
|
||||
ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
|
||||
InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
|
||||
VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
|
||||
ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
|
||||
ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText + cert material
|
||||
CertFiles map[string]string `json:"cert_files,omitempty"` // Absolute path → combined PEM (cert+chain+key), delivered with config
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
|
||||
HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
|
||||
NodeID string `json:"node_id"` // Daemon node running this ALB
|
||||
IPAddressType string `json:"ip_address_type"` // "ipv4"
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
AccountID string `json:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LoadBalancerArn string `json:"load_balancer_arn"`
|
||||
LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
|
||||
DNSName string `json:"dns_name"`
|
||||
Name string `json:"name"`
|
||||
Scheme string `json:"scheme"` // "internet-facing" or "internal"
|
||||
Type string `json:"type"` // Always "application"
|
||||
State string `json:"state"` // "provisioning", "active", "failed"
|
||||
VpcId string `json:"vpc_id"`
|
||||
SecurityGroups []string `json:"security_groups"`
|
||||
Subnets []string `json:"subnets"`
|
||||
AvailZones []AvailZoneInfo `json:"availability_zones"`
|
||||
ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
|
||||
InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
|
||||
VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
|
||||
ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
|
||||
ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText + cert material
|
||||
CertFiles map[string]string `json:"cert_files,omitempty"` // Absolute path → combined PEM (cert+chain+key), delivered with config
|
||||
HealthTargets []HealthTargetSpec `json:"health_targets,omitempty"` // NLB only: backends the nginx agent actively probes (HAProxy reports its own)
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
|
||||
HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
|
||||
NodeID string `json:"node_id"` // Daemon node running this ALB
|
||||
IPAddressType string `json:"ip_address_type"` // "ipv4"
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
AccountID string `json:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// HealthTargetSpec is a backend the nginx agent actively health-checks. It is
|
||||
// computed when the NLB config is stored and delivered to the agent via
|
||||
// GetLBConfig. ServerName matches the health checker's key
|
||||
// (sanitizeName("srv", target.Id)).
|
||||
type HealthTargetSpec struct {
|
||||
ServerName string `json:"server_name"`
|
||||
Address string `json:"address"` // ip:port to probe
|
||||
Protocol string `json:"protocol"` // TCP | HTTP | HTTPS
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// AvailZoneInfo tracks subnet-to-AZ mapping for a load balancer.
|
||||
|
||||
+111
-34
@@ -37,10 +37,27 @@ const (
|
||||
// rejects any delivered path that escapes it.
|
||||
CertDir = "/etc/haproxy/certs"
|
||||
|
||||
// Nginx paths for the NLB (L4) data plane. NLBs render an nginx `stream`
|
||||
// config because HAProxy cannot load-balance UDP; ALBs keep using the
|
||||
// HAProxy paths above. NginxCertDir holds TLS PEM files for TLS listeners
|
||||
// (referenced by both ssl_certificate and ssl_certificate_key).
|
||||
NginxConfigPath = "/etc/nginx/nginx.conf"
|
||||
NginxPIDPath = "/run/nginx.pid"
|
||||
NginxCertDir = "/etc/nginx/certs"
|
||||
|
||||
// pollInterval is how often the agent sends a heartbeat.
|
||||
pollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// Data-plane engines, duplicated from handlers/elbv2 to avoid an import cycle
|
||||
// (handlers/elbv2 imports this package for path constants). The agent runs
|
||||
// HAProxy for ALBs and nginx `stream` for NLBs, selected by the GetLBConfig
|
||||
// response.
|
||||
const (
|
||||
EngineHAProxy = "haproxy"
|
||||
EngineNginx = "nginx"
|
||||
)
|
||||
|
||||
// Agent manages HAProxy configuration inside an LB VM by polling the gateway.
|
||||
type Agent struct {
|
||||
lbID string
|
||||
@@ -56,12 +73,17 @@ type Agent struct {
|
||||
client *http.Client
|
||||
|
||||
localConfigHash string
|
||||
engine string // data-plane engine of the last applied config
|
||||
healthTargets []HealthTarget // active probe targets (nginx/NLB only)
|
||||
stopCh chan struct{}
|
||||
|
||||
// For testing: override the reload function.
|
||||
reloadFn func(configPath, pidPath string) error
|
||||
// For testing: override the reload functions (HAProxy / nginx).
|
||||
reloadFn func(configPath, pidPath string) error
|
||||
reloadNginxFn func(configPath, pidPath string) error
|
||||
// For testing: override the stats query function.
|
||||
statsFn func(socketPath string) ([]ServerStatus, error)
|
||||
// For testing: override the active health prober (nginx/NLB).
|
||||
probeFn func(targets []HealthTarget) []ServerStatus
|
||||
}
|
||||
|
||||
// New creates a new LB agent for the given load balancer.
|
||||
@@ -93,19 +115,21 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
lbID: lbID,
|
||||
gatewayURL: strings.TrimRight(gatewayURL, "/"),
|
||||
region: region,
|
||||
configPath: DefaultConfigPath,
|
||||
pidPath: DefaultPIDPath,
|
||||
certDir: CertDir,
|
||||
socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
client: client,
|
||||
stopCh: make(chan struct{}),
|
||||
reloadFn: reloadHAProxy,
|
||||
statsFn: queryHAProxyStats,
|
||||
lbID: lbID,
|
||||
gatewayURL: strings.TrimRight(gatewayURL, "/"),
|
||||
region: region,
|
||||
configPath: DefaultConfigPath,
|
||||
pidPath: DefaultPIDPath,
|
||||
certDir: CertDir,
|
||||
socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
client: client,
|
||||
stopCh: make(chan struct{}),
|
||||
reloadFn: reloadHAProxy,
|
||||
reloadNginxFn: reloadNginx,
|
||||
statsFn: queryHAProxyStats,
|
||||
probeFn: probeHealthTargets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -141,9 +165,17 @@ func (a *Agent) Stop() {
|
||||
|
||||
// tick runs one heartbeat cycle: send health, check config hash, fetch if changed.
|
||||
func (a *Agent) tick() {
|
||||
servers, err := a.statsFn(a.socketPath)
|
||||
if err != nil {
|
||||
slog.Warn("HAProxy stats unavailable", "err", err)
|
||||
// nginx (NLB) has no per-server stats socket — the agent actively probes
|
||||
// the delivered health targets instead of reading HAProxy stats.
|
||||
var servers []ServerStatus
|
||||
if a.engine == EngineNginx {
|
||||
servers = a.probeFn(a.healthTargets)
|
||||
} else {
|
||||
var statsErr error
|
||||
servers, statsErr = a.statsFn(a.socketPath)
|
||||
if statsErr != nil {
|
||||
slog.Warn("HAProxy stats unavailable", "err", statsErr)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.sendHeartbeat(servers)
|
||||
@@ -179,10 +211,21 @@ type certFile struct {
|
||||
|
||||
// configResponse is the parsed XML response from GetLBConfig.
|
||||
type configResponse struct {
|
||||
XMLName xml.Name `xml:"GetLBConfigResponse"`
|
||||
ConfigText string `xml:"GetLBConfigResult>ConfigText"`
|
||||
ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
|
||||
CertFiles []certFile `xml:"GetLBConfigResult>CertFiles>member"`
|
||||
XMLName xml.Name `xml:"GetLBConfigResponse"`
|
||||
ConfigText string `xml:"GetLBConfigResult>ConfigText"`
|
||||
ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
|
||||
Engine string `xml:"GetLBConfigResult>Engine"`
|
||||
CertFiles []certFile `xml:"GetLBConfigResult>CertFiles>member"`
|
||||
HealthTargets []HealthTarget `xml:"GetLBConfigResult>HealthTargets>member"`
|
||||
}
|
||||
|
||||
// enginePaths returns the data-plane file paths and reload function for the
|
||||
// given engine, defaulting to HAProxy when the gateway omits Engine.
|
||||
func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string, reload func(string, string) error) {
|
||||
if engine == EngineNginx {
|
||||
return NginxConfigPath, NginxPIDPath, NginxCertDir, a.reloadNginxFn
|
||||
}
|
||||
return a.configPath, a.pidPath, a.certDir, a.reloadFn
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat with health report to the gateway.
|
||||
@@ -232,22 +275,31 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
return fmt.Errorf("empty config returned")
|
||||
}
|
||||
|
||||
// Cert files must land before the config that references them via
|
||||
// `ssl crt`, otherwise HAProxy fails to start/reload on a missing path.
|
||||
if err := a.writeCertFiles(resp.CertFiles); err != nil {
|
||||
engine := resp.Engine
|
||||
if engine == "" {
|
||||
engine = EngineHAProxy
|
||||
}
|
||||
configPath, pidPath, certDir, reload := a.enginePaths(engine)
|
||||
|
||||
// Cert files must land before the config that references them (via
|
||||
// `ssl crt` / `ssl_certificate`), otherwise the engine fails to
|
||||
// start/reload on a missing path.
|
||||
if err := a.writeCertFiles(certDir, resp.CertFiles); err != nil {
|
||||
return fmt.Errorf("write cert files: %w", err)
|
||||
}
|
||||
|
||||
if err := WriteConfig(a.configPath, resp.ConfigText); err != nil {
|
||||
if err := WriteConfig(configPath, resp.ConfigText); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
if err := a.reloadFn(a.configPath, a.pidPath); err != nil {
|
||||
return fmt.Errorf("reload haproxy: %w", err)
|
||||
if err := reload(configPath, pidPath); err != nil {
|
||||
return fmt.Errorf("reload %s: %w", engine, err)
|
||||
}
|
||||
|
||||
a.engine = engine
|
||||
a.healthTargets = resp.HealthTargets
|
||||
a.localConfigHash = resp.ConfigHash
|
||||
slog.Info("Config applied", "hash", resp.ConfigHash)
|
||||
slog.Info("Config applied", "engine", engine, "hash", resp.ConfigHash, "healthTargets", len(resp.HealthTargets))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,21 +337,21 @@ func (a *Agent) signedPost(params url.Values) ([]byte, error) {
|
||||
}
|
||||
|
||||
// writeCertFiles writes each delivered TLS certificate PEM to its path 0600,
|
||||
// creating the cert dir as needed. Every path is constrained to a.certDir — a
|
||||
// creating certDir as needed. Every path is constrained to certDir — a
|
||||
// delivered path that escapes it (traversal, absolute elsewhere) is rejected
|
||||
// rather than written, so a malformed/hostile response can't drop files across
|
||||
// the filesystem.
|
||||
func (a *Agent) writeCertFiles(certs []certFile) error {
|
||||
func (a *Agent) writeCertFiles(certDir string, certs []certFile) error {
|
||||
if len(certs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(a.certDir, 0o750); err != nil {
|
||||
if err := os.MkdirAll(certDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create cert dir: %w", err)
|
||||
}
|
||||
for _, c := range certs {
|
||||
clean := filepath.Clean(c.Path)
|
||||
if clean != filepath.Join(a.certDir, filepath.Base(clean)) {
|
||||
return fmt.Errorf("cert path %q escapes %s", c.Path, a.certDir)
|
||||
if clean != filepath.Join(certDir, filepath.Base(clean)) {
|
||||
return fmt.Errorf("cert path %q escapes %s", c.Path, certDir)
|
||||
}
|
||||
if c.PEM == "" {
|
||||
return fmt.Errorf("cert %q has empty PEM", c.Path)
|
||||
@@ -357,6 +409,31 @@ func reloadHAProxy(configPath, pidPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadNginx validates then starts or reloads nginx for the NLB data plane.
|
||||
// `nginx -t` fails closed before any reload, mirroring HAProxy's
|
||||
// validate-before-bind guarantee (a bad config never replaces a good one).
|
||||
func reloadNginx(configPath, pidPath string) error {
|
||||
test := exec.Command("nginx", "-t", "-c", configPath)
|
||||
if out, err := test.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("nginx -t: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if readPID(pidPath) > 0 {
|
||||
// Master running: signal a graceful reload.
|
||||
cmd = exec.Command("nginx", "-s", "reload", "-c", configPath)
|
||||
} else {
|
||||
// Fresh start (nginx daemonizes; pid path comes from the config).
|
||||
cmd = exec.Command("nginx", "-c", configPath)
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("nginx: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPID reads the HAProxy PID from the PID file. Returns 0 if unavailable.
|
||||
func readPID(pidPath string) int {
|
||||
data, err := os.ReadFile(pidPath)
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestWriteCertFiles_WritesPEM0600(t *testing.T) {
|
||||
a := newCertTestAgent(t)
|
||||
path := filepath.Join(a.certDir, "lb-1-lst.pem")
|
||||
|
||||
err := a.writeCertFiles([]certFile{{Path: path, PEM: "LEAF\nKEY\n"}})
|
||||
err := a.writeCertFiles(a.certDir, []certFile{{Path: path, PEM: "LEAF\nKEY\n"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -38,7 +38,7 @@ func TestWriteCertFiles_WritesPEM0600(t *testing.T) {
|
||||
|
||||
func TestWriteCertFiles_Empty(t *testing.T) {
|
||||
a := newCertTestAgent(t)
|
||||
assert.NoError(t, a.writeCertFiles(nil))
|
||||
assert.NoError(t, a.writeCertFiles(a.certDir, nil))
|
||||
}
|
||||
|
||||
func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
|
||||
@@ -49,7 +49,7 @@ func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
|
||||
filepath.Join(a.certDir, "sub/dir/x.pem"),
|
||||
}
|
||||
for _, p := range cases {
|
||||
err := a.writeCertFiles([]certFile{{Path: p, PEM: "X"}})
|
||||
err := a.writeCertFiles(a.certDir, []certFile{{Path: p, PEM: "X"}})
|
||||
require.Error(t, err, "path %q must be rejected", p)
|
||||
assert.Contains(t, err.Error(), "escapes")
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
|
||||
|
||||
func TestWriteCertFiles_RejectsEmptyPEM(t *testing.T) {
|
||||
a := newCertTestAgent(t)
|
||||
err := a.writeCertFiles([]certFile{{Path: filepath.Join(a.certDir, "x.pem"), PEM: ""}})
|
||||
err := a.writeCertFiles(a.certDir, []certFile{{Path: filepath.Join(a.certDir, "x.pem"), PEM: ""}})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty PEM")
|
||||
}
|
||||
|
||||
@@ -353,6 +353,100 @@ func TestHeartbeat_StatsError(t *testing.T) {
|
||||
agent.tick()
|
||||
}
|
||||
|
||||
func TestEnginePaths_Nginx(t *testing.T) {
|
||||
agent := newTestAgent(t, "http://example.invalid")
|
||||
var nginxCalled, haproxyCalled bool
|
||||
agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
|
||||
agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
|
||||
|
||||
cfg, pid, certDir, reload := agent.enginePaths(EngineNginx)
|
||||
if cfg != NginxConfigPath || pid != NginxPIDPath || certDir != NginxCertDir {
|
||||
t.Errorf("nginx paths = %q,%q,%q want %q,%q,%q", cfg, pid, certDir, NginxConfigPath, NginxPIDPath, NginxCertDir)
|
||||
}
|
||||
_ = reload("", "")
|
||||
if !nginxCalled || haproxyCalled {
|
||||
t.Errorf("nginx engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePaths_HAProxyDefault(t *testing.T) {
|
||||
agent := newTestAgent(t, "http://example.invalid")
|
||||
var nginxCalled, haproxyCalled bool
|
||||
agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
|
||||
agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
|
||||
|
||||
// Both explicit haproxy and an empty engine fall through to HAProxy.
|
||||
for _, engine := range []string{EngineHAProxy, ""} {
|
||||
cfg, pid, certDir, reload := agent.enginePaths(engine)
|
||||
if cfg != agent.configPath || pid != agent.pidPath || certDir != agent.certDir {
|
||||
t.Errorf("engine %q paths = %q,%q,%q want haproxy defaults", engine, cfg, pid, certDir)
|
||||
}
|
||||
_ = reload("", "")
|
||||
}
|
||||
if nginxCalled || !haproxyCalled {
|
||||
t.Errorf("haproxy/empty engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_NginxSkipsStats(t *testing.T) {
|
||||
gw := fakeGateway(t, "h1", "", "active")
|
||||
defer gw.Close()
|
||||
|
||||
agent := newTestAgent(t, gw.URL)
|
||||
agent.localConfigHash = "h1"
|
||||
agent.engine = EngineNginx // stats poll is HAProxy-only
|
||||
statsCalled := false
|
||||
agent.statsFn = func(_ string) ([]ServerStatus, error) {
|
||||
statsCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
agent.tick()
|
||||
|
||||
if statsCalled {
|
||||
t.Error("nginx engine must not poll HAProxy stats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_NginxProbesHealthTargets(t *testing.T) {
|
||||
var receivedServer, receivedStatus string
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
if r.FormValue("Action") == "LBAgentHeartbeat" {
|
||||
receivedServer = r.FormValue("Servers.member.1.Server")
|
||||
receivedStatus = r.FormValue("Servers.member.1.Status")
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unexpected", http.StatusBadRequest)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
agent := newTestAgent(t, gw.URL)
|
||||
agent.localConfigHash = "h1" // match to avoid config fetch
|
||||
agent.engine = EngineNginx
|
||||
agent.healthTargets = []HealthTarget{{ServerName: "srv_i-web1", Address: "10.0.0.5:80", Protocol: "TCP"}}
|
||||
statsCalled := false
|
||||
agent.statsFn = func(_ string) ([]ServerStatus, error) { statsCalled = true; return nil, nil }
|
||||
probedTargets := 0
|
||||
agent.probeFn = func(targets []HealthTarget) []ServerStatus {
|
||||
probedTargets = len(targets)
|
||||
return []ServerStatus{{Server: "srv_i-web1", Status: "UP"}}
|
||||
}
|
||||
|
||||
agent.tick()
|
||||
|
||||
if statsCalled {
|
||||
t.Error("nginx engine must not poll HAProxy stats")
|
||||
}
|
||||
if probedTargets != 1 {
|
||||
t.Errorf("probeFn saw %d targets, want 1", probedTargets)
|
||||
}
|
||||
if receivedServer != "srv_i-web1" || receivedStatus != "UP" {
|
||||
t.Errorf("heartbeat server/status = %q/%q, want srv_i-web1/UP", receivedServer, receivedStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_EmptyConfigHash(t *testing.T) {
|
||||
// Gateway returns empty config hash (no config stored yet)
|
||||
gw := fakeGateway(t, "", "", "provisioning")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package lbagent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthTarget is one backend the agent must actively health-check, delivered
|
||||
// by GetLBConfig for the nginx (NLB) data plane. OSS nginx `stream` has no
|
||||
// active upstream probing, so the agent probes each target every tick and
|
||||
// reports the result. ServerName is the daemon-computed name the health checker
|
||||
// keys on — echoed back verbatim so the agent needs no ELBv2 naming logic.
|
||||
type HealthTarget struct {
|
||||
ServerName string `xml:"ServerName"`
|
||||
Address string `xml:"Address"` // ip:port to probe
|
||||
Protocol string `xml:"Protocol"` // TCP | HTTP | HTTPS
|
||||
Path string `xml:"Path"` // HTTP(S) path
|
||||
}
|
||||
|
||||
// probeTimeout bounds each individual target probe.
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// probeHealthTargets actively probes every delivered health target and returns
|
||||
// one ServerStatus per target (Status "UP"/"DOWN"). The Server field carries
|
||||
// the daemon-supplied ServerName so the daemon health checker matches it
|
||||
// directly. An empty target list yields a nil report (the daemon early-returns
|
||||
// on empty reports, leaving health untouched).
|
||||
func probeHealthTargets(targets []HealthTarget) []ServerStatus {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ServerStatus, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
status := "DOWN"
|
||||
if probeOne(t) {
|
||||
status = "UP"
|
||||
}
|
||||
out = append(out, ServerStatus{Server: t.ServerName, Status: status})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeOne performs a single health check against one target. TCP succeeds on a
|
||||
// completed dial; HTTP/HTTPS succeeds on a response status < 400 (HTTPS skips
|
||||
// certificate verification, matching how AWS NLB HTTPS health checks treat the
|
||||
// target's self-signed cert).
|
||||
func probeOne(t HealthTarget) bool {
|
||||
switch strings.ToUpper(t.Protocol) {
|
||||
case "HTTP", "HTTPS":
|
||||
return probeHTTP(t)
|
||||
default: // TCP and anything else falls back to a connect probe
|
||||
conn, err := net.DialTimeout("tcp", t.Address, probeTimeout)
|
||||
if err != nil {
|
||||
slog.Debug("TCP probe failed", "addr", t.Address, "err", err)
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// probeHTTP issues a GET to the target's health-check path and reports whether
|
||||
// the response status is below 400.
|
||||
func probeHTTP(t HealthTarget) bool {
|
||||
scheme := "http"
|
||||
client := &http.Client{Timeout: probeTimeout}
|
||||
if strings.EqualFold(t.Protocol, "HTTPS") {
|
||||
scheme = "https"
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // target uses a self-signed cert; NLB HTTPS checks don't verify it
|
||||
}
|
||||
}
|
||||
|
||||
path := t.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
} else if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s://%s%s", scheme, t.Address, path)
|
||||
resp, err := client.Get(url) //nolint:noctx // short fixed-timeout probe
|
||||
if err != nil {
|
||||
slog.Debug("HTTP probe failed", "url", url, "err", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode < 400
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package lbagent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProbeHealthTargets_Empty(t *testing.T) {
|
||||
if got := probeHealthTargets(nil); got != nil {
|
||||
t.Errorf("empty targets = %v, want nil (daemon early-returns on empty report)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOne_TCP(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
up := HealthTarget{ServerName: "srv_i-up", Address: ln.Addr().String(), Protocol: "TCP"}
|
||||
if !probeOne(up) {
|
||||
t.Error("open TCP port probed DOWN, want UP")
|
||||
}
|
||||
|
||||
// Closed port: pick the listener's port after closing it.
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
down := HealthTarget{ServerName: "srv_i-down", Address: addr, Protocol: "TCP"}
|
||||
if probeOne(down) {
|
||||
t.Error("closed TCP port probed UP, want DOWN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOne_HTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
addr := srv.Listener.Addr().String()
|
||||
up := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/healthz"}
|
||||
if !probeOne(up) {
|
||||
t.Error("200 health path probed DOWN, want UP")
|
||||
}
|
||||
|
||||
down := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/bad"}
|
||||
if probeOne(down) {
|
||||
t.Error("500 health path probed UP, want DOWN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeHealthTargets_ReportsServerName(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
out := probeHealthTargets([]HealthTarget{
|
||||
{ServerName: "srv_i-abc", Address: ln.Addr().String(), Protocol: "TCP"},
|
||||
})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("got %d statuses, want 1", len(out))
|
||||
}
|
||||
if out[0].Server != "srv_i-abc" {
|
||||
t.Errorf("Server = %q, want %q (daemon health checker keys on it)", out[0].Server, "srv_i-abc")
|
||||
}
|
||||
if out[0].Status != "UP" {
|
||||
t.Errorf("Status = %q, want UP", out[0].Status)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{Ar as e,Cr as t,Dr as n,Or as r,kr as i,wr as a}from"./index.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=n().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=r({key:e().min(1,`Tag key is required`).max(128),value:e().max(256)}),d=r({protocol:t([`HTTP`,`TCP`]),path:e().min(1,`Path is required`).max(1024),port:e().min(1),intervalSeconds:n().int().min(5).max(300),timeoutSeconds:n().int().min(2).max(120),healthyThresholdCount:n().int().min(2).max(10),unhealthyThresholdCount:n().int().min(2).max(10),matcher:e().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=r({name:c,protocol:t([`HTTP`,`HTTPS`,`TCP`,`UDP`,`TLS`,`TCP_UDP`]),port:l,vpcId:e().min(1,`VPC is required`),healthCheck:d,tags:a(u)}),p=`ELBSecurityPolicy-2016-08`,m=[`application`,`network`],h=[`HTTP`,`HTTPS`],g=[`TCP`,`UDP`,`TLS`,`TCP_UDP`],_=[...h,...g];function v(e){return e===`network`?g:h}function y(e){return e===`network`?`TCP`:`HTTP`}function b(e){return e===`HTTPS`||e===`TLS`}var x=r({protocol:t([`HTTP`,`HTTPS`]),port:l,defaultTargetGroupArn:e().min(1,`Target group is required`),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.protocol===`HTTPS`&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS`})}),S=/^[!-~]+$/,C=r({field:t([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:e().max(40).optional(),rawValues:e().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!S.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),w=r({priority:n().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:a(C).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:e().min(1,`Target group is required`)}),T=r({name:s,type:t(m),scheme:t([`internet-facing`,`internal`]),vpcId:e().min(1,`VPC is required`),subnetIds:a(e()).min(2,`At least 2 subnets are required`),securityGroupIds:a(e()),tags:a(u),listener:r({protocol:t(_),port:l,targetGroupMode:t([`new`,`existing`]),existingTargetGroupArn:e().optional(),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.targetGroupMode===`existing`&&!e.existingTargetGroupArn&&t.addIssue({code:`custom`,path:[`existingTargetGroupArn`],message:`Target group is required`}),b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS/TLS`})})});r({targets:a(r({instanceId:e().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),i(e(),e());export{f as a,v as c,w as i,x as n,y as o,T as r,b as s,p as t};
|
||||
import{Ar as e,Cr as t,Dr as n,Or as r,kr as i,wr as a}from"./index.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=n().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=r({key:e().min(1,`Tag key is required`).max(128),value:e().max(256)}),d=r({protocol:t([`HTTP`,`TCP`]),path:e().min(1,`Path is required`).max(1024),port:e().min(1),intervalSeconds:n().int().min(5).max(300),timeoutSeconds:n().int().min(2).max(120),healthyThresholdCount:n().int().min(2).max(10),unhealthyThresholdCount:n().int().min(2).max(10),matcher:e().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=r({name:c,protocol:t([`HTTP`,`HTTPS`,`TCP`,`UDP`,`TLS`,`TCP_UDP`]),port:l,vpcId:e().min(1,`VPC is required`),healthCheck:d,tags:a(u)}),p=`ELBSecurityPolicy-2016-08`,m=[`application`,`network`],h=[`HTTP`,`HTTPS`],g=[`TCP`,`UDP`,`TLS`,`TCP_UDP`],_=[...h,...g];function v(e){return e===`network`?g:h}function y(e){return e===`network`?`TCP`:`HTTP`}function b(e){return e===`HTTPS`||e===`TLS`}var x=r({protocol:t(_),port:l,defaultTargetGroupArn:e().min(1,`Target group is required`),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS and TLS`})}),S=/^[!-~]+$/,C=r({field:t([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:e().max(40).optional(),rawValues:e().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!S.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),w=r({priority:n().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:a(C).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:e().min(1,`Target group is required`)}),T=r({name:s,type:t(m),scheme:t([`internet-facing`,`internal`]),vpcId:e().min(1,`VPC is required`),subnetIds:a(e()).min(1,`At least 1 subnet is required`),securityGroupIds:a(e()),tags:a(u),listener:r({protocol:t(_),port:l,targetGroupMode:t([`new`,`existing`]),existingTargetGroupArn:e().optional(),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.targetGroupMode===`existing`&&!e.existingTargetGroupArn&&t.addIssue({code:`custom`,path:[`existingTargetGroupArn`],message:`Target group is required`}),b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS/TLS`})})});r({targets:a(r({instanceId:e().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),i(e(),e());export{f as a,v as c,w as i,x as n,y as o,T as r,b as s,p as t};
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
createTestQueryClient,
|
||||
renderWithClient,
|
||||
} from "@/test/elbv2-integration"
|
||||
import type { CreateListenerFormData } from "@/types/elbv2"
|
||||
import {
|
||||
ALL_LISTENER_PROTOCOLS,
|
||||
type CreateListenerFormData,
|
||||
} from "@/types/elbv2"
|
||||
|
||||
import { ListenerForm } from "./listener-form"
|
||||
|
||||
@@ -63,6 +66,7 @@ function Harness({
|
||||
<ListenerForm
|
||||
certificates={CERTS}
|
||||
form={form}
|
||||
protocols={ALL_LISTENER_PROTOCOLS}
|
||||
sslPolicies={POLICIES}
|
||||
targetGroups={targetGroups}
|
||||
/>
|
||||
@@ -128,6 +132,7 @@ describe("ListenerForm", () => {
|
||||
<ListenerForm
|
||||
certificates={CERTS}
|
||||
form={form}
|
||||
protocols={ALL_LISTENER_PROTOCOLS}
|
||||
sslPolicies={POLICIES}
|
||||
targetGroups={TGS}
|
||||
/>
|
||||
|
||||
@@ -18,10 +18,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { type CreateListenerFormData, DEFAULT_SSL_POLICY } from "@/types/elbv2"
|
||||
import {
|
||||
type CreateListenerFormData,
|
||||
DEFAULT_SSL_POLICY,
|
||||
isSecureProtocol,
|
||||
type ListenerProtocolValue,
|
||||
} from "@/types/elbv2"
|
||||
|
||||
interface ListenerFormProps {
|
||||
form: UseFormReturn<CreateListenerFormData>
|
||||
protocols: readonly ListenerProtocolValue[]
|
||||
targetGroups: TargetGroup[]
|
||||
certificates?: CertificateSummary[]
|
||||
sslPolicies?: SslPolicy[]
|
||||
@@ -37,6 +43,7 @@ function certLabel(cert: CertificateSummary): string {
|
||||
|
||||
export function ListenerForm({
|
||||
form,
|
||||
protocols,
|
||||
targetGroups,
|
||||
certificates = [],
|
||||
sslPolicies = [],
|
||||
@@ -53,19 +60,28 @@ export function ListenerForm({
|
||||
|
||||
const protocol = useWatch({ control, name: "protocol" })
|
||||
const isHttps = protocol === "HTTPS"
|
||||
const isSecure = isSecureProtocol(protocol)
|
||||
|
||||
// Side-effects of a protocol switch, applied after the field's own onChange:
|
||||
// adjust the default port (only when still on the other protocol's default)
|
||||
// and toggle the TLS fields. Moving to HTTP clears cert + policy so the
|
||||
// server doesn't reject them.
|
||||
const applyProtocolDefaults = (next: "HTTP" | "HTTPS" | null) => {
|
||||
// bump the default port (only when still on the other protocol's default) and
|
||||
// toggle the TLS fields. A secure protocol (HTTPS/TLS) keeps the certificate;
|
||||
// an SSL policy is HTTPS-only. Moving to an insecure protocol clears cert +
|
||||
// policy so the server doesn't reject them.
|
||||
const applyProtocolDefaults = (next: ListenerProtocolValue | null) => {
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
const current = getValues("port")
|
||||
if (next === "HTTPS") {
|
||||
if (isSecureProtocol(next)) {
|
||||
if (current === HTTP_PORT) {
|
||||
setValue("port", HTTPS_PORT)
|
||||
}
|
||||
if (!getValues("sslPolicy")) {
|
||||
setValue("sslPolicy", DEFAULT_SSL_POLICY)
|
||||
if (next === "HTTPS") {
|
||||
if (!getValues("sslPolicy")) {
|
||||
setValue("sslPolicy", DEFAULT_SSL_POLICY)
|
||||
}
|
||||
} else {
|
||||
setValue("sslPolicy", undefined)
|
||||
}
|
||||
} else {
|
||||
if (current === HTTPS_PORT) {
|
||||
@@ -87,7 +103,7 @@ export function ListenerForm({
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={(next: "HTTP" | "HTTPS" | null) => {
|
||||
onValueChange={(next: ListenerProtocolValue | null) => {
|
||||
field.onChange(next)
|
||||
applyProtocolDefaults(next)
|
||||
}}
|
||||
@@ -97,8 +113,11 @@ export function ListenerForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||
<SelectItem value="HTTPS">HTTPS</SelectItem>
|
||||
{protocols.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
@@ -113,14 +132,14 @@ export function ListenerForm({
|
||||
aria-invalid={!!errors.port}
|
||||
id="listener-port"
|
||||
inputMode="numeric"
|
||||
placeholder={isHttps ? "443" : "80"}
|
||||
placeholder={isSecure ? "443" : "80"}
|
||||
type="number"
|
||||
{...register("port", { valueAsNumber: true })}
|
||||
/>
|
||||
<FieldError errors={[errors.port]} />
|
||||
</Field>
|
||||
|
||||
{isHttps && (
|
||||
{isSecure && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
@@ -173,32 +192,34 @@ export function ListenerForm({
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
<label htmlFor="listener-ssl-policy">Security policy</label>
|
||||
</FieldTitle>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sslPolicy"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? DEFAULT_SSL_POLICY}
|
||||
>
|
||||
<SelectTrigger className="w-full" id="listener-ssl-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sslPolicies.map((policy) => (
|
||||
<SelectItem key={policy.Name} value={policy.Name ?? ""}>
|
||||
{policy.Name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{isHttps && (
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
<label htmlFor="listener-ssl-policy">Security policy</label>
|
||||
</FieldTitle>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sslPolicy"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? DEFAULT_SSL_POLICY}
|
||||
>
|
||||
<SelectTrigger className="w-full" id="listener-ssl-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sslPolicies.map((policy) => (
|
||||
<SelectItem key={policy.Name} value={policy.Name ?? ""}>
|
||||
{policy.Name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<CertificateImportDialog
|
||||
onImported={(arn) =>
|
||||
|
||||
@@ -67,9 +67,16 @@ describe("ListenersTab", () => {
|
||||
|
||||
it("renders empty state when no listeners", () => {
|
||||
const queryClient = seedClient({})
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
expect(screen.getByText("No listeners configured.")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add listener" }),
|
||||
@@ -100,9 +107,16 @@ describe("ListenersTab", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
expect(screen.getByText("forward → my-tg")).toBeInTheDocument()
|
||||
expect(screen.getByText("HTTP")).toBeInTheDocument()
|
||||
expect(screen.getByText("80")).toBeInTheDocument()
|
||||
@@ -123,9 +137,16 @@ describe("ListenersTab", () => {
|
||||
})
|
||||
mockSend.mockResolvedValue({})
|
||||
const user = userEvent.setup()
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
await user.click(screen.getByLabelText("Delete listener HTTP:80"))
|
||||
expect(screen.getByText(/delete listener http:80/i)).toBeInTheDocument()
|
||||
const before = mockSend.mock.calls.length
|
||||
@@ -159,9 +180,16 @@ describe("ListenersTab", () => {
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
await user.click(screen.getByRole("button", { name: "Add listener" }))
|
||||
await user.click(screen.getByLabelText("Default target group"))
|
||||
await expect(screen.findByText(/tg-in-vpc/)).resolves.toBeInTheDocument()
|
||||
@@ -195,9 +223,16 @@ describe("ListenersTab", () => {
|
||||
})
|
||||
mockSend.mockResolvedValue({})
|
||||
const user = userEvent.setup()
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
await user.click(screen.getByLabelText("Edit listener HTTP:80"))
|
||||
await expect(
|
||||
screen.findByRole("alertdialog", { name: /edit listener/i }),
|
||||
@@ -236,9 +271,16 @@ describe("ListenersTab", () => {
|
||||
})
|
||||
mockSend.mockResolvedValue({})
|
||||
const user = userEvent.setup()
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
await user.click(screen.getByRole("button", { name: "Add listener" }))
|
||||
await user.click(screen.getByLabelText("Default target group"))
|
||||
await user.click(await screen.findByText(/tg-a/))
|
||||
@@ -292,9 +334,16 @@ describe("ListenersTab", () => {
|
||||
})
|
||||
mockSend.mockResolvedValue({})
|
||||
const user = userEvent.setup()
|
||||
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
render(
|
||||
<ListenersTab
|
||||
lbType="application"
|
||||
loadBalancerArn={LB_ARN}
|
||||
vpcId="vpc-aaa"
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(queryClient),
|
||||
},
|
||||
)
|
||||
|
||||
// The TLS column summarises the policy + cert id for HTTPS listeners.
|
||||
expect(
|
||||
|
||||
@@ -34,11 +34,26 @@ import {
|
||||
import {
|
||||
type CreateListenerFormData,
|
||||
createListenerSchema,
|
||||
defaultProtocolForType,
|
||||
isSecureProtocol,
|
||||
type LbType,
|
||||
type ListenerProtocolValue,
|
||||
protocolsForType,
|
||||
} from "@/types/elbv2"
|
||||
|
||||
interface ListenersTabProps {
|
||||
loadBalancerArn: string
|
||||
vpcId: string | undefined
|
||||
lbType: LbType
|
||||
}
|
||||
|
||||
// NLB listeners default to TCP, ALB to HTTP — both on port 80.
|
||||
function listenerDefaults(lbType: LbType): CreateListenerFormData {
|
||||
return {
|
||||
protocol: defaultProtocolForType(lbType),
|
||||
port: 80,
|
||||
defaultTargetGroupArn: "",
|
||||
}
|
||||
}
|
||||
|
||||
function formatTls(listener: Listener): string {
|
||||
@@ -53,7 +68,12 @@ function formatTls(listener: Listener): string {
|
||||
return policy ?? certId ?? "—"
|
||||
}
|
||||
|
||||
export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
|
||||
export function ListenersTab({
|
||||
loadBalancerArn,
|
||||
vpcId,
|
||||
lbType,
|
||||
}: ListenersTabProps) {
|
||||
const protocols = protocolsForType(lbType)
|
||||
const { data: listenersData } = useSuspenseQuery(
|
||||
elbv2ListenersQueryOptions(loadBalancerArn),
|
||||
)
|
||||
@@ -259,9 +279,11 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
|
||||
<AddListenerDialog
|
||||
certificates={certificates}
|
||||
isPending={createMutation.isPending}
|
||||
lbType={lbType}
|
||||
onOpenChange={setAddOpen}
|
||||
onSubmit={handleCreate}
|
||||
open={addOpen}
|
||||
protocols={protocols}
|
||||
sslPolicies={sslPolicies}
|
||||
targetGroups={vpcTgs}
|
||||
/>
|
||||
@@ -272,6 +294,7 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
|
||||
listener={editTarget}
|
||||
onOpenChange={(open) => !open && setEditTarget(undefined)}
|
||||
onSubmit={handleEdit}
|
||||
protocols={protocols}
|
||||
sslPolicies={sslPolicies}
|
||||
targetGroups={vpcTgs}
|
||||
/>
|
||||
@@ -295,6 +318,8 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
|
||||
interface AddListenerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
lbType: LbType
|
||||
protocols: readonly ListenerProtocolValue[]
|
||||
targetGroups: React.ComponentProps<typeof ListenerForm>["targetGroups"]
|
||||
certificates: React.ComponentProps<typeof ListenerForm>["certificates"]
|
||||
sslPolicies: React.ComponentProps<typeof ListenerForm>["sslPolicies"]
|
||||
@@ -302,15 +327,11 @@ interface AddListenerDialogProps {
|
||||
onSubmit: (data: CreateListenerFormData) => void
|
||||
}
|
||||
|
||||
const ADD_LISTENER_DEFAULTS: CreateListenerFormData = {
|
||||
protocol: "HTTP",
|
||||
port: 80,
|
||||
defaultTargetGroupArn: "",
|
||||
}
|
||||
|
||||
function AddListenerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
lbType,
|
||||
protocols,
|
||||
targetGroups,
|
||||
certificates,
|
||||
sslPolicies,
|
||||
@@ -319,14 +340,14 @@ function AddListenerDialog({
|
||||
}: AddListenerDialogProps) {
|
||||
const form = useForm<CreateListenerFormData>({
|
||||
resolver: zodResolver(createListenerSchema),
|
||||
defaultValues: ADD_LISTENER_DEFAULTS,
|
||||
defaultValues: listenerDefaults(lbType),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(ADD_LISTENER_DEFAULTS)
|
||||
form.reset(listenerDefaults(lbType))
|
||||
}
|
||||
}, [open, form])
|
||||
}, [open, form, lbType])
|
||||
|
||||
const handleConfirm = form.handleSubmit(onSubmit)
|
||||
|
||||
@@ -353,6 +374,7 @@ function AddListenerDialog({
|
||||
<ListenerForm
|
||||
certificates={certificates}
|
||||
form={form}
|
||||
protocols={protocols}
|
||||
sslPolicies={sslPolicies}
|
||||
targetGroups={targetGroups}
|
||||
/>
|
||||
@@ -372,6 +394,7 @@ function AddListenerDialog({
|
||||
interface EditListenerDialogProps {
|
||||
listener: Listener | undefined
|
||||
onOpenChange: (open: boolean) => void
|
||||
protocols: readonly ListenerProtocolValue[]
|
||||
targetGroups: React.ComponentProps<typeof ListenerForm>["targetGroups"]
|
||||
certificates: React.ComponentProps<typeof ListenerForm>["certificates"]
|
||||
sslPolicies: React.ComponentProps<typeof ListenerForm>["sslPolicies"]
|
||||
@@ -382,6 +405,7 @@ interface EditListenerDialogProps {
|
||||
function EditListenerDialog({
|
||||
listener,
|
||||
onOpenChange,
|
||||
protocols,
|
||||
targetGroups,
|
||||
certificates,
|
||||
sslPolicies,
|
||||
@@ -403,18 +427,19 @@ function EditListenerDialog({
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
const protocol = listener.Protocol === "HTTPS" ? "HTTPS" : "HTTP"
|
||||
const protocol: ListenerProtocolValue =
|
||||
protocols.find((p) => p === listener.Protocol) ?? protocols[0] ?? "HTTP"
|
||||
const secure = isSecureProtocol(protocol)
|
||||
form.reset({
|
||||
protocol,
|
||||
port: listener.Port ?? 80,
|
||||
defaultTargetGroupArn: listener.DefaultActions?.[0]?.TargetGroupArn ?? "",
|
||||
certificateArn:
|
||||
protocol === "HTTPS"
|
||||
? listener.Certificates?.[0]?.CertificateArn
|
||||
: undefined,
|
||||
certificateArn: secure
|
||||
? listener.Certificates?.[0]?.CertificateArn
|
||||
: undefined,
|
||||
sslPolicy: protocol === "HTTPS" ? listener.SslPolicy : undefined,
|
||||
})
|
||||
}, [listener, form])
|
||||
}, [listener, form, protocols])
|
||||
|
||||
const handleConfirm = form.handleSubmit(onSubmit)
|
||||
|
||||
@@ -442,6 +467,7 @@ function EditListenerDialog({
|
||||
<ListenerForm
|
||||
certificates={certificates}
|
||||
form={form}
|
||||
protocols={protocols}
|
||||
sslPolicies={sslPolicies}
|
||||
targetGroups={targetGroups}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -469,7 +469,7 @@ export function CreateLoadBalancerPage() {
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>Subnets (select at least 2)</FieldTitle>
|
||||
<FieldTitle>Subnets (select at least 1)</FieldTitle>
|
||||
{groupedSubnets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No subnets in the selected VPC.
|
||||
|
||||
+5
-1
@@ -196,7 +196,11 @@ export function LoadBalancerDetailPage({ arn }: Props) {
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="listeners">
|
||||
<ListenersTab loadBalancerArn={arn} vpcId={lb.VpcId} />
|
||||
<ListenersTab
|
||||
lbType={isNlb ? "network" : "application"}
|
||||
loadBalancerArn={arn}
|
||||
vpcId={lb.VpcId}
|
||||
/>
|
||||
</TabsPanel>
|
||||
|
||||
{!isNlb && (
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("createLoadBalancerSchema", () => {
|
||||
expect(result.success).toBeTruthy()
|
||||
})
|
||||
|
||||
it("rejects <2 subnets", () => {
|
||||
it("accepts a single subnet", () => {
|
||||
const result = createLoadBalancerSchema.safeParse({
|
||||
name: "my-alb",
|
||||
type: "application",
|
||||
@@ -102,6 +102,20 @@ describe("createLoadBalancerSchema", () => {
|
||||
tags: [],
|
||||
listener: baseListener,
|
||||
})
|
||||
expect(result.success).toBeTruthy()
|
||||
})
|
||||
|
||||
it("rejects zero subnets", () => {
|
||||
const result = createLoadBalancerSchema.safeParse({
|
||||
name: "my-alb",
|
||||
type: "application",
|
||||
scheme: "internet-facing",
|
||||
vpcId: "vpc-1",
|
||||
subnetIds: [],
|
||||
securityGroupIds: [],
|
||||
tags: [],
|
||||
listener: baseListener,
|
||||
})
|
||||
expect(result.success).toBeFalsy()
|
||||
})
|
||||
|
||||
|
||||
@@ -106,18 +106,18 @@ export function isSecureProtocol(protocol: string): boolean {
|
||||
|
||||
export const createListenerSchema = z
|
||||
.object({
|
||||
protocol: z.enum(["HTTP", "HTTPS"]),
|
||||
protocol: z.enum(ALL_LISTENER_PROTOCOLS),
|
||||
port: portField,
|
||||
defaultTargetGroupArn: z.string().min(1, "Target group is required"),
|
||||
certificateArn: z.string().optional(),
|
||||
sslPolicy: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.protocol === "HTTPS" && !data.certificateArn) {
|
||||
if (isSecureProtocol(data.protocol) && !data.certificateArn) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["certificateArn"],
|
||||
message: "A certificate is required for HTTPS",
|
||||
message: "A certificate is required for HTTPS and TLS",
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -200,7 +200,7 @@ export const createLoadBalancerSchema = z.object({
|
||||
type: z.enum(LB_TYPES),
|
||||
scheme: z.enum(["internet-facing", "internal"]),
|
||||
vpcId: z.string().min(1, "VPC is required"),
|
||||
subnetIds: z.array(z.string()).min(2, "At least 2 subnets are required"),
|
||||
subnetIds: z.array(z.string()).min(1, "At least 1 subnet is required"),
|
||||
securityGroupIds: z.array(z.string()),
|
||||
tags: z.array(tagSchema),
|
||||
listener: z
|
||||
|
||||
@@ -77,6 +77,38 @@ func TCPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficR
|
||||
return r
|
||||
}
|
||||
|
||||
// UDPRoundRobin sends n UDP datagrams to host:port, reads the echoed line per
|
||||
// datagram, and counts responders. Matches the app-userdata UDP echo responder.
|
||||
// UDP is connectionless, so each probe writes a payload then reads with a
|
||||
// deadline; a dropped datagram counts as a failure rather than blocking.
|
||||
func UDPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficResult {
|
||||
r := TrafficResult{Distribution: map[string]int{}, Total: n}
|
||||
for i := 0; i < n; i++ {
|
||||
conn, err := net.DialTimeout("udp", fmt.Sprintf("%s:%d", host, port), timeout)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Write([]byte("ping")); err != nil {
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
buf := make([]byte, 256)
|
||||
nb, err := conn.Read(buf)
|
||||
conn.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp := strings.TrimSpace(string(buf[:nb]))
|
||||
if resp == "" {
|
||||
continue
|
||||
}
|
||||
r.Distribution[resp]++
|
||||
r.Successful++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// AssertRoundRobin asserts that r has at least minUnique distinct responders
|
||||
// and at least minSuccess successful probes. Logs the distribution either way.
|
||||
func AssertRoundRobin(t *testing.T, r TrafficResult, minUnique, minSuccess int, label string) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
lbKeyName = "lb-e2e-key"
|
||||
httpPort = 80
|
||||
tcpPort = 9000
|
||||
udpPort = 9001
|
||||
triggerPort = 9090
|
||||
probesPerRun = 20
|
||||
)
|
||||
@@ -105,6 +106,10 @@ func TestLoadBalancer(t *testing.T) {
|
||||
time.Sleep(15 * time.Second)
|
||||
runLBSuite(t, client, fixture, kindNLB, "internal", nil, "")
|
||||
})
|
||||
t.Run("Internal_NLB_UDP", func(t *testing.T) {
|
||||
time.Sleep(15 * time.Second)
|
||||
runUDPNLBSuite(t, client, fixture)
|
||||
})
|
||||
t.Run("Internal_ALB_ModifyListener", func(t *testing.T) {
|
||||
time.Sleep(15 * time.Second)
|
||||
runModifyListenerSuite(t, client, fixture)
|
||||
@@ -398,6 +403,22 @@ func configureDefaultSG(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UDP data plane for the NLB UDP suite (health-checked over TCP/9000).
|
||||
if _, err := c.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
|
||||
GroupId: aws.String(f.SecurityGroup),
|
||||
IpPermissions: []*ec2.IpPermission{{
|
||||
IpProtocol: aws.String("udp"),
|
||||
FromPort: aws.Int64(udpPort),
|
||||
ToPort: aws.Int64(udpPort),
|
||||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
||||
}},
|
||||
}); err != nil {
|
||||
var aerr awserr.Error
|
||||
if !errors.As(err, &aerr) || aerr.Code() != "InvalidPermission.Duplicate" {
|
||||
t.Fatalf("authorize udp/%d: %v", udpPort, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- App instances -------------------------------------------------------
|
||||
@@ -507,6 +528,74 @@ func runLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture, kind lbKin
|
||||
runInternalTrafficViaClient(t, c, f, kind, priv)
|
||||
}
|
||||
|
||||
// runUDPNLBSuite exercises an internal NLB with a UDP listener end-to-end. This
|
||||
// is the regression gate for nginx NLB target health: nginx `stream` has no
|
||||
// active upstream probing, so the lb-agent actively probes the targets (here
|
||||
// over TCP/9000) and reports health — without it WaitForTargetsHealthy would
|
||||
// never pass for any NLB. Once healthy, a UDP datagram round-trip through the
|
||||
// VIP proves the L4 data plane (which HAProxy cannot serve for UDP).
|
||||
func runUDPNLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||||
t.Helper()
|
||||
const label = "NLB internal UDP"
|
||||
|
||||
tgArn := createUDPTargetGroup(t, c, f, "lb-e2e-nlb-udp-tg")
|
||||
t.Cleanup(func() { deleteTargetGroup(t, c, tgArn) })
|
||||
|
||||
registerTargets(t, c, tgArn, f.AppInstanceIDs)
|
||||
t.Cleanup(func() { deregisterTargets(t, c, tgArn, f.AppInstanceIDs) })
|
||||
|
||||
lb := createLB(t, c, f, "lb-e2e-nlb-udp", "network", "internal")
|
||||
t.Cleanup(func() { deleteLB(t, c, lb) })
|
||||
|
||||
listener := createListener(t, c, lb.ARN, "UDP", udpPort, tgArn)
|
||||
t.Cleanup(func() { deleteListener(t, c, listener) })
|
||||
|
||||
assert.Equal(t, "network", lb.Type, label+" type")
|
||||
assert.Contains(t, lb.ARN, "/net/", label+" ARN must contain /net/")
|
||||
|
||||
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
|
||||
// The key assertion: NLB targets reach healthy via the agent's active
|
||||
// prober. Pre-feature this timed out (nginx reported empty server lists).
|
||||
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
|
||||
|
||||
eni := lbENI(t, c, "net", lb)
|
||||
priv := privateIP(eni)
|
||||
require.NotEmpty(t, priv, label+" needs private IP")
|
||||
|
||||
runInternalUDPViaClient(t, f, priv, label)
|
||||
}
|
||||
|
||||
// runInternalUDPViaClient drives the shared client to send UDP datagrams at the
|
||||
// NLB VIP and asserts the echoed hostnames round-robin across both backends.
|
||||
func runInternalUDPViaClient(t *testing.T, f *sharedFixture, lbIP, label string) {
|
||||
t.Helper()
|
||||
resultsFile := fmt.Sprintf("nlb-udp-%d.txt", time.Now().UnixNano())
|
||||
order := map[string]any{
|
||||
"proto": "udp",
|
||||
"ip": lbIP,
|
||||
"count": probesPerRun,
|
||||
"outfile": resultsFile,
|
||||
"udp_port": udpPort,
|
||||
}
|
||||
body, err := json.Marshal(order)
|
||||
require.NoError(t, err)
|
||||
|
||||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
|
||||
require.NoErrorf(t, err, "trigger %s probe", label)
|
||||
resp.Body.Close()
|
||||
require.Equalf(t, 200, resp.StatusCode, "trigger %s probe HTTP status", label)
|
||||
|
||||
results, err := plainHTTPGet(
|
||||
fmt.Sprintf("http://%s:%d/%s", f.ClientPublicIP, httpPort, resultsFile),
|
||||
10*time.Second)
|
||||
require.NoErrorf(t, err, "fetch %s", resultsFile)
|
||||
harness.AssertRoundRobin(t,
|
||||
harness.VerifyResultsLines(results, "udp"),
|
||||
1, probesPerRun/2, label)
|
||||
}
|
||||
|
||||
// --- LB CRUD helpers -----------------------------------------------------
|
||||
|
||||
func createTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, name, proto string, port int64, hcPath string) string {
|
||||
@@ -533,6 +622,30 @@ func createTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, nam
|
||||
return arn
|
||||
}
|
||||
|
||||
// createUDPTargetGroup creates a UDP target group. AWS (and spinifex) require a
|
||||
// UDP target group's health check to run over TCP/HTTP/HTTPS — UDP itself can't
|
||||
// be probed — so the check targets the app's TCP echo port (9000) while the
|
||||
// data plane forwards UDP on udpPort. This exercises the nginx agent's active
|
||||
// TCP prober, which is what advances NLB targets to healthy.
|
||||
func createUDPTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, name string) string {
|
||||
t.Helper()
|
||||
out, err := c.ELBv2.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String(name),
|
||||
Protocol: aws.String("UDP"),
|
||||
Port: aws.Int64(udpPort),
|
||||
VpcId: aws.String(f.VPCID),
|
||||
HealthCheckProtocol: aws.String("TCP"),
|
||||
HealthCheckPort: aws.String(fmt.Sprintf("%d", tcpPort)),
|
||||
HealthCheckIntervalSeconds: aws.Int64(10),
|
||||
HealthyThresholdCount: aws.Int64(2),
|
||||
UnhealthyThresholdCount: aws.Int64(2),
|
||||
})
|
||||
require.NoErrorf(t, err, "create-target-group %s (UDP)", name)
|
||||
arn := aws.StringValue(out.TargetGroups[0].TargetGroupArn)
|
||||
t.Logf("UDP TG %s: %s (HC TCP:%d)", name, arn, tcpPort)
|
||||
return arn
|
||||
}
|
||||
|
||||
func deleteTargetGroup(t *testing.T, c *harness.AWSClient, arn string) {
|
||||
if arn == "" {
|
||||
return
|
||||
|
||||
+13
-2
@@ -1,7 +1,8 @@
|
||||
#!/bin/bash
|
||||
# App instance user-data: dual-purpose responder for LB E2E tests.
|
||||
# App instance user-data: triple-purpose responder for LB E2E tests.
|
||||
# HTTP:80 serves JSON {"instance_id": "<hostname>"} for ALB suites
|
||||
# TCP:9000 echoes <hostname> for NLB suites
|
||||
# TCP:9000 echoes <hostname> for NLB TCP suites (also UDP-TG TCP health check)
|
||||
# UDP:9001 echoes <hostname> for NLB UDP suites
|
||||
INSTANCE_ID=$(hostname)
|
||||
|
||||
mkdir -p /tmp/httpd/alpha /tmp/httpd/beta && cd /tmp/httpd
|
||||
@@ -19,3 +20,13 @@ socketserver.TCPServer.allow_reuse_address = True
|
||||
socketserver.TCPServer(("0.0.0.0", 9000), Handler).serve_forever()
|
||||
PYEOF
|
||||
nohup python3 /tmp/tcp_echo.py > /dev/null 2>&1 &
|
||||
|
||||
cat > /tmp/udp_echo.py << 'PYEOF'
|
||||
import socketserver, os
|
||||
class Handler(socketserver.DatagramRequestHandler):
|
||||
def handle(self):
|
||||
self.wfile.write((os.uname()[1] + "\n").encode())
|
||||
socketserver.UDPServer.allow_reuse_address = True
|
||||
socketserver.UDPServer(("0.0.0.0", 9001), Handler).serve_forever()
|
||||
PYEOF
|
||||
nohup python3 /tmp/udp_echo.py > /dev/null 2>&1 &
|
||||
|
||||
+6
@@ -28,6 +28,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
count = int(body.get("count", 20))
|
||||
outfile = body["outfile"]
|
||||
tcp_port = int(body.get("tcp_port", 9000))
|
||||
udp_port = int(body.get("udp_port", 9001))
|
||||
http_port = int(body.get("http_port", 80))
|
||||
http_path = body.get("http_path", "/") or "/"
|
||||
if not http_path.startswith("/"):
|
||||
@@ -48,6 +49,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
cmd.append(f"http://{ip}:{http_port}{http_path}")
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
fh.write(r.stdout.strip() + "\n")
|
||||
elif proto == "udp":
|
||||
r = subprocess.run(
|
||||
["bash", "-c", f'echo "ping" | nc -u -w2 {ip} {udp_port} 2>/dev/null || true'],
|
||||
capture_output=True, text=True)
|
||||
fh.write(r.stdout.strip() + "\n")
|
||||
else:
|
||||
r = subprocess.run(
|
||||
["bash", "-c", f'echo "" | nc -w2 {ip} {tcp_port} 2>/dev/null || true'],
|
||||
|
||||
Reference in New Issue
Block a user