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:
joshsiv-mulga
2026-05-18 21:17:28 +10:00
parent 162d840cf1
commit d697686003
6 changed files with 1065 additions and 7 deletions
+371
View File
@@ -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))
}
+12 -1
View File
@@ -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",
+316
View File
@@ -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,
})
})
}
+166
View File
@@ -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
}
+200
View File
@@ -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
}
-6
View File
@@ -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) {}