diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d0e366422..d3b0e13c7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 diff --git a/scripts/build-microvm-image.sh b/scripts/build-microvm-image.sh index b70d846ed..e547c1993 100755 --- a/scripts/build-microvm-image.sh +++ b/scripts/build-microvm-image.sh @@ -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" diff --git a/spinifex/handlers/elbv2/agent_types.go b/spinifex/handlers/elbv2/agent_types.go index c4b46b9b0..bc4fd6fbb 100644 --- a/spinifex/handlers/elbv2/agent_types.go +++ b/spinifex/handlers/elbv2/agent_types.go @@ -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 diff --git a/spinifex/handlers/elbv2/haproxy.go b/spinifex/handlers/elbv2/haproxy.go index f4435d487..8e24fd5c8 100644 --- a/spinifex/handlers/elbv2/haproxy.go +++ b/spinifex/handlers/elbv2/haproxy.go @@ -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) diff --git a/spinifex/handlers/elbv2/haproxy_test.go b/spinifex/handlers/elbv2/haproxy_test.go index 2f325d135..f52e68303 100644 --- a/spinifex/handlers/elbv2/haproxy_test.go +++ b/spinifex/handlers/elbv2/haproxy_test.go @@ -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{ diff --git a/spinifex/handlers/elbv2/nginx.go b/spinifex/handlers/elbv2/nginx.go new file mode 100644 index 000000000..754772e28 --- /dev/null +++ b/spinifex/handlers/elbv2/nginx.go @@ -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 +} diff --git a/spinifex/handlers/elbv2/nginx_test.go b/spinifex/handlers/elbv2/nginx_test.go new file mode 100644 index 000000000..ee4ec13bb --- /dev/null +++ b/spinifex/handlers/elbv2/nginx_test.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go index cf81c08cd..f4b091f79 100644 --- a/spinifex/handlers/elbv2/service_impl.go +++ b/spinifex/handlers/elbv2/service_impl.go @@ -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 { diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go index 19125d69d..0696c6f48 100644 --- a/spinifex/handlers/elbv2/service_impl_test.go +++ b/spinifex/handlers/elbv2/service_impl_test.go @@ -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) diff --git a/spinifex/handlers/elbv2/types.go b/spinifex/handlers/elbv2/types.go index 97111d3bf..57f2cb364 100644 --- a/spinifex/handlers/elbv2/types.go +++ b/spinifex/handlers/elbv2/types.go @@ -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. diff --git a/spinifex/lbagent/agent.go b/spinifex/lbagent/agent.go index fb0201e89..0cf8a9d3c 100644 --- a/spinifex/lbagent/agent.go +++ b/spinifex/lbagent/agent.go @@ -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) diff --git a/spinifex/lbagent/agent_certs_test.go b/spinifex/lbagent/agent_certs_test.go index 00b196b6d..8c343c16a 100644 --- a/spinifex/lbagent/agent_certs_test.go +++ b/spinifex/lbagent/agent_certs_test.go @@ -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") } diff --git a/spinifex/lbagent/agent_test.go b/spinifex/lbagent/agent_test.go index 2129acb7c..38125975c 100644 --- a/spinifex/lbagent/agent_test.go +++ b/spinifex/lbagent/agent_test.go @@ -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, `activeh1`) + 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") diff --git a/spinifex/lbagent/healthcheck.go b/spinifex/lbagent/healthcheck.go new file mode 100644 index 000000000..c3d8deea1 --- /dev/null +++ b/spinifex/lbagent/healthcheck.go @@ -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 +} diff --git a/spinifex/lbagent/healthcheck_test.go b/spinifex/lbagent/healthcheck_test.go new file mode 100644 index 000000000..2a237be06 --- /dev/null +++ b/spinifex/lbagent/healthcheck_test.go @@ -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) + } +} diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id4.js b/spinifex/services/spinifexui/frontend/dist/assets/_id4.js index e4fa9aed0..17f90cda1 100644 --- a/spinifex/services/spinifexui/frontend/dist/assets/_id4.js +++ b/spinifex/services/spinifexui/frontend/dist/assets/_id4.js @@ -1 +1 @@ -import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{t as l}from"./certificate-import-dialog.js";import{a as u,d,f,g as p,h as m,o as h,r as g,s as _,t as v,u as y}from"./elbv2.js";import{A as b,D as x,Do as S,E as C,Eo as w,F as T,Ka as E,Ko as D,La as O,Li as k,N as A,So as j,T as M,Ua as N,Z as P,co as F,i as I,k as L,tt as R,w as z,yo as B}from"./index.js";import{t as ee}from"./page-heading.js";import{t as V}from"./state-badge.js";import{n as H,t as U}from"./detail-row.js";import{a as W,i as G,l as K,n as te,o as ne,r as re,s as ie,t as ae}from"./alert-dialog.js";import{a as q,r as J,t as Y}from"./field.js";import{t as oe}from"./delete-confirmation-dialog.js";import{a as se,i as ce,n as X,r as le,t as ue}from"./zod.js";import{i as de,n as fe,t as pe}from"./elbv22.js";import{a as me,i as he,n as ge,o as _e,r as ve,s as ye,t as be}from"./tabs.js";var xe=F(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),Z=B(),Q=D(S(),1),$=w(),Se=80,Ce=443;function we(e){let t=e.CertificateArn?.split(`/`).pop()??e.CertificateArn??``;return e.DomainName?`${e.DomainName} · ${t}`:t}function Te(e){let t=(0,Z.c)(55),{form:n,targetGroups:c,certificates:u,sslPolicies:d}=e,f;t[0]===u?f=t[1]:(f=u===void 0?[]:u,t[0]=u,t[1]=f);let p=f,m;t[2]===d?m=t[3]:(m=d===void 0?[]:d,t[2]=d,t[3]=m);let h=m,{control:g,register:_,getValues:v,setValue:y,formState:b}=n,{errors:x}=b,[S,C]=(0,Q.useState)(!1),w;t[4]===g?w=t[5]:(w={control:g,name:`protocol`},t[4]=g,t[5]=w);let T=se(w)===`HTTPS`,E;t[6]!==v||t[7]!==y?(E=e=>{let t=v(`port`);e===`HTTPS`?(t===Se&&y(`port`,Ce),v(`sslPolicy`)||y(`sslPolicy`,pe)):(t===Ce&&y(`port`,Se),y(`certificateArn`,void 0),y(`sslPolicy`,void 0))},t[6]=v,t[7]=y,t[8]=E):E=t[8];let D=E,A;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),t[9]=A):A=t[9];let j;t[10]===D?j=t[11]:(j=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:e=>{t.onChange(e),D(e)},value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,$.jsx)(r,{})}),(0,$.jsxs)(a,{children:[(0,$.jsx)(i,{value:`HTTP`,children:`HTTP`}),(0,$.jsx)(i,{value:`HTTPS`,children:`HTTPS`})]})]})},t[10]=D,t[11]=j);let M;t[12]!==g||t[13]!==j?(M=(0,$.jsxs)(Y,{children:[A,(0,$.jsx)(X,{control:g,name:`protocol`,render:j})]}),t[12]=g,t[13]=j,t[14]=M):M=t[14];let N;t[15]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),t[15]=N):N=t[15];let P=!!x.port,F=T?`443`:`80`,I;t[16]===_?I=t[17]:(I=_(`port`,{valueAsNumber:!0}),t[16]=_,t[17]=I);let L;t[18]!==P||t[19]!==F||t[20]!==I?(L=(0,$.jsx)(k,{"aria-invalid":P,id:`listener-port`,inputMode:`numeric`,placeholder:F,type:`number`,...I}),t[18]=P,t[19]=F,t[20]=I,t[21]=L):L=t[21];let R;t[22]===x.port?R=t[23]:(R=(0,$.jsx)(J,{errors:[x.port]}),t[22]=x.port,t[23]=R);let z;t[24]!==L||t[25]!==R?(z=(0,$.jsxs)(Y,{children:[N,L,R]}),t[24]=L,t[25]=R,t[26]=z):z=t[26];let B;t[27]!==p||t[28]!==g||t[29]!==x.certificateArn||t[30]!==S||t[31]!==T||t[32]!==y||t[33]!==h?(B=T&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(Y,{children:[(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,$.jsx)(X,{control:g,name:`certificateArn`,render:e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!x.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,$.jsx)(r,{placeholder:`Select certificate`})}),(0,$.jsx)(a,{children:p.map(Oe)})]})}}),(0,$.jsx)(J,{errors:[x.certificateArn]}),(0,$.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[p.length===0&&(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,$.jsx)(O,{className:`ml-auto`,onClick:()=>C(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),(0,$.jsxs)(Y,{children:[(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,$.jsx)(X,{control:g,name:`sslPolicy`,render:e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??`ELBSecurityPolicy-2016-08`,children:[(0,$.jsx)(o,{className:`w-full`,id:`listener-ssl-policy`,children:(0,$.jsx)(r,{})}),(0,$.jsx)(a,{children:h.map(De)})]})}})]}),(0,$.jsx)(l,{onImported:e=>y(`certificateArn`,e,{shouldValidate:!0}),onOpenChange:C,open:S})]}),t[27]=p,t[28]=g,t[29]=x.certificateArn,t[30]=S,t[31]=T,t[32]=y,t[33]=h,t[34]=B):B=t[34];let ee;t[35]===Symbol.for(`react.memo_cache_sentinel`)?(ee=(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`listener-default-tg`,children:`Default target group`})}),t[35]=ee):ee=t[35];let V;t[36]!==x.defaultTargetGroupArn||t[37]!==c?(V=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!x.defaultTargetGroupArn,className:`w-full`,id:`listener-default-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:c.map(Ee)})]})},t[36]=x.defaultTargetGroupArn,t[37]=c,t[38]=V):V=t[38];let H;t[39]!==g||t[40]!==V?(H=(0,$.jsx)(X,{control:g,name:`defaultTargetGroupArn`,render:V}),t[39]=g,t[40]=V,t[41]=H):H=t[41];let U;t[42]===x.defaultTargetGroupArn?U=t[43]:(U=(0,$.jsx)(J,{errors:[x.defaultTargetGroupArn]}),t[42]=x.defaultTargetGroupArn,t[43]=U);let W;t[44]===c.length?W=t[45]:(W=c.length===0&&(0,$.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups available in this VPC. Create one first.`}),t[44]=c.length,t[45]=W);let G;t[46]!==H||t[47]!==U||t[48]!==W?(G=(0,$.jsxs)(Y,{children:[ee,H,U,W]}),t[46]=H,t[47]=U,t[48]=W,t[49]=G):G=t[49];let K;return t[50]!==M||t[51]!==z||t[52]!==B||t[53]!==G?(K=(0,$.jsxs)($.Fragment,{children:[M,z,B,G]}),t[50]=M,t[51]=z,t[52]=B,t[53]=G,t[54]=K):K=t[54],K}function Ee(e){return(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn)}function De(e){return(0,$.jsx)(i,{value:e.Name??``,children:e.Name},e.Name)}function Oe(e){return(0,$.jsx)(i,{value:e.CertificateArn??``,children:we(e)},e.CertificateArn)}function ke({listenerArn:n,targetGroups:r}){let{data:i}=t(z(n)),a=g(),o=f(),s=_(),[l,u]=(0,Q.useState)(!1),[d,p]=(0,Q.useState)(),[m,h]=(0,Q.useState)(),v=i.Rules??[],y=new Map;for(let e of r)e.TargetGroupArn&&e.TargetGroupName&&y.set(e.TargetGroupArn,e.TargetGroupName);return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[a.error&&(0,$.jsx)(e,{error:a.error,msg:`Failed to create rule`}),o.error&&(0,$.jsx)(e,{error:o.error,msg:`Failed to modify rule`}),s.error&&(0,$.jsx)(e,{error:s.error,msg:`Failed to delete rule`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[v.length,` rule`,v.length===1?``:`s`,` (including default)`]}),(0,$.jsx)(O,{onClick:()=>u(!0),size:`sm`,children:`Add rule`})]}),(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Priority`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Conditions`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Forward to`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:v.map(e=>(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Priority}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Ae(e.Conditions,e.IsDefault===!0)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Me(e,y)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:e.IsDefault===!0?(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Edit via listener`}):(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(O,{"aria-label":`Edit rule ${e.Priority}`,onClick:()=>p(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(xe,{className:`size-4`})}),(0,$.jsx)(O,{"aria-label":`Delete rule ${e.Priority}`,onClick:()=>h(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]},e.RuleArn??e.Priority??`default`))})]})}),(0,$.jsx)(Pe,{isPending:a.isPending,mode:`add`,onOpenChange:u,onSubmit:async e=>{try{await a.mutateAsync({listenerArn:n,priority:e.priority,conditions:e.conditions.map(Ne),forwardTargetGroupArn:e.forwardTargetGroupArn}),u(!1)}catch{}},open:l,targetGroups:r}),(0,$.jsx)(Pe,{initialRule:d,isPending:o.isPending,mode:`edit`,onOpenChange:e=>!e&&p(void 0),onSubmit:async e=>{if(d?.RuleArn)try{await o.mutateAsync({ruleArn:d.RuleArn,listenerArn:n,conditions:e.conditions.map(Ne),forwardTargetGroupArn:e.forwardTargetGroupArn}),p(void 0)}catch{}},open:d!==void 0,targetGroups:r}),(0,$.jsx)(oe,{description:m?`Delete rule with priority ${m.Priority}? This cannot be undone.`:``,isPending:s.isPending,onConfirm:async()=>{if(m?.RuleArn)try{await s.mutateAsync({ruleArn:m.RuleArn,listenerArn:n})}finally{h(void 0)}},onOpenChange:e=>!e&&h(void 0),open:m!==void 0,title:`Delete rule`})]})}function Ae(e,t){return t?`default`:!e||e.length===0?`—`:e.map(e=>{let t=je(e);return`${e.Field}: ${t.join(`, `)}`}).join(` AND `)}function je(e){if(e.Field===void 0)return e.Values??[];switch(e.Field){case`host-header`:return e.HostHeaderConfig?.Values??e.Values??[];case`path-pattern`:return e.PathPatternConfig?.Values??e.Values??[];case`http-header`:{let t=e.HttpHeaderConfig?.HttpHeaderName??`?`;return(e.HttpHeaderConfig?.Values??[]).map(e=>`${t}=${e}`)}case`http-request-method`:return e.HttpRequestMethodConfig?.Values??[];case`source-ip`:return e.SourceIpConfig?.Values??[];case`query-string`:return(e.QueryStringConfig?.Values??[]).map(e=>e.Key?`${e.Key}=${e.Value??``}`:e.Value??``);default:return e.Values??[]}}function Me(e,t){let n=e.Actions?.[0];return n?n.Type===`forward`&&n.TargetGroupArn?t.get(n.TargetGroupArn)??n.TargetGroupArn:n.Type??`—`:`—`}function Ne(e){let t=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);return e.field===`query-string`?{field:e.field,values:[],queryStringPairs:t.map(e=>{let t=e.indexOf(`=`);return t===-1?{value:e}:{key:e.slice(0,t),value:e.slice(t+1)}})}:{field:e.field,values:t,httpHeaderName:e.httpHeaderName}}function Pe({open:e,onOpenChange:t,onSubmit:n,isPending:c,targetGroups:l,mode:u,initialRule:d}){let f=[{field:`path-pattern`,rawValues:``}],p=ce({resolver:ue(de),defaultValues:{priority:1,conditions:f,forwardTargetGroupArn:``}}),{fields:m,append:h,remove:g}=le({control:p.control,name:`conditions`});(0,Q.useEffect)(()=>{if(u===`add`){e||p.reset({priority:1,conditions:f,forwardTargetGroupArn:``});return}if(!d)return;let t=Number(d.Priority??1),n=d.Conditions?.map(e=>ze(e))??f;p.reset({priority:Number.isFinite(t)?t:1,conditions:n.length>0?n:f,forwardTargetGroupArn:d.Actions?.[0]?.TargetGroupArn??``})},[d,e,u]);let _=p.handleSubmit(n);return(0,$.jsx)(ae,{onOpenChange:t,open:e,children:(0,$.jsxs)(G,{className:`grid-cols-[minmax(0,1fr)]`,style:{maxWidth:`40rem`,width:`calc(100vw - 2rem)`},children:[(0,$.jsxs)(ie,{children:[(0,$.jsx)(K,{children:u===`add`?`Add rule`:`Edit rule`}),(0,$.jsx)(W,{children:`Routing rules match incoming requests and forward them to a target group. Rules evaluate in priority order; the listener default applies when no rule matches.`})]}),(0,$.jsxs)(`form`,{className:`min-w-0 space-y-4`,onSubmit:e=>{e.preventDefault(),_()},children:[(0,$.jsxs)(Y,{children:[(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`rule-priority`,children:`Priority`})}),(0,$.jsx)(k,{"aria-invalid":!!p.formState.errors.priority,id:`rule-priority`,inputMode:`numeric`,type:`number`,...p.register(`priority`,{valueAsNumber:!0})}),(0,$.jsx)(J,{errors:[p.formState.errors.priority]})]}),(0,$.jsxs)(`div`,{className:`space-y-3`,children:[(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsx)(q,{children:`Conditions`}),(0,$.jsx)(O,{disabled:m.length>=5,onClick:()=>h({field:`path-pattern`,rawValues:``}),size:`sm`,type:`button`,variant:`outline`,children:`Add condition`})]}),m.map((e,t)=>(0,$.jsx)(Fe,{form:p,index:t,onRemove:m.length>1?()=>g(t):void 0},e.id))]}),(0,$.jsxs)(Y,{children:[(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`rule-forward-tg`,children:`Forward to target group`})}),(0,$.jsx)(X,{control:p.control,name:`forwardTargetGroupArn`,render:({field:e})=>(0,$.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!p.formState.errors.forwardTargetGroupArn,className:`w-full`,id:`rule-forward-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:l.map(e=>(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,$.jsx)(J,{errors:[p.formState.errors.forwardTargetGroupArn]})]})]}),(0,$.jsxs)(ne,{children:[(0,$.jsx)(re,{disabled:c,children:`Cancel`}),(0,$.jsx)(te,{disabled:c,onClick:_,children:Be(u,c)})]})]})})}function Fe(e){let t=(0,Z.c)(54),{form:n,index:l,onRemove:u}=e,d,f,p,m,h,g,_,v,y,b;if(t[0]!==n||t[1]!==l||t[2]!==u){let e=n.watch(`conditions.${l}.field`);p=n.formState.errors.conditions?.[l],v=`space-y-2 rounded-md border p-3`;let x=`rule-cond-${l}-field`,S;t[13]===x?S=t[14]:(S=(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:x,children:`Type`})}),t[13]=x,t[14]=S);let C=`conditions.${l}.field`,w;t[15]===l?w=t[16]:(w=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`rule-cond-${l}-field`,children:(0,$.jsx)(r,{})}),(0,$.jsxs)(a,{children:[(0,$.jsx)(i,{value:`path-pattern`,children:`Path pattern`}),(0,$.jsx)(i,{value:`host-header`,children:`Host header`}),(0,$.jsx)(i,{value:`http-header`,children:`HTTP header`}),(0,$.jsx)(i,{value:`http-request-method`,children:`HTTP method`}),(0,$.jsx)(i,{value:`source-ip`,children:`Source IP CIDR`}),(0,$.jsx)(i,{value:`query-string`,children:`Query string`})]})]})},t[15]=l,t[16]=w);let T;t[17]!==n.control||t[18]!==C||t[19]!==w?(T=(0,$.jsx)(X,{control:n.control,name:C,render:w}),t[17]=n.control,t[18]=C,t[19]=w,t[20]=T):T=t[20];let E;t[21]!==T||t[22]!==S?(E=(0,$.jsxs)(`div`,{className:`flex-1`,children:[S,T]}),t[21]=T,t[22]=S,t[23]=E):E=t[23];let D;t[24]!==l||t[25]!==u?(D=u&&(0,$.jsx)(O,{"aria-label":`Remove condition ${l+1}`,className:`self-end`,onClick:u,size:`sm`,type:`button`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})}),t[24]=l,t[25]=u,t[26]=D):D=t[26],t[27]!==E||t[28]!==D?(y=(0,$.jsxs)(`div`,{className:`flex gap-2`,children:[E,D]}),t[27]=E,t[28]=D,t[29]=y):y=t[29],b=e===`http-header`&&(0,$.jsxs)(Y,{children:[(0,$.jsx)(q,{children:(0,$.jsx)(`label`,{htmlFor:`rule-cond-${l}-hdr-name`,children:`Header name`})}),(0,$.jsx)(k,{"aria-invalid":!!p?.httpHeaderName,id:`rule-cond-${l}-hdr-name`,placeholder:`X-Tenant`,...n.register(`conditions.${l}.httpHeaderName`)}),(0,$.jsx)(J,{errors:[p?.httpHeaderName]})]}),f=Y;let A=`rule-cond-${l}-values`,j;t[30]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(comma or newline separated, max 5)`}),t[30]=j):j=t[30],t[31]===A?_=t[32]:(_=(0,$.jsx)(q,{children:(0,$.jsxs)(`label`,{htmlFor:A,children:[`Values`,` `,j]})}),t[31]=A,t[32]=_),d=k,m=!!p?.rawValues,h=`rule-cond-${l}-values`,g=Ie(e),t[0]=n,t[1]=l,t[2]=u,t[3]=d,t[4]=f,t[5]=p,t[6]=m,t[7]=h,t[8]=g,t[9]=_,t[10]=v,t[11]=y,t[12]=b}else d=t[3],f=t[4],p=t[5],m=t[6],h=t[7],g=t[8],_=t[9],v=t[10],y=t[11],b=t[12];let x;t[33]!==n||t[34]!==l?(x=n.register(`conditions.${l}.rawValues`),t[33]=n,t[34]=l,t[35]=x):x=t[35];let S;t[36]!==d||t[37]!==m||t[38]!==h||t[39]!==g||t[40]!==x?(S=(0,$.jsx)(d,{"aria-invalid":m,id:h,placeholder:g,...x}),t[36]=d,t[37]=m,t[38]=h,t[39]=g,t[40]=x,t[41]=S):S=t[41];let C=p?.rawValues,w;t[42]===C?w=t[43]:(w=(0,$.jsx)(J,{errors:[C]}),t[42]=C,t[43]=w);let T;t[44]!==f||t[45]!==w||t[46]!==_||t[47]!==S?(T=(0,$.jsxs)(f,{children:[_,S,w]}),t[44]=f,t[45]=w,t[46]=_,t[47]=S,t[48]=T):T=t[48];let E;return t[49]!==T||t[50]!==v||t[51]!==y||t[52]!==b?(E=(0,$.jsxs)(`div`,{className:v,children:[y,b,T]}),t[49]=T,t[50]=v,t[51]=y,t[52]=b,t[53]=E):E=t[53],E}function Ie(e){switch(e){case`path-pattern`:return`/api/*`;case`host-header`:return`*.example.com`;case`http-header`:return`acme`;case`http-request-method`:return`GET, POST`;case`source-ip`:return`10.0.0.0/8`;case`query-string`:return`key=value`;default:return``}}var Le=new Set([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]);function Re(e){return e!==void 0&&Le.has(e)}function ze(e){let t=je(e);return{field:Re(e.Field)?e.Field:`path-pattern`,httpHeaderName:e.HttpHeaderConfig?.HttpHeaderName,rawValues:t.join(`, `)}}function Be(e,t){return t?e===`add`?`Adding…`:`Saving…`:e===`add`?`Add rule`:`Save changes`}function Ve(e){if(e.Protocol!==`HTTPS`)return`—`;let t=e.Certificates?.[0]?.CertificateArn?.split(`/`).pop(),n=e.SslPolicy;return t&&n?`${n} · ${t}`:n??t??`—`}function He({loadBalancerArn:n,vpcId:r}){let{data:i}=t(M(n)),{data:a}=t(A),{data:o}=t(T),{data:s}=t(L),l=v(),d=u(),f=y(),[p,m]=(0,Q.useState)(!1),[h,g]=(0,Q.useState)(),[_,b]=(0,Q.useState)(),[x,S]=(0,Q.useState)(),C=i.Listeners??[],w=a.TargetGroups??[],E=w.filter(e=>e.VpcId===r),D=o.CertificateSummaryList??[],k=s.SslPolicies??[],j=new Map;for(let e of w)e.TargetGroupArn&&e.TargetGroupName&&j.set(e.TargetGroupArn,e.TargetGroupName);let N=e=>{let t=e.DefaultActions?.[0];if(!t)return`—`;if(t.Type===`forward`&&t.TargetGroupArn){let e=j.get(t.TargetGroupArn);return e?`forward → ${e}`:`forward → ${t.TargetGroupArn}`}return t.Type??`—`};return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[l.error&&(0,$.jsx)(e,{error:l.error,msg:`Failed to create listener`}),f.error&&(0,$.jsx)(e,{error:f.error,msg:`Failed to modify listener`}),d.error&&(0,$.jsx)(e,{error:d.error,msg:`Failed to delete listener`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[C.length,` listener`,C.length===1?``:`s`]}),(0,$.jsx)(O,{onClick:()=>m(!0),size:`sm`,children:`Add listener`})]}),C.length===0?(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No listeners configured.`}):(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`TLS`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Default action`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Listener ARN`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:C.map(e=>{let t=e.ListenerArn??``,n=x===t;return(0,$.jsxs)(Q.Fragment,{children:[(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol}),(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Port}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Ve(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:N(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(O,{"aria-label":`${n?`Hide`:`Show`} rules for listener ${e.Protocol}:${e.Port}`,onClick:()=>S(n?void 0:t),size:`sm`,variant:`ghost`,children:n?`Hide rules`:`Rules`}),(0,$.jsx)(O,{"aria-label":`Edit listener ${e.Protocol}:${e.Port}`,onClick:()=>g(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(xe,{className:`size-4`})}),(0,$.jsx)(O,{"aria-label":`Delete listener ${e.Protocol}:${e.Port}`,onClick:()=>b(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]}),n&&t&&(0,$.jsx)(`tr`,{className:`border-b bg-muted/30 last:border-0`,children:(0,$.jsx)(`td`,{className:`px-4 py-3`,colSpan:6,children:(0,$.jsx)(ke,{listenerArn:t,targetGroups:E})})})]},t)})})]})}),(0,$.jsx)(We,{certificates:D,isPending:l.isPending,onOpenChange:m,onSubmit:async e=>{try{await l.mutateAsync({loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn,certificateArn:e.certificateArn,sslPolicy:e.sslPolicy}),m(!1)}catch{}},open:p,sslPolicies:k,targetGroups:E}),(0,$.jsx)(Ge,{certificates:D,isPending:f.isPending,listener:h,onOpenChange:e=>!e&&g(void 0),onSubmit:async e=>{if(h?.ListenerArn)try{await f.mutateAsync({listenerArn:h.ListenerArn,loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn,certificateArn:e.certificateArn,sslPolicy:e.sslPolicy}),g(void 0)}catch{}},sslPolicies:k,targetGroups:E}),(0,$.jsx)(oe,{description:_?`Delete listener ${_.Protocol}:${_.Port}? This cannot be undone.`:``,isPending:d.isPending,onConfirm:async()=>{if(_?.ListenerArn)try{await d.mutateAsync(_.ListenerArn)}finally{b(void 0)}},onOpenChange:e=>!e&&b(void 0),open:_!==void 0,title:`Delete listener`})]})}var Ue={protocol:`HTTP`,port:80,defaultTargetGroupArn:``};function We(e){let t=(0,Z.c)(36),{open:n,onOpenChange:r,targetGroups:i,certificates:a,sslPolicies:o,isPending:s,onSubmit:c}=e,l;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(l={resolver:ue(fe),defaultValues:Ue},t[0]=l):l=t[0];let u=ce(l),d,f;t[1]!==u||t[2]!==n?(d=()=>{n||u.reset(Ue)},f=[n,u],t[1]=u,t[2]=n,t[3]=d,t[4]=f):(d=t[3],f=t[4]),(0,Q.useEffect)(d,f);let p;t[5]!==u||t[6]!==c?(p=u.handleSubmit(c),t[5]=u,t[6]=c,t[7]=p):p=t[7];let m=p,h;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(h={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[8]=h):h=t[8];let g;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,$.jsxs)(ie,{children:[(0,$.jsx)(K,{children:`Add listener`}),(0,$.jsx)(W,{children:`Listeners forward incoming traffic to a default target group.`})]}),t[9]=g):g=t[9];let _;t[10]===m?_=t[11]:(_=e=>{e.preventDefault(),m()},t[10]=m,t[11]=_);let v;t[12]!==a||t[13]!==u||t[14]!==o||t[15]!==i?(v=(0,$.jsx)(Te,{certificates:a,form:u,sslPolicies:o,targetGroups:i}),t[12]=a,t[13]=u,t[14]=o,t[15]=i,t[16]=v):v=t[16];let y;t[17]!==_||t[18]!==v?(y=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:_,children:v}),t[17]=_,t[18]=v,t[19]=y):y=t[19];let b;t[20]===s?b=t[21]:(b=(0,$.jsx)(re,{disabled:s,children:`Cancel`}),t[20]=s,t[21]=b);let x=s?`Adding…`:`Add listener`,S;t[22]!==m||t[23]!==s||t[24]!==x?(S=(0,$.jsx)(te,{disabled:s,onClick:m,children:x}),t[22]=m,t[23]=s,t[24]=x,t[25]=S):S=t[25];let C;t[26]!==b||t[27]!==S?(C=(0,$.jsxs)(ne,{children:[b,S]}),t[26]=b,t[27]=S,t[28]=C):C=t[28];let w;t[29]!==C||t[30]!==y?(w=(0,$.jsxs)(G,{className:`grid-cols-[minmax(0,1fr)]`,style:h,children:[g,y,C]}),t[29]=C,t[30]=y,t[31]=w):w=t[31];let T;return t[32]!==r||t[33]!==n||t[34]!==w?(T=(0,$.jsx)(ae,{onOpenChange:r,open:n,children:w}),t[32]=r,t[33]=n,t[34]=w,t[35]=T):T=t[35],T}function Ge(e){let t=(0,Z.c)(36),{listener:n,onOpenChange:r,targetGroups:i,certificates:a,sslPolicies:o,isPending:s,onSubmit:c}=e,l=n!==void 0,u;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(u={resolver:ue(fe),defaultValues:{protocol:`HTTP`,port:80,defaultTargetGroupArn:``}},t[0]=u):u=t[0];let d=ce(u),f,p;t[1]!==d||t[2]!==n?(f=()=>{if(!n)return;let e=n.Protocol===`HTTPS`?`HTTPS`:`HTTP`;d.reset({protocol:e,port:n.Port??80,defaultTargetGroupArn:n.DefaultActions?.[0]?.TargetGroupArn??``,certificateArn:e===`HTTPS`?n.Certificates?.[0]?.CertificateArn:void 0,sslPolicy:e===`HTTPS`?n.SslPolicy:void 0})},p=[n,d],t[1]=d,t[2]=n,t[3]=f,t[4]=p):(f=t[3],p=t[4]),(0,Q.useEffect)(f,p);let m;t[5]!==d||t[6]!==c?(m=d.handleSubmit(c),t[5]=d,t[6]=c,t[7]=m):m=t[7];let h=m,g;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(g={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[8]=g):g=t[8];let _;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,$.jsxs)(ie,{children:[(0,$.jsx)(K,{children:`Edit listener`}),(0,$.jsx)(W,{children:`Update port, protocol, or default target group. Changes apply without dropping the listener.`})]}),t[9]=_):_=t[9];let v;t[10]===h?v=t[11]:(v=e=>{e.preventDefault(),h()},t[10]=h,t[11]=v);let y;t[12]!==a||t[13]!==d||t[14]!==o||t[15]!==i?(y=(0,$.jsx)(Te,{certificates:a,form:d,sslPolicies:o,targetGroups:i}),t[12]=a,t[13]=d,t[14]=o,t[15]=i,t[16]=y):y=t[16];let b;t[17]!==v||t[18]!==y?(b=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:v,children:y}),t[17]=v,t[18]=y,t[19]=b):b=t[19];let x;t[20]===s?x=t[21]:(x=(0,$.jsx)(re,{disabled:s,children:`Cancel`}),t[20]=s,t[21]=x);let S=s?`Saving…`:`Save changes`,C;t[22]!==h||t[23]!==s||t[24]!==S?(C=(0,$.jsx)(te,{disabled:s,onClick:h,children:S}),t[22]=h,t[23]=s,t[24]=S,t[25]=C):C=t[25];let w;t[26]!==x||t[27]!==C?(w=(0,$.jsxs)(ne,{children:[x,C]}),t[26]=x,t[27]=C,t[28]=w):w=t[28];let T;t[29]!==w||t[30]!==b?(T=(0,$.jsxs)(G,{className:`grid-cols-[minmax(0,1fr)]`,style:g,children:[_,b,w]}),t[29]=w,t[30]=b,t[31]=T):T=t[31];let E;return t[32]!==r||t[33]!==l||t[34]!==T?(E=(0,$.jsx)(ae,{onOpenChange:r,open:l,children:T}),t[32]=r,t[33]=l,t[34]=T,t[35]=E):E=t[35],E}function Ke(e,t){if(e.length!==t.length)return!1;let n=new Set(t);return e.every(e=>n.has(e))}function qe(e){let t=(0,Z.c)(46),{available:n,current:r,onSubmit:i,isPending:a,error:o,isSuccess:s}=e,c;t[0]===r?c=t[1]:(c=[...r],t[0]=r,t[1]=c);let l=c,[u,d]=(0,Q.useState)(l),f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=e=>{d(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},t[2]=f):f=t[2];let p=f,m;t[3]===l?m=t[4]:(m=()=>d(l),t[3]=l,t[4]=m);let h=m,g;t[5]!==l||t[6]!==u?(g=Ke(u,l),t[5]=l,t[6]=u,t[7]=g):g=t[7];let _=!g,v=u.length===0,y;t[8]!==_||t[9]!==v||t[10]!==i||t[11]!==u?(y=e=>{e.preventDefault(),!(!_||v)&&i(u)},t[8]=_,t[9]=v,t[10]=i,t[11]=u,t[12]=y):y=t[12];let b;t[13]===o?b=t[14]:(b=o&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive`,role:`alert`,children:o.message||`Failed to update security groups`}),t[13]=o,t[14]=b);let x;t[15]!==_||t[16]!==s?(x=s&&!_&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-emerald-500 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400`,role:`status`,children:`Security groups updated.`}),t[15]=_,t[16]=s,t[17]=x):x=t[17];let S;t[18]!==n||t[19]!==u?(S=n.length===0?(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No security groups in this VPC.`}):n.map(e=>(0,$.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,$.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:u.includes(e.GroupId??``),onChange:()=>p(e.GroupId??``),type:`checkbox`}),(0,$.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId)),t[18]=n,t[19]=u,t[20]=S):S=t[20];let C;t[21]===v?C=t[22]:(C=v&&(0,$.jsx)(`p`,{className:`text-xs text-destructive`,children:`At least one security group is required.`}),t[21]=v,t[22]=C);let w;t[23]!==S||t[24]!==C?(w=(0,$.jsxs)(`div`,{className:`flex flex-col gap-1 p-4`,children:[S,C]}),t[23]=S,t[24]=C,t[25]=w):w=t[25];let T=_?`Unsaved changes`:`No changes`,E;t[26]===T?E=t[27]:(E=(0,$.jsx)(`span`,{className:`mr-auto text-xs text-muted-foreground`,children:T}),t[26]=T,t[27]=E);let D=a||!_,k;t[28]!==h||t[29]!==D?(k=(0,$.jsx)(O,{disabled:D,onClick:h,size:`sm`,type:`button`,variant:`secondary`,children:`Reset`}),t[28]=h,t[29]=D,t[30]=k):k=t[30];let A=a||!_||v,j=a?`Saving…`:`Save changes`,M;t[31]!==A||t[32]!==j?(M=(0,$.jsx)(O,{disabled:A,size:`sm`,type:`submit`,children:j}),t[31]=A,t[32]=j,t[33]=M):M=t[33];let N;t[34]!==E||t[35]!==k||t[36]!==M?(N=(0,$.jsxs)(`div`,{className:`flex items-center justify-end gap-2 border-t p-4`,children:[E,k,M]}),t[34]=E,t[35]=k,t[36]=M,t[37]=N):N=t[37];let P;t[38]!==w||t[39]!==N?(P=(0,$.jsxs)(`div`,{className:`rounded-lg border bg-card`,children:[w,N]}),t[38]=w,t[39]=N,t[40]=P):P=t[40];let F;return t[41]!==P||t[42]!==y||t[43]!==b||t[44]!==x?(F=(0,$.jsxs)(`form`,{onSubmit:y,children:[b,x,P]}),t[41]=P,t[42]=y,t[43]=b,t[44]=x,t[45]=F):F=t[45],F}function Je({arn:r}){let i=j(),{data:a}=t(x(r)),{data:o}=t(C(r)),{data:s}=t(b([r])),{data:l}=t(R),{data:u}=t(P),f=h(),g=d(),_=p(),v=m(),[y,S]=(0,Q.useState)(!1),w=a.LoadBalancers?.[0];if(!w?.LoadBalancerArn)return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`Load balancer not found.`})]});let T=async()=>{try{await f.mutateAsync(r),i({to:`/ec2/describe-load-balancers`})}finally{S(!1)}},D=l.Subnets??[],k=e=>{let t=D.find(t=>t.SubnetId===e);if(!t)return e;let n=E(t.Tags),r=t.CidrBlock?` (${t.CidrBlock})`:``;return n?`${e} — ${n}${r}`:`${e}${r}`},A=o.Attributes??[],M=s?.TagDescriptions?.find(e=>e.ResourceArn===r)?.Tags??[],F=(w.SecurityGroups??[]).filter(e=>e!==void 0),I=(u.SecurityGroups??[]).filter(e=>e.VpcId===w.VpcId),L=w.Type===`network`;return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),f.error&&(0,$.jsx)(e,{error:f.error,msg:`Failed to delete load balancer`}),(0,$.jsxs)(`div`,{className:`space-y-6`,children:[(0,$.jsx)(ee,{actions:(0,$.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,$.jsxs)(O,{onClick:()=>S(!0),size:`sm`,variant:`destructive`,children:[(0,$.jsx)(c,{className:`size-4`}),`Delete`]}),(0,$.jsx)(V,{state:w.State?.Code})]}),subtitle:`Load Balancer Details`,title:w.LoadBalancerName??w.LoadBalancerArn}),(0,$.jsxs)(be,{defaultValue:`overview`,children:[(0,$.jsxs)(ge,{children:[(0,$.jsx)(he,{value:`overview`,children:`Overview`}),(0,$.jsx)(he,{value:`listeners`,children:`Listeners`}),!L&&(0,$.jsx)(he,{value:`security-groups`,children:`Security groups`}),(0,$.jsx)(he,{value:`attributes`,children:`Attributes`}),(0,$.jsx)(he,{value:`tags`,children:`Tags`})]}),(0,$.jsxs)(ve,{value:`overview`,children:[(0,$.jsxs)(H,{children:[(0,$.jsx)(H.Header,{children:`Load balancer`}),(0,$.jsxs)(H.Content,{children:[(0,$.jsx)(U,{label:`ARN`,value:w.LoadBalancerArn}),(0,$.jsx)(U,{label:`DNS name`,value:w.DNSName}),(0,$.jsx)(U,{label:`State`,value:w.State?.Reason?`${w.State.Code} — ${w.State.Reason}`:w.State?.Code}),(0,$.jsx)(U,{label:`Type`,value:w.Type}),(0,$.jsx)(U,{label:`Scheme`,value:w.Scheme}),(0,$.jsx)(U,{label:`IP address type`,value:w.IpAddressType}),(0,$.jsx)(U,{label:`VPC`,value:w.VpcId}),(0,$.jsx)(U,{label:`Created at`,value:N(w.CreatedTime)})]})]}),(0,$.jsxs)(H,{children:[(0,$.jsx)(H.Header,{children:`Availability zones`}),(0,$.jsx)(H.Content,{children:(w.AvailabilityZones??[]).map(e=>(0,$.jsx)(U,{label:e.ZoneName??`—`,value:e.SubnetId?k(e.SubnetId):void 0},`${e.ZoneName}-${e.SubnetId}`))})]}),(w.SecurityGroups??[]).length>0&&(0,$.jsxs)(H,{children:[(0,$.jsx)(H.Header,{children:`Security groups`}),(0,$.jsx)(H.Content,{children:(w.SecurityGroups??[]).map(e=>(0,$.jsx)(U,{label:`Security group`,value:e},e))})]})]}),(0,$.jsx)(ve,{value:`listeners`,children:(0,$.jsx)(He,{loadBalancerArn:r,vpcId:w.VpcId})}),!L&&(0,$.jsx)(ve,{value:`security-groups`,children:(0,$.jsx)(qe,{available:I,current:F,error:v.error,isPending:v.isPending,isSuccess:v.isSuccess,onSubmit:e=>v.mutate({loadBalancerArn:r,securityGroupIds:e})})}),(0,$.jsx)(ve,{value:`attributes`,children:(0,$.jsx)(_e,{attributes:A,error:g.error,isPending:g.isPending,isSuccess:g.isSuccess,onSubmit:e=>{g.mutate({loadBalancerArn:r,attributes:e})},specs:ye})}),(0,$.jsx)(ve,{value:`tags`,children:(0,$.jsx)(me,{error:_.error,isPending:_.isPending,isSuccess:_.isSuccess,onSubmit:e=>_.mutate({resourceArn:r,tags:e,initialKeys:M.map(e=>e.Key??``).filter(e=>e.length>0)}),tags:M})})]})]}),(0,$.jsx)(oe,{description:`Are you sure you want to delete load balancer "${w.LoadBalancerName??w.LoadBalancerArn}"? This action cannot be undone.`,isPending:f.isPending,onConfirm:T,onOpenChange:S,open:y,title:`Delete load balancer`})]})}function Ye(){let e=(0,Z.c)(2),{id:t}=I.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,$.jsx)(Je,{arn:n}),e[0]=n,e[1]=r),r}export{Ye as component}; \ No newline at end of file +import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{t as l}from"./certificate-import-dialog.js";import{a as u,d,f,g as p,h as m,o as h,r as g,s as _,t as v,u as y}from"./elbv2.js";import{A as b,D as x,Do as S,E as C,Eo as w,F as T,Ka as E,Ko as D,La as O,Li as k,N as A,So as j,T as M,Ua as N,Z as P,co as F,i as ee,k as I,tt as te,w as L,yo as R}from"./index.js";import{t as z}from"./page-heading.js";import{t as B}from"./state-badge.js";import{n as V,t as H}from"./detail-row.js";import{a as U,i as W,l as G,n as K,o as q,r as J,s as ne,t as re}from"./alert-dialog.js";import{a as Y,r as X,t as Z}from"./field.js";import{t as ie}from"./delete-confirmation-dialog.js";import{a as ae,i as oe,n as se,r as ce,t as le}from"./zod.js";import{c as ue,i as de,n as fe,o as pe,s as me,t as he}from"./elbv22.js";import{a as ge,i as _e,n as ve,o as ye,r as be,s as xe,t as Se}from"./tabs.js";var Ce=F(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),we=R(),Q=D(S(),1),$=w(),Te=80,Ee=443;function De(e){let t=e.CertificateArn?.split(`/`).pop()??e.CertificateArn??``;return e.DomainName?`${e.DomainName} · ${t}`:t}function Oe(e){let t=(0,we.c)(59),{form:n,protocols:i,targetGroups:c,certificates:u,sslPolicies:d}=e,f;t[0]===u?f=t[1]:(f=u===void 0?[]:u,t[0]=u,t[1]=f);let p=f,m;t[2]===d?m=t[3]:(m=d===void 0?[]:d,t[2]=d,t[3]=m);let h=m,{control:g,register:_,getValues:v,setValue:y,formState:b}=n,{errors:x}=b,[S,C]=(0,Q.useState)(!1),w;t[4]===g?w=t[5]:(w={control:g,name:`protocol`},t[4]=g,t[5]=w);let T=ae(w),E=T===`HTTPS`,D;t[6]===T?D=t[7]:(D=me(T),t[6]=T,t[7]=D);let A=D,j;t[8]!==v||t[9]!==y?(j=e=>{if(!e)return;let t=v(`port`);me(e)?(t===Te&&y(`port`,Ee),e===`HTTPS`?v(`sslPolicy`)||y(`sslPolicy`,he):y(`sslPolicy`,void 0)):(t===Ee&&y(`port`,Te),y(`certificateArn`,void 0),y(`sslPolicy`,void 0))},t[8]=v,t[9]=y,t[10]=j):j=t[10];let M=j,N;t[11]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),t[11]=N):N=t[11];let P;t[12]!==M||t[13]!==i?(P=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:e=>{t.onChange(e),M(e)},value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,$.jsx)(r,{})}),(0,$.jsx)(a,{children:i.map(Me)})]})},t[12]=M,t[13]=i,t[14]=P):P=t[14];let F;t[15]!==g||t[16]!==P?(F=(0,$.jsxs)(Z,{children:[N,(0,$.jsx)(se,{control:g,name:`protocol`,render:P})]}),t[15]=g,t[16]=P,t[17]=F):F=t[17];let ee;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(ee=(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),t[18]=ee):ee=t[18];let I=!!x.port,te=A?`443`:`80`,L;t[19]===_?L=t[20]:(L=_(`port`,{valueAsNumber:!0}),t[19]=_,t[20]=L);let R;t[21]!==I||t[22]!==te||t[23]!==L?(R=(0,$.jsx)(k,{"aria-invalid":I,id:`listener-port`,inputMode:`numeric`,placeholder:te,type:`number`,...L}),t[21]=I,t[22]=te,t[23]=L,t[24]=R):R=t[24];let z;t[25]===x.port?z=t[26]:(z=(0,$.jsx)(X,{errors:[x.port]}),t[25]=x.port,t[26]=z);let B;t[27]!==R||t[28]!==z?(B=(0,$.jsxs)(Z,{children:[ee,R,z]}),t[27]=R,t[28]=z,t[29]=B):B=t[29];let V;t[30]!==p||t[31]!==g||t[32]!==x.certificateArn||t[33]!==S||t[34]!==E||t[35]!==A||t[36]!==y||t[37]!==h?(V=A&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(Z,{children:[(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,$.jsx)(se,{control:g,name:`certificateArn`,render:e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!x.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,$.jsx)(r,{placeholder:`Select certificate`})}),(0,$.jsx)(a,{children:p.map(je)})]})}}),(0,$.jsx)(X,{errors:[x.certificateArn]}),(0,$.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[p.length===0&&(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,$.jsx)(O,{className:`ml-auto`,onClick:()=>C(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),E&&(0,$.jsxs)(Z,{children:[(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,$.jsx)(se,{control:g,name:`sslPolicy`,render:e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??`ELBSecurityPolicy-2016-08`,children:[(0,$.jsx)(o,{className:`w-full`,id:`listener-ssl-policy`,children:(0,$.jsx)(r,{})}),(0,$.jsx)(a,{children:h.map(Ae)})]})}})]}),(0,$.jsx)(l,{onImported:e=>y(`certificateArn`,e,{shouldValidate:!0}),onOpenChange:C,open:S})]}),t[30]=p,t[31]=g,t[32]=x.certificateArn,t[33]=S,t[34]=E,t[35]=A,t[36]=y,t[37]=h,t[38]=V):V=t[38];let H;t[39]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`listener-default-tg`,children:`Default target group`})}),t[39]=H):H=t[39];let U;t[40]!==x.defaultTargetGroupArn||t[41]!==c?(U=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!x.defaultTargetGroupArn,className:`w-full`,id:`listener-default-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:c.map(ke)})]})},t[40]=x.defaultTargetGroupArn,t[41]=c,t[42]=U):U=t[42];let W;t[43]!==g||t[44]!==U?(W=(0,$.jsx)(se,{control:g,name:`defaultTargetGroupArn`,render:U}),t[43]=g,t[44]=U,t[45]=W):W=t[45];let G;t[46]===x.defaultTargetGroupArn?G=t[47]:(G=(0,$.jsx)(X,{errors:[x.defaultTargetGroupArn]}),t[46]=x.defaultTargetGroupArn,t[47]=G);let K;t[48]===c.length?K=t[49]:(K=c.length===0&&(0,$.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups available in this VPC. Create one first.`}),t[48]=c.length,t[49]=K);let q;t[50]!==W||t[51]!==G||t[52]!==K?(q=(0,$.jsxs)(Z,{children:[H,W,G,K]}),t[50]=W,t[51]=G,t[52]=K,t[53]=q):q=t[53];let J;return t[54]!==F||t[55]!==B||t[56]!==V||t[57]!==q?(J=(0,$.jsxs)($.Fragment,{children:[F,B,V,q]}),t[54]=F,t[55]=B,t[56]=V,t[57]=q,t[58]=J):J=t[58],J}function ke(e){return(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn)}function Ae(e){return(0,$.jsx)(i,{value:e.Name??``,children:e.Name},e.Name)}function je(e){return(0,$.jsx)(i,{value:e.CertificateArn??``,children:De(e)},e.CertificateArn)}function Me(e){return(0,$.jsx)(i,{value:e,children:e},e)}function Ne({listenerArn:n,targetGroups:r}){let{data:i}=t(L(n)),a=g(),o=f(),s=_(),[l,u]=(0,Q.useState)(!1),[d,p]=(0,Q.useState)(),[m,h]=(0,Q.useState)(),v=i.Rules??[],y=new Map;for(let e of r)e.TargetGroupArn&&e.TargetGroupName&&y.set(e.TargetGroupArn,e.TargetGroupName);return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[a.error&&(0,$.jsx)(e,{error:a.error,msg:`Failed to create rule`}),o.error&&(0,$.jsx)(e,{error:o.error,msg:`Failed to modify rule`}),s.error&&(0,$.jsx)(e,{error:s.error,msg:`Failed to delete rule`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[v.length,` rule`,v.length===1?``:`s`,` (including default)`]}),(0,$.jsx)(O,{onClick:()=>u(!0),size:`sm`,children:`Add rule`})]}),(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Priority`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Conditions`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Forward to`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:v.map(e=>(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Priority}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Pe(e.Conditions,e.IsDefault===!0)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Ie(e,y)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:e.IsDefault===!0?(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Edit via listener`}):(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(O,{"aria-label":`Edit rule ${e.Priority}`,onClick:()=>p(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(Ce,{className:`size-4`})}),(0,$.jsx)(O,{"aria-label":`Delete rule ${e.Priority}`,onClick:()=>h(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]},e.RuleArn??e.Priority??`default`))})]})}),(0,$.jsx)(Re,{isPending:a.isPending,mode:`add`,onOpenChange:u,onSubmit:async e=>{try{await a.mutateAsync({listenerArn:n,priority:e.priority,conditions:e.conditions.map(Le),forwardTargetGroupArn:e.forwardTargetGroupArn}),u(!1)}catch{}},open:l,targetGroups:r}),(0,$.jsx)(Re,{initialRule:d,isPending:o.isPending,mode:`edit`,onOpenChange:e=>!e&&p(void 0),onSubmit:async e=>{if(d?.RuleArn)try{await o.mutateAsync({ruleArn:d.RuleArn,listenerArn:n,conditions:e.conditions.map(Le),forwardTargetGroupArn:e.forwardTargetGroupArn}),p(void 0)}catch{}},open:d!==void 0,targetGroups:r}),(0,$.jsx)(ie,{description:m?`Delete rule with priority ${m.Priority}? This cannot be undone.`:``,isPending:s.isPending,onConfirm:async()=>{if(m?.RuleArn)try{await s.mutateAsync({ruleArn:m.RuleArn,listenerArn:n})}finally{h(void 0)}},onOpenChange:e=>!e&&h(void 0),open:m!==void 0,title:`Delete rule`})]})}function Pe(e,t){return t?`default`:!e||e.length===0?`—`:e.map(e=>{let t=Fe(e);return`${e.Field}: ${t.join(`, `)}`}).join(` AND `)}function Fe(e){if(e.Field===void 0)return e.Values??[];switch(e.Field){case`host-header`:return e.HostHeaderConfig?.Values??e.Values??[];case`path-pattern`:return e.PathPatternConfig?.Values??e.Values??[];case`http-header`:{let t=e.HttpHeaderConfig?.HttpHeaderName??`?`;return(e.HttpHeaderConfig?.Values??[]).map(e=>`${t}=${e}`)}case`http-request-method`:return e.HttpRequestMethodConfig?.Values??[];case`source-ip`:return e.SourceIpConfig?.Values??[];case`query-string`:return(e.QueryStringConfig?.Values??[]).map(e=>e.Key?`${e.Key}=${e.Value??``}`:e.Value??``);default:return e.Values??[]}}function Ie(e,t){let n=e.Actions?.[0];return n?n.Type===`forward`&&n.TargetGroupArn?t.get(n.TargetGroupArn)??n.TargetGroupArn:n.Type??`—`:`—`}function Le(e){let t=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);return e.field===`query-string`?{field:e.field,values:[],queryStringPairs:t.map(e=>{let t=e.indexOf(`=`);return t===-1?{value:e}:{key:e.slice(0,t),value:e.slice(t+1)}})}:{field:e.field,values:t,httpHeaderName:e.httpHeaderName}}function Re({open:e,onOpenChange:t,onSubmit:n,isPending:c,targetGroups:l,mode:u,initialRule:d}){let f=[{field:`path-pattern`,rawValues:``}],p=oe({resolver:le(de),defaultValues:{priority:1,conditions:f,forwardTargetGroupArn:``}}),{fields:m,append:h,remove:g}=ce({control:p.control,name:`conditions`});(0,Q.useEffect)(()=>{if(u===`add`){e||p.reset({priority:1,conditions:f,forwardTargetGroupArn:``});return}if(!d)return;let t=Number(d.Priority??1),n=d.Conditions?.map(e=>Ue(e))??f;p.reset({priority:Number.isFinite(t)?t:1,conditions:n.length>0?n:f,forwardTargetGroupArn:d.Actions?.[0]?.TargetGroupArn??``})},[d,e,u]);let _=p.handleSubmit(n);return(0,$.jsx)(re,{onOpenChange:t,open:e,children:(0,$.jsxs)(W,{className:`grid-cols-[minmax(0,1fr)]`,style:{maxWidth:`40rem`,width:`calc(100vw - 2rem)`},children:[(0,$.jsxs)(ne,{children:[(0,$.jsx)(G,{children:u===`add`?`Add rule`:`Edit rule`}),(0,$.jsx)(U,{children:`Routing rules match incoming requests and forward them to a target group. Rules evaluate in priority order; the listener default applies when no rule matches.`})]}),(0,$.jsxs)(`form`,{className:`min-w-0 space-y-4`,onSubmit:e=>{e.preventDefault(),_()},children:[(0,$.jsxs)(Z,{children:[(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`rule-priority`,children:`Priority`})}),(0,$.jsx)(k,{"aria-invalid":!!p.formState.errors.priority,id:`rule-priority`,inputMode:`numeric`,type:`number`,...p.register(`priority`,{valueAsNumber:!0})}),(0,$.jsx)(X,{errors:[p.formState.errors.priority]})]}),(0,$.jsxs)(`div`,{className:`space-y-3`,children:[(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsx)(Y,{children:`Conditions`}),(0,$.jsx)(O,{disabled:m.length>=5,onClick:()=>h({field:`path-pattern`,rawValues:``}),size:`sm`,type:`button`,variant:`outline`,children:`Add condition`})]}),m.map((e,t)=>(0,$.jsx)(ze,{form:p,index:t,onRemove:m.length>1?()=>g(t):void 0},e.id))]}),(0,$.jsxs)(Z,{children:[(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`rule-forward-tg`,children:`Forward to target group`})}),(0,$.jsx)(se,{control:p.control,name:`forwardTargetGroupArn`,render:({field:e})=>(0,$.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!p.formState.errors.forwardTargetGroupArn,className:`w-full`,id:`rule-forward-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:l.map(e=>(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,$.jsx)(X,{errors:[p.formState.errors.forwardTargetGroupArn]})]})]}),(0,$.jsxs)(q,{children:[(0,$.jsx)(J,{disabled:c,children:`Cancel`}),(0,$.jsx)(K,{disabled:c,onClick:_,children:We(u,c)})]})]})})}function ze(e){let t=(0,we.c)(54),{form:n,index:l,onRemove:u}=e,d,f,p,m,h,g,_,v,y,b;if(t[0]!==n||t[1]!==l||t[2]!==u){let e=n.watch(`conditions.${l}.field`);p=n.formState.errors.conditions?.[l],v=`space-y-2 rounded-md border p-3`;let x=`rule-cond-${l}-field`,S;t[13]===x?S=t[14]:(S=(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:x,children:`Type`})}),t[13]=x,t[14]=S);let C=`conditions.${l}.field`,w;t[15]===l?w=t[16]:(w=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`rule-cond-${l}-field`,children:(0,$.jsx)(r,{})}),(0,$.jsxs)(a,{children:[(0,$.jsx)(i,{value:`path-pattern`,children:`Path pattern`}),(0,$.jsx)(i,{value:`host-header`,children:`Host header`}),(0,$.jsx)(i,{value:`http-header`,children:`HTTP header`}),(0,$.jsx)(i,{value:`http-request-method`,children:`HTTP method`}),(0,$.jsx)(i,{value:`source-ip`,children:`Source IP CIDR`}),(0,$.jsx)(i,{value:`query-string`,children:`Query string`})]})]})},t[15]=l,t[16]=w);let T;t[17]!==n.control||t[18]!==C||t[19]!==w?(T=(0,$.jsx)(se,{control:n.control,name:C,render:w}),t[17]=n.control,t[18]=C,t[19]=w,t[20]=T):T=t[20];let E;t[21]!==T||t[22]!==S?(E=(0,$.jsxs)(`div`,{className:`flex-1`,children:[S,T]}),t[21]=T,t[22]=S,t[23]=E):E=t[23];let D;t[24]!==l||t[25]!==u?(D=u&&(0,$.jsx)(O,{"aria-label":`Remove condition ${l+1}`,className:`self-end`,onClick:u,size:`sm`,type:`button`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})}),t[24]=l,t[25]=u,t[26]=D):D=t[26],t[27]!==E||t[28]!==D?(y=(0,$.jsxs)(`div`,{className:`flex gap-2`,children:[E,D]}),t[27]=E,t[28]=D,t[29]=y):y=t[29],b=e===`http-header`&&(0,$.jsxs)(Z,{children:[(0,$.jsx)(Y,{children:(0,$.jsx)(`label`,{htmlFor:`rule-cond-${l}-hdr-name`,children:`Header name`})}),(0,$.jsx)(k,{"aria-invalid":!!p?.httpHeaderName,id:`rule-cond-${l}-hdr-name`,placeholder:`X-Tenant`,...n.register(`conditions.${l}.httpHeaderName`)}),(0,$.jsx)(X,{errors:[p?.httpHeaderName]})]}),f=Z;let A=`rule-cond-${l}-values`,j;t[30]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(comma or newline separated, max 5)`}),t[30]=j):j=t[30],t[31]===A?_=t[32]:(_=(0,$.jsx)(Y,{children:(0,$.jsxs)(`label`,{htmlFor:A,children:[`Values`,` `,j]})}),t[31]=A,t[32]=_),d=k,m=!!p?.rawValues,h=`rule-cond-${l}-values`,g=Be(e),t[0]=n,t[1]=l,t[2]=u,t[3]=d,t[4]=f,t[5]=p,t[6]=m,t[7]=h,t[8]=g,t[9]=_,t[10]=v,t[11]=y,t[12]=b}else d=t[3],f=t[4],p=t[5],m=t[6],h=t[7],g=t[8],_=t[9],v=t[10],y=t[11],b=t[12];let x;t[33]!==n||t[34]!==l?(x=n.register(`conditions.${l}.rawValues`),t[33]=n,t[34]=l,t[35]=x):x=t[35];let S;t[36]!==d||t[37]!==m||t[38]!==h||t[39]!==g||t[40]!==x?(S=(0,$.jsx)(d,{"aria-invalid":m,id:h,placeholder:g,...x}),t[36]=d,t[37]=m,t[38]=h,t[39]=g,t[40]=x,t[41]=S):S=t[41];let C=p?.rawValues,w;t[42]===C?w=t[43]:(w=(0,$.jsx)(X,{errors:[C]}),t[42]=C,t[43]=w);let T;t[44]!==f||t[45]!==w||t[46]!==_||t[47]!==S?(T=(0,$.jsxs)(f,{children:[_,S,w]}),t[44]=f,t[45]=w,t[46]=_,t[47]=S,t[48]=T):T=t[48];let E;return t[49]!==T||t[50]!==v||t[51]!==y||t[52]!==b?(E=(0,$.jsxs)(`div`,{className:v,children:[y,b,T]}),t[49]=T,t[50]=v,t[51]=y,t[52]=b,t[53]=E):E=t[53],E}function Be(e){switch(e){case`path-pattern`:return`/api/*`;case`host-header`:return`*.example.com`;case`http-header`:return`acme`;case`http-request-method`:return`GET, POST`;case`source-ip`:return`10.0.0.0/8`;case`query-string`:return`key=value`;default:return``}}var Ve=new Set([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]);function He(e){return e!==void 0&&Ve.has(e)}function Ue(e){let t=Fe(e);return{field:He(e.Field)?e.Field:`path-pattern`,httpHeaderName:e.HttpHeaderConfig?.HttpHeaderName,rawValues:t.join(`, `)}}function We(e,t){return t?e===`add`?`Adding…`:`Saving…`:e===`add`?`Add rule`:`Save changes`}function Ge(e){return{protocol:pe(e),port:80,defaultTargetGroupArn:``}}function Ke(e){if(e.Protocol!==`HTTPS`)return`—`;let t=e.Certificates?.[0]?.CertificateArn?.split(`/`).pop(),n=e.SslPolicy;return t&&n?`${n} · ${t}`:n??t??`—`}function qe({loadBalancerArn:n,vpcId:r,lbType:i}){let a=ue(i),{data:o}=t(M(n)),{data:s}=t(A),{data:l}=t(T),{data:d}=t(I),f=v(),p=u(),m=y(),[h,g]=(0,Q.useState)(!1),[_,b]=(0,Q.useState)(),[x,S]=(0,Q.useState)(),[C,w]=(0,Q.useState)(),E=o.Listeners??[],D=s.TargetGroups??[],k=D.filter(e=>e.VpcId===r),j=l.CertificateSummaryList??[],N=d.SslPolicies??[],P=new Map;for(let e of D)e.TargetGroupArn&&e.TargetGroupName&&P.set(e.TargetGroupArn,e.TargetGroupName);let F=e=>{let t=e.DefaultActions?.[0];if(!t)return`—`;if(t.Type===`forward`&&t.TargetGroupArn){let e=P.get(t.TargetGroupArn);return e?`forward → ${e}`:`forward → ${t.TargetGroupArn}`}return t.Type??`—`};return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[f.error&&(0,$.jsx)(e,{error:f.error,msg:`Failed to create listener`}),m.error&&(0,$.jsx)(e,{error:m.error,msg:`Failed to modify listener`}),p.error&&(0,$.jsx)(e,{error:p.error,msg:`Failed to delete listener`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[E.length,` listener`,E.length===1?``:`s`]}),(0,$.jsx)(O,{onClick:()=>g(!0),size:`sm`,children:`Add listener`})]}),E.length===0?(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No listeners configured.`}):(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`TLS`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Default action`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Listener ARN`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:E.map(e=>{let t=e.ListenerArn??``,n=C===t;return(0,$.jsxs)(Q.Fragment,{children:[(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol}),(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Port}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Ke(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:F(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(O,{"aria-label":`${n?`Hide`:`Show`} rules for listener ${e.Protocol}:${e.Port}`,onClick:()=>w(n?void 0:t),size:`sm`,variant:`ghost`,children:n?`Hide rules`:`Rules`}),(0,$.jsx)(O,{"aria-label":`Edit listener ${e.Protocol}:${e.Port}`,onClick:()=>b(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(Ce,{className:`size-4`})}),(0,$.jsx)(O,{"aria-label":`Delete listener ${e.Protocol}:${e.Port}`,onClick:()=>S(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]}),n&&t&&(0,$.jsx)(`tr`,{className:`border-b bg-muted/30 last:border-0`,children:(0,$.jsx)(`td`,{className:`px-4 py-3`,colSpan:6,children:(0,$.jsx)(Ne,{listenerArn:t,targetGroups:k})})})]},t)})})]})}),(0,$.jsx)(Je,{certificates:j,isPending:f.isPending,lbType:i,onOpenChange:g,onSubmit:async e=>{try{await f.mutateAsync({loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn,certificateArn:e.certificateArn,sslPolicy:e.sslPolicy}),g(!1)}catch{}},open:h,protocols:a,sslPolicies:N,targetGroups:k}),(0,$.jsx)(Ye,{certificates:j,isPending:m.isPending,listener:_,onOpenChange:e=>!e&&b(void 0),onSubmit:async e=>{if(_?.ListenerArn)try{await m.mutateAsync({listenerArn:_.ListenerArn,loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn,certificateArn:e.certificateArn,sslPolicy:e.sslPolicy}),b(void 0)}catch{}},protocols:a,sslPolicies:N,targetGroups:k}),(0,$.jsx)(ie,{description:x?`Delete listener ${x.Protocol}:${x.Port}? This cannot be undone.`:``,isPending:p.isPending,onConfirm:async()=>{if(x?.ListenerArn)try{await p.mutateAsync(x.ListenerArn)}finally{S(void 0)}},onOpenChange:e=>!e&&S(void 0),open:x!==void 0,title:`Delete listener`})]})}function Je(e){let t=(0,we.c)(42),{open:n,onOpenChange:r,lbType:i,protocols:a,targetGroups:o,certificates:s,sslPolicies:c,isPending:l,onSubmit:u}=e,d;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(d=le(fe),t[0]=d):d=t[0];let f;t[1]===i?f=t[2]:(f=Ge(i),t[1]=i,t[2]=f);let p;t[3]===f?p=t[4]:(p={resolver:d,defaultValues:f},t[3]=f,t[4]=p);let m=oe(p),h,g;t[5]!==m||t[6]!==i||t[7]!==n?(h=()=>{n||m.reset(Ge(i))},g=[n,m,i],t[5]=m,t[6]=i,t[7]=n,t[8]=h,t[9]=g):(h=t[8],g=t[9]),(0,Q.useEffect)(h,g);let _;t[10]!==m||t[11]!==u?(_=m.handleSubmit(u),t[10]=m,t[11]=u,t[12]=_):_=t[12];let v=_,y;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(y={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[13]=y):y=t[13];let b;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,$.jsxs)(ne,{children:[(0,$.jsx)(G,{children:`Add listener`}),(0,$.jsx)(U,{children:`Listeners forward incoming traffic to a default target group.`})]}),t[14]=b):b=t[14];let x;t[15]===v?x=t[16]:(x=e=>{e.preventDefault(),v()},t[15]=v,t[16]=x);let S;t[17]!==s||t[18]!==m||t[19]!==a||t[20]!==c||t[21]!==o?(S=(0,$.jsx)(Oe,{certificates:s,form:m,protocols:a,sslPolicies:c,targetGroups:o}),t[17]=s,t[18]=m,t[19]=a,t[20]=c,t[21]=o,t[22]=S):S=t[22];let C;t[23]!==S||t[24]!==x?(C=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:x,children:S}),t[23]=S,t[24]=x,t[25]=C):C=t[25];let w;t[26]===l?w=t[27]:(w=(0,$.jsx)(J,{disabled:l,children:`Cancel`}),t[26]=l,t[27]=w);let T=l?`Adding…`:`Add listener`,E;t[28]!==v||t[29]!==l||t[30]!==T?(E=(0,$.jsx)(K,{disabled:l,onClick:v,children:T}),t[28]=v,t[29]=l,t[30]=T,t[31]=E):E=t[31];let D;t[32]!==w||t[33]!==E?(D=(0,$.jsxs)(q,{children:[w,E]}),t[32]=w,t[33]=E,t[34]=D):D=t[34];let O;t[35]!==C||t[36]!==D?(O=(0,$.jsxs)(W,{className:`grid-cols-[minmax(0,1fr)]`,style:y,children:[b,C,D]}),t[35]=C,t[36]=D,t[37]=O):O=t[37];let k;return t[38]!==r||t[39]!==n||t[40]!==O?(k=(0,$.jsx)(re,{onOpenChange:r,open:n,children:O}),t[38]=r,t[39]=n,t[40]=O,t[41]=k):k=t[41],k}function Ye(e){let t=(0,we.c)(38),{listener:n,onOpenChange:r,protocols:i,targetGroups:a,certificates:o,sslPolicies:s,isPending:c,onSubmit:l}=e,u=n!==void 0,d;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(d={resolver:le(fe),defaultValues:{protocol:`HTTP`,port:80,defaultTargetGroupArn:``}},t[0]=d):d=t[0];let f=oe(d),p,m;t[1]!==f||t[2]!==n||t[3]!==i?(p=()=>{if(!n)return;let e=i.find(e=>e===n.Protocol)??i[0]??`HTTP`,t=me(e);f.reset({protocol:e,port:n.Port??80,defaultTargetGroupArn:n.DefaultActions?.[0]?.TargetGroupArn??``,certificateArn:t?n.Certificates?.[0]?.CertificateArn:void 0,sslPolicy:e===`HTTPS`?n.SslPolicy:void 0})},m=[n,f,i],t[1]=f,t[2]=n,t[3]=i,t[4]=p,t[5]=m):(p=t[4],m=t[5]),(0,Q.useEffect)(p,m);let h;t[6]!==f||t[7]!==l?(h=f.handleSubmit(l),t[6]=f,t[7]=l,t[8]=h):h=t[8];let g=h,_;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(_={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[9]=_):_=t[9];let v;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,$.jsxs)(ne,{children:[(0,$.jsx)(G,{children:`Edit listener`}),(0,$.jsx)(U,{children:`Update port, protocol, or default target group. Changes apply without dropping the listener.`})]}),t[10]=v):v=t[10];let y;t[11]===g?y=t[12]:(y=e=>{e.preventDefault(),g()},t[11]=g,t[12]=y);let b;t[13]!==o||t[14]!==f||t[15]!==i||t[16]!==s||t[17]!==a?(b=(0,$.jsx)(Oe,{certificates:o,form:f,protocols:i,sslPolicies:s,targetGroups:a}),t[13]=o,t[14]=f,t[15]=i,t[16]=s,t[17]=a,t[18]=b):b=t[18];let x;t[19]!==y||t[20]!==b?(x=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:y,children:b}),t[19]=y,t[20]=b,t[21]=x):x=t[21];let S;t[22]===c?S=t[23]:(S=(0,$.jsx)(J,{disabled:c,children:`Cancel`}),t[22]=c,t[23]=S);let C=c?`Saving…`:`Save changes`,w;t[24]!==g||t[25]!==c||t[26]!==C?(w=(0,$.jsx)(K,{disabled:c,onClick:g,children:C}),t[24]=g,t[25]=c,t[26]=C,t[27]=w):w=t[27];let T;t[28]!==S||t[29]!==w?(T=(0,$.jsxs)(q,{children:[S,w]}),t[28]=S,t[29]=w,t[30]=T):T=t[30];let E;t[31]!==T||t[32]!==x?(E=(0,$.jsxs)(W,{className:`grid-cols-[minmax(0,1fr)]`,style:_,children:[v,x,T]}),t[31]=T,t[32]=x,t[33]=E):E=t[33];let D;return t[34]!==r||t[35]!==u||t[36]!==E?(D=(0,$.jsx)(re,{onOpenChange:r,open:u,children:E}),t[34]=r,t[35]=u,t[36]=E,t[37]=D):D=t[37],D}function Xe(e,t){if(e.length!==t.length)return!1;let n=new Set(t);return e.every(e=>n.has(e))}function Ze(e){let t=(0,we.c)(46),{available:n,current:r,onSubmit:i,isPending:a,error:o,isSuccess:s}=e,c;t[0]===r?c=t[1]:(c=[...r],t[0]=r,t[1]=c);let l=c,[u,d]=(0,Q.useState)(l),f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=e=>{d(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},t[2]=f):f=t[2];let p=f,m;t[3]===l?m=t[4]:(m=()=>d(l),t[3]=l,t[4]=m);let h=m,g;t[5]!==l||t[6]!==u?(g=Xe(u,l),t[5]=l,t[6]=u,t[7]=g):g=t[7];let _=!g,v=u.length===0,y;t[8]!==_||t[9]!==v||t[10]!==i||t[11]!==u?(y=e=>{e.preventDefault(),!(!_||v)&&i(u)},t[8]=_,t[9]=v,t[10]=i,t[11]=u,t[12]=y):y=t[12];let b;t[13]===o?b=t[14]:(b=o&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive`,role:`alert`,children:o.message||`Failed to update security groups`}),t[13]=o,t[14]=b);let x;t[15]!==_||t[16]!==s?(x=s&&!_&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-emerald-500 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400`,role:`status`,children:`Security groups updated.`}),t[15]=_,t[16]=s,t[17]=x):x=t[17];let S;t[18]!==n||t[19]!==u?(S=n.length===0?(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No security groups in this VPC.`}):n.map(e=>(0,$.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,$.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:u.includes(e.GroupId??``),onChange:()=>p(e.GroupId??``),type:`checkbox`}),(0,$.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId)),t[18]=n,t[19]=u,t[20]=S):S=t[20];let C;t[21]===v?C=t[22]:(C=v&&(0,$.jsx)(`p`,{className:`text-xs text-destructive`,children:`At least one security group is required.`}),t[21]=v,t[22]=C);let w;t[23]!==S||t[24]!==C?(w=(0,$.jsxs)(`div`,{className:`flex flex-col gap-1 p-4`,children:[S,C]}),t[23]=S,t[24]=C,t[25]=w):w=t[25];let T=_?`Unsaved changes`:`No changes`,E;t[26]===T?E=t[27]:(E=(0,$.jsx)(`span`,{className:`mr-auto text-xs text-muted-foreground`,children:T}),t[26]=T,t[27]=E);let D=a||!_,k;t[28]!==h||t[29]!==D?(k=(0,$.jsx)(O,{disabled:D,onClick:h,size:`sm`,type:`button`,variant:`secondary`,children:`Reset`}),t[28]=h,t[29]=D,t[30]=k):k=t[30];let A=a||!_||v,j=a?`Saving…`:`Save changes`,M;t[31]!==A||t[32]!==j?(M=(0,$.jsx)(O,{disabled:A,size:`sm`,type:`submit`,children:j}),t[31]=A,t[32]=j,t[33]=M):M=t[33];let N;t[34]!==E||t[35]!==k||t[36]!==M?(N=(0,$.jsxs)(`div`,{className:`flex items-center justify-end gap-2 border-t p-4`,children:[E,k,M]}),t[34]=E,t[35]=k,t[36]=M,t[37]=N):N=t[37];let P;t[38]!==w||t[39]!==N?(P=(0,$.jsxs)(`div`,{className:`rounded-lg border bg-card`,children:[w,N]}),t[38]=w,t[39]=N,t[40]=P):P=t[40];let F;return t[41]!==P||t[42]!==y||t[43]!==b||t[44]!==x?(F=(0,$.jsxs)(`form`,{onSubmit:y,children:[b,x,P]}),t[41]=P,t[42]=y,t[43]=b,t[44]=x,t[45]=F):F=t[45],F}function Qe({arn:r}){let i=j(),{data:a}=t(x(r)),{data:o}=t(C(r)),{data:s}=t(b([r])),{data:l}=t(te),{data:u}=t(P),f=h(),g=d(),_=p(),v=m(),[y,S]=(0,Q.useState)(!1),w=a.LoadBalancers?.[0];if(!w?.LoadBalancerArn)return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`Load balancer not found.`})]});let T=async()=>{try{await f.mutateAsync(r),i({to:`/ec2/describe-load-balancers`})}finally{S(!1)}},D=l.Subnets??[],k=e=>{let t=D.find(t=>t.SubnetId===e);if(!t)return e;let n=E(t.Tags),r=t.CidrBlock?` (${t.CidrBlock})`:``;return n?`${e} — ${n}${r}`:`${e}${r}`},A=o.Attributes??[],M=s?.TagDescriptions?.find(e=>e.ResourceArn===r)?.Tags??[],F=(w.SecurityGroups??[]).filter(e=>e!==void 0),ee=(u.SecurityGroups??[]).filter(e=>e.VpcId===w.VpcId),I=w.Type===`network`;return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),f.error&&(0,$.jsx)(e,{error:f.error,msg:`Failed to delete load balancer`}),(0,$.jsxs)(`div`,{className:`space-y-6`,children:[(0,$.jsx)(z,{actions:(0,$.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,$.jsxs)(O,{onClick:()=>S(!0),size:`sm`,variant:`destructive`,children:[(0,$.jsx)(c,{className:`size-4`}),`Delete`]}),(0,$.jsx)(B,{state:w.State?.Code})]}),subtitle:`Load Balancer Details`,title:w.LoadBalancerName??w.LoadBalancerArn}),(0,$.jsxs)(Se,{defaultValue:`overview`,children:[(0,$.jsxs)(ve,{children:[(0,$.jsx)(_e,{value:`overview`,children:`Overview`}),(0,$.jsx)(_e,{value:`listeners`,children:`Listeners`}),!I&&(0,$.jsx)(_e,{value:`security-groups`,children:`Security groups`}),(0,$.jsx)(_e,{value:`attributes`,children:`Attributes`}),(0,$.jsx)(_e,{value:`tags`,children:`Tags`})]}),(0,$.jsxs)(be,{value:`overview`,children:[(0,$.jsxs)(V,{children:[(0,$.jsx)(V.Header,{children:`Load balancer`}),(0,$.jsxs)(V.Content,{children:[(0,$.jsx)(H,{label:`ARN`,value:w.LoadBalancerArn}),(0,$.jsx)(H,{label:`DNS name`,value:w.DNSName}),(0,$.jsx)(H,{label:`State`,value:w.State?.Reason?`${w.State.Code} — ${w.State.Reason}`:w.State?.Code}),(0,$.jsx)(H,{label:`Type`,value:w.Type}),(0,$.jsx)(H,{label:`Scheme`,value:w.Scheme}),(0,$.jsx)(H,{label:`IP address type`,value:w.IpAddressType}),(0,$.jsx)(H,{label:`VPC`,value:w.VpcId}),(0,$.jsx)(H,{label:`Created at`,value:N(w.CreatedTime)})]})]}),(0,$.jsxs)(V,{children:[(0,$.jsx)(V.Header,{children:`Availability zones`}),(0,$.jsx)(V.Content,{children:(w.AvailabilityZones??[]).map(e=>(0,$.jsx)(H,{label:e.ZoneName??`—`,value:e.SubnetId?k(e.SubnetId):void 0},`${e.ZoneName}-${e.SubnetId}`))})]}),(w.SecurityGroups??[]).length>0&&(0,$.jsxs)(V,{children:[(0,$.jsx)(V.Header,{children:`Security groups`}),(0,$.jsx)(V.Content,{children:(w.SecurityGroups??[]).map(e=>(0,$.jsx)(H,{label:`Security group`,value:e},e))})]})]}),(0,$.jsx)(be,{value:`listeners`,children:(0,$.jsx)(qe,{lbType:I?`network`:`application`,loadBalancerArn:r,vpcId:w.VpcId})}),!I&&(0,$.jsx)(be,{value:`security-groups`,children:(0,$.jsx)(Ze,{available:ee,current:F,error:v.error,isPending:v.isPending,isSuccess:v.isSuccess,onSubmit:e=>v.mutate({loadBalancerArn:r,securityGroupIds:e})})}),(0,$.jsx)(be,{value:`attributes`,children:(0,$.jsx)(ye,{attributes:A,error:g.error,isPending:g.isPending,isSuccess:g.isSuccess,onSubmit:e=>{g.mutate({loadBalancerArn:r,attributes:e})},specs:xe})}),(0,$.jsx)(be,{value:`tags`,children:(0,$.jsx)(ge,{error:_.error,isPending:_.isPending,isSuccess:_.isSuccess,onSubmit:e=>_.mutate({resourceArn:r,tags:e,initialKeys:M.map(e=>e.Key??``).filter(e=>e.length>0)}),tags:M})})]})]}),(0,$.jsx)(ie,{description:`Are you sure you want to delete load balancer "${w.LoadBalancerName??w.LoadBalancerArn}"? This action cannot be undone.`,isPending:f.isPending,onConfirm:T,onOpenChange:S,open:y,title:`Delete load balancer`})]})}function $e(){let e=(0,we.c)(2),{id:t}=ee.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,$.jsx)(Qe,{arn:n}),e[0]=n,e[1]=r),r}export{$e as component}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js b/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js index a5366b087..39e054637 100644 --- a/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js +++ b/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js @@ -1,4 +1,4 @@ -import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./plus.js";import{t as l}from"./cli-command-panel.js";import{t as u}from"./trash-2.js";import{t as d}from"./certificate-import-dialog.js";import{n as f}from"./elbv2.js";import{Do as p,Eo as m,F as h,Ka as g,Ko as _,La as v,Li as y,N as b,So as x,Z as ee,at as te,k as ne,tt as re}from"./index.js";import{t as ie}from"./page-heading.js";import{a as S,r as C,t as w}from"./field.js";import{i as T,n as E,t as D}from"./zod.js";import{t as ae}from"./form-actions.js";import{t as oe}from"./target-group-form.js";import{a as se,c as ce,o as le,r as ue,s as O,t as de}from"./elbv22.js";var k=_(p(),1),A=m();function fe(e){let t=g(e.Tags);return t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`}function j(e){let t=g(e.Tags);return`${t?`${e.SubnetId} (${t})`:e.SubnetId} · ${e.CidrBlock}`}function pe(e){return e===`TLS`||e===`TCP`?`TCP`:e===`UDP`?`UDP`:e===`TCP_UDP`?`TCP_UDP`:`HTTP`}function me(e){let t=new Map;for(let n of e){let e=n.AvailabilityZone??`unknown`,r=t.get(e)??[];r.push(n),t.set(e,r)}return[...t.entries()].toSorted((e,t)=>e[0].localeCompare(t[0])).map(([e,t])=>({az:e,subnets:t}))}function M(){let p=x(),{data:m}=t(te),{data:g}=t(re),{data:_}=t(ee),{data:M}=t(b),{data:N}=t(h),{data:ge}=t(ne),P=f(),[F,I]=(0,k.useState)(null),[_e,L]=(0,k.useState)(!1),R=m.Vpcs??[],ve=g.Subnets??[],ye=_.SecurityGroups??[],be=M.TargetGroups??[],z=N.CertificateSummaryList??[],xe=ge.SslPolicies??[],Se=T({resolver:D(ue),defaultValues:{name:``,type:`application`,scheme:`internet-facing`,vpcId:R[0]?.VpcId??``,subnetIds:[],securityGroupIds:[],tags:[],listener:{protocol:`HTTP`,port:80,targetGroupMode:`new`,existingTargetGroupArn:``,certificateArn:void 0,sslPolicy:void 0}}}),B=T({resolver:D(se),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:R[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:Ce,register:V,watch:H,control:U,setValue:W,getValues:G,formState:{errors:K,isSubmitting:we}}=Se,q=H(`vpcId`),Te=H(`subnetIds`),Ee=H(`securityGroupIds`),J=H(`type`),Y=H(`listener.protocol`),X=H(`listener.targetGroupMode`),De=H(`tags`),Z=J===`network`,Oe=O(Y),ke=ce(J),Ae=Z?[`TCP`,`UDP`,`TLS`,`TCP_UDP`]:[`HTTP`,`HTTPS`],je=ve.filter(e=>e.VpcId===q),Q=ye.filter(e=>e.VpcId===q),Me=be.filter(e=>e.VpcId===q),Ne=e=>{let t=pe(e);B.setValue(`protocol`,t),B.setValue(`healthCheck.protocol`,t===`HTTP`?`HTTP`:`TCP`)},Pe=e=>{W(`type`,e);let t=le(e);W(`listener.protocol`,t),W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0),e===`network`&&W(`securityGroupIds`,[]),Ne(t)},Fe=e=>{W(`listener.protocol`,e);let t=G(`listener.port`);O(e)?(t===80&&W(`listener.port`,443),e===`HTTPS`&&!G(`listener.sslPolicy`)&&W(`listener.sslPolicy`,de)):(t===443&&W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0)),Ne(e)},Ie=(e=``)=>{let t=e??``;W(`vpcId`,t,{shouldValidate:!0}),W(`subnetIds`,[],{shouldValidate:!0}),W(`securityGroupIds`,[]),W(`listener.existingTargetGroupArn`,``),B.setValue(`vpcId`,t)},Le=e=>{let t=G(`subnetIds`);W(`subnetIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e],{shouldValidate:!0})},Re=e=>{let t=G(`securityGroupIds`);W(`securityGroupIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e])},ze=async e=>{I(null);let t;if(e.listener.targetGroupMode===`new`){if(!await B.trigger())return;t=B.getValues()}let n=await P.mutateAsync({lb:{name:e.name,type:e.type,scheme:e.scheme,vpcId:e.vpcId,subnetIds:e.subnetIds,securityGroupIds:e.securityGroupIds,tags:e.tags},listener:{protocol:e.listener.protocol,port:e.listener.port,targetGroupMode:e.listener.targetGroupMode,existingTargetGroupArn:e.listener.existingTargetGroupArn,certificateArn:e.listener.certificateArn,sslPolicy:e.listener.sslPolicy,newTargetGroup:t}});I(n),!n.error&&n.loadBalancerArn&&p({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n.loadBalancerArn)}})},$=me(je);return(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,A.jsx)(ie,{title:`Create Load Balancer`}),P.error&&(0,A.jsx)(e,{error:P.error,msg:`Failed to create load balancer`}),F?.error&&(0,A.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,A.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,F.failedStep]}),(0,A.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:F.error.message}),F.created.length>0&&(0,A.jsxs)(`div`,{className:`mt-3`,children:[(0,A.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources (clean up manually if needed):`}),(0,A.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:F.created.map((e,t)=>(0,A.jsxs)(`li`,{children:[e.type,`: `,e.id??`(created)`]},t))})]})]}),(0,A.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:Ce(ze),children:[(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`lb-name`,children:`Name`})}),(0,A.jsx)(y,{"aria-invalid":!!K.name,id:`lb-name`,placeholder:`my-load-balancer`,...V(`name`)}),(0,A.jsx)(C,{errors:[K.name]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Type`}),(0,A.jsx)(E,{control:U,name:`type`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Application Load Balancer`,checked:e.value===`application`,name:`lb-type`,onChange:()=>Pe(`application`),type:`radio`}),`Application (ALB)`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Network Load Balancer`,checked:e.value===`network`,name:`lb-type`,onChange:()=>Pe(`network`),type:`radio`}),`Network (NLB)`]})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Scheme`}),(0,A.jsx)(E,{control:U,name:`scheme`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internet-facing`,checked:e.value===`internet-facing`,name:`scheme`,onChange:()=>e.onChange(`internet-facing`),type:`radio`}),`Internet-facing`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internal`,checked:e.value===`internal`,name:`scheme`,onChange:()=>e.onChange(`internal`),type:`radio`}),`Internal`]})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`lb-vpc`,children:`VPC`})}),(0,A.jsx)(E,{control:U,name:`vpcId`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e=>Ie(e),value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.vpcId,className:`w-full`,id:`lb-vpc`,children:(0,A.jsx)(r,{placeholder:`Select VPC`})}),(0,A.jsx)(a,{children:R.map(e=>(0,A.jsx)(i,{value:e.VpcId??``,children:fe(e)},e.VpcId))})]})}),(0,A.jsx)(C,{errors:[K.vpcId]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Subnets (select at least 2)`}),$.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-3`,children:$.map(e=>(0,A.jsxs)(`div`,{children:[(0,A.jsx)(`p`,{className:`mb-1 text-xs font-medium text-muted-foreground`,children:e.az}),(0,A.jsx)(`div`,{className:`space-y-1`,children:e.subnets.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Subnet ${j(e)}`,checked:Te.includes(e.SubnetId??``),onChange:()=>Le(e.SubnetId??``),type:`checkbox`}),(0,A.jsx)(`span`,{className:`font-mono`,children:j(e)})]},e.SubnetId))})]},e.az))}),(0,A.jsx)(C,{errors:[K.subnetIds]})]}),!Z&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Security groups`}),Q.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-1`,children:Q.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:Ee.includes(e.GroupId??``),onChange:()=>Re(e.GroupId??``),type:`checkbox`}),(0,A.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId))})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Tags`}),(0,A.jsxs)(`div`,{className:`space-y-2`,children:[De.map((e,t)=>(0,A.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,A.jsx)(y,{placeholder:`Key`,...V(`tags.${t}.key`)}),(0,A.jsx)(y,{placeholder:`Value`,...V(`tags.${t}.value`)}),(0,A.jsx)(v,{onClick:()=>W(`tags`,G(`tags`).filter((e,n)=>n!==t)),size:`icon`,type:`button`,variant:`ghost`,children:(0,A.jsx)(u,{className:`size-3.5`})})]},t)),(0,A.jsxs)(v,{onClick:()=>W(`tags`,[...G(`tags`),{key:``,value:``}]),size:`sm`,type:`button`,variant:`outline`,children:[(0,A.jsx)(c,{className:`size-3.5`}),`Add tag`]})]})]}),(0,A.jsxs)(`div`,{className:`space-y-4 rounded-md border bg-card p-4`,children:[(0,A.jsx)(`h2`,{className:`text-sm font-semibold`,children:`First listener & default target group`}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),(0,A.jsx)(E,{control:U,name:`listener.protocol`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e=>{e&&Fe(e)},value:e.value,children:[(0,A.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,A.jsx)(r,{})}),(0,A.jsx)(a,{children:ke.map(e=>(0,A.jsx)(i,{value:e,children:e},e))})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),(0,A.jsx)(y,{"aria-invalid":!!K.listener?.port,id:`listener-port`,inputMode:`numeric`,type:`number`,...V(`listener.port`,{valueAsNumber:!0})}),(0,A.jsx)(C,{errors:[K.listener?.port]})]}),Oe&&(0,A.jsxs)(A.Fragment,{children:[(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,A.jsx)(E,{control:U,name:`listener.certificateArn`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.listener?.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,A.jsx)(r,{placeholder:`Select certificate`})}),(0,A.jsx)(a,{children:z.map(e=>(0,A.jsx)(i,{value:e.CertificateArn??``,children:e.DomainName??e.CertificateArn?.split(`/`).pop()},e.CertificateArn))})]})}),(0,A.jsx)(C,{errors:[K.listener?.certificateArn]}),(0,A.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[z.length===0&&(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,A.jsx)(v,{className:`ml-auto`,onClick:()=>L(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),Y===`HTTPS`&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,A.jsx)(E,{control:U,name:`listener.sslPolicy`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??`ELBSecurityPolicy-2016-08`,children:[(0,A.jsx)(o,{className:`w-full`,id:`listener-ssl-policy`,children:(0,A.jsx)(r,{})}),(0,A.jsx)(a,{children:xe.map(e=>(0,A.jsx)(i,{value:e.Name??``,children:e.Name},e.Name))})]})})]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Default target group`}),(0,A.jsx)(E,{control:U,name:`listener.targetGroupMode`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Create new target group`,checked:e.value===`new`,name:`tg-mode`,onChange:()=>e.onChange(`new`),type:`radio`}),`Create new`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Use existing target group`,checked:e.value===`existing`,name:`tg-mode`,onChange:()=>e.onChange(`existing`),type:`radio`}),`Use existing`]})]})})]}),X===`existing`&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`tg-existing`,children:`Target group`})}),(0,A.jsx)(E,{control:U,name:`listener.existingTargetGroupArn`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.listener?.existingTargetGroupArn,className:`w-full`,id:`tg-existing`,children:(0,A.jsx)(r,{placeholder:`Select target group`})}),(0,A.jsx)(a,{children:Me.map(e=>(0,A.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,A.jsx)(C,{errors:[K.listener?.existingTargetGroupArn]}),Me.length===0&&(0,A.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups in this VPC. Choose “Create new” instead.`})]}),X===`new`&&(0,A.jsx)(`div`,{className:`space-y-4 border-l-2 border-muted pl-4`,children:(0,A.jsx)(oe,{allowedProtocols:Ae,form:B,vpcs:R})})]}),(0,A.jsx)(d,{onImported:e=>W(`listener.certificateArn`,e,{shouldValidate:!0}),onOpenChange:L,open:_e}),(0,A.jsx)(l,{commands:he(H,B.watch)}),(0,A.jsx)(ae,{isPending:P.isPending,isSubmitting:we,onCancel:async()=>await p({to:`/ec2/describe-load-balancers`}),pendingLabel:`Creating…`,submitLabel:`Create Load Balancer`})]})]})}function he(e,t){let n=t=>{let n=e(t);return typeof n==`string`?n:``},r=t=>{let n=e(t);return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]},i=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},a=n(`name`)||``,o=n(`type`)||`application`,s=n(`scheme`)||`internet-facing`,c=r(`subnetIds`),l=r(`securityGroupIds`),u=n(`listener.protocol`)||`HTTP`,d=i(`listener.port`)??80,f=n(`listener.certificateArn`),p=n(`listener.sslPolicy`),m=n(`listener.targetGroupMode`),h=n(`listener.existingTargetGroupArn`),g=[],_={type:`comment`,value:o===`network`?`# Create NLB with listener and default target group +import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./plus.js";import{t as l}from"./cli-command-panel.js";import{t as u}from"./trash-2.js";import{t as d}from"./certificate-import-dialog.js";import{n as f}from"./elbv2.js";import{Do as p,Eo as m,F as h,Ka as g,Ko as _,La as v,Li as y,N as b,So as x,Z as ee,at as te,k as ne,tt as re}from"./index.js";import{t as ie}from"./page-heading.js";import{a as S,r as C,t as w}from"./field.js";import{i as T,n as E,t as D}from"./zod.js";import{t as ae}from"./form-actions.js";import{t as oe}from"./target-group-form.js";import{a as se,c as ce,o as le,r as ue,s as O,t as de}from"./elbv22.js";var k=_(p(),1),A=m();function fe(e){let t=g(e.Tags);return t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`}function j(e){let t=g(e.Tags);return`${t?`${e.SubnetId} (${t})`:e.SubnetId} · ${e.CidrBlock}`}function pe(e){return e===`TLS`||e===`TCP`?`TCP`:e===`UDP`?`UDP`:e===`TCP_UDP`?`TCP_UDP`:`HTTP`}function me(e){let t=new Map;for(let n of e){let e=n.AvailabilityZone??`unknown`,r=t.get(e)??[];r.push(n),t.set(e,r)}return[...t.entries()].toSorted((e,t)=>e[0].localeCompare(t[0])).map(([e,t])=>({az:e,subnets:t}))}function M(){let p=x(),{data:m}=t(te),{data:g}=t(re),{data:_}=t(ee),{data:M}=t(b),{data:N}=t(h),{data:ge}=t(ne),P=f(),[F,I]=(0,k.useState)(null),[_e,L]=(0,k.useState)(!1),R=m.Vpcs??[],ve=g.Subnets??[],ye=_.SecurityGroups??[],be=M.TargetGroups??[],z=N.CertificateSummaryList??[],xe=ge.SslPolicies??[],Se=T({resolver:D(ue),defaultValues:{name:``,type:`application`,scheme:`internet-facing`,vpcId:R[0]?.VpcId??``,subnetIds:[],securityGroupIds:[],tags:[],listener:{protocol:`HTTP`,port:80,targetGroupMode:`new`,existingTargetGroupArn:``,certificateArn:void 0,sslPolicy:void 0}}}),B=T({resolver:D(se),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:R[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:Ce,register:V,watch:H,control:U,setValue:W,getValues:G,formState:{errors:K,isSubmitting:we}}=Se,q=H(`vpcId`),Te=H(`subnetIds`),Ee=H(`securityGroupIds`),J=H(`type`),Y=H(`listener.protocol`),X=H(`listener.targetGroupMode`),De=H(`tags`),Z=J===`network`,Oe=O(Y),ke=ce(J),Ae=Z?[`TCP`,`UDP`,`TLS`,`TCP_UDP`]:[`HTTP`,`HTTPS`],je=ve.filter(e=>e.VpcId===q),Q=ye.filter(e=>e.VpcId===q),Me=be.filter(e=>e.VpcId===q),Ne=e=>{let t=pe(e);B.setValue(`protocol`,t),B.setValue(`healthCheck.protocol`,t===`HTTP`?`HTTP`:`TCP`)},Pe=e=>{W(`type`,e);let t=le(e);W(`listener.protocol`,t),W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0),e===`network`&&W(`securityGroupIds`,[]),Ne(t)},Fe=e=>{W(`listener.protocol`,e);let t=G(`listener.port`);O(e)?(t===80&&W(`listener.port`,443),e===`HTTPS`&&!G(`listener.sslPolicy`)&&W(`listener.sslPolicy`,de)):(t===443&&W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0)),Ne(e)},Ie=(e=``)=>{let t=e??``;W(`vpcId`,t,{shouldValidate:!0}),W(`subnetIds`,[],{shouldValidate:!0}),W(`securityGroupIds`,[]),W(`listener.existingTargetGroupArn`,``),B.setValue(`vpcId`,t)},Le=e=>{let t=G(`subnetIds`);W(`subnetIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e],{shouldValidate:!0})},Re=e=>{let t=G(`securityGroupIds`);W(`securityGroupIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e])},ze=async e=>{I(null);let t;if(e.listener.targetGroupMode===`new`){if(!await B.trigger())return;t=B.getValues()}let n=await P.mutateAsync({lb:{name:e.name,type:e.type,scheme:e.scheme,vpcId:e.vpcId,subnetIds:e.subnetIds,securityGroupIds:e.securityGroupIds,tags:e.tags},listener:{protocol:e.listener.protocol,port:e.listener.port,targetGroupMode:e.listener.targetGroupMode,existingTargetGroupArn:e.listener.existingTargetGroupArn,certificateArn:e.listener.certificateArn,sslPolicy:e.listener.sslPolicy,newTargetGroup:t}});I(n),!n.error&&n.loadBalancerArn&&p({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n.loadBalancerArn)}})},$=me(je);return(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,A.jsx)(ie,{title:`Create Load Balancer`}),P.error&&(0,A.jsx)(e,{error:P.error,msg:`Failed to create load balancer`}),F?.error&&(0,A.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,A.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,F.failedStep]}),(0,A.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:F.error.message}),F.created.length>0&&(0,A.jsxs)(`div`,{className:`mt-3`,children:[(0,A.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources (clean up manually if needed):`}),(0,A.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:F.created.map((e,t)=>(0,A.jsxs)(`li`,{children:[e.type,`: `,e.id??`(created)`]},t))})]})]}),(0,A.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:Ce(ze),children:[(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`lb-name`,children:`Name`})}),(0,A.jsx)(y,{"aria-invalid":!!K.name,id:`lb-name`,placeholder:`my-load-balancer`,...V(`name`)}),(0,A.jsx)(C,{errors:[K.name]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Type`}),(0,A.jsx)(E,{control:U,name:`type`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Application Load Balancer`,checked:e.value===`application`,name:`lb-type`,onChange:()=>Pe(`application`),type:`radio`}),`Application (ALB)`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Network Load Balancer`,checked:e.value===`network`,name:`lb-type`,onChange:()=>Pe(`network`),type:`radio`}),`Network (NLB)`]})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Scheme`}),(0,A.jsx)(E,{control:U,name:`scheme`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internet-facing`,checked:e.value===`internet-facing`,name:`scheme`,onChange:()=>e.onChange(`internet-facing`),type:`radio`}),`Internet-facing`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internal`,checked:e.value===`internal`,name:`scheme`,onChange:()=>e.onChange(`internal`),type:`radio`}),`Internal`]})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`lb-vpc`,children:`VPC`})}),(0,A.jsx)(E,{control:U,name:`vpcId`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e=>Ie(e),value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.vpcId,className:`w-full`,id:`lb-vpc`,children:(0,A.jsx)(r,{placeholder:`Select VPC`})}),(0,A.jsx)(a,{children:R.map(e=>(0,A.jsx)(i,{value:e.VpcId??``,children:fe(e)},e.VpcId))})]})}),(0,A.jsx)(C,{errors:[K.vpcId]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Subnets (select at least 1)`}),$.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-3`,children:$.map(e=>(0,A.jsxs)(`div`,{children:[(0,A.jsx)(`p`,{className:`mb-1 text-xs font-medium text-muted-foreground`,children:e.az}),(0,A.jsx)(`div`,{className:`space-y-1`,children:e.subnets.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Subnet ${j(e)}`,checked:Te.includes(e.SubnetId??``),onChange:()=>Le(e.SubnetId??``),type:`checkbox`}),(0,A.jsx)(`span`,{className:`font-mono`,children:j(e)})]},e.SubnetId))})]},e.az))}),(0,A.jsx)(C,{errors:[K.subnetIds]})]}),!Z&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Security groups`}),Q.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-1`,children:Q.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:Ee.includes(e.GroupId??``),onChange:()=>Re(e.GroupId??``),type:`checkbox`}),(0,A.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId))})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Tags`}),(0,A.jsxs)(`div`,{className:`space-y-2`,children:[De.map((e,t)=>(0,A.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,A.jsx)(y,{placeholder:`Key`,...V(`tags.${t}.key`)}),(0,A.jsx)(y,{placeholder:`Value`,...V(`tags.${t}.value`)}),(0,A.jsx)(v,{onClick:()=>W(`tags`,G(`tags`).filter((e,n)=>n!==t)),size:`icon`,type:`button`,variant:`ghost`,children:(0,A.jsx)(u,{className:`size-3.5`})})]},t)),(0,A.jsxs)(v,{onClick:()=>W(`tags`,[...G(`tags`),{key:``,value:``}]),size:`sm`,type:`button`,variant:`outline`,children:[(0,A.jsx)(c,{className:`size-3.5`}),`Add tag`]})]})]}),(0,A.jsxs)(`div`,{className:`space-y-4 rounded-md border bg-card p-4`,children:[(0,A.jsx)(`h2`,{className:`text-sm font-semibold`,children:`First listener & default target group`}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),(0,A.jsx)(E,{control:U,name:`listener.protocol`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e=>{e&&Fe(e)},value:e.value,children:[(0,A.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,A.jsx)(r,{})}),(0,A.jsx)(a,{children:ke.map(e=>(0,A.jsx)(i,{value:e,children:e},e))})]})})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),(0,A.jsx)(y,{"aria-invalid":!!K.listener?.port,id:`listener-port`,inputMode:`numeric`,type:`number`,...V(`listener.port`,{valueAsNumber:!0})}),(0,A.jsx)(C,{errors:[K.listener?.port]})]}),Oe&&(0,A.jsxs)(A.Fragment,{children:[(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,A.jsx)(E,{control:U,name:`listener.certificateArn`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.listener?.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,A.jsx)(r,{placeholder:`Select certificate`})}),(0,A.jsx)(a,{children:z.map(e=>(0,A.jsx)(i,{value:e.CertificateArn??``,children:e.DomainName??e.CertificateArn?.split(`/`).pop()},e.CertificateArn))})]})}),(0,A.jsx)(C,{errors:[K.listener?.certificateArn]}),(0,A.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[z.length===0&&(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,A.jsx)(v,{className:`ml-auto`,onClick:()=>L(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),Y===`HTTPS`&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,A.jsx)(E,{control:U,name:`listener.sslPolicy`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??`ELBSecurityPolicy-2016-08`,children:[(0,A.jsx)(o,{className:`w-full`,id:`listener-ssl-policy`,children:(0,A.jsx)(r,{})}),(0,A.jsx)(a,{children:xe.map(e=>(0,A.jsx)(i,{value:e.Name??``,children:e.Name},e.Name))})]})})]})]}),(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:`Default target group`}),(0,A.jsx)(E,{control:U,name:`listener.targetGroupMode`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Create new target group`,checked:e.value===`new`,name:`tg-mode`,onChange:()=>e.onChange(`new`),type:`radio`}),`Create new`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Use existing target group`,checked:e.value===`existing`,name:`tg-mode`,onChange:()=>e.onChange(`existing`),type:`radio`}),`Use existing`]})]})})]}),X===`existing`&&(0,A.jsxs)(w,{children:[(0,A.jsx)(S,{children:(0,A.jsx)(`label`,{htmlFor:`tg-existing`,children:`Target group`})}),(0,A.jsx)(E,{control:U,name:`listener.existingTargetGroupArn`,render:({field:e})=>(0,A.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(o,{"aria-invalid":!!K.listener?.existingTargetGroupArn,className:`w-full`,id:`tg-existing`,children:(0,A.jsx)(r,{placeholder:`Select target group`})}),(0,A.jsx)(a,{children:Me.map(e=>(0,A.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,A.jsx)(C,{errors:[K.listener?.existingTargetGroupArn]}),Me.length===0&&(0,A.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups in this VPC. Choose “Create new” instead.`})]}),X===`new`&&(0,A.jsx)(`div`,{className:`space-y-4 border-l-2 border-muted pl-4`,children:(0,A.jsx)(oe,{allowedProtocols:Ae,form:B,vpcs:R})})]}),(0,A.jsx)(d,{onImported:e=>W(`listener.certificateArn`,e,{shouldValidate:!0}),onOpenChange:L,open:_e}),(0,A.jsx)(l,{commands:he(H,B.watch)}),(0,A.jsx)(ae,{isPending:P.isPending,isSubmitting:we,onCancel:async()=>await p({to:`/ec2/describe-load-balancers`}),pendingLabel:`Creating…`,submitLabel:`Create Load Balancer`})]})]})}function he(e,t){let n=t=>{let n=e(t);return typeof n==`string`?n:``},r=t=>{let n=e(t);return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]},i=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},a=n(`name`)||``,o=n(`type`)||`application`,s=n(`scheme`)||`internet-facing`,c=r(`subnetIds`),l=r(`securityGroupIds`),u=n(`listener.protocol`)||`HTTP`,d=i(`listener.port`)??80,f=n(`listener.certificateArn`),p=n(`listener.sslPolicy`),m=n(`listener.targetGroupMode`),h=n(`listener.existingTargetGroupArn`),g=[],_={type:`comment`,value:o===`network`?`# Create NLB with listener and default target group `:`# Create ALB with listener and default target group diff --git a/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js b/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js index 495f28407..9b499fc2b 100644 --- a/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js +++ b/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js @@ -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}; \ No newline at end of file +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}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.test.tsx b/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.test.tsx index 74b33ab54..5086ee3a7 100644 --- a/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.test.tsx @@ -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({ @@ -128,6 +132,7 @@ describe("ListenerForm", () => { diff --git a/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.tsx b/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.tsx index bf9ebfb31..4f61b1afe 100644 --- a/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.tsx +++ b/spinifex/services/spinifexui/frontend/src/components/elbv2/listener-form.tsx @@ -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 + 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 }) => ( )} @@ -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 })} /> - {isHttps && ( + {isSecure && ( <> @@ -173,32 +192,34 @@ export function ListenerForm({ - - - - - ( - - )} - /> - + {isHttps && ( + + + + + ( + + )} + /> + + )} diff --git a/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.test.tsx b/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.test.tsx index 725bc779a..409c9847e 100644 --- a/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.test.tsx @@ -67,9 +67,16 @@ describe("ListenersTab", () => { it("renders empty state when no listeners", () => { const queryClient = seedClient({}) - render(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + wrapper: createWrapper(queryClient), + }, + ) expect(screen.getByText("No listeners configured.")).toBeInTheDocument() expect( screen.getByRole("button", { name: "Add listener" }), @@ -100,9 +107,16 @@ describe("ListenersTab", () => { ], }, }) - render(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + 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(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + 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(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + 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(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + 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(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + 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(, { - wrapper: createWrapper(queryClient), - }) + render( + , + { + wrapper: createWrapper(queryClient), + }, + ) // The TLS column summarises the policy + cert id for HTTPS listeners. expect( diff --git a/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.tsx b/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.tsx index 53c36963b..8d0a08e9d 100644 --- a/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.tsx +++ b/spinifex/services/spinifexui/frontend/src/components/elbv2/listeners-tab.tsx @@ -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) { @@ -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["targetGroups"] certificates: React.ComponentProps["certificates"] sslPolicies: React.ComponentProps["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({ 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({ @@ -372,6 +394,7 @@ function AddListenerDialog({ interface EditListenerDialogProps { listener: Listener | undefined onOpenChange: (open: boolean) => void + protocols: readonly ListenerProtocolValue[] targetGroups: React.ComponentProps["targetGroups"] certificates: React.ComponentProps["certificates"] sslPolicies: React.ComponentProps["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({ diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/create-load-balancer-page.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/create-load-balancer-page.tsx index 48ff1ef17..c34cca026 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/create-load-balancer-page.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/create-load-balancer-page.tsx @@ -469,7 +469,7 @@ export function CreateLoadBalancerPage() { - Subnets (select at least 2) + Subnets (select at least 1) {groupedSubnets.length === 0 ? (

No subnets in the selected VPC. diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/load-balancer-detail-page.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/load-balancer-detail-page.tsx index 0a4952f76..b20481156 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/load-balancer-detail-page.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(load-balancers)/-components/load-balancer-detail-page.tsx @@ -196,7 +196,11 @@ export function LoadBalancerDetailPage({ arn }: Props) { - + {!isNlb && ( diff --git a/spinifex/services/spinifexui/frontend/src/types/elbv2.test.ts b/spinifex/services/spinifexui/frontend/src/types/elbv2.test.ts index 90196f89b..e0699d509 100644 --- a/spinifex/services/spinifexui/frontend/src/types/elbv2.test.ts +++ b/spinifex/services/spinifexui/frontend/src/types/elbv2.test.ts @@ -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() }) diff --git a/spinifex/services/spinifexui/frontend/src/types/elbv2.ts b/spinifex/services/spinifexui/frontend/src/types/elbv2.ts index ddf0e8455..82f5a5c5d 100644 --- a/spinifex/services/spinifexui/frontend/src/types/elbv2.ts +++ b/spinifex/services/spinifexui/frontend/src/types/elbv2.ts @@ -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 diff --git a/tests/e2e/harness/traffic.go b/tests/e2e/harness/traffic.go index 4c93b7f41..d4289a6a1 100644 --- a/tests/e2e/harness/traffic.go +++ b/tests/e2e/harness/traffic.go @@ -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) { diff --git a/tests/e2e/lb/lb_test.go b/tests/e2e/lb/lb_test.go index 9be9215db..15a459f08 100644 --- a/tests/e2e/lb/lb_test.go +++ b/tests/e2e/lb/lb_test.go @@ -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 diff --git a/tests/e2e/lb/testdata/app-userdata.sh b/tests/e2e/lb/testdata/app-userdata.sh index cca387b6e..a79cdebe0 100644 --- a/tests/e2e/lb/testdata/app-userdata.sh +++ b/tests/e2e/lb/testdata/app-userdata.sh @@ -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": ""} for ALB suites -# TCP:9000 echoes for NLB suites +# TCP:9000 echoes for NLB TCP suites (also UDP-TG TCP health check) +# UDP:9001 echoes 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 & diff --git a/tests/e2e/lb/testdata/client-userdata.sh b/tests/e2e/lb/testdata/client-userdata.sh index 6c9cd55c3..02d388c16 100644 --- a/tests/e2e/lb/testdata/client-userdata.sh +++ b/tests/e2e/lb/testdata/client-userdata.sh @@ -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'],