mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
test(e2e-single): port run-e2e.sh phases 5f-8 to Go
Stage D of the single-node Go port (mulga-siv-89). Adds four files under tests/e2e/single/ covering Phase 5f (SG egress ACL enforcement), Phase 6 (tag CRUD), Phases 7 / 7a / 7b / 7c-pre / 7c (stop, start, attach-to-stopped negative, ModifyInstanceAttribute, reboot, multi-count RunInstances), and Phase 8 negatives (18 sub-tests asserting AWS error codes via harness.ExpectError / AssertAWSError / PostAWSAction). Wired into TestSingleNode in main_test.go; phase ordering preserved because Phase 7 chain mutates the shared instance state (stopped at end of 7, .small after 7b, running after 7c-pre, untouched by 7c siblings) and Phase 8 negatives need a running instance with the AMI/key created earlier. Removes the placeholder phase7a_AttachToStoppedError stub from volume_test.go (real impl now in lifecycle_test.go). run-e2e.sh stays in tree until step-7 cleanup per the parent plan.
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"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"
|
||||
)
|
||||
|
||||
// phase7_StopStart stops fix.Instance and waits for it to reach the
|
||||
// "stopped" state, then asserts that rebooting a stopped instance is
|
||||
// rejected with IncorrectInstanceState. Leaves the instance stopped so
|
||||
// Phase 7a / 7b can act against the stopped state. Maps to run-e2e.sh
|
||||
// ~1027–1053.
|
||||
func phase7_StopStart(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 7 — Instance State Transitions (Stop)")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
|
||||
harness.Step(t, "stop-instances %s", fix.InstanceID)
|
||||
_, err := fix.AWS.EC2.StopInstances(&ec2.StopInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "stop-instances")
|
||||
|
||||
harness.WaitForInstanceState(t, fix.AWS, fix.InstanceID, "stopped")
|
||||
|
||||
// Reboot of a stopped instance must be rejected — IncorrectInstanceState
|
||||
// is the AWS code bash's expect_error pins on.
|
||||
harness.Step(t, "reboot-instances on stopped instance (should fail)")
|
||||
harness.ExpectError(t, "IncorrectInstanceState", func() error {
|
||||
_, err := fix.AWS.EC2.RebootInstances(&ec2.RebootInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
return err
|
||||
})
|
||||
harness.Detail(t, "stopped_reject", "IncorrectInstanceState")
|
||||
}
|
||||
|
||||
// phase7a_AttachToStoppedError creates a fresh 10 GiB volume and attempts
|
||||
// to attach it to the (currently stopped) primary instance. The attach
|
||||
// must fail with IncorrectInstanceState. The test volume is deleted
|
||||
// in-line on success and via t.Cleanup on early failure. Maps to
|
||||
// run-e2e.sh ~1054–1068.
|
||||
func phase7a_AttachToStoppedError(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 7a — Attach Volume to Stopped Instance (Error Path)")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.AZName, "Phase 2 must populate fix.AZName")
|
||||
|
||||
harness.Step(t, "create-volume size=10 az=%s", fix.AZName)
|
||||
create, err := fix.AWS.EC2.CreateVolume(&ec2.CreateVolumeInput{
|
||||
Size: aws.Int64(10),
|
||||
AvailabilityZone: aws.String(fix.AZName),
|
||||
})
|
||||
require.NoError(t, err, "create-volume")
|
||||
stoppedVolID := aws.StringValue(create.VolumeId)
|
||||
require.NotEmpty(t, stoppedVolID, "create-volume returned empty VolumeId")
|
||||
harness.Detail(t, "test_volume", stoppedVolID)
|
||||
|
||||
// Best-effort cleanup if the negative assertion or anything else fails
|
||||
// before we reach the explicit delete-volume below.
|
||||
cleaned := false
|
||||
t.Cleanup(func() {
|
||||
if cleaned {
|
||||
return
|
||||
}
|
||||
_, _ = fix.AWS.EC2.DeleteVolume(&ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String(stoppedVolID),
|
||||
})
|
||||
})
|
||||
|
||||
harness.WaitForVolumeState(t, fix.AWS, stoppedVolID, "available")
|
||||
|
||||
harness.Step(t, "attach-volume %s -> %s (should fail)", stoppedVolID, fix.InstanceID)
|
||||
harness.ExpectError(t, "IncorrectInstanceState", func() error {
|
||||
_, err := fix.AWS.EC2.AttachVolume(&ec2.AttachVolumeInput{
|
||||
VolumeId: aws.String(stoppedVolID),
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
Device: aws.String("/dev/sdg"),
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
harness.Step(t, "delete-volume %s", stoppedVolID)
|
||||
_, err = fix.AWS.EC2.DeleteVolume(&ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String(stoppedVolID),
|
||||
})
|
||||
require.NoError(t, err, "delete-volume %s", stoppedVolID)
|
||||
cleaned = true
|
||||
}
|
||||
|
||||
// phase7b_ModifyInstanceAttribute changes the (stopped) instance type to
|
||||
// the same-family ".small" upsize, starts the instance, SSHes in to
|
||||
// verify the new vCPU + memory budget, then stops the instance again so
|
||||
// Phase 7c-pre can drive its own start/reboot cycle. Maps to run-e2e.sh
|
||||
// ~1070–1177.
|
||||
func phase7b_ModifyInstanceAttribute(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 7b — ModifyInstanceAttribute")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.InstanceType, "Phase 2 must populate fix.InstanceType")
|
||||
require.NotEmpty(t, fix.KeyPath, "Phase 3 must populate fix.KeyPath")
|
||||
|
||||
// Bash strips the ".nano" suffix and appends ".small" — same family,
|
||||
// more RAM at matching vCPU. Avoids xlarge (16 GiB) which the CI host
|
||||
// can't satisfy.
|
||||
if !strings.HasSuffix(fix.InstanceType, ".nano") {
|
||||
t.Fatalf("phase7b: expected fix.InstanceType to end with .nano, got %q", fix.InstanceType)
|
||||
}
|
||||
modifyType := strings.TrimSuffix(fix.InstanceType, ".nano") + ".small"
|
||||
harness.Detail(t, "from_type", fix.InstanceType, "to_type", modifyType)
|
||||
|
||||
// Look up expected vCPUs / memory for the upsized type so we can
|
||||
// assert SSH-reported values match what the AWS surface advertises.
|
||||
typesOut, err := fix.AWS.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{})
|
||||
require.NoError(t, err, "describe-instance-types")
|
||||
var expectedVCPUs int64
|
||||
var expectedMemMiB int64
|
||||
for _, it := range typesOut.InstanceTypes {
|
||||
if aws.StringValue(it.InstanceType) != modifyType {
|
||||
continue
|
||||
}
|
||||
if it.VCpuInfo != nil {
|
||||
expectedVCPUs = aws.Int64Value(it.VCpuInfo.DefaultVCpus)
|
||||
}
|
||||
if it.MemoryInfo != nil {
|
||||
expectedMemMiB = aws.Int64Value(it.MemoryInfo.SizeInMiB)
|
||||
}
|
||||
break
|
||||
}
|
||||
require.NotZero(t, expectedVCPUs, "%s missing VCpuInfo.DefaultVCpus", modifyType)
|
||||
require.NotZero(t, expectedMemMiB, "%s missing MemoryInfo.SizeInMiB", modifyType)
|
||||
harness.Detail(t, "expected_vcpus", expectedVCPUs, "expected_mem_mib", expectedMemMiB)
|
||||
|
||||
harness.Step(t, "modify-instance-attribute %s type=%s", fix.InstanceID, modifyType)
|
||||
_, err = fix.AWS.EC2.ModifyInstanceAttribute(&ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String(modifyType)},
|
||||
})
|
||||
require.NoError(t, err, "modify-instance-attribute")
|
||||
|
||||
// Verify describe-instances reflects the new type before we start.
|
||||
descOut, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-instances")
|
||||
require.NotEmpty(t, descOut.Reservations, "no reservations for %s", fix.InstanceID)
|
||||
require.NotEmpty(t, descOut.Reservations[0].Instances, "no instances for %s", fix.InstanceID)
|
||||
gotType := aws.StringValue(descOut.Reservations[0].Instances[0].InstanceType)
|
||||
require.Equalf(t, modifyType, gotType,
|
||||
"ModifyInstanceAttribute did not stick: want %s got %s", modifyType, gotType)
|
||||
// Reflect the new type in the fixture so any later phase that reads
|
||||
// fix.InstanceType sees the post-modify value.
|
||||
fix.InstanceType = modifyType
|
||||
|
||||
harness.Step(t, "start-instances %s", fix.InstanceID)
|
||||
_, err = fix.AWS.EC2.StartInstances(&ec2.StartInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "start-instances")
|
||||
|
||||
inst := harness.WaitForInstanceState(t, fix.AWS, fix.InstanceID, "running")
|
||||
fix.Instance = inst
|
||||
|
||||
// Re-discover SSH endpoint — qemu hostfwd may have rebound.
|
||||
host, port := harness.InstancePublicSSHHost(t, inst)
|
||||
fix.SSHHost, fix.SSHPort = host, port
|
||||
harness.Detail(t, "ssh_host", host, "ssh_port", port)
|
||||
|
||||
waitForSSHReady(t, host, port)
|
||||
tgt := harness.SSHTarget{User: "ec2-user", Host: host, Port: port, KeyPath: fix.KeyPath}
|
||||
|
||||
harness.Step(t, "ssh nproc")
|
||||
nprocOut := strings.TrimSpace(runSSH(t, tgt, "nproc"))
|
||||
vmVCPUs, err := strconv.ParseInt(nprocOut, 10, 64)
|
||||
require.NoErrorf(t, err, "parse nproc output %q", nprocOut)
|
||||
require.Equalf(t, expectedVCPUs, vmVCPUs,
|
||||
"vCPU mismatch after modify: VM=%d expected=%d", vmVCPUs, expectedVCPUs)
|
||||
harness.Detail(t, "vm_vcpus", vmVCPUs)
|
||||
|
||||
harness.Step(t, "ssh MemTotal")
|
||||
memOut := strings.TrimSpace(runSSH(t, tgt, "awk '/MemTotal/ {print $2}' /proc/meminfo"))
|
||||
vmMemKB, err := strconv.ParseInt(memOut, 10, 64)
|
||||
require.NoErrorf(t, err, "parse MemTotal output %q", memOut)
|
||||
vmMemMiB := vmMemKB / 1024
|
||||
// 15% margin for kernel-reserved memory, matching the bash threshold.
|
||||
expectedMemLow := expectedMemMiB * 85 / 100
|
||||
require.GreaterOrEqualf(t, vmMemMiB, expectedMemLow,
|
||||
"memory too low after modify: VM=%d MiB expected>=%d MiB (target %d MiB)",
|
||||
vmMemMiB, expectedMemLow, expectedMemMiB)
|
||||
harness.Detail(t, "vm_mem_mib", vmMemMiB, "threshold_mib", expectedMemLow)
|
||||
|
||||
// Phase 7c-pre expects the instance running again, but the bash phase
|
||||
// ordering leaves the instance stopped between 7b and 7c-pre by
|
||||
// implicitly relying on 7c-pre to start it. We restore that contract
|
||||
// explicitly so 7c-pre can drive its own start.
|
||||
harness.Step(t, "stop-instances %s (end of 7b)", fix.InstanceID)
|
||||
_, err = fix.AWS.EC2.StopInstances(&ec2.StopInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "stop-instances (end of 7b)")
|
||||
harness.WaitForInstanceState(t, fix.AWS, fix.InstanceID, "stopped")
|
||||
}
|
||||
|
||||
// phase7cPre_Reboot starts the (stopped) primary instance, captures its
|
||||
// pre-reboot private IP, issues a reboot, asserts the API never reports a
|
||||
// non-running state during a short polling window, waits for SSH to come
|
||||
// back, and verifies the guest actually rebooted (uptime < 120s) without
|
||||
// changing its private IP. Leaves the instance running. Maps to
|
||||
// run-e2e.sh ~1178–1236.
|
||||
func phase7cPre_Reboot(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 7c-pre — Reboot Running Instance")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.KeyPath, "Phase 3 must populate fix.KeyPath")
|
||||
|
||||
// Bring instance back up from the stopped state left by Phase 7b.
|
||||
harness.Step(t, "start-instances %s", fix.InstanceID)
|
||||
_, err := fix.AWS.EC2.StartInstances(&ec2.StartInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "start-instances")
|
||||
inst := harness.WaitForInstanceState(t, fix.AWS, fix.InstanceID, "running")
|
||||
fix.Instance = inst
|
||||
|
||||
host, port := harness.InstancePublicSSHHost(t, inst)
|
||||
fix.SSHHost, fix.SSHPort = host, port
|
||||
waitForSSHReady(t, host, port)
|
||||
|
||||
// Capture pre-reboot private IP for the post-reboot identity check.
|
||||
preDesc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-instances (pre-reboot)")
|
||||
require.NotEmpty(t, preDesc.Reservations, "no reservations for %s", fix.InstanceID)
|
||||
require.NotEmpty(t, preDesc.Reservations[0].Instances, "no instances for %s", fix.InstanceID)
|
||||
preRebootIP := aws.StringValue(preDesc.Reservations[0].Instances[0].PrivateIpAddress)
|
||||
harness.Detail(t, "pre_reboot_private_ip", preRebootIP)
|
||||
|
||||
harness.Step(t, "reboot-instances %s", fix.InstanceID)
|
||||
_, err = fix.AWS.EC2.RebootInstances(&ec2.RebootInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "reboot-instances")
|
||||
|
||||
// Bash polls 10× at 1s checking the state stays "running" — EC2's
|
||||
// reboot semantics don't transition the instance state at all.
|
||||
for i := 0; i < 10; i++ {
|
||||
out, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-instances during reboot poll %d", i)
|
||||
require.NotEmpty(t, out.Reservations[0].Instances, "instance disappeared during reboot")
|
||||
state := aws.StringValue(out.Reservations[0].Instances[0].State.Name)
|
||||
require.Equalf(t, "running", state,
|
||||
"instance unexpectedly left running state during reboot: %s", state)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// SSH endpoint may have rebound after the guest restart (qemu hostfwd
|
||||
// can shift), so re-discover.
|
||||
descPost, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-instances (post-reboot)")
|
||||
require.NotEmpty(t, descPost.Reservations[0].Instances, "no instances post-reboot")
|
||||
postInst := descPost.Reservations[0].Instances[0]
|
||||
fix.Instance = postInst
|
||||
host, port = harness.InstancePublicSSHHost(t, postInst)
|
||||
fix.SSHHost, fix.SSHPort = host, port
|
||||
waitForSSHReady(t, host, port)
|
||||
|
||||
tgt := harness.SSHTarget{User: "ec2-user", Host: host, Port: port, KeyPath: fix.KeyPath}
|
||||
harness.Step(t, "ssh uptime")
|
||||
uptimeOut := strings.TrimSpace(runSSH(t, tgt, "cat /proc/uptime | cut -d. -f1"))
|
||||
uptimeSecs, err := strconv.ParseInt(uptimeOut, 10, 64)
|
||||
require.NoErrorf(t, err, "parse uptime output %q", uptimeOut)
|
||||
require.LessOrEqualf(t, uptimeSecs, int64(120),
|
||||
"guest uptime %ds is > 120s — reboot may not have occurred", uptimeSecs)
|
||||
harness.Detail(t, "uptime_secs", uptimeSecs)
|
||||
|
||||
postRebootIP := aws.StringValue(postInst.PrivateIpAddress)
|
||||
assert.Equalf(t, preRebootIP, postRebootIP,
|
||||
"PrivateIpAddress changed across reboot: %s -> %s", preRebootIP, postRebootIP)
|
||||
|
||||
// Leave instance running — Phase 7c launches sibling instances next,
|
||||
// and Phase 8 expects the primary instance to still be up.
|
||||
}
|
||||
|
||||
// phase7c_RunInstancesMultiCount launches 2 sibling instances in a single
|
||||
// RunInstances call, waits for both to reach "running", then terminates
|
||||
// them and waits for both to reach "terminated". The primary fix.Instance
|
||||
// is untouched and remains running for Phase 8. Maps to run-e2e.sh
|
||||
// ~1238–1296.
|
||||
func phase7c_RunInstancesMultiCount(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 7c — RunInstances with MinCount/MaxCount > 1")
|
||||
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")
|
||||
|
||||
harness.Step(t, "run-instances ami=%s type=%s count=2", fix.AMIID, fix.InstanceType)
|
||||
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(2),
|
||||
MaxCount: aws.Int64(2),
|
||||
})
|
||||
require.NoError(t, err, "run-instances --count 2")
|
||||
require.Lenf(t, out.Instances, 2,
|
||||
"expected 2 instances from run-instances, got %d", len(out.Instances))
|
||||
|
||||
multiIDs := []string{
|
||||
aws.StringValue(out.Instances[0].InstanceId),
|
||||
aws.StringValue(out.Instances[1].InstanceId),
|
||||
}
|
||||
require.NotEmpty(t, multiIDs[0], "first sibling InstanceId empty")
|
||||
require.NotEmpty(t, multiIDs[1], "second sibling InstanceId empty")
|
||||
harness.Detail(t, "sibling_1", multiIDs[0], "sibling_2", multiIDs[1])
|
||||
|
||||
// Always tear down the siblings — pre-register before any blocking
|
||||
// wait so a t.Fatal on state polling still triggers cleanup.
|
||||
terminated := false
|
||||
t.Cleanup(func() {
|
||||
if terminated {
|
||||
return
|
||||
}
|
||||
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: aws.StringSlice(multiIDs),
|
||||
})
|
||||
})
|
||||
|
||||
for _, id := range multiIDs {
|
||||
harness.WaitForInstanceState(t, fix.AWS, id, "running")
|
||||
}
|
||||
|
||||
harness.Step(t, "terminate-instances %s %s", multiIDs[0], multiIDs[1])
|
||||
_, err = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: aws.StringSlice(multiIDs),
|
||||
})
|
||||
require.NoError(t, err, "terminate-instances")
|
||||
|
||||
for _, id := range multiIDs {
|
||||
harness.WaitForInstanceState(t, fix.AWS, id, "terminated")
|
||||
}
|
||||
terminated = true
|
||||
}
|
||||
|
||||
// waitForSSHReady polls tcp/22 (or the discovered hostfwd port) until a
|
||||
// dial succeeds, matching the 60s/2s loop in phase5aii_SSHProbe and the
|
||||
// bash wait_for_ssh helper.
|
||||
func waitForSSHReady(t *testing.T, host string, port int) {
|
||||
t.Helper()
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
harness.Step(t, "waiting for SSH port %s", addr)
|
||||
harness.Eventually(t, func() bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}, 60*time.Second, 2*time.Second, fmt.Sprintf("SSH port %s never became reachable", addr))
|
||||
}
|
||||
@@ -88,8 +88,19 @@ func TestSingleNode(t *testing.T) {
|
||||
t.Run("Phase5c_Snapshot", func(t *testing.T) { phase5c_SnapshotLifecycle(t, fix) })
|
||||
t.Run("Phase5d_SnapshotBackedLaunch", func(t *testing.T) { phase5d_SnapshotBackedLaunch(t, fix) })
|
||||
t.Run("Phase5e_CreateImage", func(t *testing.T) { phase5e_CreateImage(t, fix) })
|
||||
t.Run("Phase5f_SecurityGroupEgress", func(t *testing.T) { phase5f_SecurityGroupEgress(t, fix) })
|
||||
|
||||
// Stages D–G will append more t.Run calls here.
|
||||
t.Run("Phase6_TagManagement", func(t *testing.T) { phase6_TagManagement(t, fix) })
|
||||
|
||||
t.Run("Phase7_StopStart", func(t *testing.T) { phase7_StopStart(t, fix) })
|
||||
t.Run("Phase7a_AttachToStoppedError", func(t *testing.T) { phase7a_AttachToStoppedError(t, fix) })
|
||||
t.Run("Phase7b_ModifyInstanceAttribute", func(t *testing.T) { phase7b_ModifyInstanceAttribute(t, fix) })
|
||||
t.Run("Phase7c_pre_Reboot", func(t *testing.T) { phase7cPre_Reboot(t, fix) })
|
||||
t.Run("Phase7c_RunInstancesMultiCount", func(t *testing.T) { phase7c_RunInstancesMultiCount(t, fix) })
|
||||
|
||||
t.Run("Phase8_NegativeErrorPaths", func(t *testing.T) { phase8_NegativeErrorPaths(t, fix) })
|
||||
|
||||
// Stages E–G will append more t.Run calls here.
|
||||
|
||||
harness.OnFailure(t, func() {
|
||||
harness.DumpCmd(t, fix.Artifacts, "ec2-describe-instances.txt",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"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/require"
|
||||
)
|
||||
|
||||
// phase8_NegativeErrorPaths ports run-e2e.sh Phase 8 (lines ~1298–1399):
|
||||
// every sub-test exercises a single error code on the gateway. Each case
|
||||
// runs in its own t.Run so one failure doesn't poison the others; cleanup
|
||||
// is defensive (calls that unexpectedly succeed get torn down).
|
||||
//
|
||||
// Sub-tests:
|
||||
//
|
||||
// 8a InvalidAMIID.Malformed — RunInstances with non-ami- image id
|
||||
// 8b InvalidInstanceType — RunInstances with bogus type
|
||||
// 8c VolumeInUse — AttachVolume on root (already in-use)
|
||||
// 8d OperationNotPermitted — DetachVolume on root volume
|
||||
// 8e InvalidSnapshot.NotFound — DeleteSnapshot for unknown snap
|
||||
// 8f InvalidAction (raw HTTP) — unsupported gateway action
|
||||
// 8g InvalidAMIID.NotFound — RunInstances with bogus ami id
|
||||
// 8h InvalidKeyPair.NotFound — RunInstances with bogus key name
|
||||
// 8i InvalidVolume.NotFound — DeleteVolume for unknown vol
|
||||
// 8j InvalidKeyPair.Duplicate — CreateKeyPair re-using test-key-1
|
||||
// 8k InvalidKeyPair.Duplicate — ImportKeyPair re-using test-key-1
|
||||
// 8l InvalidKey.Format — ImportKeyPair with non-PEM/SSH material
|
||||
// 8m InvalidVolume.NotFound — DescribeVolumes with unknown vol id
|
||||
// 8n InvalidAMIID.NotFound — DescribeImages with unknown ami id
|
||||
// 8o InvalidAMIName.Duplicate — CreateImage with e2e-custom-ami name
|
||||
// 8p (idempotent) — DeleteKeyPair for unknown key must succeed
|
||||
// 8q InvalidInstanceID.NotFound — ModifyInstanceAttribute on running inst
|
||||
// 8r InvalidInstanceID.NotFound — RebootInstances on unknown id
|
||||
func phase8_NegativeErrorPaths(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 8 — Negative / Error Path Tests")
|
||||
|
||||
// Sanity: every sub-test below depends on at least one of these.
|
||||
require.NotEmpty(t, fix.AMIID, "Phase 4 must populate fix.AMIID")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.RootVolumeID, "Phase 5 must populate fix.RootVolumeID")
|
||||
require.NotEmpty(t, fix.InstanceType, "Phase 2 must populate fix.InstanceType")
|
||||
require.NotEmpty(t, fix.CustomAMIID, "Phase 5e must populate fix.CustomAMIID")
|
||||
|
||||
const existingKey = "test-key-1" // staged by Phase 3 / phase3_KeyPairs
|
||||
const customAMIName = "e2e-custom-ami"
|
||||
|
||||
// 8a: RunInstances with malformed AMI ID (missing ami- prefix).
|
||||
t.Run("8a_InvalidAMIIDMalformed", func(t *testing.T) {
|
||||
harness.Step(t, "run-instances image-id=notanami")
|
||||
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String("notanami"),
|
||||
InstanceType: aws.String(fix.InstanceType),
|
||||
KeyName: aws.String(existingKey),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
})
|
||||
cleanupUnexpectedInstances(t, fix, out)
|
||||
harness.AssertAWSError(t, err, "InvalidAMIID.Malformed")
|
||||
})
|
||||
|
||||
// 8b: RunInstances with invalid instance type.
|
||||
t.Run("8b_InvalidInstanceType", func(t *testing.T) {
|
||||
harness.Step(t, "run-instances instance-type=x99.superlarge")
|
||||
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String(fix.AMIID),
|
||||
InstanceType: aws.String("x99.superlarge"),
|
||||
KeyName: aws.String(existingKey),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
})
|
||||
cleanupUnexpectedInstances(t, fix, out)
|
||||
harness.AssertAWSError(t, err, "InvalidInstanceType")
|
||||
})
|
||||
|
||||
// 8c: AttachVolume on the already-attached root volume.
|
||||
t.Run("8c_VolumeInUse", func(t *testing.T) {
|
||||
harness.Step(t, "attach-volume %s (root, in-use)", fix.RootVolumeID)
|
||||
_, err := fix.AWS.EC2.AttachVolume(&ec2.AttachVolumeInput{
|
||||
VolumeId: aws.String(fix.RootVolumeID),
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
Device: aws.String("/dev/sdg"),
|
||||
})
|
||||
harness.AssertAWSError(t, err, "VolumeInUse")
|
||||
})
|
||||
|
||||
// 8d: DetachVolume against the boot/root volume — explicitly disallowed.
|
||||
t.Run("8d_DetachRootForbidden", func(t *testing.T) {
|
||||
harness.Step(t, "detach-volume %s (root)", fix.RootVolumeID)
|
||||
_, err := fix.AWS.EC2.DetachVolume(&ec2.DetachVolumeInput{
|
||||
VolumeId: aws.String(fix.RootVolumeID),
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
})
|
||||
harness.AssertAWSError(t, err, "OperationNotPermitted")
|
||||
})
|
||||
|
||||
// 8e: DeleteSnapshot on a non-existent snapshot id.
|
||||
t.Run("8e_DeleteSnapshotNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "delete-snapshot snap-nonexistent000000")
|
||||
_, err := fix.AWS.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{
|
||||
SnapshotId: aws.String("snap-nonexistent000000"),
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidSnapshot.NotFound")
|
||||
})
|
||||
|
||||
// 8f: Raw HTTP POST with an unsupported Action. The SDK refuses to build
|
||||
// requests for actions it doesn't know about, so this one bypasses it.
|
||||
// Bash treats InvalidAction / UnknownAction / generic "Error" as a pass;
|
||||
// here we accept either of the two canonical codes and surface anything
|
||||
// else so we notice regressions in the gateway error envelope.
|
||||
t.Run("8f_InvalidActionRawHTTP", func(t *testing.T) {
|
||||
harness.Step(t, "POST Action=DescribeFakeThings")
|
||||
status, body, code := harness.PostAWSAction(t, fix.Env, fix.AWS,
|
||||
"DescribeFakeThings", nil)
|
||||
harness.Detail(t, "status", status, "code", code)
|
||||
switch code {
|
||||
case "InvalidAction", "UnknownAction":
|
||||
// expected
|
||||
default:
|
||||
t.Fatalf("expected InvalidAction/UnknownAction, got code=%q status=%d body=%s",
|
||||
code, status, string(body))
|
||||
}
|
||||
})
|
||||
|
||||
// 8g: RunInstances with a well-formed but non-existent AMI id.
|
||||
t.Run("8g_InvalidAMIIDNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "run-instances image-id=ami-0000000000000dead")
|
||||
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-0000000000000dead"),
|
||||
InstanceType: aws.String(fix.InstanceType),
|
||||
KeyName: aws.String(existingKey),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
})
|
||||
cleanupUnexpectedInstances(t, fix, out)
|
||||
harness.AssertAWSError(t, err, "InvalidAMIID.NotFound")
|
||||
})
|
||||
|
||||
// 8h: RunInstances with non-existent key pair name.
|
||||
t.Run("8h_InvalidKeyPairNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "run-instances key-name=nonexistent-key-xyz")
|
||||
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String(fix.AMIID),
|
||||
InstanceType: aws.String(fix.InstanceType),
|
||||
KeyName: aws.String("nonexistent-key-xyz"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
})
|
||||
cleanupUnexpectedInstances(t, fix, out)
|
||||
harness.AssertAWSError(t, err, "InvalidKeyPair.NotFound")
|
||||
})
|
||||
|
||||
// 8i: DeleteVolume on an unknown volume id.
|
||||
t.Run("8i_DeleteVolumeNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "delete-volume vol-0000000000000dead")
|
||||
_, err := fix.AWS.EC2.DeleteVolume(&ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String("vol-0000000000000dead"),
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidVolume.NotFound")
|
||||
})
|
||||
|
||||
// 8j: CreateKeyPair re-using test-key-1 (still present from Phase 3).
|
||||
t.Run("8j_CreateKeyPairDuplicate", func(t *testing.T) {
|
||||
harness.Step(t, "create-key-pair %s (duplicate)", existingKey)
|
||||
out, err := fix.AWS.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{
|
||||
KeyName: aws.String(existingKey),
|
||||
})
|
||||
// If the gateway regresses and accepts the duplicate, the newly-issued
|
||||
// pair would shadow Phase 3's — defensively wipe so the rest of the
|
||||
// suite (and Stage G teardown) still sees the original.
|
||||
if err == nil && out != nil {
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{
|
||||
KeyName: aws.String(existingKey),
|
||||
})
|
||||
})
|
||||
}
|
||||
harness.AssertAWSError(t, err, "InvalidKeyPair.Duplicate")
|
||||
})
|
||||
|
||||
// 8k: ImportKeyPair re-using test-key-1.
|
||||
t.Run("8k_ImportKeyPairDuplicate", func(t *testing.T) {
|
||||
harness.Step(t, "import-key-pair %s (duplicate)", existingKey)
|
||||
_, err := fix.AWS.EC2.ImportKeyPair(&ec2.ImportKeyPairInput{
|
||||
KeyName: aws.String(existingKey),
|
||||
PublicKeyMaterial: generateImportPubKey(t),
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidKeyPair.Duplicate")
|
||||
})
|
||||
|
||||
// 8l: ImportKeyPair with garbage public-key material.
|
||||
t.Run("8l_InvalidKeyFormat", func(t *testing.T) {
|
||||
const badKeyName = "bad-format-key"
|
||||
harness.Step(t, "import-key-pair %s (bad material)", badKeyName)
|
||||
_, err := fix.AWS.EC2.ImportKeyPair(&ec2.ImportKeyPairInput{
|
||||
KeyName: aws.String(badKeyName),
|
||||
PublicKeyMaterial: []byte("not-a-valid-public-key"),
|
||||
})
|
||||
// Defensive: if the gateway erroneously accepted the key, remove it
|
||||
// before the next sub-test runs.
|
||||
if err == nil {
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{
|
||||
KeyName: aws.String(badKeyName),
|
||||
})
|
||||
})
|
||||
}
|
||||
harness.AssertAWSError(t, err, "InvalidKey.Format")
|
||||
})
|
||||
|
||||
// 8m: DescribeVolumes filtered by a non-existent volume id.
|
||||
t.Run("8m_DescribeVolumesNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "describe-volumes vol-0000000000000dead")
|
||||
_, err := fix.AWS.EC2.DescribeVolumes(&ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{aws.String("vol-0000000000000dead")},
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidVolume.NotFound")
|
||||
})
|
||||
|
||||
// 8n: DescribeImages filtered by a non-existent AMI id.
|
||||
t.Run("8n_DescribeImagesNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "describe-images ami-0000000000000dead")
|
||||
_, err := fix.AWS.EC2.DescribeImages(&ec2.DescribeImagesInput{
|
||||
ImageIds: []*string{aws.String("ami-0000000000000dead")},
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidAMIID.NotFound")
|
||||
})
|
||||
|
||||
// 8o: CreateImage with a name that already exists (Phase 5e's AMI).
|
||||
// Stored in fix.CustomAMIID, but the duplicate-name check is by name, not
|
||||
// id — assert via the well-known constant from Phase 5e.
|
||||
t.Run("8o_CreateImageDuplicateName", func(t *testing.T) {
|
||||
harness.Step(t, "create-image name=%s (duplicate)", customAMIName)
|
||||
out, err := fix.AWS.EC2.CreateImage(&ec2.CreateImageInput{
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
Name: aws.String(customAMIName),
|
||||
})
|
||||
// If a second AMI somehow got minted, deregister it so it doesn't
|
||||
// linger past the suite.
|
||||
if err == nil && out != nil && aws.StringValue(out.ImageId) != "" &&
|
||||
aws.StringValue(out.ImageId) != fix.CustomAMIID {
|
||||
extraID := aws.StringValue(out.ImageId)
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.DeregisterImage(&ec2.DeregisterImageInput{
|
||||
ImageId: aws.String(extraID),
|
||||
})
|
||||
})
|
||||
}
|
||||
harness.AssertAWSError(t, err, "InvalidAMIName.Duplicate")
|
||||
})
|
||||
|
||||
// 8p: DeleteKeyPair on an unknown key must succeed (idempotent — matches
|
||||
// AWS). This is the only positive case in Phase 8.
|
||||
t.Run("8p_DeleteKeyPairIdempotent", func(t *testing.T) {
|
||||
const missingKey = "nonexistent-key-99999"
|
||||
harness.Step(t, "delete-key-pair %s (idempotent)", missingKey)
|
||||
_, err := fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{
|
||||
KeyName: aws.String(missingKey),
|
||||
})
|
||||
require.NoError(t, err, "delete-key-pair on missing key must succeed (idempotent)")
|
||||
})
|
||||
|
||||
// 8q: ModifyInstanceAttribute on a running instance — gateway requires
|
||||
// the instance to be stopped, and the running instance isn't in the
|
||||
// stopped KV view, so the error surfaces as NotFound rather than
|
||||
// IncorrectInstanceState. Matches bash expectation.
|
||||
t.Run("8q_ModifyAttributeOnRunning", func(t *testing.T) {
|
||||
harness.Step(t, "modify-instance-attribute %s (running)", fix.InstanceID)
|
||||
_, err := fix.AWS.EC2.ModifyInstanceAttribute(&ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(fix.InstanceID),
|
||||
InstanceType: &ec2.AttributeValue{
|
||||
Value: aws.String(fix.InstanceType),
|
||||
},
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidInstanceID.NotFound")
|
||||
})
|
||||
|
||||
// 8r: RebootInstances on a non-existent instance id.
|
||||
t.Run("8r_RebootInstanceNotFound", func(t *testing.T) {
|
||||
harness.Step(t, "reboot-instances i-nonexistent")
|
||||
_, err := fix.AWS.EC2.RebootInstances(&ec2.RebootInstancesInput{
|
||||
InstanceIds: []*string{aws.String("i-nonexistent")},
|
||||
})
|
||||
harness.AssertAWSError(t, err, "InvalidInstanceID.NotFound")
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupUnexpectedInstances registers a t.Cleanup that terminates any
|
||||
// instances the gateway accidentally launched from a negative RunInstances
|
||||
// call. Most failures return nil reservations, so this is a no-op in the
|
||||
// happy path. Errors during cleanup are swallowed — the sub-test has already
|
||||
// failed if it got this far with non-nil out.
|
||||
func cleanupUnexpectedInstances(t *testing.T, fix *Fixture, out *ec2.Reservation) {
|
||||
t.Helper()
|
||||
if out == nil || len(out.Instances) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]*string, 0, len(out.Instances))
|
||||
for _, inst := range out.Instances {
|
||||
if id := aws.StringValue(inst.InstanceId); id != "" {
|
||||
ids = append(ids, aws.String(id))
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: ids,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"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/require"
|
||||
)
|
||||
|
||||
// pingReceivedRE matches the busybox/iputils summary line for 1–3 echo replies
|
||||
// (`3 packets received` or `1 received`). Mirrors the bash grep pattern.
|
||||
var pingReceivedRE = regexp.MustCompile(`[1-3] (packets )?received`)
|
||||
|
||||
// pingDroppedRE matches the summary for a fully dropped probe — either
|
||||
// `0 received` or `100% packet loss`. Mirrors the bash grep pattern.
|
||||
var pingDroppedRE = regexp.MustCompile(`0 (packets )?received|100% packet loss`)
|
||||
|
||||
// phase5f_SecurityGroupEgress flips the default SG's allow-all egress rule and
|
||||
// verifies vpcd programs OVN ACLs that actually drop traffic. Egress is tested
|
||||
// because in dev_networking mode ingress SSH bypasses OVN via QEMU hostfwd —
|
||||
// only egress hits the ACL. Maps to run-e2e.sh ~846–928.
|
||||
func phase5f_SecurityGroupEgress(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 5f — Security Group Enforcement (egress ACL)")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.DefaultSGID, "Phase 5 must populate fix.DefaultSGID")
|
||||
require.NotEmpty(t, fix.SSHHost, "Phase 5a-ii must populate fix.SSHHost")
|
||||
require.NotZero(t, fix.SSHPort, "Phase 5a-ii must populate fix.SSHPort")
|
||||
require.NotEmpty(t, fix.KeyPath, "Phase 3 must populate fix.KeyPath")
|
||||
|
||||
tgt := harness.SSHTarget{User: "ec2-user", Host: fix.SSHHost, Port: fix.SSHPort, KeyPath: fix.KeyPath}
|
||||
|
||||
// Restore allow-all egress no matter what — ignore Duplicate so a clean
|
||||
// finish (test left the rule in place) doesn't poison later phases.
|
||||
t.Cleanup(func() {
|
||||
if err := authorizeAllowAllEgress(fix.AWS, fix.DefaultSGID); err != nil &&
|
||||
!harness.ErrorCodeIs(err, "InvalidPermission.Duplicate") {
|
||||
t.Logf("WARNING: cleanup failed to restore allow-all egress on %s: %v",
|
||||
fix.DefaultSGID, err)
|
||||
}
|
||||
})
|
||||
|
||||
harness.Step(t, "discover default gateway inside VM")
|
||||
gwOut, gwErr := runSSHCombined(tgt, `ip route show default | awk '{print $3}' | head -1`)
|
||||
gw := strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
// Strip all whitespace, matching `tr -d '[:space:]'` from bash.
|
||||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, gwOut))
|
||||
if gwErr != nil || gw == "" {
|
||||
t.Skipf("Phase 5f: could not discover default gateway inside VM (err=%v, out=%q)",
|
||||
gwErr, gwOut)
|
||||
}
|
||||
harness.Detail(t, "probe_gateway", gw)
|
||||
|
||||
probeICMP := func() string {
|
||||
out, _ := runSSHCombined(tgt, fmt.Sprintf("ping -c 3 -W 2 %s", gw))
|
||||
return out
|
||||
}
|
||||
|
||||
// Test 5f-1: Baseline — allow-all egress should let ICMP through. If the
|
||||
// environment blocks ICMP between the VM and gateway regardless of SG,
|
||||
// skip the rest of the phase: we can't distinguish enforcement from a
|
||||
// network that never carried the probe in the first place.
|
||||
harness.Step(t, "5f-1 baseline egress (allow-all)")
|
||||
baseline := probeICMP()
|
||||
if !pingReceivedRE.MatchString(baseline) {
|
||||
t.Skipf("Phase 5f: baseline ICMP did not succeed; env may block ICMP regardless of SG\nOutput:\n%s",
|
||||
baseline)
|
||||
}
|
||||
harness.Detail(t, "baseline", "icmp_ok")
|
||||
|
||||
// Test 5f-2: Revoke egress → ICMP must be dropped.
|
||||
harness.Step(t, "5f-2 revoke egress -> expect drop")
|
||||
_, err := fix.AWS.EC2.RevokeSecurityGroupEgress(&ec2.RevokeSecurityGroupEgressInput{
|
||||
GroupId: aws.String(fix.DefaultSGID),
|
||||
IpPermissions: []*ec2.IpPermission{allowAllEgressPermission()},
|
||||
})
|
||||
require.NoError(t, err, "revoke-security-group-egress")
|
||||
|
||||
// ACL propagation: bash uses a flat sleep 3 — poll the probe instead so a
|
||||
// slow OVN flow install still gets bounded, fast environments don't waste
|
||||
// the full budget. Total budget kept tight: each ping is ~6s (3 echoes,
|
||||
// 2s timeout) so a 30s ceiling tolerates ~4 retries.
|
||||
var lastRevoke string
|
||||
harness.EventuallyErr(t, func() error {
|
||||
lastRevoke = probeICMP()
|
||||
if pingDroppedRE.MatchString(lastRevoke) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ICMP still succeeding after revoke; output:\n%s", lastRevoke)
|
||||
}, 30*time.Second, 1*time.Second)
|
||||
harness.Detail(t, "revoke", "icmp_dropped")
|
||||
|
||||
// Test 5f-3: Re-authorize → ICMP works again.
|
||||
harness.Step(t, "5f-3 re-authorize egress -> expect allow")
|
||||
err = authorizeAllowAllEgress(fix.AWS, fix.DefaultSGID)
|
||||
require.NoError(t, err, "authorize-security-group-egress")
|
||||
|
||||
var lastRestore string
|
||||
harness.EventuallyErr(t, func() error {
|
||||
lastRestore = probeICMP()
|
||||
if pingReceivedRE.MatchString(lastRestore) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ICMP still dropped after re-authorize; output:\n%s", lastRestore)
|
||||
}, 30*time.Second, 1*time.Second)
|
||||
harness.Detail(t, "restore", "icmp_ok")
|
||||
}
|
||||
|
||||
// allowAllEgressPermission returns the SDK shape for the default SG's stock
|
||||
// `IpProtocol=-1, CidrIp=0.0.0.0/0` rule.
|
||||
func allowAllEgressPermission() *ec2.IpPermission {
|
||||
return &ec2.IpPermission{
|
||||
IpProtocol: aws.String("-1"),
|
||||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeAllowAllEgress is the idempotent re-add used both by the happy
|
||||
// path and t.Cleanup. Returns the raw SDK error so the caller can decide
|
||||
// whether `InvalidPermission.Duplicate` is fatal in its context.
|
||||
func authorizeAllowAllEgress(c *harness.AWSClient, sgID string) error {
|
||||
_, err := c.EC2.AuthorizeSecurityGroupEgress(&ec2.AuthorizeSecurityGroupEgressInput{
|
||||
GroupId: aws.String(sgID),
|
||||
IpPermissions: []*ec2.IpPermission{allowAllEgressPermission()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// runSSHCombined runs `command` over SSH against tgt and returns combined
|
||||
// stdout+stderr regardless of exit status. Unlike runSSH (instance_test.go)
|
||||
// it does not t.Fatal on non-zero exit — needed because `ping` legitimately
|
||||
// exits non-zero when packets are dropped, which is the expected outcome of
|
||||
// the revoke test.
|
||||
func runSSHCombined(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,
|
||||
}
|
||||
var combined bytes.Buffer
|
||||
cmd := exec.Command("ssh", args...)
|
||||
cmd.Stdout = &combined
|
||||
cmd.Stderr = &combined
|
||||
err := cmd.Run()
|
||||
return combined.String(), err
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// phase6_TagManagement exercises CreateTags / DescribeTags / DeleteTags
|
||||
// against the Phase 5 instance and its root volume. Maps to run-e2e.sh
|
||||
// ~930–1026. Sub-steps mirror the bash 6a–6j checks; we additionally
|
||||
// assert in 6i that the value-conditional DeleteTags scoped to the
|
||||
// instance does NOT touch the matching tag on the root volume, which the
|
||||
// bash driver only implies via per-resource targeting.
|
||||
func phase6_TagManagement(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Phase 6 — Tag Management")
|
||||
require.NotEmpty(t, fix.InstanceID, "Phase 5 must populate fix.InstanceID")
|
||||
require.NotEmpty(t, fix.RootVolumeID, "Phase 5 must populate fix.RootVolumeID")
|
||||
|
||||
// Cleanup runs regardless of which sub-step failed so later phases see
|
||||
// a tag-free instance/volume. DeleteTags with no Value drops the tag
|
||||
// unconditionally — exactly what we want here.
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.DeleteTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID), aws.String(fix.RootVolumeID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name")},
|
||||
{Key: aws.String("Environment")},
|
||||
{Key: aws.String("DeleteMe")},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 6a: create three tags on the instance.
|
||||
harness.Step(t, "6a create-tags instance=%s (Name, Environment, DeleteMe)", fix.InstanceID)
|
||||
_, err := fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String("e2e-test")},
|
||||
{Key: aws.String("Environment"), Value: aws.String("testing")},
|
||||
{Key: aws.String("DeleteMe"), Value: aws.String("please")},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create-tags instance")
|
||||
|
||||
// 6b: filter by resource-id should return all three.
|
||||
harness.Step(t, "6b describe-tags resource-id=%s want=3", fix.InstanceID)
|
||||
tags, err := describeTags(fix, &ec2.Filter{
|
||||
Name: aws.String("resource-id"),
|
||||
Values: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-tags resource-id")
|
||||
require.Lenf(t, tags, 3, "instance tag count after 6a: %v", tagSummary(tags))
|
||||
|
||||
// 6c: tag the root volume with two tags. Bash uses VOLUME_ID (the now-
|
||||
// deleted Phase 5b volume), which is a bug in the bash driver; the Go
|
||||
// port targets the persistent RootVolumeID so the assertion in 6d
|
||||
// reflects real cross-resource state.
|
||||
harness.Step(t, "6c create-tags volume=%s (Name, Environment)", fix.RootVolumeID)
|
||||
_, err = fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(fix.RootVolumeID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String("e2e-root-vol")},
|
||||
{Key: aws.String("Environment"), Value: aws.String("testing")},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create-tags volume")
|
||||
|
||||
// 6d: filter by key=Environment should return exactly 2 (one per resource).
|
||||
harness.Step(t, "6d describe-tags key=Environment want=2")
|
||||
tags, err = describeTags(fix, &ec2.Filter{
|
||||
Name: aws.String("key"),
|
||||
Values: []*string{aws.String("Environment")},
|
||||
})
|
||||
require.NoError(t, err, "describe-tags key=Environment")
|
||||
require.Lenf(t, tags, 2, "Environment tag count across resources: %v", tagSummary(tags))
|
||||
|
||||
// 6e: filter by resource-type=instance should return 3 (all instance tags).
|
||||
harness.Step(t, "6e describe-tags resource-type=instance want=3")
|
||||
tags, err = describeTags(fix, &ec2.Filter{
|
||||
Name: aws.String("resource-type"),
|
||||
Values: []*string{aws.String("instance")},
|
||||
})
|
||||
require.NoError(t, err, "describe-tags resource-type=instance")
|
||||
require.Lenf(t, tags, 3, "instance-typed tag count: %v", tagSummary(tags))
|
||||
|
||||
// 6f: overwrite Name on the instance — CreateTags with the same Key
|
||||
// must replace the prior Value.
|
||||
harness.Step(t, "6f overwrite instance Name -> e2e-test-updated")
|
||||
_, err = fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String("e2e-test-updated")},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create-tags overwrite Name")
|
||||
|
||||
tags, err = describeTags(fix,
|
||||
&ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(fix.InstanceID)}},
|
||||
&ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Name")}},
|
||||
)
|
||||
require.NoError(t, err, "describe-tags resource-id + key=Name")
|
||||
require.Lenf(t, tags, 1, "expected exactly one Name tag on instance: %v", tagSummary(tags))
|
||||
assert.Equal(t, "e2e-test-updated", aws.StringValue(tags[0].Value), "Name tag value after overwrite")
|
||||
|
||||
// 6g: delete-tags with no Value drops the tag regardless of stored
|
||||
// value. Instance should be down to {Name, Environment}.
|
||||
harness.Step(t, "6g delete-tags Key=DeleteMe (no value)")
|
||||
_, err = fix.AWS.EC2.DeleteTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("DeleteMe")}},
|
||||
})
|
||||
require.NoError(t, err, "delete-tags DeleteMe")
|
||||
|
||||
tags, err = describeTags(fix, &ec2.Filter{
|
||||
Name: aws.String("resource-id"),
|
||||
Values: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-tags after unconditional delete")
|
||||
require.Lenf(t, tags, 2, "instance tags after deleting DeleteMe: %v", tagSummary(tags))
|
||||
|
||||
// 6h: delete-tags with a non-matching Value must be a no-op — the
|
||||
// Environment tag on the instance still holds "testing".
|
||||
harness.Step(t, "6h delete-tags Key=Environment,Value=production (no-op)")
|
||||
_, err = fix.AWS.EC2.DeleteTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("Environment"), Value: aws.String("production")}},
|
||||
})
|
||||
require.NoError(t, err, "delete-tags Environment=production")
|
||||
|
||||
tags, err = describeTags(fix,
|
||||
&ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(fix.InstanceID)}},
|
||||
&ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}},
|
||||
)
|
||||
require.NoError(t, err, "describe-tags instance Environment after no-op delete")
|
||||
require.Lenf(t, tags, 1, "Environment tag must survive non-matching delete: %v", tagSummary(tags))
|
||||
assert.Equal(t, "testing", aws.StringValue(tags[0].Value), "Environment value after no-op delete")
|
||||
|
||||
// 6i: delete-tags with the correct Value removes the tag from the
|
||||
// targeted resource only. The volume's Environment tag must survive
|
||||
// because DeleteTags was scoped to the instance.
|
||||
harness.Step(t, "6i delete-tags Key=Environment,Value=testing (instance only)")
|
||||
_, err = fix.AWS.EC2.DeleteTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(fix.InstanceID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("Environment"), Value: aws.String("testing")}},
|
||||
})
|
||||
require.NoError(t, err, "delete-tags Environment=testing")
|
||||
|
||||
tags, err = describeTags(fix,
|
||||
&ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(fix.InstanceID)}},
|
||||
&ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}},
|
||||
)
|
||||
require.NoError(t, err, "describe-tags instance Environment after match-delete")
|
||||
assert.Lenf(t, tags, 0, "Environment tag must be gone from instance: %v", tagSummary(tags))
|
||||
|
||||
tags, err = describeTags(fix,
|
||||
&ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(fix.RootVolumeID)}},
|
||||
&ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}},
|
||||
)
|
||||
require.NoError(t, err, "describe-tags volume Environment after instance-scoped delete")
|
||||
require.Lenf(t, tags, 1, "volume Environment tag must survive instance-scoped delete: %v", tagSummary(tags))
|
||||
assert.Equal(t, fix.RootVolumeID, aws.StringValue(tags[0].ResourceId), "Environment tag still attached to root volume")
|
||||
|
||||
// 6j: only Name should remain on the instance.
|
||||
harness.Step(t, "6j final describe-tags resource-id=%s want=1", fix.InstanceID)
|
||||
tags, err = describeTags(fix, &ec2.Filter{
|
||||
Name: aws.String("resource-id"),
|
||||
Values: []*string{aws.String(fix.InstanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-tags final instance")
|
||||
require.Lenf(t, tags, 1, "final instance tag count: %v", tagSummary(tags))
|
||||
assert.Equal(t, "Name", aws.StringValue(tags[0].Key), "final remaining instance tag key")
|
||||
}
|
||||
|
||||
// describeTags is a thin wrapper that builds a DescribeTagsInput from the
|
||||
// supplied filters — the test reaches for it once per sub-step so the
|
||||
// assertion lines stay readable.
|
||||
func describeTags(fix *Fixture, filters ...*ec2.Filter) ([]*ec2.TagDescription, error) {
|
||||
out, err := fix.AWS.EC2.DescribeTags(&ec2.DescribeTagsInput{Filters: filters})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Tags, nil
|
||||
}
|
||||
|
||||
// tagSummary formats a TagDescription slice as resource=key=value triples
|
||||
// for assertion failure messages — bare counts hide which tag is wrong.
|
||||
func tagSummary(tags []*ec2.TagDescription) []string {
|
||||
out := make([]string, 0, len(tags))
|
||||
for _, td := range tags {
|
||||
out = append(out, aws.StringValue(td.ResourceId)+"="+aws.StringValue(td.Key)+"="+aws.StringValue(td.Value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -154,9 +154,3 @@ func phase5bii_VolumeStatus(t *testing.T, fix *Fixture) {
|
||||
fix.RootVolumeID, len(out.VolumeStatuses))
|
||||
harness.Detail(t, "volume", fix.RootVolumeID, "status", status)
|
||||
}
|
||||
|
||||
// phase7a_AttachToStoppedError is owned by Stage D — Phase 7 stops the
|
||||
// instance first, so this negative test logically belongs with the rest of
|
||||
// the lifecycle suite.
|
||||
// TODO(stage-D): implement attach-to-stopped-instance error assertion.
|
||||
func phase7a_AttachToStoppedError(_ *testing.T, _ *Fixture) {}
|
||||
|
||||
Reference in New Issue
Block a user