mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
1014 lines
44 KiB
Go
1014 lines
44 KiB
Go
//go:build e2e
|
|
|
|
package single
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// vpcEgressNATRounds returns how many NAT Gateway up/down flip cycles the
|
|
// round loop repeats against the same private instance, overridable via
|
|
// SPINIFEX_VPCEGRESS_NAT_ROUNDS. Defaults to 1, matching the former
|
|
// standalone NAT Gateway test's single-pass behaviour; bump it for a soak
|
|
// run that repeatedly provisions and tears down the gateway to catch
|
|
// route/SNAT propagation flakiness a single cycle would not surface.
|
|
func vpcEgressNATRounds() int {
|
|
return envPositiveIntOr("SPINIFEX_VPCEGRESS_NAT_ROUNDS", 1)
|
|
}
|
|
|
|
// runVPCEgressPaths merges four checks that used to boot their own VPC
|
|
// apiece — normal-order public/private subnet egress, the route-before-subnet
|
|
// ordering regression guard, NAT Gateway egress, and instance Elastic IP
|
|
// association — around one scenario-owned VPC and its two guests, cutting
|
|
// five boots to three.
|
|
//
|
|
// The three ways a guest in this VPC reaches (or is reached from) the
|
|
// internet — a direct IGW route on its own subnet, a NAT Gateway fronting a
|
|
// private subnet, and an Elastic IP flipped onto a running instance — share
|
|
// one public guest and one private guest instead of each paying for its own.
|
|
// The fourth stage, RouteBeforeSubnet, is a regression guard for an ordering
|
|
// edge case in the first path (the IGW route can be installed on a VPC's
|
|
// main route table before any subnet exists) and needs a second VPC to
|
|
// reproduce that ordering, so it is not foldable into the shared guests
|
|
// below without losing the precondition it exists to prove.
|
|
//
|
|
// Stage order and gating:
|
|
// - VPC A (10.99.0.0/16, public + private subnet, explicit route table
|
|
// associated to the public subnet after it exists) and its public guest
|
|
// are unconditional prerequisites; any failure here is fatal in the
|
|
// ordinary Go-test sense (require), matching the originals.
|
|
// - PublicSubnetEgress proves the public guest's own IGW egress works.
|
|
// Every later stage that touches this same guest — the NAT Gateway
|
|
// bastion hop and the EIP flip — depends on its SSH datapath already
|
|
// being healthy, so PublicSubnetEgress's failure aborts the rest of the
|
|
// scenario rather than let two more stages time out for the same reason.
|
|
// - RouteBeforeSubnet builds its own second VPC end to end (IGW route
|
|
// installed on the main route table before the subnet that implicitly
|
|
// joins it even exists) and never shares state with any other stage, so
|
|
// it registers its own cleanup on its own subtest rather than the
|
|
// scenario's — its resources are freed the moment the stage ends instead
|
|
// of lingering to the end of the whole scenario. This ordering
|
|
// precondition is the one piece of coverage here that cannot be merged
|
|
// away: the explicit route table this scenario uses for the public
|
|
// subnet above exercises a different code path (CreateRouteTable +
|
|
// AssociateRouteTable) than the implicit main-route-table membership
|
|
// this stage depends on, so collapsing the two would silently stop
|
|
// testing whichever path got dropped.
|
|
// - The NAT Gateway private guest launches into VPC A's already-existing
|
|
// private subnet — no separate VPC or subnet needed — and every NAT
|
|
// stage reaches it by hopping through the public guest over SSH, reusing
|
|
// it as the bastion rather than booting a dedicated one. NATBaseline
|
|
// (no internet before any NAT Gateway exists) runs once; the up/down
|
|
// flip that follows repeats for vpcEgressNATRounds() rounds (default 1)
|
|
// so a soak run can catch propagation flakiness a single cycle would
|
|
// not surface. Each round's NATGatewayDown only runs if that round's
|
|
// NATGatewayUp succeeded — tearing down a gateway that was never
|
|
// fully provisioned would produce misleading failures rather than
|
|
// useful signal.
|
|
// - StopStartAddress runs second to last, on the same public guest, while it
|
|
// still holds the address its launch borrowed from the pool. A stop returns
|
|
// that address and the start takes a different one, so it has to come after
|
|
// every stage that reaches the guest on the original and before EIPFlip
|
|
// replaces it with an address the customer owns. It rebinds pubIP and
|
|
// bastionTgt to what the guest came back on.
|
|
// - EIPFlip runs last and reuses the same public guest as the EIP subject.
|
|
// Associating an Elastic IP rewrites the instance's public IP, which
|
|
// would corrupt every stage above that still expects the guest to
|
|
// answer on its original address — ordering it last is what lets this
|
|
// scenario reuse the guest at all instead of needing a disposable one.
|
|
// Its StopStartKeepsTheEIP step stops and restarts that guest, which is
|
|
// safe for the same reason and only while the EIP is still associated:
|
|
// the address the guest returns on is the allocation's, not the pool's.
|
|
func runVPCEgressPaths(t *testing.T, fix *Fixture) {
|
|
if !fix.PublicPool {
|
|
t.Skip("VPC egress paths scenario needs a pool of public addresses to allocate from")
|
|
}
|
|
harness.Phase(t, "Single — Three ways in and out: public subnet, NAT Gateway, and Elastic IP egress around one VPC")
|
|
|
|
c := fix.AWS
|
|
amiID := needAMI(t, fix)
|
|
instType, _ := needInstanceTypeArch(t, fix)
|
|
keyName, keyPath := needKeyPair(t, fix)
|
|
|
|
// --- VPC A: public + private subnet, explicit route table, normal ordering (subnet before route) ---
|
|
|
|
harness.Step(t, "create-vpc 10.99.0.0/16")
|
|
vpcOut, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String("10.99.0.0/16")}) // e2e:allow-create — VPC A is the egress-datapath topology under test.
|
|
require.NoError(t, err, "create-vpc")
|
|
require.NotNil(t, vpcOut.Vpc, "create-vpc returned nil Vpc")
|
|
vpcID := aws.StringValue(vpcOut.Vpc.VpcId)
|
|
require.NotEmpty(t, vpcID, "VpcId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vpcID)})
|
|
})
|
|
harness.Detail(t, "vpc", vpcID, "cidr", "10.99.0.0/16")
|
|
|
|
harness.Step(t, "create-subnet 10.99.1.0/24 (public)")
|
|
pubSubOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the public subnet is the IGW-egress datapath under test.
|
|
VpcId: aws.String(vpcID),
|
|
CidrBlock: aws.String("10.99.1.0/24"),
|
|
})
|
|
require.NoError(t, err, "create public subnet")
|
|
require.NotNil(t, pubSubOut.Subnet, "create public subnet returned nil Subnet")
|
|
pubSubnetID := aws.StringValue(pubSubOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, pubSubnetID, "public SubnetId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(pubSubnetID)})
|
|
})
|
|
|
|
harness.Step(t, "modify-subnet-attribute MapPublicIpOnLaunch=true %s", pubSubnetID)
|
|
_, err = c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(pubSubnetID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch")
|
|
harness.Detail(t, "pub_subnet", pubSubnetID, "map_public_ip", "true")
|
|
|
|
harness.Step(t, "create-subnet 10.99.2.0/24 (private)")
|
|
privSubOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the private subnet is the NAT-gateway egress datapath under test.
|
|
VpcId: aws.String(vpcID),
|
|
CidrBlock: aws.String("10.99.2.0/24"),
|
|
})
|
|
require.NoError(t, err, "create private subnet")
|
|
require.NotNil(t, privSubOut.Subnet, "create private subnet returned nil Subnet")
|
|
privSubnetID := aws.StringValue(privSubOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, privSubnetID, "private SubnetId empty")
|
|
require.Falsef(t, aws.BoolValue(privSubOut.Subnet.MapPublicIpOnLaunch),
|
|
"private subnet MapPublicIpOnLaunch must default to false (got %v)",
|
|
aws.BoolValue(privSubOut.Subnet.MapPublicIpOnLaunch))
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(privSubnetID)})
|
|
})
|
|
harness.Detail(t, "priv_subnet", privSubnetID, "map_public_ip", "false")
|
|
|
|
harness.Step(t, "create-internet-gateway")
|
|
igwOut, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) // e2e:allow-create — the IGW is the egress path under test.
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
require.NotNil(t, igwOut.InternetGateway, "create-internet-gateway returned nil InternetGateway")
|
|
igwID := aws.StringValue(igwOut.InternetGateway.InternetGatewayId)
|
|
require.NotEmpty(t, igwID, "InternetGatewayId empty")
|
|
igwDetached := false
|
|
t.Cleanup(func() {
|
|
if !igwDetached {
|
|
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
}
|
|
_, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "attach-internet-gateway %s -> %s", igwID, vpcID)
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(igwID),
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
harness.Detail(t, "igw", igwID)
|
|
|
|
harness.Step(t, "create-route-table (vpc=%s)", vpcID)
|
|
rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ // e2e:allow-create — the explicit route table exercises the CreateRouteTable+Associate egress path under test.
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "create-route-table")
|
|
require.NotNil(t, rtOut.RouteTable, "create-route-table returned nil RouteTable")
|
|
rtbID := aws.StringValue(rtOut.RouteTable.RouteTableId)
|
|
require.NotEmpty(t, rtbID, "RouteTableId empty")
|
|
rtbDeleted := false
|
|
t.Cleanup(func() {
|
|
if !rtbDeleted {
|
|
_, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
})
|
|
}
|
|
})
|
|
|
|
harness.Step(t, "associate-route-table %s <- %s", rtbID, pubSubnetID)
|
|
assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
SubnetId: aws.String(pubSubnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
assocID := aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, assocID, "AssociationId empty")
|
|
assocReleased := false
|
|
t.Cleanup(func() {
|
|
if !assocReleased {
|
|
_, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(assocID),
|
|
})
|
|
}
|
|
})
|
|
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s", igwID)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(igwID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW")
|
|
routeDeleted := false
|
|
t.Cleanup(func() {
|
|
if !routeDeleted {
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(rtbID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
}
|
|
})
|
|
harness.Detail(t, "rtb", rtbID, "assoc", assocID, "route", "0.0.0.0/0->"+igwID)
|
|
|
|
// Fresh VPC's auto-created default SG denies all ingress. Authorize
|
|
// tcp/22 from anywhere so every guest launched into VPC A (public and
|
|
// private alike) can be reached via the bastion hop below.
|
|
harness.Step(t, "describe-security-groups (default) vpc=%s", vpcID)
|
|
sgOut, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
|
Filters: []*ec2.Filter{
|
|
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}},
|
|
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
|
|
},
|
|
})
|
|
require.NoError(t, err, "describe-security-groups default")
|
|
require.NotEmpty(t, sgOut.SecurityGroups, "no default SG for vpc=%s", vpcID)
|
|
defaultSGID := aws.StringValue(sgOut.SecurityGroups[0].GroupId)
|
|
require.NotEmpty(t, defaultSGID, "default SG GroupId empty")
|
|
harness.AuthorizeSSHIngress(t, c, defaultSGID)
|
|
harness.Detail(t, "default_sg", defaultSGID, "ingress", "tcp/22<-0.0.0.0/0")
|
|
|
|
// --- Public guest: PublicSubnetEgress, then the NAT bastion hop, then (last) EIPFlip ---
|
|
|
|
harness.Step(t, "run-instances ami=%s subnet=%s (public)", amiID, pubSubnetID)
|
|
pubRunOut, err := c.EC2.RunInstances(&ec2.RunInstancesInput{ // e2e:allow-create — the public guest is the egress / EIP subject under test.
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(pubSubnetID),
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
})
|
|
require.NoError(t, err, "run-instances public")
|
|
require.NotEmpty(t, pubRunOut.Instances, "run-instances public returned no Instances")
|
|
pubInstanceID := aws.StringValue(pubRunOut.Instances[0].InstanceId)
|
|
require.NotEmpty(t, pubInstanceID, "public InstanceId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
_ = harness.WaitForInstanceStateSoft(c, pubInstanceID, "terminated", 5*time.Minute)
|
|
})
|
|
harness.Detail(t, "instance", pubInstanceID)
|
|
|
|
pubInst := harness.WaitForInstanceState(t, c, pubInstanceID, "running")
|
|
pubIP := aws.StringValue(pubInst.PublicIpAddress)
|
|
pubPrivIP := aws.StringValue(pubInst.PrivateIpAddress)
|
|
require.NotEmptyf(t, pubIP,
|
|
"public-subnet instance %s has no PublicIpAddress (MapPublicIpOnLaunch failed?)", pubInstanceID)
|
|
require.NotEmpty(t, pubPrivIP, "PrivateIpAddress empty")
|
|
harness.Detail(t, "public_ip", pubIP, "private_ip", pubPrivIP)
|
|
|
|
pubOK := t.Run("PublicSubnetEgress", func(t *testing.T) {
|
|
if !sshHealth.TryReady(pubIP, 22, keyPath, harness.SSHReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("PublicSubnetEgress SSH timeout — vpc=%s igw=%s pub=%s", vpcID, igwID, pubIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: pubIP,
|
|
LogicalIP: pubPrivIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("SSH handshake %s:22 never completed within %s (see diagnostics above)", pubIP, harness.SSHReadyBudget)
|
|
}
|
|
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath}
|
|
|
|
harness.Step(t, "ssh id (smoke)")
|
|
idOut := harness.RunSSH(t, tgt, "id")
|
|
require.Containsf(t, idOut, "ubuntu", "ssh id did not report ubuntu\n%s", idOut)
|
|
|
|
// Verify external connectivity through the IGW + 0.0.0.0/0 route. ping
|
|
// is a soft check: WAN egress depends on the hosting network and isn't
|
|
// part of the EC2 surface contract.
|
|
harness.Step(t, "verify external connectivity via IGW (ping 8.8.8.8)")
|
|
pingOK := false
|
|
harness.EventuallyErr(t, func() error {
|
|
out, perr := runSSHQuiet(tgt, "ping -c 2 -W 5 8.8.8.8 >/dev/null 2>&1 && echo yes || echo no")
|
|
if perr != nil {
|
|
return fmt.Errorf("ssh ping: %w (out=%q)", perr, out)
|
|
}
|
|
if strings.TrimSpace(out) == "yes" {
|
|
pingOK = true
|
|
return nil
|
|
}
|
|
return fmt.Errorf("ping did not succeed (out=%q)", strings.TrimSpace(out))
|
|
}, 90*time.Second, 5*time.Second)
|
|
if pingOK {
|
|
harness.Detail(t, "external_ping", "ok")
|
|
} else {
|
|
t.Logf("WARN: external ping inconclusive — may depend on WAN gateway")
|
|
}
|
|
})
|
|
if !pubOK {
|
|
t.Fatalf("PublicSubnetEgress stage failed; skipping every later stage that reuses this guest as the NAT bastion / EIP subject")
|
|
}
|
|
|
|
bastionTgt := harness.SSHTarget{User: "ubuntu", Host: pubIP, Port: 22, KeyPath: keyPath}
|
|
|
|
// --- RouteBeforeSubnet: its own second VPC, fully self-contained ---
|
|
|
|
t.Run("RouteBeforeSubnet", func(t *testing.T) {
|
|
az := needAZ(t, fix)
|
|
const vpcCIDR = "10.231.0.0/16"
|
|
const subnetCIDR = "10.231.1.0/24"
|
|
|
|
vOut, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(vpcCIDR)}) // e2e:allow-create — a second VPC reproduces the route-before-subnet ordering under test.
|
|
require.NoError(t, err, "create-vpc")
|
|
require.NotNil(t, vOut.Vpc, "create-vpc returned nil Vpc")
|
|
vID := aws.StringValue(vOut.Vpc.VpcId)
|
|
require.NotEmpty(t, vID, "VpcId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vID)}) })
|
|
|
|
iOut, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) // e2e:allow-create — this IGW is routed on the main RT before any subnet exists — the ordering under test.
|
|
require.NoError(t, err, "create-internet-gateway")
|
|
require.NotNil(t, iOut.InternetGateway, "create-internet-gateway returned nil InternetGateway")
|
|
iID := aws.StringValue(iOut.InternetGateway.InternetGatewayId)
|
|
require.NotEmpty(t, iID, "InternetGatewayId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID), VpcId: aws.String(vID),
|
|
})
|
|
_, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID),
|
|
})
|
|
})
|
|
_, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
|
|
InternetGatewayId: aws.String(iID), VpcId: aws.String(vID),
|
|
})
|
|
require.NoError(t, err, "attach-internet-gateway")
|
|
|
|
mainRT := mainRouteTableID(t, c, vID)
|
|
harness.Detail(t, "vpc", vID, "igw", iID, "main_rtb", mainRT)
|
|
|
|
// Route FIRST, on the main RT, before the subnet exists.
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s on main RT %s (before subnet)", iID, mainRT)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(mainRT),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
GatewayId: aws.String(iID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW on main RT")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(mainRT),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
})
|
|
|
|
// Subnet AFTER the route (implicit main-RT membership).
|
|
harness.Step(t, "create-subnet %s (implicit main RT, after IGW route)", subnetCIDR)
|
|
sOut, err := c.EC2.CreateSubnet(&ec2.CreateSubnetInput{ // e2e:allow-create — the subnet created after the main-RT IGW route is the ordering regression under test.
|
|
VpcId: aws.String(vID),
|
|
CidrBlock: aws.String(subnetCIDR),
|
|
AvailabilityZone: aws.String(az),
|
|
})
|
|
require.NoError(t, err, "create-subnet")
|
|
require.NotNil(t, sOut.Subnet, "create-subnet returned nil Subnet")
|
|
sID := aws.StringValue(sOut.Subnet.SubnetId)
|
|
require.NotEmpty(t, sID, "SubnetId empty")
|
|
t.Cleanup(func() { _, _ = c.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{SubnetId: aws.String(sID)}) })
|
|
|
|
_, err = c.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
|
|
SubnetId: aws.String(sID),
|
|
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
|
})
|
|
require.NoError(t, err, "modify-subnet-attribute MapPublicIpOnLaunch on %s", sID)
|
|
|
|
// Open SG so routing is provably the only barrier.
|
|
sgID := harness.EnsureSG(t, fix.Harness, vID, "vpcegress-routebeforesubnet-sg")
|
|
harness.AuthorizeSSHIngress(t, c, sgID)
|
|
harness.Detail(t, "subnet", sID, "cidr", subnetCIDR, "sg", sgID)
|
|
|
|
instanceID := launchBaselineInstance(t, fix, amiID, instType, keyName, sID, []string{sgID})
|
|
rbPubIP := instancePublicIP(t, fix, instanceID)
|
|
harness.Detail(t, "instance", instanceID, "public_ip", rbPubIP)
|
|
|
|
harness.Step(t, "expecting external SSH to instance in fresh-VPC public subnet")
|
|
if !sshHealth.TryReady(rbPubIP, 22, keyPath, harness.SSHReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, instanceID,
|
|
fmt.Sprintf("RouteBeforeSubnet SSH timeout — vpc=%s igw=%s pub=%s", vID, iID, rbPubIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: rbPubIP,
|
|
LogicalIP: instancePrivateIP(t, fix, instanceID),
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("tcp/22 to %s in fresh-VPC subnet %s never became reachable — the "+
|
|
"per-subnet IGW egress policy was not installed for a subnet created "+
|
|
"after the main-RT IGW route existed", rbPubIP, sID)
|
|
}
|
|
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: rbPubIP, Port: 22, KeyPath: keyPath}
|
|
idOut := harness.RunSSH(t, tgt, "id")
|
|
assert.Containsf(t, idOut, "ubuntu", "ssh id in fresh-VPC subnet\n%s", idOut)
|
|
})
|
|
|
|
// --- NAT Gateway: private guest in VPC A's private subnet, bastion-hop via the public guest ---
|
|
|
|
harness.Step(t, "run-instances private (VPC A private subnet)")
|
|
privRunOut, err := c.EC2.RunInstances(&ec2.RunInstancesInput{ // e2e:allow-create — the private guest is the NAT-gateway egress subject under test.
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(privSubnetID),
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
})
|
|
require.NoError(t, err, "run-instances private")
|
|
require.NotEmpty(t, privRunOut.Instances, "run-instances private returned no Instances")
|
|
privInstanceID := aws.StringValue(privRunOut.Instances[0].InstanceId)
|
|
require.NotEmpty(t, privInstanceID, "private InstanceId empty")
|
|
t.Cleanup(func() {
|
|
_, _ = c.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(privInstanceID)},
|
|
})
|
|
_ = harness.WaitForInstanceStateSoft(c, privInstanceID, "terminated", 5*time.Minute)
|
|
})
|
|
|
|
privInst := harness.WaitForInstanceState(t, c, privInstanceID, "running")
|
|
privIP := aws.StringValue(privInst.PrivateIpAddress)
|
|
require.NotEmptyf(t, privIP, "private instance %s has no PrivateIpAddress", privInstanceID)
|
|
require.Emptyf(t, aws.StringValue(privInst.PublicIpAddress),
|
|
"private instance %s unexpectedly has a public IP (got %q)",
|
|
privInstanceID, aws.StringValue(privInst.PublicIpAddress))
|
|
harness.Detail(t, "private_instance", privInstanceID, "private_ip", privIP)
|
|
|
|
// Copy the keypair to the bastion so it can hop into the private guest.
|
|
harness.Step(t, "scp keypair -> bastion:/tmp/key.pem")
|
|
scpKey(t, keyPath, pubIP)
|
|
_ = harness.RunSSH(t, bastionTgt, "chmod 600 /tmp/key.pem")
|
|
|
|
privProbe := fmt.Sprintf(
|
|
"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "+
|
|
"-o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes "+
|
|
"-i /tmp/key.pem ubuntu@%s hostname",
|
|
privIP,
|
|
)
|
|
harness.Step(t, "wait for private SSH via bastion")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, rerr := runSSHQuiet(bastionTgt, privProbe)
|
|
if rerr != nil {
|
|
return fmt.Errorf("bastion->priv hostname: %w (out=%q)", rerr, out)
|
|
}
|
|
if strings.TrimSpace(out) == "" {
|
|
return fmt.Errorf("empty hostname response from private VM")
|
|
}
|
|
return nil
|
|
}, 3*time.Minute, 5*time.Second)
|
|
|
|
pingCmd := func() string {
|
|
return fmt.Sprintf(
|
|
"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "+
|
|
"-o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes "+
|
|
"-i /tmp/key.pem ubuntu@%s 'ping -c 1 -W 3 8.8.8.8'",
|
|
privIP,
|
|
)
|
|
}
|
|
|
|
t.Run("NATBaseline", func(t *testing.T) {
|
|
harness.Step(t, "baseline: private VM has no internet")
|
|
if _, err := runSSHQuiet(bastionTgt, pingCmd()); err == nil {
|
|
t.Fatalf("baseline ping unexpectedly succeeded — private VM has internet without NAT GW")
|
|
}
|
|
})
|
|
|
|
rounds := vpcEgressNATRounds()
|
|
for round := 1; round <= rounds; round++ {
|
|
round := round
|
|
t.Run(fmt.Sprintf("Round%d", round), func(t *testing.T) {
|
|
roundT := t
|
|
|
|
var natGWID, natAllocID, natPubIP, natRTBID, natAssocID string
|
|
eipReleased := false
|
|
natDeleted := false
|
|
natRTBDeleted := false
|
|
natAssocReleased := false
|
|
natRouteDeleted := false
|
|
|
|
natUpOK := t.Run("NATGatewayUp", func(t *testing.T) {
|
|
harness.Step(t, "allocate-address (NAT EIP)")
|
|
eipOut, err := c.EC2.AllocateAddress(&ec2.AllocateAddressInput{ // e2e:allow-create — the NAT gateway's Elastic IP is part of the egress path under test.
|
|
Domain: aws.String("vpc"),
|
|
})
|
|
require.NoError(t, err, "allocate-address vpc")
|
|
natAllocID = aws.StringValue(eipOut.AllocationId)
|
|
natPubIP = aws.StringValue(eipOut.PublicIp)
|
|
require.NotEmpty(t, natAllocID, "AllocationId empty")
|
|
roundT.Cleanup(func() {
|
|
if eipReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
})
|
|
harness.Detail(t, "eip", natPubIP, "alloc", natAllocID)
|
|
|
|
harness.Step(t, "create-nat-gateway in %s", pubSubnetID)
|
|
natOut, err := c.EC2.CreateNatGateway(&ec2.CreateNatGatewayInput{ // e2e:allow-create — the NAT gateway is the egress datapath under test.
|
|
SubnetId: aws.String(pubSubnetID),
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
require.NoError(t, err, "create-nat-gateway")
|
|
require.NotNil(t, natOut.NatGateway, "create-nat-gateway returned nil NatGateway")
|
|
natGWID = aws.StringValue(natOut.NatGateway.NatGatewayId)
|
|
require.NotEmpty(t, natGWID, "NatGatewayId empty")
|
|
roundT.Cleanup(func() {
|
|
if natDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteNatGateway(&ec2.DeleteNatGatewayInput{
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
_ = waitForNATGatewayStateSoft(c, natGWID, "deleted", 5*time.Minute)
|
|
})
|
|
harness.Detail(t, "nat_gw", natGWID)
|
|
|
|
waitForNATGatewayState(t, c, natGWID, "available")
|
|
|
|
// Route table -> associate -> CreateRoute. The association
|
|
// must exist before the route so the SNAT publication fires
|
|
// off it, mirroring the original test's ordering.
|
|
harness.Step(t, "create-route-table")
|
|
rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ // e2e:allow-create — the private-subnet route table steers egress through the NAT gateway under test.
|
|
VpcId: aws.String(vpcID),
|
|
})
|
|
require.NoError(t, err, "create-route-table")
|
|
require.NotNil(t, rtOut.RouteTable, "create-route-table returned nil RouteTable")
|
|
natRTBID = aws.StringValue(rtOut.RouteTable.RouteTableId)
|
|
require.NotEmpty(t, natRTBID, "RouteTableId empty")
|
|
roundT.Cleanup(func() {
|
|
if natRTBDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "associate-route-table %s <- %s", natRTBID, privSubnetID)
|
|
assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
SubnetId: aws.String(privSubnetID),
|
|
})
|
|
require.NoError(t, err, "associate-route-table")
|
|
natAssocID = aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, natAssocID, "AssociationId empty")
|
|
roundT.Cleanup(func() {
|
|
if natAssocReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(natAssocID),
|
|
})
|
|
})
|
|
|
|
harness.Step(t, "create-route 0.0.0.0/0 -> %s", natGWID)
|
|
_, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
require.NoError(t, err, "create-route 0.0.0.0/0")
|
|
roundT.Cleanup(func() {
|
|
if natRouteDeleted {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
})
|
|
|
|
// OVN needs a beat to install datapath flows after SNAT
|
|
// publishes. Snapshot the SNAT datapath on failure so the
|
|
// bundle carries OVN/OVS state to root-cause the loss.
|
|
harness.OnFailure(t, func() {
|
|
harness.DumpVPCFlowDiagnostics(t, c, privInstanceID,
|
|
fmt.Sprintf("NAT GW egress — nat_gw=%s external_ip=%s logical_ip=%s", natGWID, natPubIP, privIP),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: natPubIP,
|
|
LogicalIP: privIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
})
|
|
harness.Step(t, "verify private VM reaches 8.8.8.8 via NAT GW")
|
|
harness.EventuallyErr(t, func() error {
|
|
out, perr := runSSHQuiet(bastionTgt, pingCmd())
|
|
if perr != nil {
|
|
return fmt.Errorf("ping via NAT GW: %w (out=%q)", perr, out)
|
|
}
|
|
return nil
|
|
}, 2*time.Minute, 5*time.Second)
|
|
})
|
|
|
|
if !natUpOK {
|
|
t.Logf("round %d: NATGatewayUp failed; skipping NATGatewayDown since there is nothing fully provisioned to tear down", round)
|
|
return
|
|
}
|
|
|
|
t.Run("NATGatewayDown", func(t *testing.T) {
|
|
// Mirror the create-time ordering in reverse: delete
|
|
// nat-gateway -> disassociate route table -> delete route ->
|
|
// delete route table -> release address. Each step flips the
|
|
// corresponding latch so the safety-net roundT.Cleanup
|
|
// callbacks above become no-ops on the success path.
|
|
harness.Step(t, "delete-nat-gateway %s", natGWID)
|
|
_, err := c.EC2.DeleteNatGateway(&ec2.DeleteNatGatewayInput{
|
|
NatGatewayId: aws.String(natGWID),
|
|
})
|
|
require.NoError(t, err, "delete-nat-gateway")
|
|
|
|
_, err = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{
|
|
AssociationId: aws.String(natAssocID),
|
|
})
|
|
require.NoError(t, err, "disassociate-route-table")
|
|
natAssocReleased = true
|
|
|
|
_, err = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
|
})
|
|
require.NoError(t, err, "delete-route")
|
|
natRouteDeleted = true
|
|
|
|
_, err = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{
|
|
RouteTableId: aws.String(natRTBID),
|
|
})
|
|
require.NoError(t, err, "delete-route-table")
|
|
natRTBDeleted = true
|
|
|
|
waitForNATGatewayState(t, c, natGWID, "deleted")
|
|
natDeleted = true
|
|
|
|
_, err = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{
|
|
AllocationId: aws.String(natAllocID),
|
|
})
|
|
require.NoError(t, err, "release-address")
|
|
eipReleased = true
|
|
|
|
// OVN datapath can briefly cache the old SNAT flow, so poll
|
|
// for the loss rather than asserting immediately.
|
|
harness.Step(t, "verify private VM lost internet after NAT GW teardown")
|
|
harness.EventuallyErr(t, func() error {
|
|
if _, perr := runSSHQuiet(bastionTgt, pingCmd()); perr == nil {
|
|
return fmt.Errorf("ping still succeeding after NAT GW deletion")
|
|
}
|
|
return nil
|
|
}, 90*time.Second, 5*time.Second)
|
|
})
|
|
})
|
|
}
|
|
|
|
// --- StopStartAddress: the auto-assigned address across a stop, before any EIP ---
|
|
|
|
// AWS takes an auto-assigned public address back when an instance stops and
|
|
// hands it a different one on the start. This is the only stage that sees
|
|
// that, so it runs while the guest still holds the address its launch
|
|
// borrowed and before EIPFlip replaces it with one the customer owns.
|
|
t.Run("StopStartAddress", func(t *testing.T) {
|
|
harness.SkipIfNoOVN(t)
|
|
|
|
harness.Step(t, "stop-instances %s (holding %s)", pubInstanceID, pubIP)
|
|
_, err := c.EC2.StopInstances(&ec2.StopInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
require.NoError(t, err, "stop-instances")
|
|
stopped := harness.WaitForInstanceState(t, c, pubInstanceID, "stopped")
|
|
|
|
// A stopped instance has no datapath and no address, which is what
|
|
// distinguishes a borrowed address from an Elastic IP: the EIP stage
|
|
// above asserts the opposite for the same guest.
|
|
require.Emptyf(t, aws.StringValue(stopped.PublicIpAddress),
|
|
"stopped instance still reports public address %s — it was never returned to the pool",
|
|
aws.StringValue(stopped.PublicIpAddress))
|
|
harness.Detail(t, "stopped_public_ip", "released")
|
|
|
|
harness.Step(t, "start-instances %s", pubInstanceID)
|
|
_, err = c.EC2.StartInstances(&ec2.StartInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
require.NoError(t, err, "start-instances")
|
|
started := harness.WaitForInstanceState(t, c, pubInstanceID, "running")
|
|
|
|
newIP := aws.StringValue(started.PublicIpAddress)
|
|
require.NotEmptyf(t, newIP,
|
|
"instance %s came back with no public address — the subnet auto-assigns one and the start did not take it",
|
|
pubInstanceID)
|
|
harness.Detail(t, "restarted_public_ip", newIP)
|
|
|
|
// A pool we number hands out the address after the cursor, so the one
|
|
// just returned is the last it would pick. A DHCP server is free to
|
|
// renew the same lease for the same client, so there the only honest
|
|
// assertion is that an address came back at all.
|
|
if fix.PublicPoolSource == "static" {
|
|
assert.NotEqualf(t, pubIP, newIP,
|
|
"restart returned the same address %s — the stop did not release it, or the pool re-handed it", pubIP)
|
|
}
|
|
|
|
harness.Step(t, "ssh to guest via %s after restart", newIP)
|
|
if !sshHealth.TryReady(newIP, 22, keyPath, harness.SSHReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("StopStartAddress SSH timeout — was=%s now=%s instance=%s", pubIP, newIP, pubInstanceID),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: newIP,
|
|
LogicalIP: pubPrivIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("guest unreachable via %s within %s after restart (see diagnostics above)", newIP, harness.SSHReadyBudget)
|
|
}
|
|
out := harness.RunSSH(t, harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath}, "id")
|
|
require.Containsf(t, out, "ubuntu", "ssh after restart did not report ubuntu\n%s", out)
|
|
harness.Detail(t, "datapath", "reachable_on_new_address_ok")
|
|
|
|
// Every later stage reaching this guest must use the address it is
|
|
// actually on, so the stale one is replaced rather than left behind.
|
|
pubIP = newIP
|
|
bastionTgt = harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath}
|
|
})
|
|
|
|
// --- EIPFlip: last, on the same public guest ---
|
|
|
|
t.Run("EIPFlip", func(t *testing.T) {
|
|
harness.SkipIfNoOVN(t)
|
|
|
|
harness.Step(t, "allocate-address (vpc)")
|
|
eipOut, err := c.EC2.AllocateAddress(&ec2.AllocateAddressInput{Domain: aws.String("vpc")}) // e2e:allow-create — the Elastic IP flipped onto the guest is the datapath under test.
|
|
require.NoError(t, err, "allocate-address")
|
|
allocID := aws.StringValue(eipOut.AllocationId)
|
|
eipIP := aws.StringValue(eipOut.PublicIp)
|
|
require.NotEmpty(t, allocID, "AllocationId empty")
|
|
require.NotEmpty(t, eipIP, "EIP PublicIp empty")
|
|
eipReleased := false
|
|
t.Cleanup(func() {
|
|
if eipReleased {
|
|
return
|
|
}
|
|
_, _ = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String(allocID)})
|
|
})
|
|
harness.Detail(t, "eip", eipIP, "alloc", allocID)
|
|
|
|
harness.Step(t, "associate-address %s -> %s", eipIP, pubInstanceID)
|
|
assocOut, err := c.EC2.AssociateAddress(&ec2.AssociateAddressInput{
|
|
AllocationId: aws.String(allocID),
|
|
InstanceId: aws.String(pubInstanceID),
|
|
})
|
|
require.NoError(t, err, "associate-address")
|
|
eipAssocID := aws.StringValue(assocOut.AssociationId)
|
|
require.NotEmpty(t, eipAssocID, "AssociationId empty")
|
|
disassociated := false
|
|
t.Cleanup(func() {
|
|
if disassociated {
|
|
return
|
|
}
|
|
_, _ = c.EC2.DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String(eipAssocID)})
|
|
})
|
|
harness.Detail(t, "assoc", eipAssocID)
|
|
|
|
// OVN needs a beat to install the DNAT/SNAT flows after the
|
|
// association publishes; poll the SSH handshake rather than
|
|
// asserting immediately.
|
|
harness.Step(t, "ssh to guest via EIP %s", eipIP)
|
|
if !sshHealth.TryReady(eipIP, 22, keyPath, harness.SSHReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("EIP SSH timeout — eip=%s instance=%s", eipIP, pubInstanceID),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: eipIP,
|
|
LogicalIP: pubPrivIP,
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("guest unreachable via EIP %s within %s (see diagnostics above)", eipIP, harness.SSHReadyBudget)
|
|
}
|
|
tgt := harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}
|
|
idOut := harness.RunSSH(t, tgt, "id")
|
|
require.Containsf(t, idOut, "ubuntu", "ssh via EIP id did not report ubuntu\n%s", idOut)
|
|
harness.Detail(t, "datapath", "eip_reachable_ok")
|
|
|
|
// An Elastic IP outlives the instance it is attached to, so a stop must
|
|
// leave the allocation, the association and the address itself intact —
|
|
// and a start must bring the same address back, not a new one. On a DHCP
|
|
// pool this is also the only outside view of the lease still renewing.
|
|
t.Run("StopStartKeepsTheEIP", func(t *testing.T) {
|
|
harness.Step(t, "stop-instances %s", pubInstanceID)
|
|
_, err := c.EC2.StopInstances(&ec2.StopInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
require.NoError(t, err, "stop-instances")
|
|
harness.WaitForInstanceState(t, c, pubInstanceID, "stopped")
|
|
|
|
harness.Step(t, "verify EIP %s survives the stop", eipIP)
|
|
addrOut, err := c.EC2.DescribeAddresses(&ec2.DescribeAddressesInput{
|
|
AllocationIds: []*string{aws.String(allocID)},
|
|
})
|
|
require.NoError(t, err, "describe-addresses while stopped")
|
|
require.Lenf(t, addrOut.Addresses, 1,
|
|
"allocation %s vanished while the instance was stopped — the customer's Elastic IP went back to the pool", allocID)
|
|
stopped := addrOut.Addresses[0]
|
|
assert.Equal(t, eipIP, aws.StringValue(stopped.PublicIp), "the allocation changed address across a stop")
|
|
assert.Equal(t, eipAssocID, aws.StringValue(stopped.AssociationId), "the association was dropped by the stop")
|
|
assert.Equal(t, pubInstanceID, aws.StringValue(stopped.InstanceId), "the association no longer names the instance")
|
|
|
|
// A stopped instance has no datapath, so it reports no address even
|
|
// though the allocation above still holds it. The two views disagree
|
|
// on purpose, and only the allocation is the customer's property.
|
|
stoppedInst, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
require.NoError(t, err, "describe-instances while stopped")
|
|
require.NotEmpty(t, stoppedInst.Reservations)
|
|
require.NotEmpty(t, stoppedInst.Reservations[0].Instances)
|
|
assert.Empty(t, aws.StringValue(stoppedInst.Reservations[0].Instances[0].PublicIpAddress),
|
|
"a stopped instance still reports a public address")
|
|
|
|
harness.Step(t, "start-instances %s", pubInstanceID)
|
|
_, err = c.EC2.StartInstances(&ec2.StartInstancesInput{
|
|
InstanceIds: []*string{aws.String(pubInstanceID)},
|
|
})
|
|
require.NoError(t, err, "start-instances")
|
|
started := harness.WaitForInstanceState(t, c, pubInstanceID, "running")
|
|
|
|
require.Equalf(t, eipIP, aws.StringValue(started.PublicIpAddress),
|
|
"instance came back on a different address than the Elastic IP associated to it")
|
|
|
|
harness.Step(t, "ssh to guest via EIP %s after restart", eipIP)
|
|
if !sshHealth.TryReady(eipIP, 22, keyPath, harness.SSHReadyBudget) {
|
|
harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID,
|
|
fmt.Sprintf("EIP SSH timeout after restart — eip=%s instance=%s", eipIP, pubInstanceID),
|
|
harness.VPCDiagnosticsOpts{
|
|
ExternalIP: eipIP,
|
|
LogicalIP: aws.StringValue(started.PrivateIpAddress),
|
|
ArtifactDir: fix.ArtifactDir(t),
|
|
})
|
|
t.Fatalf("guest unreachable via EIP %s within %s after restart (see diagnostics above)", eipIP, harness.SSHReadyBudget)
|
|
}
|
|
out := harness.RunSSH(t, harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}, "id")
|
|
require.Containsf(t, out, "ubuntu", "ssh via EIP after restart did not report ubuntu\n%s", out)
|
|
harness.Detail(t, "datapath", "eip_reachable_after_restart_ok")
|
|
})
|
|
|
|
harness.Step(t, "disassociate-address %s", eipAssocID)
|
|
_, err = c.EC2.DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String(eipAssocID)})
|
|
require.NoError(t, err, "disassociate-address")
|
|
disassociated = true
|
|
|
|
// The EIP no longer maps to the guest, but DNAT teardown can lag and
|
|
// a single transient SSH error must not be read as teardown —
|
|
// require several consecutive unreachable probes before declaring
|
|
// success.
|
|
harness.Step(t, "verify guest unreachable via EIP %s after disassociate", eipIP)
|
|
const wantUnreachable = 3
|
|
consecutive := 0
|
|
harness.EventuallyErr(t, func() error {
|
|
if _, sErr := runSSHQuiet(tgt, "true"); sErr == nil {
|
|
consecutive = 0
|
|
return fmt.Errorf("EIP %s still reaches guest after disassociate", eipIP)
|
|
}
|
|
if consecutive++; consecutive < wantUnreachable {
|
|
return fmt.Errorf("EIP %s unreachable %d/%d — confirming it is not a transient blip",
|
|
eipIP, consecutive, wantUnreachable)
|
|
}
|
|
return nil
|
|
}, 90*time.Second, 5*time.Second)
|
|
|
|
// Rule out an instance-death false pass: the unreachability must be
|
|
// the EIP teardown, not the subject VM having gone away mid-poll.
|
|
harness.WaitForInstanceState(t, c, pubInstanceID, "running", harness.WithTimeout(30*time.Second))
|
|
harness.Detail(t, "datapath", "eip_unreachable_after_disassociate_ok")
|
|
|
|
harness.Step(t, "release-address %s", allocID)
|
|
_, err = c.EC2.ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String(allocID)})
|
|
require.NoError(t, err, "release-address")
|
|
eipReleased = true
|
|
})
|
|
}
|
|
|
|
// waitForNATGatewayState polls DescribeNatGateways until State == target.
|
|
func waitForNATGatewayState(t *testing.T, c *harness.AWSClient, id, target string) {
|
|
t.Helper()
|
|
var lastState string
|
|
harness.EventuallyErr(t, func() error {
|
|
out, err := c.EC2.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
|
NatGatewayIds: []*string{aws.String(id)},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("describe-nat-gateways %s: %w", id, err)
|
|
}
|
|
if len(out.NatGateways) == 0 {
|
|
// "deleted" is a terminal state that DescribeNatGateways can
|
|
// stop returning once the record is reaped — treat as success
|
|
// only when that's what the caller wanted.
|
|
if target == "deleted" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s not found", id)
|
|
}
|
|
lastState = aws.StringValue(out.NatGateways[0].State)
|
|
if lastState == target {
|
|
return nil
|
|
}
|
|
if lastState == "failed" {
|
|
msg := aws.StringValue(out.NatGateways[0].FailureMessage)
|
|
return fmt.Errorf("%s entered failed state: %s", id, msg)
|
|
}
|
|
return fmt.Errorf("%s state=%s want=%s", id, lastState, target)
|
|
}, 5*time.Minute, 2*time.Second)
|
|
t.Logf("nat gateway %s reached state %s", id, target)
|
|
}
|
|
|
|
// waitForNATGatewayStateSoft is the cleanup-time variant: never calls
|
|
// t.Fatal, just polls best-effort with a caller-supplied timeout.
|
|
func waitForNATGatewayStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
out, err := c.EC2.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
|
NatGatewayIds: []*string{aws.String(id)},
|
|
})
|
|
if err == nil {
|
|
if len(out.NatGateways) == 0 && target == "deleted" {
|
|
return nil
|
|
}
|
|
if len(out.NatGateways) > 0 && aws.StringValue(out.NatGateways[0].State) == target {
|
|
return nil
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("nat gw %s did not reach %s within %s", id, target, timeout)
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
// scpKey copies the harness PEM to /tmp/key.pem on the bastion so it can
|
|
// hop into a guest that has no direct SSH ingress of its own.
|
|
func scpKey(t *testing.T, keyPath, host string) {
|
|
t.Helper()
|
|
args := []string{
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
"-o", "ConnectTimeout=10",
|
|
"-o", "BatchMode=yes",
|
|
"-i", keyPath,
|
|
keyPath,
|
|
"ubuntu@" + host + ":/tmp/key.pem",
|
|
}
|
|
cmd := exec.Command("scp", args...)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("scp %s -> %s:/tmp/key.pem: %v\n%s", keyPath, host, err, string(out))
|
|
}
|
|
}
|
|
|
|
// sshQuietHardTimeout hard-caps one runSSHQuiet call. ConnectTimeout bounds only
|
|
// the TCP connect; a post-connect banner/kex wedge (e.g. probing an EIP whose
|
|
// DNAT was just torn down) is otherwise unbounded and can outlast the caller's
|
|
// EventuallyErr budget. The ctx deadline kills ssh regardless of which phase hangs.
|
|
const sshQuietHardTimeout = 30 * time.Second
|
|
|
|
// runSSHQuiet is the EventuallyErr-friendly variant of harness.RunSSH: it returns
|
|
// stdout+stderr and the error rather than calling t.Fatal, so polling
|
|
// loops can iterate on transient failures (cloud-init not done, OVN
|
|
// flow not installed yet, etc).
|
|
func runSSHQuiet(tgt harness.SSHTarget, command string) (string, error) {
|
|
args := []string{
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
"-o", "ConnectTimeout=5",
|
|
"-o", "BatchMode=yes",
|
|
"-p", strconv.Itoa(tgt.Port),
|
|
"-i", tgt.KeyPath,
|
|
tgt.User + "@" + tgt.Host,
|
|
command,
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), sshQuietHardTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
|
cmd.WaitDelay = 5 * time.Second // return even if ssh ignores the kill
|
|
out, err := cmd.CombinedOutput()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return string(out), fmt.Errorf("ssh %s@%s:%d timed out after %s: %w",
|
|
tgt.User, tgt.Host, tgt.Port, sshQuietHardTimeout, ctx.Err())
|
|
}
|
|
return string(out), err
|
|
}
|