diff --git a/tests/e2e/manifest-lint/baseline.txt b/tests/e2e/manifest-lint/baseline.txt index 774e21536..4c6b03af1 100644 --- a/tests/e2e/manifest-lint/baseline.txt +++ b/tests/e2e/manifest-lint/baseline.txt @@ -76,7 +76,6 @@ create tests/e2e/single/baseline_test.go CreateVpc #1 create tests/e2e/single/baseline_test.go RunInstances #1 create tests/e2e/single/datapath_test.go CreateSecurityGroup #1 create tests/e2e/single/datapath_test.go RunInstances #1 -create tests/e2e/single/keypair_test.go ImportKeyPair #1 create tests/e2e/single/lifecycle_test.go CreateVolume #1 create tests/e2e/single/lifecycle_test.go RunInstances #1 create tests/e2e/single/natgw_test.go AllocateAddress #1 @@ -98,8 +97,6 @@ create tests/e2e/single/volume_durability_test.go CreateVolume #1 create tests/e2e/single/volume_test.go CreateVolume #1 create tests/e2e/single/vpc_test.go CreateInternetGateway #1 create tests/e2e/single/vpc_test.go CreateRouteTable #1 -create tests/e2e/single/vpc_test.go CreateRouteTable #2 -create tests/e2e/single/vpc_test.go CreateRouteTable #3 create tests/e2e/single/vpc_test.go CreateSubnet #1 create tests/e2e/single/vpc_test.go CreateSubnet #2 create tests/e2e/single/vpc_test.go CreateVpc #1 diff --git a/tests/e2e/single/helpers_test.go b/tests/e2e/single/helpers_test.go index 752ae20c0..e4d86333f 100644 --- a/tests/e2e/single/helpers_test.go +++ b/tests/e2e/single/helpers_test.go @@ -3,12 +3,15 @@ package single import ( + "crypto/rand" + "crypto/rsa" "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" + "golang.org/x/crypto/ssh" ) // Package-local bootstrappers wrapping harness.Discover*/Ensure* so each Test* @@ -111,3 +114,15 @@ func needInstance(t *testing.T, fix *Fixture) (inst *ec2.Instance, rootVolumeID require.NotEmpty(t, rootVolumeID, "could not resolve root volume from BlockDeviceMappings") return inst, rootVolumeID } + +// generateImportPubKey returns an OpenSSH-formatted public key (matching the +// `ssh-keygen -t rsa` output the bash script feeds into import-key-pair). +// 2048-bit to match the bash key length. +func generateImportPubKey(t *testing.T) []byte { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + pub, err := ssh.NewPublicKey(&priv.PublicKey) + require.NoError(t, err, "ssh.NewPublicKey") + return ssh.MarshalAuthorizedKey(pub) +} diff --git a/tests/e2e/single/keypair_test.go b/tests/e2e/single/keypair_test.go deleted file mode 100644 index 0d8f7eaa0..000000000 --- a/tests/e2e/single/keypair_test.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build e2e - -package single - -import ( - "crypto/rand" - "crypto/rsa" - "os" - "strings" - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/ec2" - "github.com/mulgadc/spinifex/tests/e2e/harness" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/ssh" -) - -// runKeyPairs exercises CreateKeyPair / ImportKeyPair / DescribeKeyPairs / -// DeleteKeyPair. The primary key pair is materialised via harness.EnsureKeyPair -// — it owns the create path's assertions, memoizes for downstream Phase 5+ -// callers, and registers TestSingleNode-scoped cleanup. test-key-2 is created -// via import then deleted within this phase. Maps to run-e2e.sh ~204–231. -func runKeyPairs(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — SSH Key Management") - - const key2 = "test-key-2" - - // Clean up leftover import-test key from a prior failed run. - _, _ = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(key2)}) - - harness.Step(t, "ensure primary key pair (Phase 5+ prereq)") - keyName, pemPath := needKeyPair(t, fix) - material, rerr := os.ReadFile(pemPath) - require.NoError(t, rerr, "read PEM %s", pemPath) - require.NotEmpty(t, material, "EnsureKeyPair PEM empty") - require.True(t, strings.HasPrefix(string(material), "-----BEGIN"), - "PEM must start with -----BEGIN (got prefix %q)", firstN(string(material), 32)) - harness.Detail(t, "key", keyName, "pem", pemPath) - - harness.Step(t, "import-key-pair %q (local-generated RSA)", key2) - pubMaterial := generateImportPubKey(t) - _, err := fix.AWS.EC2.ImportKeyPair(&ec2.ImportKeyPairInput{ - KeyName: aws.String(key2), - PublicKeyMaterial: pubMaterial, - }) - require.NoError(t, err, "import-key-pair %s", key2) - harness.Detail(t, "imported", key2) - - harness.Step(t, "describe-key-pairs (both present)") - listed := describeKeyNames(t, fix) - assert.Contains(t, listed, keyName, "describe-key-pairs missing %s (got %v)", keyName, listed) - assert.Contains(t, listed, key2, "describe-key-pairs missing %s (got %v)", key2, listed) - - harness.Step(t, "delete %q", key2) - _, err = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(key2)}) - require.NoError(t, err, "delete-key-pair %s", key2) - - harness.Step(t, "describe-key-pairs (only %s remains)", keyName) - remaining := describeKeyNames(t, fix) - assert.Contains(t, remaining, keyName, "describe-key-pairs lost %s after deleting %s", keyName, key2) - assert.NotContains(t, remaining, key2, "describe-key-pairs still lists %s after delete", key2) -} - -// generateImportPubKey returns an OpenSSH-formatted public key (matching the -// `ssh-keygen -t rsa` output the bash script feeds into import-key-pair). -// 2048-bit to match the bash key length. -func generateImportPubKey(t *testing.T) []byte { - t.Helper() - priv, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err, "generate RSA key") - pub, err := ssh.NewPublicKey(&priv.PublicKey) - require.NoError(t, err, "ssh.NewPublicKey") - return ssh.MarshalAuthorizedKey(pub) -} - -func describeKeyNames(t *testing.T, fix *Fixture) []string { - t.Helper() - out, err := fix.AWS.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) - require.NoError(t, err, "describe-key-pairs") - names := make([]string, 0, len(out.KeyPairs)) - for _, kp := range out.KeyPairs { - names = append(names, aws.StringValue(kp.KeyName)) - } - return names -} - -func firstN(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} diff --git a/tests/e2e/single/tags_test.go b/tests/e2e/single/tags_test.go deleted file mode 100644 index 43e8eacb2..000000000 --- a/tests/e2e/single/tags_test.go +++ /dev/null @@ -1,201 +0,0 @@ -//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" -) - -// runTagManagement 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 runTagManagement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — Tag Management") - - inst, rootVolumeID := needInstance(t, fix) - instanceID := aws.StringValue(inst.InstanceId) - - // 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(instanceID), aws.String(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)", instanceID) - _, err := fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{ - Resources: []*string{aws.String(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", instanceID) - tags, err := describeTags(fix, &ec2.Filter{ - Name: aws.String("resource-id"), - Values: []*string{aws.String(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)", rootVolumeID) - _, err = fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{ - Resources: []*string{aws.String(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(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(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(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(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(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(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(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(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(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, 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", instanceID) - tags, err = describeTags(fix, &ec2.Filter{ - Name: aws.String("resource-id"), - Values: []*string{aws.String(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/tests_test.go b/tests/e2e/single/tests_test.go index 77a95df46..1aacbb63d 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -30,21 +30,11 @@ func TestSerialConsoleAccess(t *testing.T) { runSerialConsoleAccess(t, requireSingleNodeFixture(t)) } -func TestKeyPairs(t *testing.T) { - t.Parallel() - runKeyPairs(t, requireSingleNodeFixture(t)) -} - func TestImage(t *testing.T) { t.Parallel() runImage(t, requireSingleNodeFixture(t)) } -func TestTagManagement(t *testing.T) { - t.Parallel() - runTagManagement(t, requireSingleNodeFixture(t)) -} - func TestAccountScoping(t *testing.T) { t.Parallel() runAccountScoping(t, requireSingleNodeFixture(t)) @@ -62,18 +52,6 @@ func TestVPCSubnetE2E(t *testing.T) { runVPCSubnetE2E(t, requireSingleNodeFixture(t)) } -func TestRouteTableValidation(t *testing.T) { - t.Parallel() - runRouteTableValidation(t, requireSingleNodeFixture(t)) -} - -// TestReplaceRouteConvergence owns its own scratch VPCs/IGWs end to end and -// never touches the singleton or default VPC, so it is parallel-safe. -func TestReplaceRouteConvergence(t *testing.T) { - t.Parallel() - runReplaceRouteConvergence(t, requireSingleNodeFixture(t)) -} - // --- Sequential: singleton VM lifecycle --- // TestClusterStatsCLI exercises the spx cluster CLI; its get-vms baseline is diff --git a/tests/e2e/single/vpc_test.go b/tests/e2e/single/vpc_test.go index 0c1777839..c7cb12d14 100644 --- a/tests/e2e/single/vpc_test.go +++ b/tests/e2e/single/vpc_test.go @@ -3,14 +3,12 @@ package single import ( - "errors" "fmt" "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/tests/e2e/harness" "github.com/stretchr/testify/require" @@ -292,472 +290,3 @@ func runVPCSubnetE2E(t *testing.T, fix *Fixture) { t.Logf("WARN: external ping inconclusive — may depend on WAN gateway") } } - -// runRouteTableValidation ports run-e2e.sh Phase 8c (~2626-2745). -// -// Validates route-table CRUD + invariants against the *default* VPC's main -// route table (always runs — no pool-mode gating). Three sub-tests: -// -// Step1_DefaultMainRouteTable — DescribeRouteTables filter by vpc-id + -// association.main asserts the local route is present. The IGW route -// is verified only when the default VPC has an attached IGW (some -// bootstraps skip it on private-only fixtures). -// Step2_CustomRouteTableLifecycle — CreateRouteTable, verify the implicit -// local route, AssociateRouteTable to a default subnet, DisassociateRouteTable, -// DeleteRoute on a manually-added route, DeleteRouteTable. -// Step3_ErrorPaths — DeleteRoute on the local route + DeleteRouteTable on -// the main route table must both fail. -// -// Each step registers its own cleanup so a partial run still releases the -// scratch route table / association regardless of which assertion fired. -func runRouteTableValidation(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — Route Table Validation") - - c := fix.AWS - - // Default VPC + subnet + SG are owned by the harness Ensure* memo, so - // repeated calls across phases (and post-migration: across sibling - // tests) hit the same cached IDs without re-discovering. - def := harness.EnsureDefaultVPC(t, fix.Harness) - require.NotEmpty(t, def.VPCID, "default VPC ID required") - require.NotEmpty(t, def.SubnetID, "default subnet ID required") - harness.Detail(t, "vpc", def.VPCID, "subnet", def.SubnetID) - - // --- Step 1: Default VPC main route table ----------------------------- - var mainRTBID string - t.Run("Step1_DefaultMainRouteTable", func(t *testing.T) { - harness.Step(t, "describe-route-tables filter=vpc-id,association.main") - out, err := c.EC2.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ - Filters: []*ec2.Filter{ - {Name: aws.String("vpc-id"), Values: []*string{aws.String(def.VPCID)}}, - {Name: aws.String("association.main"), Values: []*string{aws.String("true")}}, - }, - }) - require.NoError(t, err, "describe-route-tables (main)") - require.NotEmptyf(t, out.RouteTables, - "no main route table found for default VPC %s", def.VPCID) - mainRTBID = aws.StringValue(out.RouteTables[0].RouteTableId) - require.NotEmpty(t, mainRTBID, "main RouteTableId empty") - harness.Detail(t, "main_rtb", mainRTBID) - - // GetRouteTableAssociations equivalent: confirm at least one - // association of the main RT is flagged Main=true. The aws-sdk-go - // surface folds this into RouteTable.Associations[].Main, so a - // separate API call isn't needed — but assert it explicitly so a - // regression that drops the Main flag would fail here, not silently. - mainFlagSeen := false - for _, a := range out.RouteTables[0].Associations { - if aws.BoolValue(a.Main) { - mainFlagSeen = true - break - } - } - require.Truef(t, mainFlagSeen, - "main route table %s has no Association with Main=true", mainRTBID) - - // Local route is mandatory on every route table — the gateway - // auto-creates it at CreateRouteTable / CreateVpc time. - localFound := false - for _, r := range out.RouteTables[0].Routes { - if aws.StringValue(r.GatewayId) == "local" { - localFound = true - harness.Detail(t, "local_route", aws.StringValue(r.DestinationCidrBlock)) - break - } - } - require.Truef(t, localFound, - "main route table %s missing local route", mainRTBID) - - // IGW route (0.0.0.0/0 -> igw-...) — soft check. Default VPCs that - // admin init wires with an IGW must have it; private-only bootstraps - // legitimately don't, so just log when missing rather than fail. - igwRoute := "" - for _, r := range out.RouteTables[0].Routes { - if aws.StringValue(r.DestinationCidrBlock) == "0.0.0.0/0" { - igwRoute = aws.StringValue(r.GatewayId) - break - } - } - if igwRoute != "" { - harness.Detail(t, "default_route", "0.0.0.0/0->"+igwRoute) - } else { - t.Logf("note: default VPC main RT has no 0.0.0.0/0 route (no IGW attached)") - } - }) - - // Bail if Step 1 didn't seat mainRTBID — Steps 2/3 dereference it. - if mainRTBID == "" { - t.Fatalf("Step 1 did not resolve the main route table; cannot continue") - } - - // --- Step 2: Custom route table CRUD lifecycle ------------------------ - t.Run("Step2_CustomRouteTableLifecycle", func(t *testing.T) { - harness.Step(t, "create-route-table (vpc=%s)", def.VPCID) - ctOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ - VpcId: aws.String(def.VPCID), - }) - require.NoError(t, err, "create custom route table") - require.NotNil(t, ctOut.RouteTable, "create-route-table returned nil RouteTable") - customRTB := aws.StringValue(ctOut.RouteTable.RouteTableId) - require.NotEmpty(t, customRTB, "custom RouteTableId empty") - rtbDeleted := false - t.Cleanup(func() { - if !rtbDeleted { - _, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{ - RouteTableId: aws.String(customRTB), - }) - } - }) - harness.Detail(t, "custom_rtb", customRTB) - - // Local route must be present on the freshly-created table. - descCustom, err := c.EC2.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ - RouteTableIds: []*string{aws.String(customRTB)}, - }) - require.NoError(t, err, "describe custom route table") - require.NotEmpty(t, descCustom.RouteTables, "custom RT not visible after create") - customLocal := "" - for _, r := range descCustom.RouteTables[0].Routes { - if aws.StringValue(r.GatewayId) == "local" { - customLocal = aws.StringValue(r.DestinationCidrBlock) - break - } - } - require.NotEmptyf(t, customLocal, - "custom route table %s missing local route", customRTB) - harness.Detail(t, "custom_local_route", customLocal) - - // Find an attached IGW for the default VPC so we can add a real - // 0.0.0.0/0 route. If none is attached the test still exercises - // associate/disassociate + delete on the local route only. - igwForVPC := "" - igws, err := c.EC2.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{ - Filters: []*ec2.Filter{ - {Name: aws.String("attachment.vpc-id"), Values: []*string{aws.String(def.VPCID)}}, - }, - }) - require.NoError(t, err, "describe-internet-gateways") - if len(igws.InternetGateways) > 0 { - igwForVPC = aws.StringValue(igws.InternetGateways[0].InternetGatewayId) - harness.Detail(t, "vpc_igw", igwForVPC) - } - - routeAdded := false - if igwForVPC != "" { - harness.Step(t, "create-route 0.0.0.0/0 -> %s", igwForVPC) - _, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{ - RouteTableId: aws.String(customRTB), - DestinationCidrBlock: aws.String("0.0.0.0/0"), - GatewayId: aws.String(igwForVPC), - }) - require.NoError(t, err, "create-route 0.0.0.0/0 -> IGW (custom RT)") - routeAdded = true - t.Cleanup(func() { - _, _ = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{ - RouteTableId: aws.String(customRTB), - DestinationCidrBlock: aws.String("0.0.0.0/0"), - }) - }) - } - - // Associate with the default subnet, then disassociate. The bash - // driver uses `default-for-az` to find a subnet; def.SubnetID - // is already that subnet (DiscoverDefaultVPC prefers DefaultForAz). - harness.Step(t, "associate-route-table %s <- %s", customRTB, def.SubnetID) - assocOut, err := c.EC2.AssociateRouteTable(&ec2.AssociateRouteTableInput{ - RouteTableId: aws.String(customRTB), - SubnetId: aws.String(def.SubnetID), - }) - require.NoError(t, err, "associate-route-table custom") - assocID := aws.StringValue(assocOut.AssociationId) - require.NotEmpty(t, assocID, "custom AssociationId empty") - assocReleased := false - t.Cleanup(func() { - if !assocReleased { - _, _ = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{ - AssociationId: aws.String(assocID), - }) - } - }) - harness.Detail(t, "custom_assoc", assocID) - - harness.Step(t, "disassociate-route-table %s", assocID) - _, err = c.EC2.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{ - AssociationId: aws.String(assocID), - }) - require.NoError(t, err, "disassociate-route-table") - assocReleased = true - - if routeAdded { - harness.Step(t, "delete-route 0.0.0.0/0") - _, err = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{ - RouteTableId: aws.String(customRTB), - DestinationCidrBlock: aws.String("0.0.0.0/0"), - }) - require.NoError(t, err, "delete-route 0.0.0.0/0") - } - - harness.Step(t, "delete-route-table %s", customRTB) - _, err = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{ - RouteTableId: aws.String(customRTB), - }) - require.NoError(t, err, "delete-route-table") - rtbDeleted = true - }) - - // --- Step 3: Error paths ----------------------------------------------- - t.Run("Step3_ErrorPaths", func(t *testing.T) { - // Scratch RT for the "cannot delete local route" assertion. The bash - // version uses a fresh table so the failure doesn't poison the main - // table or the Step 2 lifecycle table. - harness.Step(t, "create-route-table (scratch for error paths)") - ctOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{ - VpcId: aws.String(def.VPCID), - }) - require.NoError(t, err, "create scratch RT") - require.NotNil(t, ctOut.RouteTable, "create-route-table returned nil RouteTable") - errRTB := aws.StringValue(ctOut.RouteTable.RouteTableId) - require.NotEmpty(t, errRTB, "scratch RouteTableId empty") - t.Cleanup(func() { - _, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{ - RouteTableId: aws.String(errRTB), - }) - }) - - // Discover the scratch table's local CIDR (the VPC's CIDR block). - descScratch, err := c.EC2.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ - RouteTableIds: []*string{aws.String(errRTB)}, - }) - require.NoError(t, err, "describe scratch RT") - require.NotEmpty(t, descScratch.RouteTables, "scratch RT not visible") - scratchLocal := "" - for _, r := range descScratch.RouteTables[0].Routes { - if aws.StringValue(r.GatewayId) == "local" { - scratchLocal = aws.StringValue(r.DestinationCidrBlock) - break - } - } - require.NotEmptyf(t, scratchLocal, - "scratch RT %s missing local route", errRTB) - - // DeleteRoute on the local CIDR must be rejected. Bash just asserts - // non-zero exit; we assert *any* error here rather than pin a code, - // because the gateway might surface this as InvalidParameterValue, - // OperationNotPermitted, or a custom "cannot delete local route" code. - // If the gateway settles on a single code in future we can tighten this - // to harness.AssertAWSError. - harness.Step(t, "delete-route local=%s (must fail)", scratchLocal) - _, err = c.EC2.DeleteRoute(&ec2.DeleteRouteInput{ - RouteTableId: aws.String(errRTB), - DestinationCidrBlock: aws.String(scratchLocal), - }) - require.Errorf(t, err, - "delete-route on local route %s succeeded — must be rejected", scratchLocal) - // Sanity: the rejection should be an AWS-shaped error, not a transport - // error. Surface the code in the test log so a future tightening to - // AssertAWSError has a known target. - var aerr awserr.Error - if errors.As(err, &aerr) { - harness.Detail(t, "delete_local_err", aerr.Code()) - } - - // DeleteRouteTable on the main RT must be rejected — same shape. - harness.Step(t, "delete-route-table %s (main, must fail)", mainRTBID) - _, err = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{ - RouteTableId: aws.String(mainRTBID), - }) - require.Errorf(t, err, - "delete-route-table on main RT %s succeeded — must be rejected", mainRTBID) - if errors.As(err, &aerr) { - harness.Detail(t, "delete_main_err", aerr.Code()) - } - }) -} - -// runReplaceRouteConvergence validates ReplaceRoute's control-plane contract: -// a route installed against one IGW target re-points to a different IGW, with -// the route record converging to the new gateway. It then walks the negative -// matrix (no-such-route, local-route, missing-target, unknown-IGW, cross-VPC -// IGW) so each rejection path stays pinned to its AWS error code. -// -// V1 scope note: ReplaceRoute updates the route record but publishes no vpc -// event, so it does NOT reprogram the OVN datapath. There is no datapath to -// assert here — the contract under test is control-plane convergence only. -// When ReplaceRoute begins emitting add/delete-igw-route events, extend this -// with a guest-egress assertion mirroring runNATGateway. -// -// Owns two scratch VPCs + three IGWs end to end, so it never touches the -// singleton or default VPC and is safe in the parallel bucket. No pool mode or -// OVN needed — every step is pure KV control plane. -func runReplaceRouteConvergence(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — ReplaceRoute Convergence (control plane)") - - c := fix.AWS - - // --- Primary VPC + two attached IGWs ----------------------------------- - vpcID := createScratchVPC(t, c, "10.77.0.0/16") - igw1 := createAttachedIGW(t, c, vpcID) - igw2 := createAttachedIGW(t, c, vpcID) - - // --- Secondary VPC + its own IGW (cross-VPC rejection target) ---------- - otherVPCID := createScratchVPC(t, c, "10.88.0.0/16") - igwOther := createAttachedIGW(t, c, otherVPCID) - - // --- Custom route table in the primary VPC ----------------------------- - harness.Step(t, "create-route-table (vpc=%s)", vpcID) - // e2e:allow-create — scratch route table owned by this ReplaceRoute test. - rtOut, err := c.EC2.CreateRouteTable(&ec2.CreateRouteTableInput{VpcId: aws.String(vpcID)}) - require.NoError(t, err, "create-route-table") - require.NotNil(t, rtOut.RouteTable, "create-route-table returned nil RouteTable") - rtbID := aws.StringValue(rtOut.RouteTable.RouteTableId) - require.NotEmpty(t, rtbID, "RouteTableId empty") - t.Cleanup(func() { - _, _ = c.EC2.DeleteRouteTable(&ec2.DeleteRouteTableInput{RouteTableId: aws.String(rtbID)}) - }) - - const dest = "192.0.2.0/24" - - // --- CreateRoute -> igw1 ----------------------------------------------- - harness.Step(t, "create-route %s -> %s", dest, igw1) - _, err = c.EC2.CreateRoute(&ec2.CreateRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String(dest), - GatewayId: aws.String(igw1), - }) - require.NoError(t, err, "create-route %s -> igw1", dest) - require.Equalf(t, igw1, routeGateway(t, c, rtbID, dest), - "route %s should target igw1 after create", dest) - - // --- ReplaceRoute -> igw2 (genuine target convergence) ----------------- - harness.Step(t, "replace-route %s -> %s", dest, igw2) - _, err = c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String(dest), - GatewayId: aws.String(igw2), - }) - require.NoError(t, err, "replace-route %s -> igw2", dest) - require.Equalf(t, igw2, routeGateway(t, c, rtbID, dest), - "route %s did not converge to igw2 after replace-route", dest) - harness.Detail(t, "convergence", dest+": "+igw1+" -> "+igw2) - - // --- Negative matrix --------------------------------------------------- - // Unknown destination CIDR — route doesn't exist in the table. - harness.Step(t, "replace-route on absent route (must fail)") - harness.ExpectError(t, "InvalidRoute.NotFound", func() error { - _, e := c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String("198.51.100.0/24"), - GatewayId: aws.String(igw2), - }) - return e - }) - - // Local route is immutable. - harness.Step(t, "replace-route on local route (must fail)") - harness.ExpectError(t, "InvalidParameterValue", func() error { - _, e := c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String("10.77.0.0/16"), - GatewayId: aws.String(igw2), - }) - return e - }) - - // Missing target — V1 requires a GatewayId. - harness.Step(t, "replace-route with no target (must fail)") - harness.ExpectError(t, "InvalidParameterValue", func() error { - _, e := c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String(dest), - }) - return e - }) - - // Unknown IGW target. - harness.Step(t, "replace-route to non-existent IGW (must fail)") - harness.ExpectError(t, "InvalidInternetGatewayID.NotFound", func() error { - _, e := c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String(dest), - GatewayId: aws.String("igw-00000000000000000"), - }) - return e - }) - - // IGW attached to a different VPC than the route table. - harness.Step(t, "replace-route to cross-VPC IGW %s (must fail)", igwOther) - harness.ExpectError(t, "InvalidParameterValue", func() error { - _, e := c.EC2.ReplaceRoute(&ec2.ReplaceRouteInput{ - RouteTableId: aws.String(rtbID), - DestinationCidrBlock: aws.String(dest), - GatewayId: aws.String(igwOther), - }) - return e - }) - - // Each rejection left the route untouched at igw2. - require.Equalf(t, igw2, routeGateway(t, c, rtbID, dest), - "route %s drifted off igw2 after the rejection matrix", dest) -} - -// createScratchVPC creates a VPC with the given CIDR and registers its deletion. -func createScratchVPC(t *testing.T, c *harness.AWSClient, cidr string) string { - t.Helper() - harness.Step(t, "create-vpc %s", cidr) - // e2e:allow-create — scratch VPC owned end to end by the caller. - out, err := c.EC2.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String(cidr)}) - require.NoError(t, err, "create-vpc %s", cidr) - require.NotNil(t, out.Vpc, "create-vpc %s returned nil Vpc", cidr) - id := aws.StringValue(out.Vpc.VpcId) - require.NotEmpty(t, id, "VpcId empty for %s", cidr) - t.Cleanup(func() { - _, _ = c.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(id)}) - }) - return id -} - -// createAttachedIGW creates an internet gateway, attaches it to vpcID, and -// registers detach+delete cleanup (LIFO-safe: detach runs before the owning -// VPC's delete). -func createAttachedIGW(t *testing.T, c *harness.AWSClient, vpcID string) string { - t.Helper() - // e2e:allow-create — scratch IGW owned by the caller's ReplaceRoute test. - out, err := c.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) - require.NoError(t, err, "create-internet-gateway") - require.NotNil(t, out.InternetGateway, "create-internet-gateway returned nil IGW") - igwID := aws.StringValue(out.InternetGateway.InternetGatewayId) - require.NotEmpty(t, igwID, "InternetGatewayId empty") - _, err = c.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{ - InternetGatewayId: aws.String(igwID), - VpcId: aws.String(vpcID), - }) - require.NoError(t, err, "attach-internet-gateway %s -> %s", igwID, vpcID) - t.Cleanup(func() { - _, _ = c.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{ - InternetGatewayId: aws.String(igwID), - VpcId: aws.String(vpcID), - }) - _, _ = c.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ - InternetGatewayId: aws.String(igwID), - }) - }) - return igwID -} - -// routeGateway returns the GatewayId of the route matching destCidr in rtbID, -// failing the test if the route is absent. -func routeGateway(t *testing.T, c *harness.AWSClient, rtbID, destCidr string) string { - t.Helper() - out, err := c.EC2.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ - RouteTableIds: []*string{aws.String(rtbID)}, - }) - require.NoError(t, err, "describe-route-tables %s", rtbID) - require.NotEmpty(t, out.RouteTables, "route table %s not found", rtbID) - for _, r := range out.RouteTables[0].Routes { - if aws.StringValue(r.DestinationCidrBlock) == destCidr { - return aws.StringValue(r.GatewayId) - } - } - t.Fatalf("route %s not found in table %s", destCidr, rtbID) - return "" -}