test(e2e): remove single/ tests superseded by daemon-lite ports

TestKeyPairs, TestTagManagement, TestRouteTableValidation, and
TestReplaceRouteConvergence now have proven-equivalent ports in
tests/integration/ (mutation testing confirmed parity). Delete the
e2e originals and their runX helpers, along with keypair_test.go's
and tags_test.go's file-local helpers left with no caller.
generateImportPubKey is shared by other single/ tests so it moves to
helpers_test.go instead of being deleted. Slims the manifest-lint
baseline for the removed create-call entries.
This commit is contained in:
joshsiv-mulga
2026-07-20 12:33:54 +10:00
parent 111dfe1b73
commit 0bd3054d64
6 changed files with 15 additions and 791 deletions
-3
View File
@@ -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
+15
View File
@@ -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)
}
-94
View File
@@ -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]
}
-201
View File
@@ -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
}
-22
View File
@@ -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
-471
View File
@@ -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 ""
}