diff --git a/tests/integration/daemon_lite.go b/tests/integration/daemon_lite.go new file mode 100644 index 000000000..7076d836e --- /dev/null +++ b/tests/integration/daemon_lite.go @@ -0,0 +1,247 @@ +//go:build integration + +package integration + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "testing" + + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/config" + handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw" + handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key" + handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable" + handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" + "github.com/mulgadc/spinifex/spinifex/objectstore" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" +) + +// testPredastoreBucket is the bucket name key/tags hand to their object +// stores. StartDaemonLite backs both with objectstore.NewMemoryObjectStore, +// so the name is never resolved against a real Predastore — it only needs to +// be non-empty and stable across a test's key/tags calls. +const testPredastoreBucket = "integration-test-bucket" + +// DaemonLite is a minimal in-process stand-in for a live spinifex daemon. It +// subscribes the REAL key/tags/route-table/VPC-subnet-SG/IGW service +// implementations — the same production code a live daemon runs — to the +// NATS subjects the gateway's handlers_ec2_*.NewNATS*Service clients call, +// so a test exercises genuine daemon-side business logic instead of a +// StubSubject canned reply. +// +// Scope is deliberately narrow: only resources whose service impl never +// calls viperblock.New are wired here (key, tags, route table, VPC/subnet/SG, +// IGW). Instance lifecycle (needs vm.Manager + QEMU), volume/snapshot/image +// creation (construct viperblock inline), and anything OVN-backed are not +// wired — those need real provisioning DaemonLite intentionally avoids. +type DaemonLite struct { + Key *handlers_ec2_key.KeyServiceImpl + Tags *handlers_ec2_tags.TagsServiceImpl + VPC *handlers_ec2_vpc.VPCServiceImpl + RouteTable *handlers_ec2_routetable.RouteTableServiceImpl + IGW *handlers_ec2_igw.IGWServiceImpl + + // MemStore backs Key and Tags — exposed so a test can seed or inspect + // stored objects directly without going through NATS. + MemStore *objectstore.MemoryObjectStore +} + +// StartDaemonLite constructs the in-scope service impls against gw.NATSConn +// (memory-backed for key/tags, embedded-JetStream-backed for VPC/route +// table/IGW — the same wiring pattern as +// daemon/daemon_handlers_test.go:createFullTestDaemonWithStore and +// daemon/daemon_wire_lb_test.go:newSubscribeTestDaemon) and subscribes them to +// every subject those five resources answer on a live daemon. Every +// subscription is torn down via t.Cleanup. +// +// Must be called before a test issues any request for a subject it wires — +// StubSubject and StartDaemonLite must never cover the same subject in one +// test, since NATS would deliver the request to both plain subscribers and +// whichever responds first wins the race. +func StartDaemonLite(t *testing.T, gw *Gateway) *DaemonLite { + t.Helper() + + nc := gw.NATSConn + memStore := objectstore.NewMemoryObjectStore() + cfg := &config.Config{ + AZ: testAZ, + Predastore: config.PredastoreConfig{Bucket: testPredastoreBucket}, + } + + keySvc := handlers_ec2_key.NewKeyServiceImplWithStore(memStore, cfg.Predastore.Bucket) + tagsSvc := handlers_ec2_tags.NewTagsServiceImplWithStore(cfg, memStore) + + vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(cfg, nc) + require.NoError(t, err, "construct VPC service") + + rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(cfg, nc) + require.NoError(t, err, "construct route table service") + + igwSvc, err := handlers_ec2_igw.NewIGWServiceImplWithNATS(cfg, nc) + require.NoError(t, err, "construct IGW service") + + dl := &DaemonLite{ + Key: keySvc, + Tags: tagsSvc, + VPC: vpcSvc, + RouteTable: rtbSvc, + IGW: igwSvc, + MemStore: memStore, + } + + // CreateVpc/EnsureDefaultVPC/DeleteVpc synchronously round-trip through + // vpcd (the OVN topology-translation daemon) to provision/tear down each + // VPC's default security group (handlers/ec2/vpc/security_group.go + // createDefaultSecurityGroupInternal/deleteSecurityGroupInternal -> + // requestSGEvent -> utils.RequestEvent). vpcd itself is out of scope for + // this tier (it's an external OVN process, not a key/tags/routetable/vpc + // service impl), so it is stubbed here exactly like any other + // out-of-scope daemon-side responder: a fixed {"success":true} ack on + // "vpc.create-sg"/"vpc.delete-sg", satisfying utils.RequestEvent's + // {success,error} reply contract. The SG record itself is written to the + // KV store by the real service impl before this event is even sent, so + // stubbing the vpcd ack never substitutes for in-scope logic under test — + // it only unblocks the synchronous call so CreateVpc/DeleteVpc can + // complete instead of failing with ServerInternal on every VPC creation. + gw.StubSubject(t, "vpc.create-sg", []byte(`{"success":true}`)) + gw.StubSubject(t, "vpc.delete-sg", []byte(`{"success":true}`)) + + // A live daemon creates the account's default VPC by reacting to the + // "iam.account.created" event SeedBootstrap publishes at gateway startup + // (daemon/daemon_handlers_vpc.go handleAccountCreated). StartGateway has + // already published that event by the time this function runs, so + // subscribing to it here would never fire — call the same idempotent + // EnsureDefaultVPC the handler calls directly instead. IGW auto-attach + // (ensureDefaultVPCInfrastructureFor) is not replicated: the default VPC + // is left with no IGW, which the ported route-table test already treats + // as a soft, non-fatal condition. + _, err = dl.VPC.EnsureDefaultVPC(gw.AccountID) + require.NoError(t, err, "EnsureDefaultVPC for %s", gw.AccountID) + + dl.subscribe(t, nc) + + return dl +} + +// dispatch replicates daemon.handleNATSRequest's unmarshal -> service -> +// marshal -> respond envelope (daemon/daemon_handlers.go), so a subscribed +// service impl answers a NATS request exactly like the real daemon handler +// would, without needing an exported hook into the unexported daemon package. +func dispatch[I any, O any](msg *nats.Msg, serviceFn func(context.Context, *I, string) (*O, error)) { + ctx, span := utils.StartConsumerSpan(msg) + defer span.End() + + accountID := utils.AccountIDFromMsg(msg) + input := new(I) + if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil { + utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue)) + if err := msg.Respond(errResp); err != nil { + slog.Error("dispatch: failed to respond to NATS request", "err", err) + } + return + } + + output, err := serviceFn(ctx, input, accountID) + if err != nil { + utils.MarkSpanError(span, err) + if respErr := msg.Respond(utils.GenerateErrorPayload(awserrors.ValidErrorCode(err.Error()))); respErr != nil { + slog.Error("dispatch: failed to respond to NATS request", "err", respErr) + } + return + } + + jsonResponse, err := json.Marshal(output) + if err != nil { + slog.Error("dispatch: failed to marshal response", "err", err) + if respErr := msg.Respond(utils.GenerateErrorPayload(awserrors.ErrorServerInternal)); respErr != nil { + slog.Error("dispatch: failed to respond to NATS request", "err", respErr) + } + return + } + if err := msg.Respond(jsonResponse); err != nil { + slog.Error("dispatch: failed to respond to NATS request", "err", err) + } +} + +// sub registers a plain (non-queue-group) subscription and its t.Cleanup +// unsubscribe — DaemonLite only ever runs one subscriber per subject in a +// given test, so there's no fan-out to coordinate. +func sub(t *testing.T, nc *nats.Conn, subject string, handler nats.MsgHandler) { + t.Helper() + s, err := nc.Subscribe(subject, handler) + require.NoError(t, err, "subscribe %s", subject) + t.Cleanup(func() { _ = s.Unsubscribe() }) +} + +// subscribe wires every subject the four in-scope ported tests +// (TestKeyPairs, TestTagManagement, TestRouteTableValidation, +// TestReplaceRouteConvergence) exercise, plus their supporting VPC/subnet/SG/ +// IGW subjects, to the real service impls held on dl. +func (dl *DaemonLite) subscribe(t *testing.T, nc *nats.Conn) { + t.Helper() + + // Key pairs. + sub(t, nc, "ec2.CreateKeyPair", func(m *nats.Msg) { dispatch(m, dl.Key.CreateKeyPair) }) + sub(t, nc, "ec2.DeleteKeyPair", func(m *nats.Msg) { dispatch(m, dl.Key.DeleteKeyPair) }) + sub(t, nc, "ec2.DescribeKeyPairs", func(m *nats.Msg) { dispatch(m, dl.Key.DescribeKeyPairs) }) + sub(t, nc, "ec2.ImportKeyPair", func(m *nats.Msg) { dispatch(m, dl.Key.ImportKeyPair) }) + + // Tags. Unlike the live daemon's handleEC2CreateTags/handleEC2DeleteTags + // (daemon/daemon_handlers_tag.go), this dispatches straight to + // TagsServiceImpl and skips the instance-ID routing split and the + // recordTagMirrors projection onto owning resource records — both exist + // only to support instance/volume tagging, which is out of scope here + // (see mulga-xaa9f), and neither is observed by the ported + // TestTagManagement assertions (all of which read back through + // DescribeTags, the central tag store both paths write identically). + sub(t, nc, "ec2.CreateTags", func(m *nats.Msg) { dispatch(m, dl.Tags.CreateTags) }) + sub(t, nc, "ec2.DeleteTags", func(m *nats.Msg) { dispatch(m, dl.Tags.DeleteTags) }) + sub(t, nc, "ec2.DescribeTags", func(m *nats.Msg) { dispatch(m, dl.Tags.DescribeTags) }) + + // Route tables. + sub(t, nc, "ec2.CreateRouteTable", func(m *nats.Msg) { dispatch(m, dl.RouteTable.CreateRouteTable) }) + sub(t, nc, "ec2.DeleteRouteTable", func(m *nats.Msg) { dispatch(m, dl.RouteTable.DeleteRouteTable) }) + sub(t, nc, "ec2.DescribeRouteTables", func(m *nats.Msg) { dispatch(m, dl.RouteTable.DescribeRouteTables) }) + sub(t, nc, "ec2.CreateRoute", func(m *nats.Msg) { dispatch(m, dl.RouteTable.CreateRoute) }) + sub(t, nc, "ec2.DeleteRoute", func(m *nats.Msg) { dispatch(m, dl.RouteTable.DeleteRoute) }) + sub(t, nc, "ec2.ReplaceRoute", func(m *nats.Msg) { dispatch(m, dl.RouteTable.ReplaceRoute) }) + sub(t, nc, "ec2.AssociateRouteTable", func(m *nats.Msg) { dispatch(m, dl.RouteTable.AssociateRouteTable) }) + sub(t, nc, "ec2.DisassociateRouteTable", func(m *nats.Msg) { dispatch(m, dl.RouteTable.DisassociateRouteTable) }) + sub(t, nc, "ec2.ReplaceRouteTableAssociation", func(m *nats.Msg) { dispatch(m, dl.RouteTable.ReplaceRouteTableAssociation) }) + + // VPC / subnet / ENI / security group. + sub(t, nc, "ec2.CreateVpc", func(m *nats.Msg) { dispatch(m, dl.VPC.CreateVpc) }) + sub(t, nc, "ec2.DeleteVpc", func(m *nats.Msg) { dispatch(m, dl.VPC.DeleteVpc) }) + sub(t, nc, "ec2.DescribeVpcs", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeVpcs) }) + sub(t, nc, "ec2.CreateSubnet", func(m *nats.Msg) { dispatch(m, dl.VPC.CreateSubnet) }) + sub(t, nc, "ec2.DeleteSubnet", func(m *nats.Msg) { dispatch(m, dl.VPC.DeleteSubnet) }) + sub(t, nc, "ec2.DescribeSubnets", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeSubnets) }) + sub(t, nc, "ec2.ModifySubnetAttribute", func(m *nats.Msg) { dispatch(m, dl.VPC.ModifySubnetAttribute) }) + sub(t, nc, "ec2.ModifyVpcAttribute", func(m *nats.Msg) { dispatch(m, dl.VPC.ModifyVpcAttribute) }) + sub(t, nc, "ec2.DescribeVpcAttribute", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeVpcAttribute) }) + sub(t, nc, "ec2.CreateNetworkInterface", func(m *nats.Msg) { dispatch(m, dl.VPC.CreateNetworkInterface) }) + sub(t, nc, "ec2.DeleteNetworkInterface", func(m *nats.Msg) { dispatch(m, dl.VPC.DeleteNetworkInterface) }) + sub(t, nc, "ec2.DescribeNetworkInterfaces", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeNetworkInterfaces) }) + sub(t, nc, "ec2.ModifyNetworkInterfaceAttribute", func(m *nats.Msg) { dispatch(m, dl.VPC.ModifyNetworkInterfaceAttribute) }) + sub(t, nc, "ec2.CreateSecurityGroup", func(m *nats.Msg) { dispatch(m, dl.VPC.CreateSecurityGroup) }) + sub(t, nc, "ec2.DeleteSecurityGroup", func(m *nats.Msg) { dispatch(m, dl.VPC.DeleteSecurityGroup) }) + sub(t, nc, "ec2.DescribeSecurityGroups", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeSecurityGroups) }) + sub(t, nc, "ec2.DescribeSecurityGroupRules", func(m *nats.Msg) { dispatch(m, dl.VPC.DescribeSecurityGroupRules) }) + sub(t, nc, "ec2.AuthorizeSecurityGroupIngress", func(m *nats.Msg) { dispatch(m, dl.VPC.AuthorizeSecurityGroupIngress) }) + sub(t, nc, "ec2.AuthorizeSecurityGroupEgress", func(m *nats.Msg) { dispatch(m, dl.VPC.AuthorizeSecurityGroupEgress) }) + sub(t, nc, "ec2.RevokeSecurityGroupIngress", func(m *nats.Msg) { dispatch(m, dl.VPC.RevokeSecurityGroupIngress) }) + sub(t, nc, "ec2.RevokeSecurityGroupEgress", func(m *nats.Msg) { dispatch(m, dl.VPC.RevokeSecurityGroupEgress) }) + + // Internet gateways. + sub(t, nc, "ec2.CreateInternetGateway", func(m *nats.Msg) { dispatch(m, dl.IGW.CreateInternetGateway) }) + sub(t, nc, "ec2.DeleteInternetGateway", func(m *nats.Msg) { dispatch(m, dl.IGW.DeleteInternetGateway) }) + sub(t, nc, "ec2.DescribeInternetGateways", func(m *nats.Msg) { dispatch(m, dl.IGW.DescribeInternetGateways) }) + sub(t, nc, "ec2.AttachInternetGateway", func(m *nats.Msg) { dispatch(m, dl.IGW.AttachInternetGateway) }) + sub(t, nc, "ec2.DetachInternetGateway", func(m *nats.Msg) { dispatch(m, dl.IGW.DetachInternetGateway) }) +} diff --git a/tests/integration/keypair_test.go b/tests/integration/keypair_test.go new file mode 100644 index 000000000..1deb0d716 --- /dev/null +++ b/tests/integration/keypair_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package integration + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +// TestKeyPairs is ported from tests/e2e/single/keypair_test.go runKeyPairs, +// driving the REAL key/service_impl.go over the harness's DaemonLite instead +// of a live spinifex daemon. Exercises CreateKeyPair / ImportKeyPair / +// DescribeKeyPairs / DeleteKeyPair with the same assertions as the E2E +// source: both keys visible after import, only the primary key remains after +// delete. +func TestKeyPairs(t *testing.T) { + gw := StartGateway(t) + StartDaemonLite(t, gw) + ec2Cli := gw.EC2Client(t) + + const key1 = "test-key-1" + const key2 = "test-key-2" + + createOut, err := ec2Cli.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(key1)}) + require.NoError(t, err, "create-key-pair %s", key1) + require.NotEmpty(t, aws.StringValue(createOut.KeyMaterial), "CreateKeyPair PEM empty") + require.Contains(t, aws.StringValue(createOut.KeyMaterial), "-----BEGIN", + "PEM must contain -----BEGIN (got prefix %q)", firstN(aws.StringValue(createOut.KeyMaterial), 32)) + + pubMaterial := generateImportPubKey(t) + _, err = ec2Cli.ImportKeyPair(&ec2.ImportKeyPairInput{ + KeyName: aws.String(key2), + PublicKeyMaterial: pubMaterial, + }) + require.NoError(t, err, "import-key-pair %s", key2) + + listed := describeKeyNames(t, ec2Cli) + assert.Contains(t, listed, key1, "describe-key-pairs missing %s (got %v)", key1, listed) + assert.Contains(t, listed, key2, "describe-key-pairs missing %s (got %v)", key2, listed) + + _, err = ec2Cli.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(key2)}) + require.NoError(t, err, "delete-key-pair %s", key2) + + remaining := describeKeyNames(t, ec2Cli) + assert.Contains(t, remaining, key1, "describe-key-pairs lost %s after deleting %s", key1, 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 E2E source's import-key-pair step feeds in). +// 2048-bit to match the E2E 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, ec2Cli *ec2.EC2) []string { + t.Helper() + out, err := ec2Cli.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/integration/routetable_test.go b/tests/integration/routetable_test.go new file mode 100644 index 000000000..c07b3e377 --- /dev/null +++ b/tests/integration/routetable_test.go @@ -0,0 +1,429 @@ +//go:build integration + +package integration + +import ( + "testing" + + "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/stretchr/testify/require" +) + +// defaultVPCInfo resolves the account's default VPC + its default subnet, +// created by StartDaemonLite's EnsureDefaultVPC call — the in-process +// equivalent of the E2E source's harness.EnsureDefaultVPC. +func defaultVPCInfo(t *testing.T, c *ec2.EC2) (vpcID, subnetID string) { + t.Helper() + vpcsOut, err := c.DescribeVpcs(&ec2.DescribeVpcsInput{ + Filters: []*ec2.Filter{{Name: aws.String("is-default"), Values: []*string{aws.String("true")}}}, + }) + require.NoError(t, err, "describe-vpcs is-default") + require.NotEmpty(t, vpcsOut.Vpcs, "no default VPC found") + vpcID = aws.StringValue(vpcsOut.Vpcs[0].VpcId) + require.NotEmpty(t, vpcID, "default VpcId empty") + + subOut, err := c.DescribeSubnets(&ec2.DescribeSubnetsInput{ + Filters: []*ec2.Filter{{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}}}, + }) + require.NoError(t, err, "describe-subnets vpc-id=%s", vpcID) + require.NotEmpty(t, subOut.Subnets, "no subnet found in default VPC %s", vpcID) + subnetID = aws.StringValue(subOut.Subnets[0].SubnetId) + require.NotEmpty(t, subnetID, "default SubnetId empty") + return vpcID, subnetID +} + +// createScratchVPC creates a VPC with the given CIDR and registers its deletion. +func createScratchVPC(t *testing.T, c *ec2.EC2, cidr string) string { + t.Helper() + out, err := c.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.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 *ec2.EC2, vpcID string) string { + t.Helper() + out, err := c.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.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.DetachInternetGateway(&ec2.DetachInternetGatewayInput{ + InternetGatewayId: aws.String(igwID), + VpcId: aws.String(vpcID), + }) + _, _ = c.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 *ec2.EC2, rtbID, destCidr string) string { + t.Helper() + out, err := c.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 "" +} + +// TestRouteTableValidation is ported from tests/e2e/single/vpc_test.go +// runRouteTableValidation, driving the REAL routetable/service_impl.go (and +// vpc/service_impl.go for the default-VPC main table) over the harness's +// DaemonLite. +// +// Step1's IGW check remains a soft, non-fatal log: DaemonLite's default VPC +// (via VPCServiceImpl.EnsureDefaultVPC) has no IGW auto-attached — the live +// daemon's IGW auto-attach on account creation +// (daemon.ensureDefaultVPCInfrastructureFor) lives in the daemon package and +// is not replicated here — so this always takes the "no IGW attached" +// branch, exactly as the E2E source's comment anticipates for a +// private-only bootstrap. +func TestRouteTableValidation(t *testing.T) { + gw := StartGateway(t) + StartDaemonLite(t, gw) + c := gw.EC2Client(t) + + vpcID, subnetID := defaultVPCInfo(t, c) + + // --- Step 1: Default VPC main route table ----------------------------- + var mainRTBID string + t.Run("Step1_DefaultMainRouteTable", func(t *testing.T) { + out, err := c.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("vpc-id"), Values: []*string{aws.String(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", vpcID) + mainRTBID = aws.StringValue(out.RouteTables[0].RouteTableId) + require.NotEmpty(t, mainRTBID, "main RouteTableId empty") + + 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) + + localFound := false + for _, r := range out.RouteTables[0].Routes { + if aws.StringValue(r.GatewayId) == "local" { + localFound = true + break + } + } + require.Truef(t, localFound, + "main route table %s missing local route", mainRTBID) + + // IGW route (0.0.0.0/0 -> igw-...) — soft check, see doc comment above. + 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 == "" { + t.Logf("note: default VPC main RT has no 0.0.0.0/0 route (no IGW attached)") + } + }) + + 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) { + ctOut, err := c.CreateRouteTable(&ec2.CreateRouteTableInput{ + VpcId: aws.String(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.DeleteRouteTable(&ec2.DeleteRouteTableInput{ + RouteTableId: aws.String(customRTB), + }) + } + }) + + descCustom, err := c.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) + + // Find an attached IGW for the default VPC so we can add a real + // 0.0.0.0/0 route. None is attached in this tier (see doc comment), + // so this always exercises the associate/disassociate + local-route + // delete path only, same as the E2E source's no-IGW fallback. + igwForVPC := "" + igws, err := c.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("attachment.vpc-id"), Values: []*string{aws.String(vpcID)}}, + }, + }) + require.NoError(t, err, "describe-internet-gateways") + if len(igws.InternetGateways) > 0 { + igwForVPC = aws.StringValue(igws.InternetGateways[0].InternetGatewayId) + } + + routeAdded := false + if igwForVPC != "" { + _, err = c.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.DeleteRoute(&ec2.DeleteRouteInput{ + RouteTableId: aws.String(customRTB), + DestinationCidrBlock: aws.String("0.0.0.0/0"), + }) + }) + } + + assocOut, err := c.AssociateRouteTable(&ec2.AssociateRouteTableInput{ + RouteTableId: aws.String(customRTB), + SubnetId: aws.String(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.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{ + AssociationId: aws.String(assocID), + }) + } + }) + + _, err = c.DisassociateRouteTable(&ec2.DisassociateRouteTableInput{ + AssociationId: aws.String(assocID), + }) + require.NoError(t, err, "disassociate-route-table") + assocReleased = true + + if routeAdded { + _, err = c.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") + } + + _, err = c.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) { + ctOut, err := c.CreateRouteTable(&ec2.CreateRouteTableInput{ + VpcId: aws.String(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.DeleteRouteTable(&ec2.DeleteRouteTableInput{ + RouteTableId: aws.String(errRTB), + }) + }) + + descScratch, err := c.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. Assert *any* error + // (matching the E2E source) rather than pin a code, since the + // gateway might surface this as InvalidParameterValue, + // OperationNotPermitted, or a custom "cannot delete local route" code. + _, err = c.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) + var aerr awserr.Error + if e, ok := err.(awserr.Error); ok { + aerr = e + t.Logf("delete_local_err=%s", aerr.Code()) + } + + // DeleteRouteTable on the main RT must be rejected — same shape. + _, err = c.DeleteRouteTable(&ec2.DeleteRouteTableInput{ + RouteTableId: aws.String(mainRTBID), + }) + require.Errorf(t, err, + "delete-route-table on main RT %s succeeded — must be rejected", mainRTBID) + if e, ok := err.(awserr.Error); ok { + t.Logf("delete_main_err=%s", e.Code()) + } + }) +} + +// TestReplaceRouteConvergence is ported from tests/e2e/single/vpc_test.go +// runReplaceRouteConvergence, driving the REAL routetable/service_impl.go +// ReplaceRoute over the harness's DaemonLite. 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. 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 — identical assertions to the E2E source. +// +// V1 scope note: ReplaceRoute updates the route record but publishes no vpc +// event, so it does NOT reprogram any datapath — there is no datapath to +// assert here (no OVN in this tier either way); the contract under test is +// control-plane convergence only, same as the E2E source. +func TestReplaceRouteConvergence(t *testing.T) { + gw := StartGateway(t) + StartDaemonLite(t, gw) + c := gw.EC2Client(t) + + // --- 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 ----------------------------- + rtOut, err := c.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.DeleteRouteTable(&ec2.DeleteRouteTableInput{RouteTableId: aws.String(rtbID)}) + }) + + const dest = "192.0.2.0/24" + + // --- CreateRoute -> igw1 ----------------------------------------------- + _, err = c.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) ----------------- + _, err = c.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) + + // --- Negative matrix --------------------------------------------------- + // Unknown destination CIDR — route doesn't exist in the table. + _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("198.51.100.0/24"), + GatewayId: aws.String(igw2), + }) + requireAWSErrorCode(t, err, "InvalidRoute.NotFound") + + // Local route is immutable. + _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String("10.77.0.0/16"), + GatewayId: aws.String(igw2), + }) + requireAWSErrorCode(t, err, "InvalidParameterValue") + + // Missing target — V1 requires a GatewayId. + _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String(dest), + }) + requireAWSErrorCode(t, err, "InvalidParameterValue") + + // Unknown IGW target. + _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String(dest), + GatewayId: aws.String("igw-00000000000000000"), + }) + requireAWSErrorCode(t, err, "InvalidInternetGatewayID.NotFound") + + // IGW attached to a different VPC than the route table. + _, err = c.ReplaceRoute(&ec2.ReplaceRouteInput{ + RouteTableId: aws.String(rtbID), + DestinationCidrBlock: aws.String(dest), + GatewayId: aws.String(igwOther), + }) + requireAWSErrorCode(t, err, "InvalidParameterValue") + + // 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) +} diff --git a/tests/integration/tags_test.go b/tests/integration/tags_test.go new file mode 100644 index 000000000..5bd43492b --- /dev/null +++ b/tests/integration/tags_test.go @@ -0,0 +1,215 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTagManagement is ported from tests/e2e/single/tags_test.go +// runTagManagement, driving the REAL tags/service_impl.go over the harness's +// DaemonLite. The E2E source tags a running instance and its root EBS +// volume; instance lifecycle and volume creation are out of scope for this +// tier (they need vm.Manager/QEMU and viperblock respectively — see +// mulga-xaa9f), so this port substitutes two in-scope resources that stand +// in for the same two-resource-isolation shape: a scratch VPC (stand-in for +// "instance") and its own auto-created main route table (stand-in for "root +// volume"). CreateTags/DescribeTags/DeleteTags never inspect resource +// existence or type beyond the ID string (handlers/ec2/tags/service_impl.go +// getResourceType), so every assertion below — per-resource tag counts, +// resource-id/resource-type/key filtering, Name overwrite-on-recreate, +// unconditional vs. value-scoped delete-tags, cross-resource isolation — +// exercises the identical code path the E2E source exercised, just against +// "vpc" and "route-table" resource-type strings instead of "instance" and +// "volume". +func TestTagManagement(t *testing.T) { + gw := StartGateway(t) + StartDaemonLite(t, gw) + c := gw.EC2Client(t) + + vpcOut, err := c.CreateVpc(&ec2.CreateVpcInput{CidrBlock: aws.String("10.201.0.0/16")}) + require.NoError(t, err, "create-vpc") + require.NotNil(t, vpcOut.Vpc, "create-vpc returned nil Vpc") + vpcID := aws.StringValue(vpcOut.Vpc.VpcId) + require.NotEmpty(t, vpcID, "VpcId empty") + t.Cleanup(func() { + _, _ = c.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(vpcID)}) + }) + + rtOut, err := c.DescribeRouteTables(&ec2.DescribeRouteTablesInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}}, + {Name: aws.String("association.main"), Values: []*string{aws.String("true")}}, + }, + }) + require.NoError(t, err, "describe-route-tables (main)") + require.NotEmpty(t, rtOut.RouteTables, "no main route table found for vpc %s", vpcID) + rtbID := aws.StringValue(rtOut.RouteTables[0].RouteTableId) + require.NotEmpty(t, rtbID, "main RouteTableId empty") + + // Cleanup runs regardless of which sub-step failed so later assertions + // see a tag-free vpc/route-table pair. + t.Cleanup(func() { + _, _ = c.DeleteTags(&ec2.DeleteTagsInput{ + Resources: []*string{aws.String(vpcID), aws.String(rtbID)}, + Tags: []*ec2.Tag{ + {Key: aws.String("Name")}, + {Key: aws.String("Environment")}, + {Key: aws.String("DeleteMe")}, + }, + }) + }) + + // 6a: create three tags on the vpc (stand-in for "instance"). + _, err = c.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{aws.String(vpcID)}, + 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 vpc") + + // 6b: filter by resource-id should return all three. + tags, err := describeTags(c, &ec2.Filter{ + Name: aws.String("resource-id"), + Values: []*string{aws.String(vpcID)}, + }) + require.NoError(t, err, "describe-tags resource-id") + require.Lenf(t, tags, 3, "vpc tag count after 6a: %v", tagSummary(tags)) + + // 6c: tag the route table (stand-in for "root volume") with two tags. + _, err = c.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{aws.String(rtbID)}, + 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 route table") + + // 6d: filter by key=Environment should return exactly 2 (one per resource). + tags, err = describeTags(c, &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=vpc should return 3 (all vpc tags). + tags, err = describeTags(c, &ec2.Filter{ + Name: aws.String("resource-type"), + Values: []*string{aws.String("vpc")}, + }) + require.NoError(t, err, "describe-tags resource-type=vpc") + require.Lenf(t, tags, 3, "vpc-typed tag count: %v", tagSummary(tags)) + + // 6f: overwrite Name on the vpc — CreateTags with the same Key must + // replace the prior Value. + _, err = c.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{aws.String(vpcID)}, + Tags: []*ec2.Tag{ + {Key: aws.String("Name"), Value: aws.String("e2e-test-updated")}, + }, + }) + require.NoError(t, err, "create-tags overwrite Name") + + tags, err = describeTags(c, + &ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(vpcID)}}, + &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 vpc: %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. VPC should be down to {Name, Environment}. + _, err = c.DeleteTags(&ec2.DeleteTagsInput{ + Resources: []*string{aws.String(vpcID)}, + Tags: []*ec2.Tag{{Key: aws.String("DeleteMe")}}, + }) + require.NoError(t, err, "delete-tags DeleteMe") + + tags, err = describeTags(c, &ec2.Filter{ + Name: aws.String("resource-id"), + Values: []*string{aws.String(vpcID)}, + }) + require.NoError(t, err, "describe-tags after unconditional delete") + require.Lenf(t, tags, 2, "vpc 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 vpc still holds "testing". + _, err = c.DeleteTags(&ec2.DeleteTagsInput{ + Resources: []*string{aws.String(vpcID)}, + Tags: []*ec2.Tag{{Key: aws.String("Environment"), Value: aws.String("production")}}, + }) + require.NoError(t, err, "delete-tags Environment=production") + + tags, err = describeTags(c, + &ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(vpcID)}}, + &ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}}, + ) + require.NoError(t, err, "describe-tags vpc 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 route table's Environment tag must survive + // because DeleteTags was scoped to the vpc. + _, err = c.DeleteTags(&ec2.DeleteTagsInput{ + Resources: []*string{aws.String(vpcID)}, + Tags: []*ec2.Tag{{Key: aws.String("Environment"), Value: aws.String("testing")}}, + }) + require.NoError(t, err, "delete-tags Environment=testing") + + tags, err = describeTags(c, + &ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(vpcID)}}, + &ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}}, + ) + require.NoError(t, err, "describe-tags vpc Environment after match-delete") + assert.Lenf(t, tags, 0, "Environment tag must be gone from vpc: %v", tagSummary(tags)) + + tags, err = describeTags(c, + &ec2.Filter{Name: aws.String("resource-id"), Values: []*string{aws.String(rtbID)}}, + &ec2.Filter{Name: aws.String("key"), Values: []*string{aws.String("Environment")}}, + ) + require.NoError(t, err, "describe-tags route table Environment after vpc-scoped delete") + require.Lenf(t, tags, 1, "route table Environment tag must survive vpc-scoped delete: %v", tagSummary(tags)) + assert.Equal(t, rtbID, aws.StringValue(tags[0].ResourceId), "Environment tag still attached to main route table") + + // 6j: only Name should remain on the vpc. + tags, err = describeTags(c, &ec2.Filter{ + Name: aws.String("resource-id"), + Values: []*string{aws.String(vpcID)}, + }) + require.NoError(t, err, "describe-tags final vpc") + require.Lenf(t, tags, 1, "final vpc tag count: %v", tagSummary(tags)) + assert.Equal(t, "Name", aws.StringValue(tags[0].Key), "final remaining vpc 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(c *ec2.EC2, filters ...*ec2.Filter) ([]*ec2.TagDescription, error) { + out, err := c.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 +}