- 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'],