mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
287 lines
12 KiB
Go
287 lines
12 KiB
Go
//go:build e2e
|
|
|
|
package rds
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/rds"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// The topology's own address space. A dedicated VPC rather than a second
|
|
// subnet in the default one: EnsureDefaultVPC and DiscoverDefaultVPC both
|
|
// take the first subnet DescribeSubnets happens to return, which is stable
|
|
// only while there is one, and TestSubnetAndParameterGroups builds a subnet
|
|
// group over every subnet the default VPC has.
|
|
crossSubnetVPCCIDR = "10.42.0.0/16"
|
|
crossSubnetClientCIDR = "10.42.1.0/24"
|
|
crossSubnetDBCIDR = "10.42.2.0/24"
|
|
|
|
crossSubnetTable = "e2e_cross_subnet"
|
|
crossSubnetNote = "hello from the next subnet over"
|
|
)
|
|
|
|
// crossSubnetNet is the topology the test owns: a client subnet with a public
|
|
// path for SSH and apt, and a DB subnet with no route table of its own, which
|
|
// leaves it on the main table CreateVpc writes — intra-VPC routing and nothing
|
|
// else, exactly what a private DB subnet is.
|
|
type crossSubnetNet struct {
|
|
VPCID string
|
|
ClientSubnetID string
|
|
DBSubnetID string
|
|
ClientSGID string
|
|
DBSGID string
|
|
}
|
|
|
|
// TestCrossSubnetConnectivity is the suite's second client leg, and the only one
|
|
// that proves the endpoint is reachable from the VPC rather than from the
|
|
// endpoint ENI's own subnet.
|
|
//
|
|
// The distinction is invisible to every other test here: a client on the ENI's
|
|
// subnet is answered on-link, with no routing decision to get wrong, and the
|
|
// whole suite has only ever had one subnet in play. A client anywhere else needs
|
|
// the DB VM to route the reply back out the ENI it arrived on, which is the
|
|
// asymmetry a dual-NIC guest gets wrong by default.
|
|
func TestCrossSubnetConnectivity(t *testing.T) {
|
|
f := requireRDSFixture(t)
|
|
t.Parallel()
|
|
reserveDBVMs(t, dbClass)
|
|
|
|
suffix := time.Now().Unix()
|
|
id := fmt.Sprintf("%s-xsubnet-%d", dbInstancePfx, suffix)
|
|
subnetGroup := fmt.Sprintf("%s-xsubnet-%d", dbInstancePfx, suffix)
|
|
|
|
topo := setupCrossSubnetVPC(t, f)
|
|
|
|
harness.Phase(t, "Creating DB subnet group %q over the DB subnet alone", subnetGroup)
|
|
createDBSubnetGroup(t, f, subnetGroup, topo.DBSubnetID)
|
|
|
|
// Started before the client VM so the two boots overlap, as TestConnectivity
|
|
// does: the create returns immediately and the engine bootstraps while apt
|
|
// runs in the client.
|
|
harness.Phase(t, "Creating DB instance %q in the DB subnet", id)
|
|
createDBInstance(t, f, id, func(in *rds.CreateDBInstanceInput) {
|
|
in.DBSubnetGroupName = aws.String(subnetGroup)
|
|
in.VpcSecurityGroupIds = aws.StringSlice([]string{topo.DBSGID})
|
|
})
|
|
|
|
// A fixture bound to this test, not the process one the shared default-VPC
|
|
// client hangs off: this VM has to be terminated before the cleanup below it
|
|
// deletes the subnet it is sitting in, and the process fixture drains after
|
|
// every test in the package has finished.
|
|
clientFx := harness.NewFixture(t, f.AWS)
|
|
client := harness.RDSClientVMIn(t, f.AWS, clientFx, f.Env,
|
|
harness.RDSClientPlacement{SubnetID: topo.ClientSubnetID, SGID: topo.ClientSGID})
|
|
|
|
instance := waitForAvailable(t, f, id)
|
|
endpoint := aws.StringValue(instance.Endpoint.Address)
|
|
require.NotEmpty(t, endpoint, "an available instance must publish an endpoint")
|
|
|
|
eni := harness.DBEndpointENI(t, f.AWS, id)
|
|
privateIP := aws.StringValue(eni.PrivateIpAddress)
|
|
require.NotEmpty(t, privateIP, "the endpoint ENI must carry a private address")
|
|
|
|
// Without this the test could pass for the wrong reason: a subnet group over
|
|
// one subnet is the only thing pinning the endpoint away from the client, and
|
|
// an endpoint that landed in the client's subnet would be the same
|
|
// same-subnet case TestConnectivity already covers.
|
|
require.Equal(t, topo.DBSubnetID, aws.StringValue(eni.SubnetId),
|
|
"the subnet group has one subnet, so the endpoint ENI must be in it")
|
|
|
|
conn := harness.PSQLConn{
|
|
Host: privateIP, Port: aws.Int64Value(instance.Endpoint.Port),
|
|
User: dbMasterUser, Password: dbMasterPassword, DBName: dbName,
|
|
}
|
|
|
|
// The assertion the whole test exists for. It fails as a connect timeout,
|
|
// which is indistinguishable from a security-group denial at the client — so
|
|
// the group here admits the client subnet outright, leaving the return path
|
|
// as the only thing left to be wrong.
|
|
t.Run("AClientInAnotherSubnetRoundTripsARow", func(t *testing.T) {
|
|
harness.PSQL(t, client, conn, fmt.Sprintf(
|
|
"CREATE TABLE %s (id int primary key, note text); INSERT INTO %s VALUES (1, '%s');",
|
|
crossSubnetTable, crossSubnetTable, crossSubnetNote))
|
|
|
|
out := harness.PSQL(t, client, conn, fmt.Sprintf("SELECT note FROM %s WHERE id = 1;", crossSubnetTable))
|
|
assert.Equal(t, crossSubnetNote, strings.TrimSpace(out),
|
|
"a row written from outside the endpoint's own subnet must read back")
|
|
})
|
|
|
|
// The address proves the datapath; the name is what an application tier in
|
|
// its own subnet is actually configured with.
|
|
t.Run("TheEndpointNameReachesTheSameDatabase", func(t *testing.T) {
|
|
requireEndpointName(t, endpoint)
|
|
|
|
addrs := harness.ResolveInGuest(t, client, endpoint)
|
|
assert.Contains(t, addrs, privateIP, "the endpoint name must resolve to the customer ENI's address")
|
|
|
|
byName := conn
|
|
byName.Host = endpoint
|
|
out := harness.PSQL(t, client, byName, fmt.Sprintf("SELECT note FROM %s WHERE id = 1;", crossSubnetTable))
|
|
assert.Equal(t, crossSubnetNote, strings.TrimSpace(out),
|
|
"the name and the address must reach the same database across subnets")
|
|
})
|
|
}
|
|
|
|
// setupCrossSubnetVPC builds the two-subnet VPC and registers its teardown. No
|
|
// CreateWorkerEgress: only the client needs egress and it has a public IP, so
|
|
// the NAT gateway that helper also builds would be waited on for nothing.
|
|
func setupCrossSubnetVPC(t *testing.T, f *Fixture) crossSubnetNet {
|
|
t.Helper()
|
|
c := f.AWS
|
|
|
|
harness.Phase(t, "Creating a VPC with a client subnet and a private DB subnet")
|
|
topo := crossSubnetNet{VPCID: harness.CreateVPC(t, c, crossSubnetVPCCIDR)}
|
|
t.Cleanup(func() { harness.DeleteVPC(t, c, topo.VPCID) })
|
|
|
|
topo.ClientSubnetID = harness.CreateSubnet(t, c, topo.VPCID, crossSubnetClientCIDR)
|
|
t.Cleanup(func() { harness.DeleteSubnet(t, c, topo.ClientSubnetID) })
|
|
topo.DBSubnetID = harness.CreateSubnet(t, c, topo.VPCID, crossSubnetDBCIDR)
|
|
t.Cleanup(func() { harness.DeleteSubnet(t, c, topo.DBSubnetID) })
|
|
|
|
// The client needs a public IP for the runner's SSH and for apt. The DB
|
|
// subnet gets neither that nor a route table association: it keeps the main
|
|
// table, which routes inside the VPC and nowhere else.
|
|
_, err := c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(topo.ClientSubnetID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch")
|
|
|
|
igw, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) // e2e:allow-create — the client subnet's own path out.
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
igwID := aws.StringValue(igw.InternetGateway.InternetGatewayId)
|
|
t.Cleanup(func() {
|
|
if _, derr := c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID), VpcId: aws.String(topo.VPCID),
|
|
}); derr != nil {
|
|
t.Logf("detach igw %s: %v", igwID, derr)
|
|
}
|
|
if _, derr := c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
}); derr != nil {
|
|
t.Logf("delete igw %s: %v", igwID, derr)
|
|
}
|
|
})
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID), VpcId: aws.String(topo.VPCID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
|
|
// The default route goes on a table associated with the client subnet, not on
|
|
// the main table: putting it on main would give the DB subnet a way out too,
|
|
// and a private DB subnet is the layout under test.
|
|
rt, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{VpcId: aws.String(topo.VPCID)}) // e2e:allow-create — the client subnet's public table.
|
|
require.NoError(t, err, "create-route-table")
|
|
rtID := aws.StringValue(rt.RouteTable.RouteTableId)
|
|
t.Cleanup(func() {
|
|
if _, derr := c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{RouteTableId: aws.String(rtID)}); derr != nil {
|
|
t.Logf("delete route table %s: %v", rtID, derr)
|
|
}
|
|
})
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(rtID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(igwID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> igw")
|
|
|
|
assoc, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(rtID), SubnetId: aws.String(topo.ClientSubnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
assocID := aws.StringValue(assoc.AssociationId)
|
|
t.Cleanup(func() {
|
|
if _, derr := c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(assocID),
|
|
}); derr != nil {
|
|
t.Logf("disassociate route table %s: %v", assocID, derr)
|
|
}
|
|
})
|
|
|
|
topo.ClientSGID = createCrossSubnetSG(t, f, topo.VPCID, "client", crossSubnetSSHIngress())
|
|
topo.DBSGID = createCrossSubnetSG(t, f, topo.VPCID, "db", crossSubnetDBIngress())
|
|
|
|
t.Logf("cross-subnet topology: vpc=%s client=%s (%s) db=%s (%s)",
|
|
topo.VPCID, topo.ClientSubnetID, crossSubnetClientCIDR, topo.DBSubnetID, crossSubnetDBCIDR)
|
|
return topo
|
|
}
|
|
|
|
// The runner reaches the client over SSH from wherever CI happens to run it.
|
|
func crossSubnetSSHIngress() *ec2.IpPermission {
|
|
return &ec2.IpPermission{
|
|
IpProtocol: aws.String("tcp"),
|
|
FromPort: aws.Int64(22),
|
|
ToPort: aws.Int64(22),
|
|
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
|
}
|
|
}
|
|
|
|
// The endpoint admits the client subnet by CIDR. A source group would be the
|
|
// same rule via a membership the control plane derives, and this test is about
|
|
// the return path: a denial and a missing route both present as a timeout, so
|
|
// the group is the one part left with nothing to get wrong.
|
|
func crossSubnetDBIngress() *ec2.IpPermission {
|
|
return &ec2.IpPermission{
|
|
IpProtocol: aws.String("tcp"),
|
|
FromPort: aws.Int64(harness.PostgresEnginePort),
|
|
ToPort: aws.Int64(harness.PostgresEnginePort),
|
|
IpRanges: []*ec2.IpRange{{CidrIp: aws.String(crossSubnetClientCIDR)}},
|
|
}
|
|
}
|
|
|
|
func createCrossSubnetSG(t *testing.T, f *Fixture, vpcID, role string, ingress *ec2.IpPermission) string {
|
|
t.Helper()
|
|
name := fmt.Sprintf("%s-xsubnet-%s-%d", dbInstancePfx, role, time.Now().UnixNano())
|
|
out, err := f.AWS.EC2.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{ // e2e:allow-create — this test's own VPC, so no fixture group fits.
|
|
VpcId: aws.String(vpcID),
|
|
GroupName: aws.String(name),
|
|
Description: aws.String("rds e2e cross-subnet " + role),
|
|
})
|
|
require.NoError(t, err, "create-security-group %s", name)
|
|
sgID := aws.StringValue(out.GroupId)
|
|
t.Cleanup(func() {
|
|
if _, derr := f.AWS.EC2.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{
|
|
GroupId: aws.String(sgID),
|
|
}); derr != nil {
|
|
t.Logf("delete sg %s: %v", sgID, derr)
|
|
}
|
|
})
|
|
|
|
_, err = f.AWS.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
|
|
GroupId: aws.String(sgID),
|
|
IpPermissions: []*ec2.IpPermission{ingress},
|
|
})
|
|
require.NoError(t, err, "authorize-security-group-ingress on %s", sgID)
|
|
return sgID
|
|
}
|
|
|
|
// A DB subnet group over exactly the subnets named, which is what places the
|
|
// endpoint ENI somewhere the client is not.
|
|
func createDBSubnetGroup(t *testing.T, f *Fixture, name string, subnetIDs ...string) {
|
|
t.Helper()
|
|
out, err := f.AWS.RDS.CreateDBSubnetGroup(&rds.CreateDBSubnetGroupInput{
|
|
DBSubnetGroupName: aws.String(name),
|
|
DBSubnetGroupDescription: aws.String("rds e2e cross-subnet group"),
|
|
SubnetIds: aws.StringSlice(subnetIDs),
|
|
})
|
|
require.NoError(t, err, "create-db-subnet-group %s", name)
|
|
require.NotNil(t, out.DBSubnetGroup)
|
|
t.Cleanup(func() {
|
|
if _, derr := f.AWS.RDS.DeleteDBSubnetGroup(&rds.DeleteDBSubnetGroupInput{
|
|
DBSubnetGroupName: aws.String(name),
|
|
}); derr != nil && !harness.ErrorCodeIs(derr, "DBSubnetGroupNotFoundFault") {
|
|
t.Logf("cleanup: delete-db-subnet-group %s: %v", name, derr)
|
|
}
|
|
})
|
|
}
|