mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 19:25:52 +00:00
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:
@@ -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) })
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user