feat(integration): daemon-lite harness for key/tags/routetable/vpc

Adds DaemonLite, an in-process stand-in for a live spinifex daemon that
subscribes the real key, tags, route table, and VPC/subnet/SG/IGW service
implementations directly to their NATS subjects, backed by memory and
embedded-JetStream stores. Ports TestKeyPairs, TestTagManagement,
TestRouteTableValidation, and TestReplaceRouteConvergence from
tests/e2e/single so the integration tier drives genuine daemon-side
business logic instead of StubSubject canned replies.

vpcd (OVN) is out of scope, so its "vpc.create-sg"/"vpc.delete-sg"
acknowledgements are stubbed with a fixed success reply — the SG record
itself is still written by the real service impl before that event fires,
so the stub only unblocks the synchronous round-trip rather than
replacing tested behavior.
This commit is contained in:
joshsiv-mulga
2026-07-20 12:04:58 +10:00
parent df93463234
commit 48b5f737e2
4 changed files with 975 additions and 0 deletions
+247
View File
@@ -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) })
}
+84
View File
@@ -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]
}
+429
View File
@@ -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)
}
+215
View File
@@ -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
}