Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions providers/aws/ec2/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,39 @@ func (m *Mock) UntagResource(_ context.Context, id string, keys []string) error

return nil
}

// ResourceTags returns a copy of the current tags on an EC2 instance, volume,
// snapshot, or image, or NotFound for an unknown ID. The EC2 CreateTags and
// DeleteTags handler reads it for every resource in a batch before writing, so
// an unknown id, a tag-limit breach, or a DeleteTags value mismatch is decided
// before any resource changes. It takes the same locks as TagResource.
func (m *Mock) ResourceTags(_ context.Context, id string) (map[string]string, error) {
var out map[string]string

snapshot := func(src map[string]string) {
out = make(map[string]string, len(src))
for k, v := range src {
out[k] = v
}
}

if strings.HasPrefix(id, "i-") {
if !m.mutateInstanceTags(id, snapshot) {
return nil, cerrors.Newf(cerrors.NotFound, "resource %q not found", id)
}

return out, nil
}

m.mu.Lock()
defer m.mu.Unlock()

src, ok := m.tagsOf(id)
if !ok {
return nil, cerrors.Newf(cerrors.NotFound, "resource %q not found", id)
}

snapshot(src)

return out, nil
}
51 changes: 51 additions & 0 deletions providers/aws/ec2/tags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ec2

import (
"context"
"testing"

cerrors "github.com/stackshy/cloudemu/v2/errors"
"github.com/stackshy/cloudemu/v2/services/compute/driver"
)

