From d69768600360e0c867b9a4e2f076aa440352f050 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Mon, 18 May 2026 21:17:28 +1000 Subject: [PATCH] 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. --- tests/e2e/single/lifecycle_test.go | 371 +++++++++++++++++++++++++ tests/e2e/single/main_test.go | 13 +- tests/e2e/single/negative_test.go | 316 +++++++++++++++++++++ tests/e2e/single/securitygroup_test.go | 166 +++++++++++ tests/e2e/single/tags_test.go | 200 +++++++++++++ tests/e2e/single/volume_test.go | 6 - 6 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/single/lifecycle_test.go create mode 100644 tests/e2e/single/negative_test.go create mode 100644 tests/e2e/single/securitygroup_test.go create mode 100644 tests/e2e/single/tags_test.go diff --git a/tests/e2e/single/lifecycle_test.go b/tests/e2e/single/lifecycle_test.go new file mode 100644 index 000000000..953957450 --- /dev/null +++ b/tests/e2e/single/lifecycle_test.go @@ -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)) +} diff --git a/tests/e2e/single/main_test.go b/tests/e2e/single/main_test.go index 537fed2ae..7811b3304 100644 --- a/tests/e2e/single/main_test.go +++ b/tests/e2e/single/main_test.go @@ -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", diff --git a/tests/e2e/single/negative_test.go b/tests/e2e/single/negative_test.go new file mode 100644 index 000000000..c136eeaa6 --- /dev/null +++ b/tests/e2e/single/negative_test.go @@ -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, + }) + }) +} diff --git a/tests/e2e/single/securitygroup_test.go b/tests/e2e/single/securitygroup_test.go new file mode 100644 index 000000000..2be7b8f2c --- /dev/null +++ b/tests/e2e/single/securitygroup_test.go @@ -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 +} diff --git a/tests/e2e/single/tags_test.go b/tests/e2e/single/tags_test.go new file mode 100644 index 000000000..8d3026f23 --- /dev/null +++ b/tests/e2e/single/tags_test.go @@ -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 +} diff --git a/tests/e2e/single/volume_test.go b/tests/e2e/single/volume_test.go index a295b21f8..dc39fed0e 100644 --- a/tests/e2e/single/volume_test.go +++ b/tests/e2e/single/volume_test.go @@ -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) {}