mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
4b4542a4c5
The cross-host ICMP probe fired once, with no convergence budget, the moment sshd first answered on the source guest. The default SG self-ingress rule matches on the northd-derived $<pg>_ip4 address set, which only reaches the remote chassis after northd regenerates it from port-group membership and ovn-controller installs the flows there, so a first-packet burst can be swallowed by the priority-900 default drop with no log line anywhere. Give the probe the same 45s/2s budget the single-node suite already uses, and snapshot NB/SB address sets, port groups, ACLs, port bindings and br-int flows from every node plus the source guest on the first failed burst rather than at teardown, so the state is captured while the drop is still observable. Emit attempt count and convergence time so a pass that needed most of the budget still surfaces as a propagation delay instead of a silent green.
271 lines
11 KiB
Go
271 lines
11 KiB
Go
//go:build e2e
|
|
|
|
package multinode
|
|
|
|
import (
|
|
"context"
|
|
"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/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Fresh-install reachability baseline. Run before needInstanceTrio so the
|
|
// default SG/subnet/route-table are in their pristine state.
|
|
//
|
|
// - runMultinodeSameSGCrossHostComms: proves the default SG self-reference
|
|
// rule spans chassis — ICMP between two instances on different nodes
|
|
// succeeds with no added ingress rule.
|
|
|
|
// sameSGPingTimeout budgets cross-chassis datapath convergence, matching the
|
|
// single-node suite's pingConverged window.
|
|
const sameSGPingTimeout = 45 * time.Second
|
|
|
|
// baselineLaunch launches one instance with the given SGs into subnetID,
|
|
// registers a terminate-and-wait cleanup, and returns the instance ID once "running".
|
|
func baselineLaunch(t *testing.T, fix *Fixture, amiID, instType, keyName, subnetID string, sgIDs []string) string {
|
|
t.Helper()
|
|
sgs := make([]*string, 0, len(sgIDs))
|
|
for _, id := range sgIDs {
|
|
sgs = append(sgs, aws.String(id))
|
|
}
|
|
input := &ec2.RunInstancesInput{
|
|
ImageId: aws.String(amiID),
|
|
InstanceType: aws.String(instType),
|
|
KeyName: aws.String(keyName),
|
|
SubnetId: aws.String(subnetID),
|
|
SecurityGroupIds: sgs,
|
|
MinCount: aws.Int64(1),
|
|
MaxCount: aws.Int64(1),
|
|
}
|
|
var id string
|
|
for attempt := 1; attempt <= 6; attempt++ {
|
|
out, err := fix.AWS.EC2.RunInstances(input)
|
|
if err == nil {
|
|
require.NotEmpty(t, out.Instances, "RunInstances returned no instances")
|
|
id = aws.StringValue(out.Instances[0].InstanceId)
|
|
break
|
|
}
|
|
if !strings.Contains(err.Error(), "InsufficientInstanceCapacity") {
|
|
t.Fatalf("RunInstances: %v", err)
|
|
}
|
|
t.Logf("baselineLaunch attempt %d: InsufficientInstanceCapacity, retrying", attempt)
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
require.NotEmpty(t, id, "RunInstances never succeeded")
|
|
t.Cleanup(func() {
|
|
if _, err := fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
}); err != nil {
|
|
t.Errorf("terminate instance %s: %v", id, err)
|
|
return
|
|
}
|
|
harness.WaitForInstanceState(t, fix.AWS, id, "terminated")
|
|
})
|
|
harness.WaitForInstanceState(t, fix.AWS, id, "running")
|
|
return id
|
|
}
|
|
|
|
// instancePrivateIP returns the instance's primary VPC private IP.
|
|
func instancePrivateIP(t *testing.T, fix *Fixture, id string) string {
|
|
t.Helper()
|
|
out, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
|
InstanceIds: []*string{aws.String(id)},
|
|
})
|
|
require.NoError(t, err, "describe-instances %s", id)
|
|
require.NotEmpty(t, out.Reservations, "no reservations for %s", id)
|
|
require.NotEmpty(t, out.Reservations[0].Instances, "no instances for %s", id)
|
|
ip := aws.StringValue(out.Reservations[0].Instances[0].PrivateIpAddress)
|
|
require.NotEmptyf(t, ip, "instance %s has no private IP", id)
|
|
return ip
|
|
}
|
|
|
|
// sshCapture runs cmd over SSH and returns combined output + error without fataling.
|
|
func sshCapture(pem, user, host string, port int, cmd string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
out, err := harness.RunGuestSSH(ctx, harness.SSHTarget{
|
|
User: user, Host: host, Port: port, KeyPath: pem,
|
|
}, cmd)
|
|
return string(out), err
|
|
}
|
|
|
|
// pingConvergedCrossHost probes dstPriv over ICMP from the source guest, retrying
|
|
// until the cross-chassis datapath converges (0% loss) or timeout elapses. The
|
|
// default SG self-ingress rule matches on $<pg>_ip4, an address set ovn-northd
|
|
// derives from port-group membership, so the peer only becomes reachable once
|
|
// that set has propagated to the remote chassis and ovn-controller has installed
|
|
// the flows there. Returns the last output, whether it converged, the attempt
|
|
// count and how long convergence took.
|
|
func pingConvergedCrossHost(keyPath, host string, port int, dstPriv string, timeout time.Duration, onFirstFail func()) (string, bool, int, time.Duration) {
|
|
start := time.Now()
|
|
deadline := start.Add(timeout)
|
|
var out string
|
|
for attempt := 1; ; attempt++ {
|
|
var err error
|
|
out, err = sshCapture(keyPath, "ubuntu", host, port,
|
|
fmt.Sprintf("ping -c 3 -W 2 %s", dstPriv))
|
|
if err == nil && strings.Contains(out, "0% packet loss") {
|
|
return out, true, attempt, time.Since(start)
|
|
}
|
|
if attempt == 1 && onFirstFail != nil {
|
|
onFirstFail()
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return out, false, attempt, time.Since(start)
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|
|
|
|
// dumpSameSGDiag snapshots the OVN/OVS state the default-SG self-ingress rule
|
|
// depends on, from every cluster node plus the source guest, into t's artifact
|
|
// dir under the given tag. Best-effort: SSH errors are recorded, never fatal.
|
|
func dumpSameSGDiag(t *testing.T, fix *Fixture, tag, keyPath, host string, port int, srcID, dstID, dstPriv string) {
|
|
t.Helper()
|
|
if fix.Env == nil || fix.Cluster == nil || len(fix.Cluster.Nodes) == 0 {
|
|
t.Logf("dumpSameSGDiag(%s): no env or cluster nodes; skipping", tag)
|
|
return
|
|
}
|
|
artifactDir := fix.ArtifactDir(t)
|
|
t.Logf("dumpSameSGDiag(%s): src=%s dst=%s dst_ip=%s nodes=%d -> %s",
|
|
tag, srcID, dstID, dstPriv, len(fix.Cluster.Nodes), artifactDir)
|
|
|
|
probes := []struct {
|
|
name string
|
|
cmd string
|
|
}{
|
|
{"ovn-nb-address-set", "sudo ovn-nbctl --no-leader-only list address_set 2>&1 || true"},
|
|
{"ovn-sb-address-set", "sudo ovn-sbctl --no-leader-only list address_set 2>&1 || true"},
|
|
{"ovn-nb-port-group", "sudo ovn-nbctl --no-leader-only list port_group 2>&1 || true"},
|
|
{"ovn-nb-acl", "sudo ovn-nbctl --no-leader-only list acl 2>&1 || true"},
|
|
{"ovn-sb-port-binding", "sudo ovn-sbctl --no-leader-only list port_binding 2>&1 || true"},
|
|
{"ovs-ofctl-br-int", "sudo ovs-ofctl dump-flows br-int 2>&1 || true"},
|
|
{"ovs-ofctl-br-int-dst", fmt.Sprintf("sudo ovs-ofctl dump-flows br-int 2>/dev/null | grep -F %s || true", dstPriv)},
|
|
{"ovn-sbctl-show", "sudo ovn-sbctl --no-leader-only show 2>&1 || true"},
|
|
{"arp", "arp -an 2>&1 || true"},
|
|
}
|
|
|
|
ssh := harness.NewPeerSSH()
|
|
for _, n := range fix.Cluster.Nodes {
|
|
for _, p := range probes {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
out, err := ssh.Run(ctx, n.Addr, p.cmd)
|
|
cancel()
|
|
header := fmt.Sprintf("# tag=%s host=%s cmd=%s\n", tag, n.Addr, p.cmd)
|
|
if err != nil {
|
|
header += fmt.Sprintf("# (ssh error: %v)\n", err)
|
|
}
|
|
name := fmt.Sprintf("samesg-%s-%s-%s.log", tag, n.Name, p.name)
|
|
harness.DumpFile(t, artifactDir, name, append([]byte(header), out...))
|
|
}
|
|
}
|
|
|
|
guestProbes := []struct {
|
|
name string
|
|
cmd string
|
|
}{
|
|
{"ip-neigh", "ip neigh"},
|
|
{"ip-addr", "ip -br addr"},
|
|
{"ip-route", "ip route"},
|
|
{"ping-dst", fmt.Sprintf("ping -c 3 -W 2 %s 2>&1 || true", dstPriv)},
|
|
}
|
|
for _, p := range guestProbes {
|
|
out, err := sshCapture(keyPath, "ubuntu", host, port, p.cmd)
|
|
header := fmt.Sprintf("# tag=%s guest=%s cmd=%s\n", tag, srcID, p.cmd)
|
|
if err != nil {
|
|
header += fmt.Sprintf("# (ssh error: %v)\n", err)
|
|
}
|
|
name := fmt.Sprintf("samesg-%s-guest-%s.log", tag, p.name)
|
|
harness.DumpFile(t, artifactDir, name, append([]byte(header), out...))
|
|
}
|
|
}
|
|
|
|
// runMultinodeSameSGCrossHostComms launches two instances on different nodes sharing
|
|
// the default SG and asserts ICMP-ping succeeds across hosts. ICMP is permitted only
|
|
// by the default SG self-reference rule, proving it is enforced across chassis.
|
|
func runMultinodeSameSGCrossHostComms(t *testing.T, fix *Fixture) {
|
|
harness.Phase(t, "Multinode — Baseline: same default-SG instances communicate across hosts")
|
|
|
|
vpcID, defSGID, subnetID := harness.DiscoverDefaultVPC(t, fix.AWS)
|
|
instType, arch := needInstanceTypeArch(t, fix)
|
|
amiID := needAMI(t, fix, arch)
|
|
keyName, keyPath := needKeyPair(t, fix)
|
|
|
|
// Dedicated SG for runner SSH — opens tcp/22 only; ICMP between guests
|
|
// still depends on the default SG self-ingress, keeping the signal clean.
|
|
runnerSG := harness.EnsureSG(t, fix.Harness, vpcID, "baseline-runnersg")
|
|
harness.AuthorizeSSHIngress(t, fix.AWS, runnerSG)
|
|
|
|
// Launch instances until two land on distinct nodes. Bounded so a degenerate
|
|
// single-node placement can't loop forever.
|
|
type placed struct {
|
|
id string
|
|
node string
|
|
}
|
|
var instances []placed
|
|
var srcIdx, dstIdx = -1, -1
|
|
for attempt := 0; attempt < 4 && (srcIdx < 0 || dstIdx < 0); attempt++ {
|
|
id := baselineLaunch(t, fix, amiID, instType, keyName, subnetID, []string{defSGID, runnerSG})
|
|
node := harness.InstanceHostingNode(t, fix.Cluster, id)
|
|
nodeName := ""
|
|
if node != nil {
|
|
nodeName = node.Name
|
|
}
|
|
instances = append(instances, placed{id: id, node: nodeName})
|
|
// Re-scan for a distinct-node pair.
|
|
srcIdx, dstIdx = -1, -1
|
|
for i := range instances {
|
|
for j := range instances {
|
|
if i != j && instances[i].node != "" && instances[i].node != instances[j].node {
|
|
srcIdx, dstIdx = i, j
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if srcIdx < 0 || dstIdx < 0 {
|
|
t.Skipf("could not place two instances on distinct nodes (got %v); scheduler colocated", instances)
|
|
}
|
|
src, dst := instances[srcIdx], instances[dstIdx]
|
|
harness.Detail(t, "src", src.id, "src_node", src.node, "dst", dst.id, "dst_node", dst.node)
|
|
|
|
dstPriv := instancePrivateIP(t, fix, dst.id)
|
|
|
|
// Shell into the source instance via its dedicated runner-SSH SG.
|
|
host, port := harness.GuestSSHEndpoint(t, fix.AWS, fix.Cluster, src.id)
|
|
harness.GuestSSHReady(t, host, port, "ubuntu", keyPath,
|
|
harness.WithTimeout(3*time.Minute), harness.WithPoll(3*time.Second))
|
|
|
|
harness.Step(t, "ping %s (%s) from %s across hosts via default-SG self-ingress", dst.id, dstPriv, src.id)
|
|
// Snapshot OVN/OVS state on the first failed burst, while the drop is still
|
|
// observable. A later capture only ever shows the converged steady state,
|
|
// which cannot distinguish "rule not enforced" from "rule not yet propagated".
|
|
out, converged, attempts, elapsed := pingConvergedCrossHost(keyPath, host, port, dstPriv,
|
|
sameSGPingTimeout, func() {
|
|
dumpSameSGDiag(t, fix, "firstfail", keyPath, host, port, src.id, dst.id, dstPriv)
|
|
})
|
|
harness.Detail(t, "ping_attempts", attempts, "ping_converged", converged,
|
|
"ping_convergence_ms", elapsed.Milliseconds())
|
|
if !converged {
|
|
dumpSameSGDiag(t, fix, "final", keyPath, host, port, src.id, dst.id, dstPriv)
|
|
}
|
|
require.Truef(t, converged,
|
|
"cross-host ping %s -> %s never converged within %s (%d attempts); default SG self-ingress not enforced across chassis\n%s",
|
|
src.id, dst.id, sameSGPingTimeout, attempts, out)
|
|
assert.Containsf(t, out, "0% packet loss",
|
|
"cross-host ping had loss; default SG self-ingress datapath degraded\n%s", out)
|
|
// A pass that needed most of the budget is a product signal, not a green tick:
|
|
// the datapath took that long to converge, and only the retry hid it.
|
|
if attempts > 1 {
|
|
t.Logf("cross-host default-SG datapath converged only after %d attempts (%s); "+
|
|
"sustained multi-attempt convergence indicates an OVN address-set propagation delay, not a test flake",
|
|
attempts, elapsed.Round(time.Second))
|
|
}
|
|
}
|