// TestResourceTagsReturnsACopy covers ResourceTags on an instance and a volume
// (the two lock paths): it reports the current tags, the returned map is a copy,
// and an unknown id is NotFound. The EC2 CreateTags/DeleteTags handler reads it
// to check a batch before writing.
func TestResourceTagsReturnsACopy(t *testing.T) {
ctx := context.Background()
m := newTestMock()

insts, err := m.RunInstances(ctx, driver.InstanceConfig{ImageID: "ami-123", InstanceType: "t2.micro"}, 1)
if err != nil {
t.Fatalf("RunInstances: %v", err)
}

vol, err := m.CreateVolume(ctx, driver.VolumeConfig{Size: 8, AvailabilityZone: "us-east-1a"})
if err != nil {
t.Fatalf("CreateVolume: %v", err)
}

for _, id := range []string{insts[0].ID, vol.ID} {
if err := m.TagResource(ctx, id, map[string]string{"env": "prod"}); err != nil {
t.Fatalf("TagResource(%s): %v", id, err)
}

got, err := m.ResourceTags(ctx, id)
if err != nil || got["env"] != "prod" {
t.Fatalf("ResourceTags(%s) = %v, %v; want env=prod", id, got, err)
}

got["env"] = "mutated"

if again, _ := m.ResourceTags(ctx, id); again["env"] != "prod" {
t.Fatalf("ResourceTags(%s) returned the live map: %v", id, again)
}
}

for _, id := range []string{"i-missing", "vol-missing", "x-unknown"} {
if _, err := m.ResourceTags(ctx, id); !cerrors.IsNotFound(err) {
t.Fatalf("ResourceTags(%s) = %v, want NotFound", id, err)
}
}
}
8 changes: 8 additions & 0 deletions providers/aws/vpc/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ func (m *Mock) DeleteVPCEndpoint(
func (m *Mock) DescribeVPCEndpoints(
_ context.Context, ids []string,
) ([]driver.VPCEndpoint, error) {
m.mu.RLock()
defer m.mu.RUnlock()

for _, id := range ids {
if !m.endpoints.Has(id) {
return nil, errors.Newf(
Expand All @@ -129,6 +132,11 @@ func (m *Mock) DescribeVPCEndpoints(
func (m *Mock) ModifyVPCEndpoint(
_ context.Context, id string, cfg driver.VPCEndpointConfig,
) (*driver.VPCEndpoint, error) {
// The field writes below go through the stored pointer, so they need m.mu:
// DescribeVPCEndpoints and the EC2 tag writer touch the same record under it.
m.mu.Lock()
defer m.mu.Unlock()

ep, ok := m.endpoints.Get(id)
if !ok {
return nil, errors.Newf(
Expand Down
96 changes: 92 additions & 4 deletions providers/aws/vpc/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import (
// UpdateResourceTags merges tags onto a VPC-family resource that has no
// dedicated Update*Tags method: route tables, internet gateways, NAT
// gateways, network ACLs, DHCP option sets, peering connections, managed
// prefix lists, and egress-only internet gateways. An unknown or missing id
// is NotFound, so the wire layer can map it to the InvalidID.NotFound code
// real EC2 returns for CreateTags on a non-existent resource.
// prefix lists, egress-only internet gateways, security-group rules, Elastic IP
// allocations, VPC endpoints and VPC endpoint services. An unknown or missing id
// is NotFound; the wire layer maps it to the resource-specific code real EC2
// defines for it (InvalidAllocationID.NotFound for eipalloc-,
// InvalidVpcEndpointId.NotFound for vpce-, InvalidVpcEndpointServiceId.NotFound
// for vpce-svc-, InvalidSecurityGroupRuleId.NotFound for sgr-), and to the
// generic InvalidID.NotFound for the rest.
func (m *Mock) UpdateResourceTags(_ context.Context, id string, tags map[string]string) error {
if !m.mutateResourceTags(id, func(existing map[string]string) map[string]string {
return mergeTagMap(existing, tags)
Expand All @@ -24,10 +28,20 @@ func (m *Mock) UpdateResourceTags(_ context.Context, id string, tags map[string]
return nil
}

// reservedTagPrefix is the namespace of AWS-generated tags, which EC2 DeleteTags
// never removes.
const reservedTagPrefix = "aws:"

// RemoveResourceTags drops the given tag keys from a VPC-family resource, the
// DeleteTags counterpart to UpdateResourceTags.
// DeleteTags counterpart to UpdateResourceTags. An empty key list deletes every
// user-defined tag and keeps the AWS-generated "aws:" ones, which is what EC2
// DeleteTags does when the Tag parameter is omitted.
func (m *Mock) RemoveResourceTags(_ context.Context, id string, keys []string) error {
if !m.mutateResourceTags(id, func(existing map[string]string) map[string]string {
if len(keys) == 0 {
return keepReservedTags(existing)
}

return removeTagMapKeys(existing, keys)
}) {
return errors.Newf(errors.NotFound, "resource %q not found", id)
Expand All @@ -36,6 +50,54 @@ func (m *Mock) RemoveResourceTags(_ context.Context, id string, keys []string) e
return nil
}

// ResourceTags returns a copy of the current tags on any resource the EC2 tag
// API addresses through this provider: VPCs, subnets and security groups plus
// every id UpdateResourceTags accepts. It is NotFound for an unknown id, so the
// EC2 CreateTags/DeleteTags handler can check every resource in a batch
// (existence, the per-resource tag limit, DeleteTags value matching) before it
// writes to any of them. The read goes through the same store/m.mu path as the
// writers, so it never races a concurrent tag write.
func (m *Mock) ResourceTags(_ context.Context, id string) (map[string]string, error) {
var out map[string]string

snapshot := func(existing map[string]string) map[string]string {
out = copyTags(existing)
return existing
}

var found bool

switch {
case strings.HasPrefix(id, "vpc-"):
found = m.vpcs.Update(id, func(v *vpcData) *vpcData { v.Tags = snapshot(v.Tags); return v })
case strings.HasPrefix(id, "subnet-"):
found = m.subnets.Update(id, func(s *subnetData) *subnetData { s.Tags = snapshot(s.Tags); return s })
case strings.HasPrefix(id, "sg-"):
found = m.securityGroups.Update(id, func(sg *sgData) *sgData { sg.Tags = snapshot(sg.Tags); return sg })
default:
found = m.mutateResourceTags(id, snapshot)
}

if !found {
return nil, errors.Newf(errors.NotFound, "resource %q not found", id)
}

return out, nil
}

// keepReservedTags returns a fresh map holding only the "aws:" tags of existing.
func keepReservedTags(existing map[string]string) map[string]string {
out := make(map[string]string)

for k, v := range existing {
if strings.HasPrefix(k, reservedTagPrefix) {
out[k] = v
}
}

return out
}

// mutateResourceTags routes an id to the store that owns it and applies
// transform to that record's tag map inside memstore.Update, so the store lock
// covers the read-modify-write. It reports whether the id matched a known
Expand Down Expand Up @@ -63,6 +125,32 @@ func (m *Mock) mutateResourceTags(id string, transform func(map[string]string) m
})
case strings.HasPrefix(id, "sgr-"):
return m.mutateRuleTags(id, transform)
default:
return m.mutateAddressingTags(id, transform)
}
}

// mutateAddressingTags is the mutateResourceTags continuation for Elastic IP
// allocations (eipalloc-), VPC endpoint services (vpce-svc-) and VPC endpoints
// (vpce-). These are the same records AllocateAddress / CreateVpcEndpoint
// TagSpecifications write, so tags added after creation show up on the
// Describe calls. vpce-svc- is checked before vpce- because it shares the prefix.
// m.mu is held because the Describe readers for these records read their fields
// under m.mu.RLock (see the Mock.mu comment).
func (m *Mock) mutateAddressingTags(id string, transform func(map[string]string) map[string]string) bool {
m.mu.Lock()
defer m.mu.Unlock()

switch {
case strings.HasPrefix(id, "eipalloc-"):
return m.eips.Update(id, func(v *eipData) *eipData { v.Tags = transform(v.Tags); return v })
case strings.HasPrefix(id, "vpce-svc-"):
return m.endpointServices.Update(id, func(v *driver.EndpointService) *driver.EndpointService {
v.Tags = transform(v.Tags)
return v
})
case strings.HasPrefix(id, "vpce-"):
return m.endpoints.Update(id, func(v *driver.VPCEndpoint) *driver.VPCEndpoint { v.Tags = transform(v.Tags); return v })
default:
return false
}
Expand Down
Loading
Loading