mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
1655 lines
59 KiB
Go
1655 lines
59 KiB
Go
//go:build e2e
|
||
|
||
package lb
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/x509"
|
||
_ "embed"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go/aws"
|
||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||
"github.com/aws/aws-sdk-go/service/acm"
|
||
"github.com/aws/aws-sdk-go/service/ec2"
|
||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
//go:embed testdata/app-userdata.sh
|
||
var appUserData string
|
||
|
||
const (
|
||
lbVPCCIDR = "10.200.0.0/16"
|
||
lbSubnetCIDR = "10.200.1.0/24"
|
||
lbKeyName = "lb-e2e-key"
|
||
httpPort = 80
|
||
tcpPort = 9000
|
||
udpPort = 9001
|
||
triggerPort = 9090
|
||
probesPerRun = 20
|
||
)
|
||
|
||
// LB kind: ALB or NLB. Used to parameterise the per-suite path.
|
||
type lbKind string
|
||
|
||
const (
|
||
kindALB lbKind = "ALB"
|
||
kindNLB lbKind = "NLB"
|
||
)
|
||
|
||
// TestLoadBalancer runs 4 LB variants (ALB/NLB × internet-facing/internal) as sequential
|
||
// subtests, each with its own LB + listener + TG. Sequential scheduling keeps peak instance
|
||
// count low (≤4) so the suite passes on capacity-constrained dev nodes.
|
||
func TestLoadBalancer(t *testing.T) {
|
||
env := harness.LoadEnv(t)
|
||
skipIfDevNetworking(t, env)
|
||
|
||
// Resolve peer availability before building the shared fixture so the "skip" message
|
||
// appears immediately rather than after minutes of VPC/VM setup.
|
||
peer := pickPeer(env)
|
||
var ssh *harness.PeerSSH
|
||
if peer != "" {
|
||
ssh = harness.NewPeerSSH()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
if err := ssh.Ping(ctx, peer); err != nil {
|
||
t.Logf("cannot SSH to peer %s: %v (internet-facing suites will skip)", peer, err)
|
||
peer = ""
|
||
ssh = nil
|
||
}
|
||
}
|
||
if peer == "" {
|
||
t.Logf("no peer node available — internet-facing subtests will skip; building shared fixture for internal subtests")
|
||
}
|
||
|
||
artifacts := harness.ArtifactDir(t, env)
|
||
client := harness.NewAWSClient(t, env)
|
||
fixture := setupSharedFixture(t, client, artifacts)
|
||
|
||
t.Run("InternetFacing_ALB", func(t *testing.T) {
|
||
if peer == "" {
|
||
t.Skip("no peer node available")
|
||
}
|
||
runLBSuite(t, client, fixture, kindALB, "internet-facing", ssh, peer)
|
||
})
|
||
t.Run("InternetFacing_NLB", func(t *testing.T) {
|
||
if peer == "" {
|
||
t.Skip("no peer node available")
|
||
}
|
||
runLBSuite(t, client, fixture, kindNLB, "internet-facing", ssh, peer)
|
||
})
|
||
t.Run("InternetFacing_ALB_HTTPS", func(t *testing.T) {
|
||
if peer == "" {
|
||
// HTTPS handshake is driven from the test runner against the LB
|
||
// public IP; gate on the same peer-available signal the sibling
|
||
// internet-facing subtests use, where driver→LB reachability holds.
|
||
t.Skip("no peer node available")
|
||
}
|
||
runHTTPSCertSuite(t, client, fixture)
|
||
})
|
||
// Gate remaining internal subtests on Internal_ALB: if the LB never reaches active,
|
||
// the rest will time out identically — fail fast instead of burning ~5min each.
|
||
albOK := t.Run("Internal_ALB", func(t *testing.T) {
|
||
runLBSuite(t, client, fixture, kindALB, "internal", nil, "")
|
||
})
|
||
skipIfInternalBroken := func(t *testing.T) {
|
||
if !albOK {
|
||
t.Skip("Internal_ALB failed (LB never reached active) — skipping remaining internal subtests to fail fast")
|
||
}
|
||
}
|
||
t.Run("Internal_NLB", func(t *testing.T) {
|
||
skipIfInternalBroken(t)
|
||
// Allow time for the ALB sys.micro VM's resources to be reclaimed before
|
||
// NLB races the deallocate on capacity-tight hosts.
|
||
time.Sleep(15 * time.Second)
|
||
runLBSuite(t, client, fixture, kindNLB, "internal", nil, "")
|
||
})
|
||
t.Run("Internal_NLB_UDP", func(t *testing.T) {
|
||
skipIfInternalBroken(t)
|
||
time.Sleep(15 * time.Second)
|
||
runUDPNLBSuite(t, client, fixture)
|
||
})
|
||
t.Run("Internal_ALB_ModifyListener", func(t *testing.T) {
|
||
skipIfInternalBroken(t)
|
||
time.Sleep(15 * time.Second)
|
||
runModifyListenerSuite(t, client, fixture)
|
||
})
|
||
t.Run("Internal_ALB_ListenerRules", func(t *testing.T) {
|
||
skipIfInternalBroken(t)
|
||
time.Sleep(15 * time.Second)
|
||
runListenerRulesSuite(t, client, fixture)
|
||
})
|
||
}
|
||
|
||
// --- Fixture: shared VPC, subnet, IGW, SG, app instances ----------------
|
||
|
||
type sharedFixture struct {
|
||
VPCID string
|
||
SubnetID string
|
||
IGWID string
|
||
MainRouteTableID string
|
||
SecurityGroup string
|
||
AMIID string
|
||
InstanceType string
|
||
AppInstanceIDs []string
|
||
ClientID string
|
||
ClientPublicIP string
|
||
}
|
||
|
||
func setupSharedFixture(t *testing.T, c *harness.AWSClient, artifacts string) *sharedFixture {
|
||
t.Helper()
|
||
f := &sharedFixture{}
|
||
|
||
harness.Phase(t, "Discovering cluster capacity")
|
||
f.InstanceType = discoverNanoInstanceType(t, c)
|
||
f.AMIID = discoverAMI(t, c)
|
||
|
||
harness.Phase(t, "Ensuring SSH key pair %q", lbKeyName)
|
||
ensureKeyPair(t, c)
|
||
t.Cleanup(func() { deleteKeyPair(t, c) })
|
||
|
||
harness.Phase(t, "Creating shared VPC topology (%s)", lbVPCCIDR)
|
||
createVPC(t, c, f)
|
||
t.Cleanup(func() { deleteVPC(t, c, f) })
|
||
createIGW(t, c, f)
|
||
t.Cleanup(func() { deleteIGW(t, c, f) })
|
||
createSubnet(t, c, f)
|
||
t.Cleanup(func() { deleteSubnet(t, c, f) })
|
||
addPublicRoute(t, c, f)
|
||
t.Cleanup(func() { deletePublicRoute(t, c, f) })
|
||
configureDefaultSG(t, c, f)
|
||
|
||
harness.Phase(t, "Launching app instances (2× %s)", f.InstanceType)
|
||
launchAppInstances(t, c, f)
|
||
t.Cleanup(func() { terminateInstances(t, c, f.AppInstanceIDs) })
|
||
|
||
harness.Phase(t, "Launching probe client")
|
||
launchSharedProbeClient(t, c, f)
|
||
t.Cleanup(func() { terminateInstances(t, c, []string{f.ClientID}) })
|
||
|
||
harness.OnFailure(t, func() {
|
||
dumpDaemonLogs(t, artifacts, "setup")
|
||
})
|
||
return f
|
||
}
|
||
|
||
//go:embed testdata/client-userdata.sh
|
||
var clientUserData string
|
||
|
||
// --- Discovery helpers ---------------------------------------------------
|
||
|
||
func skipIfDevNetworking(t *testing.T, env *harness.Env) {
|
||
t.Helper()
|
||
cfg := os.ExpandEnv("$HOME/spinifex/config/spinifex.toml")
|
||
if env.ConfigDir != "" {
|
||
cfg = env.ConfigDir + "/spinifex.toml"
|
||
}
|
||
raw, err := os.ReadFile(cfg)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if bytes.Contains(raw, []byte("dev_networking = true")) {
|
||
t.Skip("dev_networking enabled in spinifex.toml; LB E2E requires pool mode w/ external IPAM")
|
||
}
|
||
}
|
||
|
||
func pickPeer(env *harness.Env) string {
|
||
if len(env.NodeIPs) < 2 {
|
||
return ""
|
||
}
|
||
return env.NodeIPs[1]
|
||
}
|
||
|
||
func discoverNanoInstanceType(t *testing.T, c *harness.AWSClient) string {
|
||
t.Helper()
|
||
out, err := c.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{})
|
||
require.NoError(t, err)
|
||
for _, it := range out.InstanceTypes {
|
||
name := aws.StringValue(it.InstanceType)
|
||
if strings.Contains(name, "nano") {
|
||
t.Logf("instance type: %s", name)
|
||
return name
|
||
}
|
||
}
|
||
t.Fatal("no nano instance type available")
|
||
return ""
|
||
}
|
||
|
||
func discoverAMI(t *testing.T, c *harness.AWSClient) string {
|
||
t.Helper()
|
||
out, err := c.EC2.DescribeImages(&ec2.DescribeImagesInput{})
|
||
require.NoError(t, err)
|
||
var fallback, nonAlpine, ubuntu string
|
||
for _, img := range out.Images {
|
||
id := aws.StringValue(img.ImageId)
|
||
name := aws.StringValue(img.Name)
|
||
if fallback == "" {
|
||
fallback = id
|
||
}
|
||
if !strings.Contains(strings.ToLower(name), "alpine") && nonAlpine == "" {
|
||
nonAlpine = id
|
||
}
|
||
if strings.HasPrefix(name, "ami-ubuntu") {
|
||
ubuntu = id
|
||
break
|
||
}
|
||
}
|
||
for _, candidate := range []string{ubuntu, nonAlpine, fallback} {
|
||
if candidate != "" {
|
||
t.Logf("AMI: %s", candidate)
|
||
return candidate
|
||
}
|
||
}
|
||
t.Fatal("no AMIs available")
|
||
return ""
|
||
}
|
||
|
||
func ensureKeyPair(t *testing.T, c *harness.AWSClient) {
|
||
t.Helper()
|
||
_, _ = c.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(lbKeyName)})
|
||
_, err := c.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(lbKeyName)})
|
||
require.NoError(t, err, "create key pair %s", lbKeyName)
|
||
}
|
||
|
||
func deleteKeyPair(t *testing.T, c *harness.AWSClient) {
|
||
t.Helper()
|
||
if _, err := c.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(lbKeyName)}); err != nil {
|
||
t.Logf("delete key pair: %v", err)
|
||
}
|
||
}
|
||
|
||
// --- VPC / Subnet / IGW / SG --------------------------------------------
|
||
|
||
func createVPC(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(lbVPCCIDR)})
|
||
require.NoError(t, err)
|
||
f.VPCID = aws.StringValue(out.Vpc.VpcId)
|
||
t.Logf("VPC: %s", f.VPCID)
|
||
}
|
||
|
||
func deleteVPC(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
if f.VPCID == "" {
|
||
return
|
||
}
|
||
if _, err := c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(f.VPCID)}); err != nil {
|
||
t.Logf("delete VPC %s: %v", f.VPCID, err)
|
||
}
|
||
}
|
||
|
||
func createIGW(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{})
|
||
require.NoError(t, err)
|
||
f.IGWID = aws.StringValue(out.InternetGateway.InternetGatewayId)
|
||
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
||
InternetGatewayId: aws.String(f.IGWID),
|
||
VpcId: aws.String(f.VPCID),
|
||
})
|
||
require.NoError(t, err)
|
||
t.Logf("IGW: %s (attached to %s)", f.IGWID, f.VPCID)
|
||
}
|
||
|
||
func deleteIGW(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
if f.IGWID == "" {
|
||
return
|
||
}
|
||
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
||
InternetGatewayId: aws.String(f.IGWID),
|
||
VpcId: aws.String(f.VPCID),
|
||
})
|
||
if _, err := c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
||
InternetGatewayId: aws.String(f.IGWID),
|
||
}); err != nil {
|
||
t.Logf("delete IGW: %v", err)
|
||
}
|
||
}
|
||
|
||
func createSubnet(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{
|
||
VpcId: aws.String(f.VPCID),
|
||
CidrBlock: aws.String(lbSubnetCIDR),
|
||
})
|
||
require.NoError(t, err)
|
||
f.SubnetID = aws.StringValue(out.Subnet.SubnetId)
|
||
_, err = c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
||
SubnetId: aws.String(f.SubnetID),
|
||
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
||
})
|
||
require.NoError(t, err)
|
||
t.Logf("subnet: %s (MapPublicIpOnLaunch)", f.SubnetID)
|
||
}
|
||
|
||
func deleteSubnet(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
if f.SubnetID == "" {
|
||
return
|
||
}
|
||
if _, err := c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(f.SubnetID)}); err != nil {
|
||
t.Logf("delete subnet: %v", err)
|
||
}
|
||
}
|
||
|
||
// addPublicRoute installs 0.0.0.0/0 → IGW on the VPC's main route table so the daemon
|
||
// classifies the subnet as public and does not install an OVN LR egress DROP.
|
||
func addPublicRoute(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||
Filters: []*ec2.Filter{
|
||
{Name: aws.String("vpc-id"), Values: []*string{aws.String(f.VPCID)}},
|
||
{Name: aws.String("association.main"), Values: []*string{aws.String("true")}},
|
||
},
|
||
})
|
||
require.NoError(t, err, "describe main route table")
|
||
require.NotEmpty(t, out.RouteTables, "VPC %s has no main route table", f.VPCID)
|
||
f.MainRouteTableID = aws.StringValue(out.RouteTables[0].RouteTableId)
|
||
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
||
RouteTableId: aws.String(f.MainRouteTableID),
|
||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||
GatewayId: aws.String(f.IGWID),
|
||
})
|
||
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW")
|
||
t.Logf("public route: %s 0.0.0.0/0 -> %s", f.MainRouteTableID, f.IGWID)
|
||
}
|
||
|
||
func deletePublicRoute(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
if f.MainRouteTableID == "" {
|
||
return
|
||
}
|
||
if _, err := c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
||
RouteTableId: aws.String(f.MainRouteTableID),
|
||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||
}); err != nil {
|
||
t.Logf("delete public route: %v", err)
|
||
}
|
||
}
|
||
|
||
func configureDefaultSG(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
||
Filters: []*ec2.Filter{
|
||
{Name: aws.String("vpc-id"), Values: []*string{aws.String(f.VPCID)}},
|
||
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
|
||
},
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, out.SecurityGroups, "VPC default SG missing")
|
||
f.SecurityGroup = aws.StringValue(out.SecurityGroups[0].GroupId)
|
||
t.Logf("default SG: %s", f.SecurityGroup)
|
||
|
||
// Use structured IpPermissions form — vpcd ignores the top-level shorthand fields
|
||
// so the OVN ACL would never be installed otherwise.
|
||
for _, port := range []int64{httpPort, tcpPort, triggerPort} {
|
||
_, err := c.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
|
||
GroupId: aws.String(f.SecurityGroup),
|
||
IpPermissions: []*ec2.IpPermission{{
|
||
IpProtocol: aws.String("tcp"),
|
||
FromPort: aws.Int64(port),
|
||
ToPort: aws.Int64(port),
|
||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
||
}},
|
||
})
|
||
if err != nil {
|
||
var aerr awserr.Error
|
||
if !errors.As(err, &aerr) || aerr.Code() != "InvalidPermission.Duplicate" {
|
||
t.Fatalf("authorize tcp/%d: %v", port, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 -------------------------------------------------------
|
||
|
||
func launchAppInstances(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
for i := 0; i < 2; i++ {
|
||
out, err := c.EC2.RunInstances(&ec2.RunInstancesInput{
|
||
ImageId: aws.String(f.AMIID),
|
||
InstanceType: aws.String(f.InstanceType),
|
||
KeyName: aws.String(lbKeyName),
|
||
SubnetId: aws.String(f.SubnetID),
|
||
MinCount: aws.Int64(1),
|
||
MaxCount: aws.Int64(1),
|
||
UserData: aws.String(base64Encode(appUserData)),
|
||
})
|
||
require.NoErrorf(t, err, "run-instances app %d", i+1)
|
||
require.NotEmpty(t, out.Instances)
|
||
id := aws.StringValue(out.Instances[0].InstanceId)
|
||
f.AppInstanceIDs = append(f.AppInstanceIDs, id)
|
||
t.Logf("app instance %d: %s", i+1, id)
|
||
}
|
||
|
||
for _, id := range f.AppInstanceIDs {
|
||
harness.WaitForInstanceRunning(t, c, id, 120*time.Second)
|
||
}
|
||
}
|
||
|
||
func terminateInstances(t *testing.T, c *harness.AWSClient, ids []string) {
|
||
t.Helper()
|
||
if len(ids) == 0 {
|
||
return
|
||
}
|
||
awsIDs := make([]*string, len(ids))
|
||
for i, id := range ids {
|
||
awsIDs[i] = aws.String(id)
|
||
}
|
||
if _, err := c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: awsIDs}); err != nil {
|
||
t.Logf("terminate instances: %v", err)
|
||
return
|
||
}
|
||
harness.WaitForInstanceTerminated(t, c, ids, 60*time.Second)
|
||
}
|
||
|
||
// --- LB suite: one LB (ALB or NLB) × one scheme (internet-facing/internal)
|
||
|
||
// runLBSuite creates one TG + LB + listener, asserts scheme/DNS/ENI invariants, runs
|
||
// traffic tests, then tears down before returning. Self-contained so subtests don't
|
||
// accumulate LB system instances on capacity-constrained dev nodes.
|
||
func runLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture, kind lbKind, scheme string, ssh *harness.PeerSSH, peer string) {
|
||
t.Helper()
|
||
suffix := "int"
|
||
if scheme == "internet-facing" {
|
||
suffix = "inet"
|
||
}
|
||
lbName := strings.ToLower(string(kind))
|
||
label := fmt.Sprintf("%s %s", kind, scheme)
|
||
|
||
var proto, hcPath, eniDescPrefix, lbType string
|
||
var port int64
|
||
if kind == kindALB {
|
||
proto, port, hcPath, eniDescPrefix, lbType = "HTTP", httpPort, "/index.html", "app", "application"
|
||
} else {
|
||
proto, port, hcPath, eniDescPrefix, lbType = "TCP", tcpPort, "", "net", "network"
|
||
}
|
||
|
||
tgArn := createTargetGroup(t, c, f, fmt.Sprintf("lb-e2e-%s-%s-tg", lbName, suffix), proto, port, hcPath)
|
||
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, fmt.Sprintf("lb-e2e-%s-%s", lbName, suffix), lbType, scheme)
|
||
t.Cleanup(func() { deleteLB(t, c, lb) })
|
||
|
||
listener := createListener(t, c, lb.ARN, proto, port, tgArn)
|
||
t.Cleanup(func() { deleteListener(t, c, listener) })
|
||
|
||
assert.Equal(t, scheme, lb.Scheme, label+" scheme")
|
||
assert.Equal(t, lbType, lb.Type, label+" type")
|
||
if kind == kindNLB {
|
||
assert.Contains(t, lb.ARN, "/net/", label+" ARN must contain /net/")
|
||
}
|
||
|
||
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
|
||
if kind == kindNLB {
|
||
captureLBConsoleOnFailure(t, c, eniDescPrefix, lb)
|
||
}
|
||
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
|
||
|
||
eni := lbENI(t, c, eniDescPrefix, lb)
|
||
|
||
if scheme == "internet-facing" {
|
||
ip := publicIP(eni)
|
||
require.NotEmpty(t, ip, label+" needs public IP")
|
||
runInternetFacingTrafficSingle(t, kind, ssh, peer, ip)
|
||
if kind == kindNLB {
|
||
runNLBDeregisterDraining(t, c, tgArn, f.AppInstanceIDs[0])
|
||
}
|
||
return
|
||
}
|
||
|
||
// internal
|
||
assert.Empty(t, publicIP(eni), label+" must not have public IP")
|
||
priv := privateIP(eni)
|
||
require.NotEmpty(t, priv, label+" needs private IP")
|
||
assertInternalDNS(t, c, lb.ARN, label)
|
||
runInternalTrafficViaClient(t, c, f, kind, priv)
|
||
}
|
||
|
||
// runHTTPSCertSuite exercises the ACM → HTTPS listener path end-to-end:
|
||
// ImportCertificate a self-signed leaf → create an internet-facing ALB HTTPS
|
||
// listener referencing the cert ARN → complete a TLS handshake against the LB
|
||
// and assert HAProxy serves exactly the imported cert → drive one HTTPS GET
|
||
// that terminates TLS at the LB and forwards to a healthy backend →
|
||
// DeleteCertificate. The cert is minted for the LB's public IP (known only
|
||
// after the LB is active) so the end-to-end GET can verify the chain by IP SAN.
|
||
func runHTTPSCertSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
const label = "ALB internet-facing HTTPS (ACM)"
|
||
const httpsPort int64 = 443
|
||
|
||
authorizeSGPort(t, c, f, "tcp", httpsPort)
|
||
|
||
tgArn := createTargetGroup(t, c, f, "lb-e2e-https-tg", "HTTP", httpPort, "/index.html")
|
||
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-https", "application", "internet-facing")
|
||
t.Cleanup(func() { deleteLB(t, c, lb) })
|
||
|
||
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
|
||
eni := lbENI(t, c, "app", lb)
|
||
pubIP := publicIP(eni)
|
||
require.NotEmpty(t, pubIP, label+" needs a public IP for the TLS handshake")
|
||
|
||
// Mint a self-signed leaf for the LB's public IP and import it into ACM.
|
||
certPEM, keyPEM, leaf, err := harness.GenerateSelfSignedCertPEM(pubIP)
|
||
require.NoError(t, err, "generate self-signed cert")
|
||
imp, err := c.ACM.ImportCertificate(&acm.ImportCertificateInput{
|
||
Certificate: certPEM,
|
||
PrivateKey: keyPEM,
|
||
})
|
||
require.NoError(t, err, "acm import-certificate")
|
||
certArn := aws.StringValue(imp.CertificateArn)
|
||
require.NotEmpty(t, certArn, "ImportCertificate returned empty CertificateArn")
|
||
require.Contains(t, certArn, ":certificate/", "cert ARN must be an ACM certificate ARN")
|
||
t.Cleanup(func() {
|
||
if _, derr := c.ACM.DeleteCertificate(&acm.DeleteCertificateInput{
|
||
CertificateArn: aws.String(certArn),
|
||
}); derr != nil {
|
||
t.Logf("delete certificate %s: %v", certArn, derr)
|
||
}
|
||
})
|
||
t.Logf("imported cert: %s", certArn)
|
||
|
||
listener := createHTTPSListener(t, c, lb.ARN, httpsPort, tgArn, certArn)
|
||
t.Cleanup(func() { deleteListener(t, c, listener) })
|
||
|
||
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
|
||
|
||
// TLS handshake: HAProxy must present exactly the imported leaf. The
|
||
// lb-agent picks up the new listener cert on its reconcile tick, so poll
|
||
// the handshake until the served cert matches before asserting.
|
||
var served *x509.Certificate
|
||
harness.EventuallyErr(t, func() error {
|
||
got, ferr := harness.FetchServedCert(pubIP, int(httpsPort), 5*time.Second)
|
||
if ferr != nil {
|
||
return ferr
|
||
}
|
||
if !harness.FingerprintMatches(got, leaf) {
|
||
return fmt.Errorf("served cert does not match imported cert yet")
|
||
}
|
||
served = got
|
||
return nil
|
||
}, 90*time.Second, 3*time.Second)
|
||
require.NotNil(t, served, label+" never served the imported cert")
|
||
t.Logf("TLS handshake ok — HAProxy served the imported cert (CN=%s)", served.Subject.CommonName)
|
||
|
||
version, _, err := harness.FetchTLSPosture(pubIP, int(httpsPort), 5*time.Second)
|
||
require.NoError(t, err, "fetch TLS posture")
|
||
assert.GreaterOrEqualf(t, version, uint16(0x0303), "negotiated TLS version must be >= 1.2, got 0x%04x", version)
|
||
|
||
// End-to-end: one HTTPS GET that terminates TLS at the LB and forwards to a
|
||
// backend. Verify the chain against the imported cert (IP SAN matches pubIP).
|
||
pool := x509.NewCertPool()
|
||
require.True(t, pool.AppendCertsFromPEM(certPEM), "build CA pool from imported cert")
|
||
url := fmt.Sprintf("https://%s:%d/index.html", pubIP, httpsPort)
|
||
harness.EventuallyErr(t, func() error {
|
||
status, body, gerr := harness.HTTPSGet(url, pool, 10*time.Second)
|
||
if gerr != nil {
|
||
return gerr
|
||
}
|
||
if status != http.StatusOK {
|
||
return fmt.Errorf("status %d (body=%q)", status, string(body))
|
||
}
|
||
return nil
|
||
}, 90*time.Second, 3*time.Second)
|
||
t.Logf("HTTPS GET %s -> 200 (TLS terminated at LB, forwarded to backend)", url)
|
||
}
|
||
|
||
// createHTTPSListener creates an HTTPS listener referencing certArn. Distinct
|
||
// from createListener because the ELBv2 handler requires Certificates for HTTPS
|
||
// and rejects them for HTTP.
|
||
func createHTTPSListener(t *testing.T, c *harness.AWSClient, lbArn string, port int64, tgArn, certArn string) string {
|
||
t.Helper()
|
||
// e2e:allow-create — the HTTPS listener is the subject under test (ACM cert -> termination).
|
||
out, err := c.ELBv2.CreateListener(&elbv2.CreateListenerInput{
|
||
LoadBalancerArn: aws.String(lbArn),
|
||
Protocol: aws.String("HTTPS"),
|
||
Port: aws.Int64(port),
|
||
Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(certArn)}},
|
||
DefaultActions: []*elbv2.Action{{
|
||
Type: aws.String("forward"),
|
||
TargetGroupArn: aws.String(tgArn),
|
||
}},
|
||
})
|
||
require.NoError(t, err, "create-listener HTTPS")
|
||
require.NotEmpty(t, out.Listeners, "create-listener HTTPS returned no listeners")
|
||
arn := aws.StringValue(out.Listeners[0].ListenerArn)
|
||
t.Logf("listener HTTPS:%d -> %s (cert %s)", port, arn, certArn)
|
||
return arn
|
||
}
|
||
|
||
// authorizeSGPort idempotently opens proto/port from 0.0.0.0/0 on the shared
|
||
// default SG, tolerating the Duplicate code on re-runs.
|
||
func authorizeSGPort(t *testing.T, c *harness.AWSClient, f *sharedFixture, proto string, port int64) {
|
||
t.Helper()
|
||
_, err := c.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
|
||
GroupId: aws.String(f.SecurityGroup),
|
||
IpPermissions: []*ec2.IpPermission{{
|
||
IpProtocol: aws.String(proto),
|
||
FromPort: aws.Int64(port),
|
||
ToPort: aws.Int64(port),
|
||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
||
}},
|
||
})
|
||
if err != nil {
|
||
var aerr awserr.Error
|
||
if !errors.As(err, &aerr) || aerr.Code() != "InvalidPermission.Duplicate" {
|
||
t.Fatalf("authorize %s/%d: %v", proto, port, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// runUDPNLBSuite exercises an internal NLB with a UDP listener. The lb-agent probes
|
||
// targets over TCP/9000 (nginx stream has no active health-check); once healthy, a
|
||
// UDP datagram round-trip through the VIP proves the L4 data plane.
|
||
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)
|
||
captureLBConsoleOnFailure(t, c, "net", lb)
|
||
// 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 {
|
||
t.Helper()
|
||
in := &elbv2.CreateTargetGroupInput{
|
||
Name: aws.String(name),
|
||
Protocol: aws.String(proto),
|
||
Port: aws.Int64(port),
|
||
VpcId: aws.String(f.VPCID),
|
||
HealthCheckIntervalSeconds: aws.Int64(5),
|
||
HealthyThresholdCount: aws.Int64(2),
|
||
UnhealthyThresholdCount: aws.Int64(2),
|
||
}
|
||
if hcPath != "" {
|
||
in.HealthCheckPath = aws.String(hcPath)
|
||
} else {
|
||
in.HealthCheckProtocol = aws.String("TCP")
|
||
in.HealthCheckIntervalSeconds = aws.Int64(10)
|
||
}
|
||
out, err := c.ELBv2.CreateTargetGroup(in)
|
||
require.NoErrorf(t, err, "create-target-group %s", name)
|
||
arn := aws.StringValue(out.TargetGroups[0].TargetGroupArn)
|
||
t.Logf("TG %s: %s", name, arn)
|
||
return arn
|
||
}
|
||
|
||
// createUDPTargetGroup creates a UDP TG with a TCP health check (UDP can't be probed),
|
||
// exercising the nginx agent's active TCP prober that 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
|
||
}
|
||
if _, err := c.ELBv2.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: aws.String(arn)}); err != nil {
|
||
t.Logf("delete TG %s: %v", arn, err)
|
||
}
|
||
}
|
||
|
||
func registerTargets(t *testing.T, c *harness.AWSClient, tgArn string, instanceIDs []string) {
|
||
t.Helper()
|
||
targets := make([]*elbv2.TargetDescription, len(instanceIDs))
|
||
for i, id := range instanceIDs {
|
||
targets[i] = &elbv2.TargetDescription{Id: aws.String(id)}
|
||
}
|
||
_, err := c.ELBv2.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||
TargetGroupArn: aws.String(tgArn),
|
||
Targets: targets,
|
||
})
|
||
require.NoError(t, err, "register-targets")
|
||
}
|
||
|
||
func deregisterTargets(t *testing.T, c *harness.AWSClient, tgArn string, instanceIDs []string) {
|
||
if tgArn == "" || len(instanceIDs) == 0 {
|
||
return
|
||
}
|
||
targets := make([]*elbv2.TargetDescription, len(instanceIDs))
|
||
for i, id := range instanceIDs {
|
||
targets[i] = &elbv2.TargetDescription{Id: aws.String(id)}
|
||
}
|
||
if _, err := c.ELBv2.DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||
TargetGroupArn: aws.String(tgArn),
|
||
Targets: targets,
|
||
}); err != nil {
|
||
t.Logf("deregister-targets: %v", err)
|
||
}
|
||
}
|
||
|
||
type lbInfo struct {
|
||
ARN, ID, Scheme, Type string
|
||
}
|
||
|
||
func createLB(t *testing.T, c *harness.AWSClient, f *sharedFixture, name, lbType, scheme string) lbInfo {
|
||
t.Helper()
|
||
in := &elbv2.CreateLoadBalancerInput{
|
||
Name: aws.String(name),
|
||
Subnets: []*string{aws.String(f.SubnetID)},
|
||
Scheme: aws.String(scheme),
|
||
}
|
||
if lbType == "network" {
|
||
in.Type = aws.String("network")
|
||
}
|
||
out, err := c.ELBv2.CreateLoadBalancer(in)
|
||
require.NoErrorf(t, err, "create-load-balancer %s", name)
|
||
require.NotEmpty(t, out.LoadBalancers)
|
||
lb := out.LoadBalancers[0]
|
||
arn := aws.StringValue(lb.LoadBalancerArn)
|
||
parts := strings.Split(arn, "/")
|
||
info := lbInfo{
|
||
ARN: arn,
|
||
ID: parts[len(parts)-1],
|
||
Scheme: aws.StringValue(lb.Scheme),
|
||
Type: aws.StringValue(lb.Type),
|
||
}
|
||
t.Logf("LB %s: %s (scheme=%s type=%s)", name, info.ARN, info.Scheme, info.Type)
|
||
return info
|
||
}
|
||
|
||
func deleteLB(t *testing.T, c *harness.AWSClient, lb lbInfo) {
|
||
if lb.ARN == "" {
|
||
return
|
||
}
|
||
prefix := "app"
|
||
if lb.Type == "network" {
|
||
prefix = "net"
|
||
}
|
||
parts := strings.Split(lb.ARN, "/")
|
||
require.GreaterOrEqualf(t, len(parts), 3, "deleteLB: malformed LB ARN %q", lb.ARN)
|
||
lbName := parts[len(parts)-2]
|
||
filter := fmt.Sprintf("ELB %s/%s/%s", prefix, lbName, lb.ID)
|
||
|
||
// Capture the underlying sys.micro VM id before deleting so we can wait
|
||
// for it to actually terminate. ELBv2.DeleteLoadBalancer returns once
|
||
// the LB resource is gone, but the system VM termination is async and
|
||
// holds the vCPU/memory allocation until reaped — without this wait,
|
||
// the next suite's createLB can race the capacity reclaim and trip the
|
||
// reserve, ending up in terminal state=failed.
|
||
var sysInstanceID string
|
||
if eniOut, err := c.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||
Filters: []*ec2.Filter{{
|
||
Name: aws.String("description"),
|
||
Values: []*string{aws.String(filter)},
|
||
}},
|
||
}); err == nil && len(eniOut.NetworkInterfaces) > 0 && eniOut.NetworkInterfaces[0].Attachment != nil {
|
||
sysInstanceID = aws.StringValue(eniOut.NetworkInterfaces[0].Attachment.InstanceId)
|
||
}
|
||
|
||
if _, err := c.ELBv2.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||
LoadBalancerArn: aws.String(lb.ARN),
|
||
}); err != nil {
|
||
t.Logf("delete LB %s: %v", lb.ARN, err)
|
||
return
|
||
}
|
||
// Block until describe reports LoadBalancerNotFound — the daemon doesn't
|
||
// mark the LB gone until the underlying sys.micro VM has been torn down
|
||
// and its vCPU/memory deallocated. Polling this avoids the capacity race
|
||
// where the next LB createLB fires before deallocation completes (sys
|
||
// instances are filtered from DescribeInstances so WaitForInstanceTerminated
|
||
// is a no-op for them).
|
||
waitForLBGone(t, c, lb.ARN, 60*time.Second)
|
||
harness.WaitForENICleanup(t, c, filter, lb.ARN, 30*time.Second)
|
||
if sysInstanceID != "" {
|
||
harness.WaitForInstanceTerminated(t, c, []string{sysInstanceID}, 60*time.Second)
|
||
}
|
||
}
|
||
|
||
// waitForLBGone polls until DescribeLoadBalancers returns LoadBalancerNotFound,
|
||
// indicating the underlying system VM has terminated and released its capacity.
|
||
func waitForLBGone(t *testing.T, c *harness.AWSClient, arn string, timeout time.Duration) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
for {
|
||
out, err := c.ELBv2.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
|
||
LoadBalancerArns: []*string{aws.String(arn)},
|
||
})
|
||
if err != nil {
|
||
var aerr awserr.Error
|
||
if errors.As(err, &aerr) && aerr.Code() == "LoadBalancerNotFound" {
|
||
return
|
||
}
|
||
}
|
||
if err == nil && len(out.LoadBalancers) == 0 {
|
||
return
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Logf("waitForLBGone: %s still visible after %s (continuing)", arn, timeout)
|
||
return
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
}
|
||
|
||
func createListener(t *testing.T, c *harness.AWSClient, lbArn, proto string, port int64, tgArn string) string {
|
||
t.Helper()
|
||
out, err := c.ELBv2.CreateListener(&elbv2.CreateListenerInput{
|
||
LoadBalancerArn: aws.String(lbArn),
|
||
Protocol: aws.String(proto),
|
||
Port: aws.Int64(port),
|
||
DefaultActions: []*elbv2.Action{{
|
||
Type: aws.String("forward"),
|
||
TargetGroupArn: aws.String(tgArn),
|
||
}},
|
||
})
|
||
require.NoError(t, err, "create-listener")
|
||
arn := aws.StringValue(out.Listeners[0].ListenerArn)
|
||
t.Logf("listener %s:%d -> %s", proto, port, arn)
|
||
return arn
|
||
}
|
||
|
||
func deleteListener(t *testing.T, c *harness.AWSClient, arn string) {
|
||
if arn == "" {
|
||
return
|
||
}
|
||
if _, err := c.ELBv2.DeleteListener(&elbv2.DeleteListenerInput{ListenerArn: aws.String(arn)}); err != nil {
|
||
t.Logf("delete listener: %v", err)
|
||
}
|
||
}
|
||
|
||
// modifyListenerPort changes the listener's port in place. Verifies that
|
||
// HAProxy is reconciled without the listener being deleted (no traffic outage).
|
||
func modifyListenerPort(t *testing.T, c *harness.AWSClient, listenerArn string, port int64) {
|
||
t.Helper()
|
||
_, err := c.ELBv2.ModifyListener(&elbv2.ModifyListenerInput{
|
||
ListenerArn: aws.String(listenerArn),
|
||
Port: aws.Int64(port),
|
||
})
|
||
require.NoErrorf(t, err, "modify-listener port=%d", port)
|
||
t.Logf("listener %s: port → %d", listenerArn, port)
|
||
}
|
||
|
||
// modifyListenerDefaultTG swaps the listener's default target group.
|
||
func modifyListenerDefaultTG(t *testing.T, c *harness.AWSClient, listenerArn, tgArn string) {
|
||
t.Helper()
|
||
_, err := c.ELBv2.ModifyListener(&elbv2.ModifyListenerInput{
|
||
ListenerArn: aws.String(listenerArn),
|
||
DefaultActions: []*elbv2.Action{{
|
||
Type: aws.String("forward"),
|
||
TargetGroupArn: aws.String(tgArn),
|
||
}},
|
||
})
|
||
require.NoError(t, err, "modify-listener default actions")
|
||
t.Logf("listener %s: default TG → %s", listenerArn, tgArn)
|
||
}
|
||
|
||
// describeListenerPort fetches the current port of a listener — used to
|
||
// assert that ModifyListener actually persisted before probing.
|
||
func describeListenerPort(t *testing.T, c *harness.AWSClient, listenerArn string) int64 {
|
||
t.Helper()
|
||
out, err := c.ELBv2.DescribeListeners(&elbv2.DescribeListenersInput{
|
||
ListenerArns: []*string{aws.String(listenerArn)},
|
||
})
|
||
require.NoError(t, err, "describe-listeners")
|
||
require.NotEmpty(t, out.Listeners)
|
||
return aws.Int64Value(out.Listeners[0].Port)
|
||
}
|
||
|
||
// runModifyListenerSuite verifies in-place listener edits: port change and DefaultActions swap.
|
||
// Regression gate: the listener must stay alive across edits (not delete+recreate).
|
||
func runModifyListenerSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
const label = "ALB internal ModifyListener"
|
||
const altPort int64 = 8090
|
||
|
||
tgA := createTargetGroup(t, c, f, "lb-e2e-mod-tg-a", "HTTP", httpPort, "/index.html")
|
||
t.Cleanup(func() { deleteTargetGroup(t, c, tgA) })
|
||
|
||
tgB := createTargetGroup(t, c, f, "lb-e2e-mod-tg-b", "HTTP", httpPort, "/index.html")
|
||
t.Cleanup(func() { deleteTargetGroup(t, c, tgB) })
|
||
|
||
registerTargets(t, c, tgA, f.AppInstanceIDs)
|
||
t.Cleanup(func() { deregisterTargets(t, c, tgA, f.AppInstanceIDs) })
|
||
registerTargets(t, c, tgB, f.AppInstanceIDs)
|
||
t.Cleanup(func() { deregisterTargets(t, c, tgB, f.AppInstanceIDs) })
|
||
|
||
lb := createLB(t, c, f, "lb-e2e-mod", "application", "internal")
|
||
t.Cleanup(func() { deleteLB(t, c, lb) })
|
||
|
||
listener := createListener(t, c, lb.ARN, "HTTP", httpPort, tgA)
|
||
t.Cleanup(func() { deleteListener(t, c, listener) })
|
||
|
||
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
|
||
harness.WaitForTargetsHealthy(t, c, tgA, 2, label+" tgA", 2*time.Minute)
|
||
|
||
eni := lbENI(t, c, "app", lb)
|
||
priv := privateIP(eni)
|
||
require.NotEmpty(t, priv, label+" needs private IP")
|
||
|
||
// Baseline: traffic on the original port.
|
||
probeAtPort(t, f, priv, httpPort, label+" before modify (port 80)")
|
||
|
||
// In-place port change.
|
||
modifyListenerPort(t, c, listener, altPort)
|
||
assert.Equal(t, altPort, describeListenerPort(t, c, listener), "listener port persisted")
|
||
// lb-agent picks up the new HAProxy config on its next 5s tick, not
|
||
// synchronously with the ModifyListener API. Poll until the listener
|
||
// actually answers on altPort before driving the assertive probe.
|
||
waitForListenerServing(t, f, priv, altPort, label+" port 8090", 60*time.Second)
|
||
probeAtPort(t, f, priv, altPort, label+" after port-modify (port 8090)")
|
||
|
||
// Defaults: in-place TG swap on same port.
|
||
modifyListenerDefaultTG(t, c, listener, tgB)
|
||
harness.WaitForTargetsHealthy(t, c, tgB, 2, label+" tgB (post-swap)", 2*time.Minute)
|
||
probeAtPort(t, f, priv, altPort, label+" after TG-swap (still port 8090, now tgB)")
|
||
}
|
||
|
||
// runListenerRulesSuite exercises ALB listener rules: CreateRule, ModifyRule, DescribeRules,
|
||
// and DeleteRule. Two TGs with disjoint backends so probes confirm which TG served.
|
||
// Also tests host-header condition to verify a second ACL field reaches HAProxy.
|
||
func runListenerRulesSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
const label = "ALB internal ListenerRules"
|
||
|
||
require.Len(t, f.AppInstanceIDs, 2, label+" needs 2 app instances")
|
||
appA, appB := f.AppInstanceIDs[0], f.AppInstanceIDs[1]
|
||
|
||
tgA := createTargetGroup(t, c, f, "lb-e2e-rul-tg-a", "HTTP", httpPort, "/index.html")
|
||
t.Cleanup(func() { deleteTargetGroup(t, c, tgA) })
|
||
tgB := createTargetGroup(t, c, f, "lb-e2e-rul-tg-b", "HTTP", httpPort, "/alpha/index.html")
|
||
t.Cleanup(func() { deleteTargetGroup(t, c, tgB) })
|
||
|
||
registerTargets(t, c, tgA, []string{appA})
|
||
t.Cleanup(func() { deregisterTargets(t, c, tgA, []string{appA}) })
|
||
registerTargets(t, c, tgB, []string{appB})
|
||
t.Cleanup(func() { deregisterTargets(t, c, tgB, []string{appB}) })
|
||
|
||
lb := createLB(t, c, f, "lb-e2e-rul", "application", "internal")
|
||
t.Cleanup(func() { deleteLB(t, c, lb) })
|
||
listener := createListener(t, c, lb.ARN, "HTTP", httpPort, tgA)
|
||
t.Cleanup(func() { deleteListener(t, c, listener) })
|
||
|
||
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
|
||
harness.WaitForTargetsHealthy(t, c, tgA, 1, label+" tgA", 2*time.Minute)
|
||
|
||
eni := lbENI(t, c, "app", lb)
|
||
priv := privateIP(eni)
|
||
require.NotEmpty(t, priv, label+" needs private IP")
|
||
|
||
waitForPathServing(t, f, priv, httpPort, "/", "", label+" baseline", 60*time.Second)
|
||
base := probeAtPath(t, f, priv, httpPort, "/", "", label+" default -> tgA")
|
||
require.Equal(t, 1, base.Unique(), label+" default expects 1 responder (tgA single backend)")
|
||
appAHost := singleResponder(base)
|
||
|
||
// CreateRule wires tgB to the listener; only then does spinifex begin
|
||
// health-checking tgB. Wait for healthy AFTER rule creation.
|
||
ruleArn := createPathRule(t, c, listener, 10, "/alpha*", tgB)
|
||
ruleCleanup := func() {
|
||
if ruleArn == "" {
|
||
return
|
||
}
|
||
deleteRule(t, c, ruleArn)
|
||
}
|
||
t.Cleanup(ruleCleanup)
|
||
|
||
harness.WaitForTargetsHealthy(t, c, tgB, 1, label+" tgB (post-rule)", 2*time.Minute)
|
||
waitForPathRoutedAway(t, f, priv, httpPort, "/alpha/", appAHost, label+" wait rule active", 60*time.Second)
|
||
ruleResp := probeAtPath(t, f, priv, httpPort, "/alpha/", "", label+" path /alpha/ -> tgB")
|
||
require.Equal(t, 1, ruleResp.Unique(), label+" rule probe expects 1 responder")
|
||
appBHost := singleResponder(ruleResp)
|
||
require.NotEqual(t, appAHost, appBHost, label+" /alpha/ must route to tgB, not default")
|
||
|
||
// Default route unaffected by rule.
|
||
stillDefault := probeAtPath(t, f, priv, httpPort, "/", "", label+" default unchanged")
|
||
require.Equal(t, 1, stillDefault.Unique())
|
||
assert.Equal(t, appAHost, singleResponder(stillDefault), label+" / must still hit tgA")
|
||
|
||
// DescribeRules: rule + default present.
|
||
rules := describeRules(t, c, listener)
|
||
assert.GreaterOrEqual(t, len(rules), 2, label+" expect rule + default")
|
||
assert.True(t, hasRule(rules, ruleArn), label+" CreateRule arn must appear in DescribeRules")
|
||
|
||
// ModifyRule: /alpha* -> /beta*. /alpha now falls back to default;
|
||
// /beta* now reaches tgB (appB).
|
||
modifyPathRule(t, c, ruleArn, "/beta*")
|
||
waitForPathRoutedTo(t, f, priv, httpPort, "/beta/", appBHost, label+" wait modify active", 60*time.Second)
|
||
modified := probeAtPath(t, f, priv, httpPort, "/alpha/", "", label+" /alpha/ falls to default after modify")
|
||
require.Equal(t, 1, modified.Unique())
|
||
assert.Equal(t, appAHost, singleResponder(modified), label+" /alpha/ should hit default after pattern change")
|
||
|
||
// /beta/ now routes to tgB.
|
||
betaResp := probeAtPath(t, f, priv, httpPort, "/beta/", "", label+" /beta/ -> tgB")
|
||
require.Equal(t, 1, betaResp.Unique())
|
||
assert.Equal(t, appBHost, singleResponder(betaResp), label+" /beta/ must hit tgB after modify")
|
||
|
||
// DeleteRule: /beta also falls to default.
|
||
deleteRule(t, c, ruleArn)
|
||
ruleArn = ""
|
||
waitForPathRoutedAway(t, f, priv, httpPort, "/beta/", appBHost, label+" wait delete active", 60*time.Second)
|
||
afterDelete := probeAtPath(t, f, priv, httpPort, "/beta/", "", label+" /beta/ after delete")
|
||
require.Equal(t, 1, afterDelete.Unique())
|
||
assert.Equal(t, appAHost, singleResponder(afterDelete), label+" /beta/ must hit default after delete")
|
||
}
|
||
|
||
// createPathRule adds a path-pattern listener rule. Returns the rule ARN.
|
||
func createPathRule(t *testing.T, c *harness.AWSClient, listenerArn string, priority int64, pattern, tgArn string) string {
|
||
t.Helper()
|
||
out, err := c.ELBv2.CreateRule(&elbv2.CreateRuleInput{
|
||
ListenerArn: aws.String(listenerArn),
|
||
Priority: aws.Int64(priority),
|
||
Conditions: []*elbv2.RuleCondition{{
|
||
Field: aws.String("path-pattern"),
|
||
PathPatternConfig: &elbv2.PathPatternConditionConfig{
|
||
Values: []*string{aws.String(pattern)},
|
||
},
|
||
}},
|
||
Actions: []*elbv2.Action{{
|
||
Type: aws.String("forward"),
|
||
TargetGroupArn: aws.String(tgArn),
|
||
}},
|
||
})
|
||
require.NoErrorf(t, err, "create-rule priority=%d pattern=%s", priority, pattern)
|
||
require.NotEmpty(t, out.Rules)
|
||
arn := aws.StringValue(out.Rules[0].RuleArn)
|
||
t.Logf("rule %s: priority=%d path=%s -> %s", arn, priority, pattern, tgArn)
|
||
return arn
|
||
}
|
||
|
||
func modifyPathRule(t *testing.T, c *harness.AWSClient, ruleArn, pattern string) {
|
||
t.Helper()
|
||
_, err := c.ELBv2.ModifyRule(&elbv2.ModifyRuleInput{
|
||
RuleArn: aws.String(ruleArn),
|
||
Conditions: []*elbv2.RuleCondition{{
|
||
Field: aws.String("path-pattern"),
|
||
PathPatternConfig: &elbv2.PathPatternConditionConfig{
|
||
Values: []*string{aws.String(pattern)},
|
||
},
|
||
}},
|
||
})
|
||
require.NoErrorf(t, err, "modify-rule pattern=%s", pattern)
|
||
t.Logf("rule %s: path -> %s", ruleArn, pattern)
|
||
}
|
||
|
||
func deleteRule(t *testing.T, c *harness.AWSClient, ruleArn string) {
|
||
if ruleArn == "" {
|
||
return
|
||
}
|
||
if _, err := c.ELBv2.DeleteRule(&elbv2.DeleteRuleInput{RuleArn: aws.String(ruleArn)}); err != nil {
|
||
t.Logf("delete rule %s: %v", ruleArn, err)
|
||
}
|
||
}
|
||
|
||
func describeRules(t *testing.T, c *harness.AWSClient, listenerArn string) []*elbv2.Rule {
|
||
t.Helper()
|
||
out, err := c.ELBv2.DescribeRules(&elbv2.DescribeRulesInput{
|
||
ListenerArn: aws.String(listenerArn),
|
||
})
|
||
require.NoError(t, err, "describe-rules")
|
||
return out.Rules
|
||
}
|
||
|
||
func hasRule(rules []*elbv2.Rule, arn string) bool {
|
||
for _, r := range rules {
|
||
if aws.StringValue(r.RuleArn) == arn {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func singleResponder(r harness.TrafficResult) string {
|
||
for k := range r.Distribution {
|
||
return k
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// probeAtPath drives the shared client to issue probesPerRun probes at lbIP:port with the
|
||
// given path and optional Host header. Asserts at least half succeed and returns the
|
||
// distribution so callers can assert which backend(s) served.
|
||
func probeAtPath(t *testing.T, f *sharedFixture, lbIP string, port int64, path, host, label string) harness.TrafficResult {
|
||
t.Helper()
|
||
r, err := probeOnceAt(f, lbIP, port, path, host, probesPerRun)
|
||
require.NoErrorf(t, err, "trigger probe %s", label)
|
||
for inst, count := range r.Distribution {
|
||
t.Logf(" %s: %s -> %d", label, inst, count)
|
||
}
|
||
t.Logf(" %s: %d/%d successful, %d unique", label, r.Successful, r.Total, r.Unique())
|
||
require.GreaterOrEqualf(t, r.Successful, probesPerRun/2, "%s probes succeeded", label)
|
||
return r
|
||
}
|
||
|
||
// waitForPathServing polls until the path/host combination returns any
|
||
// successful response. Use after CreateRule/ModifyRule/DeleteRule to bridge
|
||
// the lb-agent reconcile window.
|
||
func waitForPathServing(t *testing.T, f *sharedFixture, lbIP string, port int64, path, host, label string, timeout time.Duration) {
|
||
t.Helper()
|
||
harness.EventuallyErr(t, func() error {
|
||
r, err := probeOnceAt(f, lbIP, port, path, host, 2)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if r.Successful == 0 {
|
||
return fmt.Errorf("%s: 0/%d successful", label, r.Total)
|
||
}
|
||
return nil
|
||
}, timeout, 2*time.Second)
|
||
}
|
||
|
||
// waitForPathRoutedAway polls a path until responses stop coming from awayFrom.
|
||
// Use after CreateRule/ModifyRule/DeleteRule to wait out lb-agent reconcile.
|
||
// Requires at least one successful probe to avoid passing on a transient outage.
|
||
func waitForPathRoutedAway(t *testing.T, f *sharedFixture, lbIP string, port int64, path, awayFrom, label string, timeout time.Duration) {
|
||
t.Helper()
|
||
harness.EventuallyErr(t, func() error {
|
||
r, err := probeOnceAt(f, lbIP, port, path, "", 2)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if r.Successful == 0 {
|
||
return fmt.Errorf("%s: 0/%d successful", label, r.Total)
|
||
}
|
||
if r.Distribution[awayFrom] > 0 {
|
||
return fmt.Errorf("%s: still seeing %s", label, awayFrom)
|
||
}
|
||
return nil
|
||
}, timeout, 2*time.Second)
|
||
}
|
||
|
||
// waitForPathRoutedTo polls a path until responses come from wantHost.
|
||
// Use when the caller already knows the expected backend hostname.
|
||
func waitForPathRoutedTo(t *testing.T, f *sharedFixture, lbIP string, port int64, path, wantHost, label string, timeout time.Duration) {
|
||
t.Helper()
|
||
harness.EventuallyErr(t, func() error {
|
||
r, err := probeOnceAt(f, lbIP, port, path, "", 2)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if r.Distribution[wantHost] == 0 {
|
||
return fmt.Errorf("%s: %s not yet observed", label, wantHost)
|
||
}
|
||
return nil
|
||
}, timeout, 2*time.Second)
|
||
}
|
||
|
||
// probeOnceAt is probeOnce with explicit path + Host-header support. Returns
|
||
// errors rather than failing so callers can poll.
|
||
func probeOnceAt(f *sharedFixture, lbIP string, port int64, path, host string, count int) (harness.TrafficResult, error) {
|
||
resultsFile := fmt.Sprintf("rul-%d-%d.txt", port, time.Now().UnixNano())
|
||
order := map[string]any{
|
||
"proto": "http",
|
||
"ip": lbIP,
|
||
"count": count,
|
||
"outfile": resultsFile,
|
||
"http_port": port,
|
||
"http_path": path,
|
||
"host": host,
|
||
"tcp_port": tcpPort,
|
||
}
|
||
body, err := json.Marshal(order)
|
||
if err != nil {
|
||
return harness.TrafficResult{}, err
|
||
}
|
||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return harness.TrafficResult{}, fmt.Errorf("trigger: %w", err)
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
return harness.TrafficResult{}, fmt.Errorf("trigger status %d", resp.StatusCode)
|
||
}
|
||
results, err := plainHTTPGet(
|
||
fmt.Sprintf("http://%s:%d/%s", f.ClientPublicIP, httpPort, resultsFile),
|
||
10*time.Second)
|
||
if err != nil {
|
||
return harness.TrafficResult{}, fmt.Errorf("fetch %s: %w", resultsFile, err)
|
||
}
|
||
return harness.VerifyResultsLines(results, "http"), nil
|
||
}
|
||
|
||
// waitForListenerServing polls the shared client until at least one probe returns
|
||
// a successful instance_id payload. Used after ModifyListener to avoid racing
|
||
// the lb-agent's poll-driven HAProxy reload (5s tick + reload latency).
|
||
func waitForListenerServing(t *testing.T, f *sharedFixture, lbIP string, port int64, label string, timeout time.Duration) {
|
||
t.Helper()
|
||
harness.EventuallyErr(t, func() error {
|
||
r, err := probeOnce(f, lbIP, port, 2)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if r.Successful == 0 {
|
||
return fmt.Errorf("%s: listener not serving on :%d (0/%d successful)", label, port, r.Total)
|
||
}
|
||
return nil
|
||
}, timeout, 2*time.Second)
|
||
}
|
||
|
||
// probeOnce drives the shared client to issue `count` HTTP probes at
|
||
// lbIP:port and returns the parsed TrafficResult. Errors are returned rather
|
||
// than failing the test so callers can poll.
|
||
func probeOnce(f *sharedFixture, lbIP string, port int64, count int) (harness.TrafficResult, error) {
|
||
resultsFile := fmt.Sprintf("mod-%d-%d.txt", port, time.Now().UnixNano())
|
||
order := map[string]any{
|
||
"proto": "http",
|
||
"ip": lbIP,
|
||
"count": count,
|
||
"outfile": resultsFile,
|
||
"http_port": port,
|
||
"tcp_port": tcpPort,
|
||
}
|
||
body, err := json.Marshal(order)
|
||
if err != nil {
|
||
return harness.TrafficResult{}, err
|
||
}
|
||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return harness.TrafficResult{}, fmt.Errorf("trigger: %w", err)
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
return harness.TrafficResult{}, fmt.Errorf("trigger status %d", resp.StatusCode)
|
||
}
|
||
results, err := plainHTTPGet(
|
||
fmt.Sprintf("http://%s:%d/%s", f.ClientPublicIP, httpPort, resultsFile),
|
||
10*time.Second)
|
||
if err != nil {
|
||
return harness.TrafficResult{}, fmt.Errorf("fetch %s: %w", resultsFile, err)
|
||
}
|
||
return harness.VerifyResultsLines(results, "http"), nil
|
||
}
|
||
|
||
// probeAtPort runs round-robin probes from the shared client to lbIP:port and
|
||
// asserts both app instances respond. Mirrors runInternalTrafficViaClient but
|
||
// takes an explicit port so ModifyListener edits can be verified.
|
||
func probeAtPort(t *testing.T, f *sharedFixture, lbIP string, port int64, label string) {
|
||
t.Helper()
|
||
resultsFile := fmt.Sprintf("mod-%d-%d.txt", port, time.Now().UnixNano())
|
||
order := map[string]any{
|
||
"proto": "http",
|
||
"ip": lbIP,
|
||
"count": probesPerRun,
|
||
"outfile": resultsFile,
|
||
"http_port": port,
|
||
"tcp_port": tcpPort,
|
||
}
|
||
body, err := json.Marshal(order)
|
||
require.NoError(t, err)
|
||
|
||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
|
||
require.NoErrorf(t, err, "trigger probe %s", label)
|
||
resp.Body.Close()
|
||
require.Equalf(t, 200, resp.StatusCode, "trigger probe %s 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, "http"),
|
||
1, probesPerRun/2, label)
|
||
}
|
||
|
||
func lbENI(t *testing.T, c *harness.AWSClient, prefix string, lb lbInfo) *ec2.NetworkInterface {
|
||
t.Helper()
|
||
parts := strings.Split(lb.ARN, "/")
|
||
require.GreaterOrEqualf(t, len(parts), 3, "lbENI: malformed LB ARN %q", lb.ARN)
|
||
lbName := parts[len(parts)-2]
|
||
desc := fmt.Sprintf("ELB %s/%s/%s", prefix, lbName, lb.ID)
|
||
var eni *ec2.NetworkInterface
|
||
harness.EventuallyErr(t, func() error {
|
||
out, err := c.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||
Filters: []*ec2.Filter{{
|
||
Name: aws.String("description"),
|
||
Values: []*string{aws.String(desc)},
|
||
}},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(out.NetworkInterfaces) == 0 {
|
||
return fmt.Errorf("no ENI for %s", desc)
|
||
}
|
||
eni = out.NetworkInterfaces[0]
|
||
return nil
|
||
}, 30*time.Second, 2*time.Second)
|
||
return eni
|
||
}
|
||
|
||
// captureLBConsoleOnFailure registers an on-failure dump of the NLB VM's serial console.
|
||
// nginx activation errors land on the guest console, not the host journal — so an
|
||
// "0/N healthy" timeout is otherwise undiagnosable from CI artifacts.
|
||
func captureLBConsoleOnFailure(t *testing.T, c *harness.AWSClient, eniDescPrefix string, lb lbInfo) {
|
||
t.Helper()
|
||
eni := lbENI(t, c, eniDescPrefix, lb)
|
||
if eni.Attachment == nil {
|
||
return
|
||
}
|
||
instanceID := aws.StringValue(eni.Attachment.InstanceId)
|
||
if instanceID == "" {
|
||
return
|
||
}
|
||
dir := harness.ArtifactDir(t, harness.LoadEnv(t))
|
||
harness.OnFailure(t, func() {
|
||
harness.DumpInstanceConsole(t, c, instanceID, dir, "lb-vm-console.log")
|
||
})
|
||
}
|
||
|
||
func publicIP(eni *ec2.NetworkInterface) string {
|
||
if eni == nil || eni.Association == nil {
|
||
return ""
|
||
}
|
||
return aws.StringValue(eni.Association.PublicIp)
|
||
}
|
||
|
||
func privateIP(eni *ec2.NetworkInterface) string {
|
||
if eni == nil {
|
||
return ""
|
||
}
|
||
return aws.StringValue(eni.PrivateIpAddress)
|
||
}
|
||
|
||
func assertInternalDNS(t *testing.T, c *harness.AWSClient, lbArn, label string) {
|
||
t.Helper()
|
||
out, err := c.ELBv2.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
|
||
LoadBalancerArns: []*string{aws.String(lbArn)},
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, out.LoadBalancers)
|
||
dns := aws.StringValue(out.LoadBalancers[0].DNSName)
|
||
assert.True(t, strings.HasPrefix(dns, "internal-"), "%s internal DNS missing internal- prefix: %s", label, dns)
|
||
}
|
||
|
||
// --- Internet-facing traffic ---------------------------------------------
|
||
|
||
// runInternetFacingTrafficSingle drives one LB (ALB or NLB) over its public
|
||
// IP. Runs probes locally from the test driver, and if a peer node is wired
|
||
// up, also runs the same probes from there to exercise inter-node routing.
|
||
func runInternetFacingTrafficSingle(t *testing.T, kind lbKind, ssh *harness.PeerSSH, peer, ip string) {
|
||
t.Helper()
|
||
if kind == kindALB {
|
||
url := fmt.Sprintf("http://%s:%d", ip, httpPort)
|
||
harness.AssertRoundRobin(t,
|
||
harness.HTTPRoundRobin(url, probesPerRun, 5*time.Second),
|
||
2, probesPerRun/2, "ALB inet (local)")
|
||
if ssh != nil && peer != "" {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
harness.AssertRoundRobin(t,
|
||
remoteHTTPRoundRobin(t, ctx, ssh, peer, url, probesPerRun),
|
||
2, probesPerRun/2, "ALB inet (remote)")
|
||
}
|
||
return
|
||
}
|
||
harness.AssertRoundRobin(t,
|
||
harness.TCPRoundRobin(ip, tcpPort, probesPerRun, 5*time.Second),
|
||
1, probesPerRun/2, "NLB inet (local)")
|
||
if ssh != nil && peer != "" {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
harness.AssertRoundRobin(t,
|
||
remoteTCPRoundRobin(t, ctx, ssh, peer, ip, tcpPort, probesPerRun),
|
||
1, probesPerRun/2, "NLB inet (remote)")
|
||
}
|
||
}
|
||
|
||
func remoteHTTPRoundRobin(t *testing.T, ctx context.Context, ssh *harness.PeerSSH, peer, url string, n int) harness.TrafficResult {
|
||
t.Helper()
|
||
var lines []string
|
||
for i := 0; i < n; i++ {
|
||
out, err := ssh.Run(ctx, peer, fmt.Sprintf("curl -s --max-time 5 '%s/'", url))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
lines = append(lines, strings.TrimSpace(string(out)))
|
||
}
|
||
return harness.VerifyResultsLines(strings.Join(lines, "\n"), "http")
|
||
}
|
||
|
||
func remoteTCPRoundRobin(t *testing.T, ctx context.Context, ssh *harness.PeerSSH, peer, host string, port, n int) harness.TrafficResult {
|
||
t.Helper()
|
||
var lines []string
|
||
for i := 0; i < n; i++ {
|
||
out, err := ssh.Run(ctx, peer, fmt.Sprintf("echo '' | nc -w5 '%s' %d", host, port))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
lines = append(lines, strings.TrimSpace(string(out)))
|
||
}
|
||
return harness.VerifyResultsLines(strings.Join(lines, "\n"), "tcp")
|
||
}
|
||
|
||
func runNLBDeregisterDraining(t *testing.T, c *harness.AWSClient, tgArn, targetID string) {
|
||
t.Helper()
|
||
_, err := c.ELBv2.DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||
TargetGroupArn: aws.String(tgArn),
|
||
Targets: []*elbv2.TargetDescription{{Id: aws.String(targetID)}},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(3 * time.Second)
|
||
out, err := c.ELBv2.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: aws.String(tgArn)})
|
||
require.NoError(t, err)
|
||
|
||
remaining := len(out.TargetHealthDescriptions)
|
||
draining := 0
|
||
for _, th := range out.TargetHealthDescriptions {
|
||
if aws.StringValue(th.TargetHealth.State) == "draining" {
|
||
draining++
|
||
}
|
||
}
|
||
t.Logf("NLB deregister: %d remaining, %d draining", remaining, draining)
|
||
assert.True(t, remaining == 1 || draining >= 1, "NLB deregister: expected 1 remaining or >=1 draining")
|
||
}
|
||
|
||
// --- Shared probe client + per-suite trigger ----------------------------
|
||
|
||
// launchSharedProbeClient launches one client VM whose user-data exposes http.server on :80
|
||
// and a JSON trigger endpoint on :9090. Both internal suites share the same client
|
||
// to avoid the ~60s cold-boot cost of a per-suite client.
|
||
func launchSharedProbeClient(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
|
||
t.Helper()
|
||
out, err := c.EC2.RunInstances(&ec2.RunInstancesInput{
|
||
ImageId: aws.String(f.AMIID),
|
||
InstanceType: aws.String(f.InstanceType),
|
||
KeyName: aws.String(lbKeyName),
|
||
SubnetId: aws.String(f.SubnetID),
|
||
MinCount: aws.Int64(1),
|
||
MaxCount: aws.Int64(1),
|
||
UserData: aws.String(base64Encode(clientUserData)),
|
||
})
|
||
require.NoError(t, err, "run-instances probe client")
|
||
f.ClientID = aws.StringValue(out.Instances[0].InstanceId)
|
||
t.Logf("probe client: %s", f.ClientID)
|
||
|
||
harness.WaitForInstanceRunning(t, c, f.ClientID, 120*time.Second)
|
||
eni := harness.InstanceENI(t, c, f.ClientID)
|
||
f.ClientPublicIP = publicIP(eni)
|
||
require.NotEmpty(t, f.ClientPublicIP, "probe client needs public IP")
|
||
t.Logf("probe client public IP: %s", f.ClientPublicIP)
|
||
|
||
// Wait until the trigger server is accepting connections before the
|
||
// first suite starts firing probes at it.
|
||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||
harness.Eventually(t, func() bool {
|
||
req, _ := http.NewRequest(http.MethodGet, triggerURL, nil)
|
||
client := &http.Client{Timeout: 3 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
resp.Body.Close()
|
||
return true
|
||
}, 5*time.Minute, 5*time.Second, "probe client trigger server not ready")
|
||
}
|
||
|
||
// runInternalTrafficViaClient POSTs a probe order to the shared client's
|
||
// trigger server, then fetches the results file it wrote and parses it.
|
||
func runInternalTrafficViaClient(t *testing.T, _ *harness.AWSClient, f *sharedFixture, kind lbKind, lbIP string) {
|
||
t.Helper()
|
||
var proto, resultsFile string
|
||
if kind == kindALB {
|
||
proto, resultsFile = "http", fmt.Sprintf("alb-%d.txt", time.Now().UnixNano())
|
||
} else {
|
||
proto, resultsFile = "tcp", fmt.Sprintf("nlb-%d.txt", time.Now().UnixNano())
|
||
}
|
||
|
||
order := map[string]any{
|
||
"proto": proto,
|
||
"ip": lbIP,
|
||
"count": probesPerRun,
|
||
"outfile": resultsFile,
|
||
"http_port": httpPort,
|
||
"tcp_port": tcpPort,
|
||
}
|
||
body, err := json.Marshal(order)
|
||
require.NoError(t, err)
|
||
|
||
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
|
||
client := &http.Client{Timeout: 90 * time.Second}
|
||
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
|
||
require.NoErrorf(t, err, "trigger %s probe", kind)
|
||
resp.Body.Close()
|
||
require.Equalf(t, 200, resp.StatusCode, "trigger %s probe HTTP status", kind)
|
||
|
||
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, proto),
|
||
1, probesPerRun/2, string(kind)+" internal")
|
||
}
|
||
|
||
// plainHTTPGet fetches a plain-HTTP URL (no TLS). The probe client serves
|
||
// over port 80 with no cert, so the harness HTTPSGet helper would refuse.
|
||
func plainHTTPGet(url string, timeout time.Duration) (string, error) {
|
||
client := &http.Client{Timeout: timeout}
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
|
||
}
|
||
buf := new(bytes.Buffer)
|
||
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
func dumpDaemonLogs(t *testing.T, dir, label string) {
|
||
t.Helper()
|
||
harness.DumpCmd(t, dir, fmt.Sprintf("daemon-%s.log", label),
|
||
"journalctl", "-u", "spinifex-daemon", "--no-pager", "-n", "200")
|
||
}
|
||
|
||
func base64Encode(s string) string {
|
||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||
}
|