Files
spinifex/tests/e2e/single/instance_test.go
T
joshsiv-mulga 2a556f30e7 test(e2e-single): probe full SSH handshake (not just TCP port open)
CI run 26036025293 hit "Connection timed out during banner exchange"
on Phase5a-ii / 8b / 8d even with the 3m TCP-port-open budget: tcp/22
opens immediately, but sshd is still finishing pam/cloud-init, so the
first real runSSH call afterwards races the half-open daemon.

Switch waitForSSHReady to issue an actual ssh BatchMode + ConnectTimeout
handshake against the target, retrying until the daemon completes the
banner exchange. Drop the four TCP-only Eventually loops in favour of
the upgraded helper:

- instance_test.go phase5aii_SSHProbe
- lifecycle_test.go waitForSSHReady (Phase 7b / 7c-pre / 7c)
- vpc_test.go phase8b_VPCSubnetE2E
- natgw_test.go phase8d_NATGateway (bastion)

Signature: waitForSSHReady(t, host, port, keyPath). Backwards-compat
alias waitForSSHHandshake exposed for the instance_test caller. Proper
harness hoist tracked under mulga-siv-101.
2026-05-18 23:49:35 +10:00

223 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build e2e
package single
import (
"bytes"
"net"
"os/exec"
"strconv"
"strings"
"testing"
"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"
)
// phase5_LaunchInstance discovers the default VPC, opens tcp/22 on the
// default SG, and starts a single nano instance. Maps to run-e2e.sh ~257–343.
func phase5_LaunchInstance(t *testing.T, fix *Fixture) {
harness.Phase(t, "Phase 5 — Instance Lifecycle")
require.NotEmpty(t, fix.AMIID, "Phase 4 must populate fix.AMIID")
require.NotEmpty(t, fix.InstanceType, "Phase 2 must populate fix.InstanceType")
require.NotEmpty(t, fix.KeyName, "Phase 3 must populate fix.KeyName")
if fix.DefaultVPCID == "" {
vpcID, sgID, subnetID := harness.DiscoverDefaultVPC(t, fix.AWS)
fix.DefaultVPCID, fix.DefaultSGID, fix.DefaultSubnetID = vpcID, sgID, subnetID
harness.Detail(t, "vpc", vpcID, "sg", sgID, "subnet", subnetID)
}
harness.AuthorizeSSHIngress(t, fix.AWS, fix.DefaultSGID)
harness.Step(t, "run-instances ami=%s type=%s key=%s", fix.AMIID, fix.InstanceType, fix.KeyName)
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
ImageId: aws.String(fix.AMIID),
InstanceType: aws.String(fix.InstanceType),
KeyName: aws.String(fix.KeyName),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
SecurityGroupIds: []*string{aws.String(fix.DefaultSGID)},
})
require.NoError(t, err, "run-instances")
require.NotEmpty(t, out.Instances, "run-instances returned no Instances")
fix.InstanceID = aws.StringValue(out.Instances[0].InstanceId)
require.NotEmpty(t, fix.InstanceID, "run-instances returned empty InstanceId")
harness.Detail(t, "instance", fix.InstanceID)
inst := harness.WaitForInstanceState(t, fix.AWS, fix.InstanceID, "running")
fix.Instance = inst
// Root volume: the BDM whose DeviceName matches RootDeviceName. Fall
// back to the first BDM if RootDeviceName is empty (some drivers don't
// echo it back).
rootDev := aws.StringValue(inst.RootDeviceName)
for _, bdm := range inst.BlockDeviceMappings {
if rootDev != "" && aws.StringValue(bdm.DeviceName) != rootDev {
continue
}
if bdm.Ebs != nil {
fix.RootVolumeID = aws.StringValue(bdm.Ebs.VolumeId)
break
}
}
if fix.RootVolumeID == "" && len(inst.BlockDeviceMappings) > 0 && inst.BlockDeviceMappings[0].Ebs != nil {
fix.RootVolumeID = aws.StringValue(inst.BlockDeviceMappings[0].Ebs.VolumeId)
}
require.NotEmpty(t, fix.RootVolumeID, "could not resolve root volume from BlockDeviceMappings")
harness.Detail(t, "root_volume", fix.RootVolumeID, "root_device", rootDev)
}
// phase5aPre_ClusterStats re-runs `spx get vms` now that a VM is running.
// Single-node only — multinode uses a different cluster surface. Maps to
// run-e2e.sh ~345–353.
func phase5aPre_ClusterStats(t *testing.T, fix *Fixture) {
harness.Phase(t, "Phase 5a-pre — Cluster Stats CLI (with running VM)")
if fix.Env.Mode != harness.ModeSingle {
t.Skipf("Phase 5a-pre is single-node only (mode=%s)", fix.Env.Mode)
}
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
vms := harness.SpxGetVMs(t)
assert.Containsf(t, vms, fix.InstanceID,
"spx get vms should list running instance %s\n%s", fix.InstanceID, vms)
}
// phase5a_Metadata round-trips DescribeInstances and asserts the basic
// metadata fields match what RunInstances saw. Maps to run-e2e.sh ~355–379.
func phase5a_Metadata(t *testing.T, fix *Fixture) {
harness.Phase(t, "Phase 5a — Instance Metadata Validation")
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
out, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String(fix.InstanceID)},
})
require.NoError(t, err, "describe-instances %s", fix.InstanceID)
require.NotEmpty(t, out.Reservations, "no reservations for %s", fix.InstanceID)
require.NotEmpty(t, out.Reservations[0].Instances, "no instances for %s", fix.InstanceID)
inst := out.Reservations[0].Instances[0]
assert.Equal(t, fix.InstanceType, aws.StringValue(inst.InstanceType), "InstanceType mismatch")
assert.Equal(t, fix.KeyName, aws.StringValue(inst.KeyName), "KeyName mismatch")
assert.Equal(t, fix.AMIID, aws.StringValue(inst.ImageId), "ImageId mismatch")
assert.GreaterOrEqual(t, len(inst.BlockDeviceMappings), 1,
"expected at least 1 BlockDeviceMapping, got %d", len(inst.BlockDeviceMappings))
harness.Detail(t,
"type", aws.StringValue(inst.InstanceType),
"key", aws.StringValue(inst.KeyName),
"image", aws.StringValue(inst.ImageId),
"bdms", len(inst.BlockDeviceMappings),
)
}
// phase5aii_SSHProbe waits for SSH to become reachable, then runs `id`,
// `lsblk`, and `hostname` against the guest to confirm the VM booted and
// presents the expected root volume. Maps to run-e2e.sh ~381–463.
func phase5aii_SSHProbe(t *testing.T, fix *Fixture) {
harness.Phase(t, "Phase 5a-ii — SSH Connectivity & Volume Verification")
require.NotNil(t, fix.Instance, "Phase 5 must populate fix.Instance")
require.NotEmpty(t, fix.KeyPath, "Phase 3 must populate fix.KeyPath")
require.NotEmpty(t, fix.RootVolumeID, "Phase 5 must populate fix.RootVolumeID")
host, port := harness.InstancePublicSSHHost(t, fix.Instance)
fix.SSHHost, fix.SSHPort = host, port
harness.Detail(t, "ssh_host", host, "ssh_port", port)
addr := net.JoinHostPort(host, strconv.Itoa(port))
harness.Step(t, "waiting for SSH handshake %s", addr)
waitForSSHHandshake(t, host, port, fix.KeyPath)
_ = addr
tgt := harness.SSHTarget{User: "ec2-user", Host: host, Port: port, KeyPath: fix.KeyPath}
harness.Step(t, "ssh id")
idOut := runSSH(t, tgt, "id")
assert.Containsf(t, idOut, "ec2-user", "ssh id should report ec2-user\n%s", idOut)
harness.Step(t, "lsblk root-volume cross-check vs API")
guestGiB := harness.LsblkRootGiB(t, tgt)
vols, err := fix.AWS.EC2.DescribeVolumes(&ec2.DescribeVolumesInput{
VolumeIds: []*string{aws.String(fix.RootVolumeID)},
})
require.NoError(t, err, "describe-volumes %s", fix.RootVolumeID)
require.NotEmpty(t, vols.Volumes, "no volume for %s", fix.RootVolumeID)
apiGiB := int(aws.Int64Value(vols.Volumes[0].Size))
// lsblk rounds down — bash treats equality as required, but on backing
// stores where the VM's view is a hair under the API size the rounding
// loses 1 GiB. Allow ±1 to match the bash intent without flaking on it.
diff := guestGiB - apiGiB
if diff < 0 {
diff = -diff
}
assert.LessOrEqualf(t, diff, 1,
"root volume size mismatch: guest=%d GiB api=%d GiB", guestGiB, apiGiB)
harness.Detail(t, "guest_gib", guestGiB, "api_gib", apiGiB)
harness.Step(t, "ssh hostname")
hn := strings.TrimSpace(runSSH(t, tgt, "hostname"))
// Bash uses `spinifex-vm-<first 8 hex chars of instance ID>` and treats
// a missing prefix as a non-fatal warning. Replicate the soft check via
// t.Logf rather than asserting — the spx hostname format is not part of
// the EC2 surface contract.
shortID := strings.TrimPrefix(fix.InstanceID, "i-")
if len(shortID) > 8 {
shortID = shortID[:8]
}
if strings.Contains(hn, shortID) {
harness.Detail(t, "hostname", hn, "matches_short_id", shortID)
} else {
t.Logf("hostname %q does not contain short id %q (non-fatal)", hn, shortID)
}
}
// phase5aiii_ConsoleOutput verifies GetConsoleOutput round-trips the
// instance ID. The payload itself is base64 cloud-init output; bash doesn't
// decode it and neither do we. Maps to run-e2e.sh ~465–478.
func phase5aiii_ConsoleOutput(t *testing.T, fix *Fixture) {
harness.Phase(t, "Phase 5a-iii — Console Output")
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
out, err := fix.AWS.EC2.GetConsoleOutput(&ec2.GetConsoleOutputInput{
InstanceId: aws.String(fix.InstanceID),
})
require.NoError(t, err, "get-console-output %s", fix.InstanceID)
require.Equal(t, fix.InstanceID, aws.StringValue(out.InstanceId),
"GetConsoleOutput returned a different InstanceId")
harness.Detail(t,
"instance", aws.StringValue(out.InstanceId),
"has_output", out.Output != nil && aws.StringValue(out.Output) != "",
)
}
// runSSH is a thin wrapper around `ssh` matching the option set used by
// harness.LsblkRootGiB. Returns stdout. t.Fatal on non-zero exit so callers
// can chain assertions on the output without nil-checking err.
func runSSH(t *testing.T, tgt harness.SSHTarget, command string) string {
t.Helper()
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,
}
var stdout, stderr bytes.Buffer
cmd := exec.Command("ssh", args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("ssh %s@%s:%d %q failed: %v\nstderr: %s",
tgt.User, tgt.Host, tgt.Port, command, err, stderr.String())
}
return stdout.String()
}