Files
spinifex/tests/e2e/single/eni_test.go
T
2026-06-25 13:48:08 +10:00

263 lines
10 KiB
Go

//go:build e2e
package single
import (
"fmt"
"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/require"
)
// runENIHotplug exercises the secondary-ENI hotplug datapath end to end:
// CreateNetworkInterface in the singleton's subnet, AttachNetworkInterface at
// DeviceIndex 1 to the running guest, then assert the new virtio-net NIC
// surfaces inside the guest kernel carrying the ENI's MAC. Proves the QMP
// device_add path (gateway -> ec2.cmd.<id> daemon -> QEMU hot-plug) reaches
// the guest, not just the control plane. OVN-gated; reuses the singleton VM
// and restores it (detach + delete) before returning.
func runENIHotplug(t *testing.T, fix *Fixture) {
harness.Phase(t, "Single — Secondary ENI Hotplug Datapath (OVN)")
harness.SkipIfNoOVN(t)
requireSSHHealthy(t)
c := fix.AWS
inst, _ := needInstance(t, fix)
instanceID := aws.StringValue(inst.InstanceId)
_, keyPath := needKeyPair(t, fix)
def := harness.EnsureDefaultVPC(t, fix.Harness)
require.NotEmpty(t, def.SubnetID, "default subnet ID required")
require.NotEmpty(t, def.SGID, "default SG ID required")
harness.AuthorizeSSHIngress(t, c, def.SGID)
// SSH to the singleton via its public IP — the in-guest `ip link` snapshot
// needs a live datapath. Fatal (with sticky-skip) if the handshake never
// completes; an unreachable singleton is a datapath bug, not test flake.
host, port := harness.InstancePublicSSHHost(t, inst)
waitForSSHReady(t, host, port, keyPath)
tgt := harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}
// Snapshot the guest's MAC inventory before the hotplug so we detect the
// brand-new interface rather than match a NIC that already existed.
before := guestMACSet(t, tgt)
harness.Detail(t, "guest_macs_before", strings.Join(setKeys(before), ","))
// --- CreateNetworkInterface -------------------------------------------
harness.Step(t, "create-network-interface subnet=%s", def.SubnetID)
// e2e:allow-create — the secondary ENI is the subject hot-plugged under test.
cniOut, err := c.EC2.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(def.SubnetID),
Groups: []*string{aws.String(def.SGID)},
Description: aws.String("e2e-eni-hotplug"),
})
require.NoError(t, err, "create-network-interface")
require.NotNil(t, cniOut.NetworkInterface, "create-network-interface returned nil ENI")
eniID := aws.StringValue(cniOut.NetworkInterface.NetworkInterfaceId)
eniMAC := strings.ToLower(aws.StringValue(cniOut.NetworkInterface.MacAddress))
eniIP := aws.StringValue(cniOut.NetworkInterface.PrivateIpAddress)
require.NotEmpty(t, eniID, "ENI NetworkInterfaceId empty")
require.NotEmpty(t, eniMAC, "ENI MacAddress empty")
require.NotEmpty(t, eniIP, "ENI PrivateIpAddress empty")
harness.Detail(t, "eni", eniID, "eni_mac", eniMAC, "eni_ip", eniIP)
// Delete cleanup runs last (LIFO); the detach cleanup registered next must
// flip the ENI back to "available" before this fires.
t.Cleanup(func() {
if _, derr := c.EC2.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
NetworkInterfaceId: aws.String(eniID),
}); derr != nil && !harness.ErrorCodeIs(derr, "InvalidNetworkInterfaceID.NotFound") {
t.Logf("WARNING: cleanup delete ENI %s: %v", eniID, derr)
}
})
// --- AttachNetworkInterface (hot-plug) --------------------------------
harness.Step(t, "attach-network-interface %s -> %s device-index=1", eniID, instanceID)
attachOut, err := c.EC2.AttachNetworkInterface(&ec2.AttachNetworkInterfaceInput{
NetworkInterfaceId: aws.String(eniID),
InstanceId: aws.String(instanceID),
DeviceIndex: aws.Int64(1),
})
require.NoError(t, err, "attach-network-interface")
attachmentID := aws.StringValue(attachOut.AttachmentId)
require.NotEmpty(t, attachmentID, "AttachmentId empty")
detached := false
t.Cleanup(func() {
if detached {
return
}
if _, derr := c.EC2.DetachNetworkInterface(&ec2.DetachNetworkInterfaceInput{
AttachmentId: aws.String(attachmentID),
}); derr != nil && !harness.ErrorCodeIs(derr, "InvalidAttachmentID.NotFound") {
t.Logf("WARNING: cleanup detach ENI %s: %v", eniID, derr)
}
_ = waitForENIStatusSoft(c, eniID, "available", 2*time.Minute)
})
harness.Detail(t, "attachment", attachmentID)
// --- Control plane: ENI shows attached to the singleton ---------------
harness.Step(t, "wait ENI %s in-use attached to %s", eniID, instanceID)
waitForENIAttached(t, c, eniID, instanceID)
// --- Datapath: new NIC surfaces in the guest carrying the ENI MAC -----
harness.Step(t, "assert guest NIC with MAC %s appears", eniMAC)
if _, existed := before[eniMAC]; existed {
t.Fatalf("ENI MAC %s was already present before attach — cannot prove a fresh hotplug", eniMAC)
}
harness.EventuallyErr(t, func() error {
now := guestMACSet(t, tgt)
if _, ok := now[eniMAC]; ok {
return nil
}
return fmt.Errorf("ENI MAC %s not yet visible in guest (have: %s)",
eniMAC, strings.Join(setKeys(now), ","))
}, 90*time.Second, 3*time.Second)
harness.Detail(t, "datapath", "guest_nic_hotplugged_ok")
// Cross-check the control-plane secondary IP is the one assigned to the
// hotplugged ENI. In-guest IP autoconfiguration of the secondary NIC is
// OS/cloud-init dependent, so the IP assertion stays at the API level.
eni, err := describeENI(c, eniID)
require.NoError(t, err, "describe ENI %s after attach", eniID)
require.Equal(t, eniIP, aws.StringValue(eni.PrivateIpAddress),
"attached ENI %s private IP drifted from create-time value", eniID)
// --- Detach + assert the NIC drops out of the guest -------------------
harness.Step(t, "detach-network-interface %s", attachmentID)
_, err = c.EC2.DetachNetworkInterface(&ec2.DetachNetworkInterfaceInput{
AttachmentId: aws.String(attachmentID),
})
require.NoError(t, err, "detach-network-interface")
waitForENIStatus(t, c, eniID, "available")
detached = true
// Guest NIC removal is a soft check: virtio-net unplug + udev cleanup can
// lag the control-plane detach, and a lingering link doesn't break the EC2
// contract the way a missing hotplug would. Poll briefly, warn if it stays.
harness.Step(t, "verify guest NIC for MAC %s disappears", eniMAC)
dropped := false
deadline := time.Now().Add(45 * time.Second)
for time.Now().Before(deadline) {
if _, ok := guestMACSet(t, tgt)[eniMAC]; !ok {
dropped = true
break
}
time.Sleep(3 * time.Second)
}
if dropped {
harness.Detail(t, "datapath", "guest_nic_removed_ok")
} else {
t.Logf("WARN: guest NIC for MAC %s still present after detach (udev lag?)", eniMAC)
}
// --- DeleteNetworkInterface -------------------------------------------
harness.Step(t, "delete-network-interface %s", eniID)
_, err = c.EC2.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
NetworkInterfaceId: aws.String(eniID),
})
require.NoError(t, err, "delete-network-interface")
}
// guestMACSet returns the set of lowercased MAC addresses currently present on
// the guest's network interfaces, read from sysfs so the list tracks the live
// kernel view (including hotplugged NICs). The all-zero loopback MAC is
// dropped so it never masks a real match.
func guestMACSet(t *testing.T, tgt harness.SSHTarget) map[string]struct{} {
t.Helper()
out, err := harness.GuestExec(tgt, "cat /sys/class/net/*/address")
if err != nil {
t.Fatalf("guest read MAC inventory: %v\n%s", err, out)
}
set := map[string]struct{}{}
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
mac := strings.ToLower(strings.TrimSpace(line))
if mac == "" || mac == "00:00:00:00:00:00" {
continue
}
set[mac] = struct{}{}
}
return set
}
// setKeys returns the keys of a string set as a slice for log/Detail output.
func setKeys(m map[string]struct{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// describeENI returns the single ENI record for eniID, erroring if absent.
func describeENI(c *harness.AWSClient, eniID string) (*ec2.NetworkInterface, error) {
out, err := c.EC2.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(eniID)},
})
if err != nil {
return nil, fmt.Errorf("describe-network-interfaces %s: %w", eniID, err)
}
if len(out.NetworkInterfaces) == 0 {
return nil, fmt.Errorf("ENI %s not found", eniID)
}
return out.NetworkInterfaces[0], nil
}
// waitForENIAttached polls until eniID reports Status=in-use with an
// Attachment bound to instanceID.
func waitForENIAttached(t *testing.T, c *harness.AWSClient, eniID, instanceID string) {
t.Helper()
harness.EventuallyErr(t, func() error {
eni, err := describeENI(c, eniID)
if err != nil {
return err
}
status := aws.StringValue(eni.Status)
if eni.Attachment == nil {
return fmt.Errorf("ENI %s status=%s has no attachment yet", eniID, status)
}
if got := aws.StringValue(eni.Attachment.InstanceId); got != instanceID {
return fmt.Errorf("ENI %s attached to %q want %q", eniID, got, instanceID)
}
if status != "in-use" {
return fmt.Errorf("ENI %s status=%s want in-use", eniID, status)
}
return nil
}, 90*time.Second, 3*time.Second)
}
// waitForENIStatus polls until eniID reaches target status (e.g. "available").
func waitForENIStatus(t *testing.T, c *harness.AWSClient, eniID, target string) {
t.Helper()
harness.EventuallyErr(t, func() error {
eni, err := describeENI(c, eniID)
if err != nil {
return err
}
if got := aws.StringValue(eni.Status); got != target {
return fmt.Errorf("ENI %s status=%s want=%s", eniID, got, target)
}
return nil
}, 90*time.Second, 3*time.Second)
}
// waitForENIStatusSoft is the cleanup-time variant of waitForENIStatus: never
// calls t.Fatal, just polls best-effort within timeout.
func waitForENIStatusSoft(c *harness.AWSClient, eniID, target string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
eni, err := describeENI(c, eniID)
if err == nil && aws.StringValue(eni.Status) == target {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("ENI %s did not reach %s within %s", eniID, target, timeout)
}
time.Sleep(2 * time.Second)
}
}