mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: EC2 central-record resource tagging (#469)
This commit is contained in:
+7
-7
@@ -155,10 +155,10 @@ Spot Instance Requests (SIRs) are a **mock** over the on-demand `run-instances`
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519) | `--key-format`, `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519), `--tag-specifications` | `--key-format`, `--dry-run` | **DONE** |
|
||||
| `describe-key-pairs` | `--key-names`, `--key-pair-ids`, `--filters` (key-pair-id, key-name, fingerprint, tag:*) | `--max-results`, `--dry-run` | **DONE** |
|
||||
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | **DONE** |
|
||||
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
| `import-key-pair` | `--key-name`, `--public-key-material`, `--tag-specifications` | `--dry-run` | **DONE** |
|
||||
|
||||
### EC2 — AMI Images
|
||||
|
||||
@@ -181,7 +181,7 @@ Spot Instance Requests (SIRs) are a **mock** over the on-demand `run-instances`
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `describe-volumes` | `--volume-ids`, `--filters` (volume-id, status, size, volume-type, attachment.instance-id, attachment.status, attachment.device, availability-zone, tag:*), persisted `DeleteOnTermination` | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
|
||||
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3), `--snapshot-id` | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput`, `--tag-specifications` | **DONE** |
|
||||
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3), `--snapshot-id`, `--tag-specifications` | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput` | **DONE** |
|
||||
| `delete-volume` | `--volume-id` | `--dry-run` | **DONE** |
|
||||
| `modify-volume` | `--volume-id`, `--size`, `--volume-type`, `--iops` | `--throughput`, `--dry-run`, `--multi-attach-enabled` | **DONE** |
|
||||
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (auto-assigns `/dev/sd[f-p]`) | `--dry-run` | **DONE** |
|
||||
@@ -293,7 +293,7 @@ KV CRUD only — no OVN/OVS integration. EIGWs are stored but have no effect on
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-route-table` | `--vpc-id` | `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
| `create-route-table` | `--vpc-id`, `--tag-specifications` | `--dry-run` | **DONE** |
|
||||
| `delete-route-table` | `--route-table-id` | `--dry-run` | **DONE** |
|
||||
| `describe-route-tables` | `--route-table-ids`, `--filters` (vpc-id, route-table-id, association.main, association.route-table-association-id, association.subnet-id, route.destination-cidr-block, route.gateway-id) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
|
||||
| `create-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id`, `--nat-gateway-id` | `--egress-only-internet-gateway-id`, `--vpc-peering-connection-id`, `--dry-run` | **DONE** |
|
||||
@@ -336,7 +336,7 @@ EIP commands are only registered when an external IPAM pool is configured.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type`, `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
| `create-nat-gateway` | `--subnet-id`, `--allocation-id`, `--tag-specifications` | `--connectivity-type`, `--dry-run` | **DONE** |
|
||||
| `delete-nat-gateway` | `--nat-gateway-id` | `--dry-run` | **DONE** |
|
||||
| `describe-nat-gateways` | `--nat-gateway-ids`, `--filters` (vpc-id, state) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
|
||||
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | **NOT STARTED** |
|
||||
@@ -346,9 +346,9 @@ EIP commands are only registered when an external IPAM pool is configured.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level`, `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster), `--tag-specifications` | `--partition-count`, `--spread-level`, `--dry-run` | **DONE** |
|
||||
| `delete-placement-group` | `--group-name` | `--dry-run` | **DONE** |
|
||||
| `describe-placement-groups` | `--group-names`, `--group-ids`, `--filters` (strategy, state, spread-level, group-name) | `--dry-run` | **DONE** |
|
||||
| `describe-placement-groups` | `--group-names`, `--group-ids`, `--filters` (strategy, state, spread-level, group-name, tag:*, tag-key, tag-value) | `--dry-run` | **DONE** |
|
||||
|
||||
### EC2 — VPC Peering
|
||||
|
||||
|
||||
@@ -17,41 +17,75 @@ func (d *Daemon) handleEC2DescribeTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.DescribeTags)
|
||||
}
|
||||
|
||||
// createTags writes the generic tag store, then mirrors tags into owning records
|
||||
// so tag-filtered describes observe them: subnet/vpc (e.g. LBC subnet
|
||||
// auto-discovery via DescribeSubnets) and AMIs (DescribeImages).
|
||||
// recordTagMirror is implemented by every service whose resource records
|
||||
// mirror the central tag store, so tag-filtered describes observe tags
|
||||
// written through create-tags/delete-tags.
|
||||
type recordTagMirror interface {
|
||||
ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error
|
||||
RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error
|
||||
}
|
||||
|
||||
// recordTagMirrors collects the initialized services owning centrally-stored
|
||||
// records: vpc covers vpc/subnet/sg/eni, the rest own their prefix.
|
||||
func (d *Daemon) recordTagMirrors() []recordTagMirror {
|
||||
var mirrors []recordTagMirror
|
||||
if d.vpcService != nil {
|
||||
mirrors = append(mirrors, d.vpcService)
|
||||
}
|
||||
if d.imageService != nil {
|
||||
mirrors = append(mirrors, d.imageService)
|
||||
}
|
||||
if d.volumeService != nil {
|
||||
mirrors = append(mirrors, d.volumeService)
|
||||
}
|
||||
if d.snapshotService != nil {
|
||||
mirrors = append(mirrors, d.snapshotService)
|
||||
}
|
||||
if d.routeTableService != nil {
|
||||
mirrors = append(mirrors, d.routeTableService)
|
||||
}
|
||||
if d.igwService != nil {
|
||||
mirrors = append(mirrors, d.igwService)
|
||||
}
|
||||
if d.eigwService != nil {
|
||||
mirrors = append(mirrors, d.eigwService)
|
||||
}
|
||||
if d.eipService != nil {
|
||||
mirrors = append(mirrors, d.eipService)
|
||||
}
|
||||
if d.natGatewayService != nil {
|
||||
mirrors = append(mirrors, d.natGatewayService)
|
||||
}
|
||||
if d.keyService != nil {
|
||||
mirrors = append(mirrors, d.keyService)
|
||||
}
|
||||
return mirrors
|
||||
}
|
||||
|
||||
// createTags writes the generic tag store, then mirrors tags into the owning
|
||||
// records so tag-filtered describes observe them.
|
||||
func (d *Daemon) createTags(input *ec2.CreateTagsInput, accountID string) (*ec2.CreateTagsOutput, error) {
|
||||
out, err := d.tagsService.CreateTags(input, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.vpcService != nil {
|
||||
if err := d.vpcService.ApplyRecordTags(input, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if d.imageService != nil {
|
||||
if err := d.imageService.ApplyRecordTags(input, accountID); err != nil {
|
||||
for _, m := range d.recordTagMirrors() {
|
||||
if err := m.ApplyRecordTags(input, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteTags removes from the generic tag store, then mirrors the removal into
|
||||
// the owning subnet/vpc records and AMI configs.
|
||||
// deleteTags removes from the generic tag store, then mirrors the removal
|
||||
// into the owning records.
|
||||
func (d *Daemon) deleteTags(input *ec2.DeleteTagsInput, accountID string) (*ec2.DeleteTagsOutput, error) {
|
||||
out, err := d.tagsService.DeleteTags(input, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.vpcService != nil {
|
||||
if err := d.vpcService.RemoveRecordTags(input, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if d.imageService != nil {
|
||||
if err := d.imageService.RemoveRecordTags(input, accountID); err != nil {
|
||||
for _, m := range d.recordTagMirrors() {
|
||||
if err := m.RemoveRecordTags(input, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package handlers_ec2_eigw
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func eigwTagVal(tags []*ec2.Tag, key string) string {
|
||||
for _, tg := range tags {
|
||||
if tg.Key != nil && *tg.Key == key {
|
||||
return aws.StringValue(tg.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestEIGWRecordTagsMirror(t *testing.T) {
|
||||
svc := setupTestEIGWService(t)
|
||||
eigwID := createTestEIGW(t, svc)
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(eigwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
|
||||
EgressOnlyInternetGatewayIds: []*string{aws.String(eigwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.EgressOnlyInternetGateways, 1)
|
||||
assert.Equal(t, "yes", eigwTagVal(out.EgressOnlyInternetGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "v", eigwTagVal(out.EgressOnlyInternetGateways[0].Tags, "drop"))
|
||||
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(eigwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err = svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
|
||||
EgressOnlyInternetGatewayIds: []*string{aws.String(eigwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.EgressOnlyInternetGateways, 1)
|
||||
assert.Equal(t, "yes", eigwTagVal(out.EgressOnlyInternetGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "", eigwTagVal(out.EgressOnlyInternetGateways[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestEIGWRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc := setupTestEIGWService(t)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("eigw-missing"), aws.String("igw-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -245,3 +245,26 @@ func (s *EgressOnlyIGWServiceImpl) recordToEC2(record *EgressOnlyIGWRecord) *ec2
|
||||
|
||||
return eigw
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning egress-only IGW KV
|
||||
// record so tag-filtered describes observe tags added after create. Resource
|
||||
// ids this service does not own are skipped; absent records are a no-op.
|
||||
func (s *EgressOnlyIGWServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.eigwKV, accountID, "eigw-", input.Resources,
|
||||
func(r *EgressOnlyIGWRecord) *map[string]string { return &r.Tags },
|
||||
utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning egress-only IGW KV
|
||||
// record with AWS-faithful delete semantics.
|
||||
func (s *EgressOnlyIGWServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.eigwKV, accountID, "eigw-", input.Resources,
|
||||
func(r *EgressOnlyIGWRecord) *map[string]string { return &r.Tags },
|
||||
utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers_ec2_eip
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func eipTagVal(tags []*ec2.Tag, key string) string {
|
||||
for _, tg := range tags {
|
||||
if tg.Key != nil && *tg.Key == key {
|
||||
return aws.StringValue(tg.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestEIPRecordTagsMirror(t *testing.T) {
|
||||
svc, _ := setupTestEIP(t)
|
||||
alloc, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
allocID := *alloc.AllocationId
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(allocID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
|
||||
AllocationIds: []*string{aws.String(allocID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Addresses, 1)
|
||||
assert.Equal(t, "yes", eipTagVal(out.Addresses[0].Tags, "keep"))
|
||||
assert.Equal(t, "v", eipTagVal(out.Addresses[0].Tags, "drop"))
|
||||
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(allocID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err = svc.DescribeAddresses(&ec2.DescribeAddressesInput{
|
||||
AllocationIds: []*string{aws.String(allocID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Addresses, 1)
|
||||
assert.Equal(t, "yes", eipTagVal(out.Addresses[0].Tags, "keep"))
|
||||
assert.Equal(t, "", eipTagVal(out.Addresses[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestEIPRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc, _ := setupTestEIP(t)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("eipalloc-missing"), aws.String("nat-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -692,3 +692,26 @@ func (s *EIPServiceImpl) eipRecordToEC2(record *EIPRecord) *ec2.Address {
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning EIP KV record so
|
||||
// tag-filtered describes observe tags added after create. Resource ids this
|
||||
// service does not own are skipped; absent records are a no-op.
|
||||
func (s *EIPServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.eipKV, accountID, "eipalloc-", input.Resources,
|
||||
func(r *EIPRecord) *map[string]string { return &r.Tags },
|
||||
utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning EIP KV record with
|
||||
// AWS-faithful delete semantics.
|
||||
func (s *EIPServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.eipKV, accountID, "eipalloc-", input.Resources,
|
||||
func(r *EIPRecord) *map[string]string { return &r.Tags },
|
||||
utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package handlers_ec2_igw
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func igwTagVal(tags []*ec2.Tag, key string) string {
|
||||
for _, tg := range tags {
|
||||
if tg.Key != nil && *tg.Key == key {
|
||||
return aws.StringValue(tg.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestIGWRecordTagsMirror(t *testing.T) {
|
||||
svc, _ := setupTestIGWService(t)
|
||||
igwID := createTestIGW(t, svc)
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(igwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
|
||||
InternetGatewayIds: []*string{aws.String(igwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InternetGateways, 1)
|
||||
assert.Equal(t, "yes", igwTagVal(out.InternetGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "v", igwTagVal(out.InternetGateways[0].Tags, "drop"))
|
||||
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(igwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err = svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
|
||||
InternetGatewayIds: []*string{aws.String(igwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InternetGateways, 1)
|
||||
assert.Equal(t, "yes", igwTagVal(out.InternetGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "", igwTagVal(out.InternetGateways[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestIGWRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc, _ := setupTestIGWService(t)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("igw-missing"), aws.String("eigw-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -427,3 +427,26 @@ func (s *IGWServiceImpl) recordToEC2(record *IGWRecord) *ec2.InternetGateway {
|
||||
|
||||
return igw
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning IGW KV record so
|
||||
// tag-filtered describes observe tags added after create. Resource ids this
|
||||
// service does not own are skipped; absent records are a no-op.
|
||||
func (s *IGWServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.igwKV, accountID, "igw-", input.Resources,
|
||||
func(r *IGWRecord) *map[string]string { return &r.Tags },
|
||||
utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning IGW KV record with
|
||||
// AWS-faithful delete semantics.
|
||||
func (s *IGWServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.igwKV, accountID, "igw-", input.Resources,
|
||||
func(r *IGWRecord) *map[string]string { return &r.Tags },
|
||||
utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
@@ -701,13 +701,7 @@ func (s *ImageServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
merge := func(tags map[string]string) {
|
||||
for _, t := range input.Tags {
|
||||
if t.Key != nil && t.Value != nil {
|
||||
tags[*t.Key] = *t.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
merge := utils.MergeTagsMut(input)
|
||||
for _, res := range input.Resources {
|
||||
if res == nil || !strings.HasPrefix(*res, "ami-") {
|
||||
continue
|
||||
@@ -726,24 +720,7 @@ func (s *ImageServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountI
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
remove := func(tags map[string]string) {
|
||||
if len(input.Tags) == 0 {
|
||||
for k := range tags {
|
||||
delete(tags, k)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, t := range input.Tags {
|
||||
if t.Key == nil {
|
||||
continue
|
||||
}
|
||||
if t.Value == nil {
|
||||
delete(tags, *t.Key)
|
||||
} else if cur, ok := tags[*t.Key]; ok && cur == *t.Value {
|
||||
delete(tags, *t.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
remove := utils.RemoveTagsMut(input)
|
||||
for _, res := range input.Resources {
|
||||
if res == nil || !strings.HasPrefix(*res, "ami-") {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers_ec2_key
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func keyPairTagSpecs(resourceType string) []*ec2.TagSpecification {
|
||||
return []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String(resourceType),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String("build-key")},
|
||||
{Key: aws.String("env"), Value: aws.String("dev")},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestCreateKeyPair_TagSpecifications(t *testing.T) {
|
||||
requireSSHKeygen(t)
|
||||
svc, _ := newTestKeyService()
|
||||
|
||||
out, err := svc.CreateKeyPair(&ec2.CreateKeyPairInput{
|
||||
KeyName: aws.String("tagged-create-key"),
|
||||
TagSpecifications: keyPairTagSpecs("key-pair"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tags := filterutil.EC2TagsToMap(out.Tags)
|
||||
assert.Equal(t, "build-key", tags["Name"])
|
||||
assert.Equal(t, "dev", tags["env"])
|
||||
|
||||
// Persisted metadata round-trips through DescribeKeyPairs.
|
||||
desc, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
|
||||
KeyPairIds: []*string{out.KeyPairId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.KeyPairs, 1)
|
||||
descTags := filterutil.EC2TagsToMap(desc.KeyPairs[0].Tags)
|
||||
assert.Equal(t, "build-key", descTags["Name"])
|
||||
assert.Equal(t, "dev", descTags["env"])
|
||||
}
|
||||
|
||||
func TestImportKeyPair_TagSpecifications(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
|
||||
out, err := svc.ImportKeyPair(&ec2.ImportKeyPairInput{
|
||||
KeyName: aws.String("tagged-import-key"),
|
||||
PublicKeyMaterial: []byte(testED25519PubKey),
|
||||
TagSpecifications: keyPairTagSpecs("key-pair"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tags := filterutil.EC2TagsToMap(out.Tags)
|
||||
assert.Equal(t, "build-key", tags["Name"])
|
||||
|
||||
// Tag filter matches the imported key.
|
||||
desc, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
|
||||
Filters: []*ec2.Filter{{
|
||||
Name: aws.String("tag:env"),
|
||||
Values: []*string{aws.String("dev")},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.KeyPairs, 1)
|
||||
assert.Equal(t, "tagged-import-key", *desc.KeyPairs[0].KeyName)
|
||||
|
||||
// Non-matching tag filter excludes it.
|
||||
desc, err = svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
|
||||
Filters: []*ec2.Filter{{
|
||||
Name: aws.String("tag:env"),
|
||||
Values: []*string{aws.String("prod")},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, desc.KeyPairs)
|
||||
}
|
||||
|
||||
func TestImportKeyPair_TagSpecificationsWrongType(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
|
||||
// Tag specs for a different resource type are ignored.
|
||||
out, err := svc.ImportKeyPair(&ec2.ImportKeyPairInput{
|
||||
KeyName: aws.String("untagged-import-key"),
|
||||
PublicKeyMaterial: []byte(testED25519PubKey),
|
||||
TagSpecifications: keyPairTagSpecs("volume"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.Tags)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package handlers_ec2_key
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func readKeyPairTags(t *testing.T, svc *KeyServiceImpl, accountID, keyPairID string) map[string]string {
|
||||
t.Helper()
|
||||
result, err := svc.store.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer result.Body.Close()
|
||||
body, err := io.ReadAll(result.Body)
|
||||
require.NoError(t, err)
|
||||
var metadata ec2.CreateKeyPairOutput
|
||||
require.NoError(t, json.Unmarshal(body, &metadata))
|
||||
return filterutil.EC2TagsToMap(metadata.Tags)
|
||||
}
|
||||
|
||||
func TestKeyPairRecordTagsMirror(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
out := importTestKey(t, svc, "tag-mirror-key")
|
||||
keyPairID := *out.KeyPairId
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(keyPairID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
tags := readKeyPairTags(t, svc, testAccountID, keyPairID)
|
||||
assert.Equal(t, "yes", tags["keep"])
|
||||
assert.Equal(t, "v", tags["drop"])
|
||||
|
||||
// Value-mismatched delete is a no-op; matched delete removes.
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(keyPairID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
tags = readKeyPairTags(t, svc, testAccountID, keyPairID)
|
||||
assert.Equal(t, "yes", tags["keep"])
|
||||
_, ok := tags["drop"]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestKeyPairRecordTagsMirror_CrossAccountNoMutation(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
out := importTestKey(t, svc, "tag-guard-key")
|
||||
keyPairID := *out.KeyPairId
|
||||
|
||||
// Another account's mirror misses under its own prefix and no-ops.
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(keyPairID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("evil"), Value: aws.String("1")}},
|
||||
}, "999999999999"))
|
||||
|
||||
tags := readKeyPairTags(t, svc, testAccountID, keyPairID)
|
||||
assert.Empty(t, tags)
|
||||
}
|
||||
|
||||
func TestKeyPairRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("key-missing"), aws.String("pg-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -164,11 +164,13 @@ func (s *KeyServiceImpl) CreateKeyPair(input *ec2.CreateKeyPairInput, accountID
|
||||
|
||||
// Build response (similar to AWS EC2)
|
||||
keyPairID := utils.GenerateResourceID("key")
|
||||
tags := utils.MapToEC2Tags(utils.ExtractTags(input.TagSpecifications, "key-pair"))
|
||||
output := &ec2.CreateKeyPairOutput{
|
||||
KeyFingerprint: aws.String(fingerprint),
|
||||
KeyMaterial: aws.String(string(privateKeyData)),
|
||||
KeyName: aws.String(keyName),
|
||||
KeyPairId: aws.String(keyPairID),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
// Store metadata file (CreateKeyPairOutput without KeyMaterial) for keyPairId lookups
|
||||
@@ -176,6 +178,7 @@ func (s *KeyServiceImpl) CreateKeyPair(input *ec2.CreateKeyPairInput, accountID
|
||||
KeyFingerprint: aws.String(fingerprint),
|
||||
KeyName: aws.String(keyName),
|
||||
KeyPairId: aws.String(keyPairID),
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to store key pair metadata", "err", err, "keyPairId", keyPairID)
|
||||
@@ -616,7 +619,7 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
|
||||
KeyFingerprint: metadata.KeyFingerprint,
|
||||
KeyName: metadata.KeyName,
|
||||
KeyType: aws.String(keyType),
|
||||
Tags: []*ec2.Tag{},
|
||||
Tags: metadata.Tags,
|
||||
}
|
||||
|
||||
// Use S3 object LastModified as CreateTime
|
||||
@@ -761,11 +764,12 @@ func (s *KeyServiceImpl) ImportKeyPair(input *ec2.ImportKeyPairInput, accountID
|
||||
keyPairID := utils.GenerateResourceID("key")
|
||||
|
||||
// Build response output
|
||||
tags := utils.MapToEC2Tags(utils.ExtractTags(input.TagSpecifications, "key-pair"))
|
||||
output := &ec2.ImportKeyPairOutput{
|
||||
KeyFingerprint: aws.String(fingerprint),
|
||||
KeyName: aws.String(keyName),
|
||||
KeyPairId: aws.String(keyPairID),
|
||||
Tags: []*ec2.Tag{}, // TODO: Implement tag support from input.TagSpecifications
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
// Store metadata file (without public key material)
|
||||
@@ -773,6 +777,7 @@ func (s *KeyServiceImpl) ImportKeyPair(input *ec2.ImportKeyPairInput, accountID
|
||||
KeyFingerprint: aws.String(fingerprint),
|
||||
KeyName: aws.String(keyName),
|
||||
KeyPairId: aws.String(keyPairID),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
err = s.storeKeyPairMetadata(accountID, keyPairID, metadataOutput)
|
||||
@@ -792,3 +797,64 @@ func (s *KeyServiceImpl) ImportKeyPair(input *ec2.ImportKeyPairInput, accountID
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning key-pair metadata so
|
||||
// DescribeKeyPairs observes tags added after create. Non-key ids and key pairs
|
||||
// absent from the caller's account prefix are skipped.
|
||||
func (s *KeyServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorKeyPairTags(input.Resources, accountID, utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning key-pair metadata with
|
||||
// AWS-faithful delete semantics.
|
||||
func (s *KeyServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorKeyPairTags(input.Resources, accountID, utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
// mirrorKeyPairTags read-modify-writes the metadata Tags slice for each key-
|
||||
// id, converting through a map to reuse the shared merge/remove semantics.
|
||||
// Metadata is stored under the caller's account prefix, so a cross-account or
|
||||
// absent key pair simply misses and no-ops.
|
||||
func (s *KeyServiceImpl) mirrorKeyPairTags(resources []*string, accountID string, mut func(map[string]string)) error {
|
||||
for _, res := range resources {
|
||||
if res == nil || !strings.HasPrefix(*res, "key-") {
|
||||
continue
|
||||
}
|
||||
metadataPath := fmt.Sprintf("keys/%s/%s.json", accountID, *res)
|
||||
result, err := s.store.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(metadataPath),
|
||||
})
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to get key pair metadata: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(result.Body)
|
||||
result.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read key pair metadata: %w", err)
|
||||
}
|
||||
var metadata ec2.CreateKeyPairOutput
|
||||
if err := json.Unmarshal(body, &metadata); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal key pair metadata: %w", err)
|
||||
}
|
||||
tags := filterutil.EC2TagsToMap(metadata.Tags)
|
||||
if tags == nil {
|
||||
tags = map[string]string{}
|
||||
}
|
||||
mut(tags)
|
||||
metadata.Tags = utils.MapToEC2Tags(tags)
|
||||
if err := s.storeKeyPairMetadata(accountID, *res, &metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package handlers_ec2_natgw
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func natgwTagVal(tags []*ec2.Tag, key string) string {
|
||||
for _, tg := range tags {
|
||||
if tg.Key != nil && *tg.Key == key {
|
||||
return aws.StringValue(tg.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestNatGatewayRecordTagsMirror(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
natgwID := createTestNatGateway(t, svc)
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(natgwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
NatGatewayIds: []*string{aws.String(natgwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NatGateways, 1)
|
||||
assert.Equal(t, "yes", natgwTagVal(out.NatGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "v", natgwTagVal(out.NatGateways[0].Tags, "drop"))
|
||||
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(natgwID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err = svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
NatGatewayIds: []*string{aws.String(natgwID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NatGateways, 1)
|
||||
assert.Equal(t, "yes", natgwTagVal(out.NatGateways[0].Tags, "keep"))
|
||||
assert.Equal(t, "", natgwTagVal(out.NatGateways[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestNatGatewayRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("nat-missing"), aws.String("igw-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -529,3 +529,26 @@ func recordToEC2(record *NatGatewayRecord) *ec2.NatGateway {
|
||||
|
||||
return ngw
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning NAT gateway KV record so
|
||||
// tag-filtered describes observe tags added after create. Resource ids this
|
||||
// service does not own are skipped; absent records are a no-op.
|
||||
func (s *NatGatewayServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.natgwKV, accountID, "nat-", input.Resources,
|
||||
func(r *NatGatewayRecord) *map[string]string { return &r.Tags },
|
||||
utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning NAT gateway KV record
|
||||
// with AWS-faithful delete semantics.
|
||||
func (s *NatGatewayServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.natgwKV, accountID, "nat-", input.Resources,
|
||||
func(r *NatGatewayRecord) *map[string]string { return &r.Tags },
|
||||
utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handlers_ec2_placementgroup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTaggedGroup(t *testing.T, svc *PlacementGroupServiceImpl, name string, tags map[string]string) *ec2.PlacementGroup {
|
||||
t.Helper()
|
||||
var ec2Tags []*ec2.Tag
|
||||
for k, v := range tags {
|
||||
ec2Tags = append(ec2Tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
out, err := svc.CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String(name),
|
||||
Strategy: aws.String("spread"),
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String("placement-group"),
|
||||
Tags: ec2Tags,
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out.PlacementGroup
|
||||
}
|
||||
|
||||
func describeWithFilter(t *testing.T, svc *PlacementGroupServiceImpl, name, value string) []*ec2.PlacementGroup {
|
||||
t.Helper()
|
||||
out, err := svc.DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{
|
||||
Filters: []*ec2.Filter{{
|
||||
Name: aws.String(name),
|
||||
Values: []*string{aws.String(value)},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out.PlacementGroups
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_TagSpecifications(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
pg := createTaggedGroup(t, svc, "tagged-group", map[string]string{"Name": "web", "env": "dev"})
|
||||
|
||||
tags := filterutil.EC2TagsToMap(pg.Tags)
|
||||
assert.Equal(t, "web", tags["Name"])
|
||||
assert.Equal(t, "dev", tags["env"])
|
||||
|
||||
out, err := svc.DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{
|
||||
GroupNames: []*string{aws.String("tagged-group")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.PlacementGroups, 1)
|
||||
descTags := filterutil.EC2TagsToMap(out.PlacementGroups[0].Tags)
|
||||
assert.Equal(t, "web", descTags["Name"])
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_TagSpecificationsWrongType(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("untagged-group"),
|
||||
Strategy: aws.String("cluster"),
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String("volume"),
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.PlacementGroup.Tags)
|
||||
}
|
||||
|
||||
func TestDescribePlacementGroups_TagFilters(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTaggedGroup(t, svc, "pg-dev", map[string]string{"env": "dev"})
|
||||
createTaggedGroup(t, svc, "pg-prod", map[string]string{"env": "prod"})
|
||||
|
||||
groups := describeWithFilter(t, svc, "tag:env", "dev")
|
||||
require.Len(t, groups, 1)
|
||||
assert.Equal(t, "pg-dev", *groups[0].GroupName)
|
||||
|
||||
groups = describeWithFilter(t, svc, "tag-key", "env")
|
||||
assert.Len(t, groups, 2)
|
||||
|
||||
groups = describeWithFilter(t, svc, "tag-value", "prod")
|
||||
require.Len(t, groups, 1)
|
||||
assert.Equal(t, "pg-prod", *groups[0].GroupName)
|
||||
|
||||
groups = describeWithFilter(t, svc, "tag:env", "staging")
|
||||
assert.Empty(t, groups)
|
||||
|
||||
groups = describeWithFilter(t, svc, "tag-key", "missing")
|
||||
assert.Empty(t, groups)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type PlacementGroupRecord struct {
|
||||
// NodeInstances tracks which node hosts which instances in this group.
|
||||
// Key = node name, Value = list of instance IDs on that node.
|
||||
NodeInstances map[string][]string `json:"node_instances"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementGroupServiceImpl implements placement group operations with NATS JetStream persistence.
|
||||
@@ -102,6 +103,7 @@ func (s *PlacementGroupServiceImpl) CreatePlacementGroup(input *ec2.CreatePlacem
|
||||
SpreadLevel: ec2.SpreadLevelHost,
|
||||
AccountID: accountID,
|
||||
NodeInstances: make(map[string][]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "placement-group"),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
@@ -164,6 +166,8 @@ var describePlacementGroupsValidFilters = map[string]bool{
|
||||
"state": true,
|
||||
"spread-level": true,
|
||||
"group-name": true,
|
||||
"tag-key": true,
|
||||
"tag-value": true,
|
||||
}
|
||||
|
||||
// DescribePlacementGroups lists placement groups with optional filters.
|
||||
@@ -255,6 +259,9 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(input *ec2.DescribeP
|
||||
// pgMatchesFilters checks whether a placement group record matches all parsed filters.
|
||||
func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "group-id":
|
||||
if !filterutil.MatchesAny(values, record.GroupId) {
|
||||
@@ -276,11 +283,30 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string)
|
||||
if !filterutil.MatchesAny(values, record.GroupName) {
|
||||
return false
|
||||
}
|
||||
case "tag-key":
|
||||
if !pgMatchesAnyTag(record.Tags, values, func(k, _ string) string { return k }) {
|
||||
return false
|
||||
}
|
||||
case "tag-value":
|
||||
if !pgMatchesAnyTag(record.Tags, values, func(_, v string) string { return v }) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return filterutil.MatchesTags(filters, record.Tags)
|
||||
}
|
||||
|
||||
// pgMatchesAnyTag reports whether any tag's selected field (key or value)
|
||||
// matches any of the filter values.
|
||||
func pgMatchesAnyTag(tags map[string]string, values []string, field func(k, v string) string) bool {
|
||||
for k, v := range tags {
|
||||
if filterutil.MatchesAny(values, field(k, v)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPlacementGroupRecord reads a placement group record from KV with its revision for CAS operations.
|
||||
@@ -578,6 +604,7 @@ func (s *PlacementGroupServiceImpl) recordToEC2(record *PlacementGroupRecord) *e
|
||||
GroupName: aws.String(record.GroupName),
|
||||
Strategy: aws.String(record.Strategy),
|
||||
State: aws.String(record.State),
|
||||
Tags: utils.MapToEC2Tags(record.Tags),
|
||||
}
|
||||
if record.Strategy == ec2.PlacementStrategySpread {
|
||||
pg.SpreadLevel = aws.String(record.SpreadLevel)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers_ec2_routetable
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func rtbTagVal(tags []*ec2.Tag, key string) string {
|
||||
for _, tg := range tags {
|
||||
if tg.Key != nil && *tg.Key == key {
|
||||
return aws.StringValue(tg.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRouteTableRecordTagsMirror(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
rtbID := createTestRtb(t, svc)
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(rtbID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
RouteTableIds: []*string{aws.String(rtbID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.RouteTables, 1)
|
||||
assert.Equal(t, "yes", rtbTagVal(out.RouteTables[0].Tags, "keep"))
|
||||
assert.Equal(t, "v", rtbTagVal(out.RouteTables[0].Tags, "drop"))
|
||||
|
||||
// Value-mismatched delete is a no-op; matched delete removes.
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(rtbID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
RouteTableIds: []*string{aws.String(rtbID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.RouteTables, 1)
|
||||
assert.Equal(t, "yes", rtbTagVal(out.RouteTables[0].Tags, "keep"))
|
||||
assert.Equal(t, "", rtbTagVal(out.RouteTables[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestRouteTableRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
// Absent rtb + non-rtb id: both skipped without error.
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("rtb-missing"), aws.String("vpc-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -1566,3 +1566,26 @@ func recordToEC2(record *RouteTableRecord) *ec2.RouteTable {
|
||||
|
||||
return rtb
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning route-table KV record so
|
||||
// tag-filtered describes observe tags added after create. Resource ids this
|
||||
// service does not own are skipped; absent records are a no-op.
|
||||
func (s *RouteTableServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.rtbKV, accountID, "rtb-", input.Resources,
|
||||
func(r *RouteTableRecord) *map[string]string { return &r.Tags },
|
||||
utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning route-table KV record
|
||||
// with AWS-faithful delete semantics.
|
||||
func (s *RouteTableServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return utils.MirrorKVRecordTags(s.rtbKV, accountID, "rtb-", input.Resources,
|
||||
func(r *RouteTableRecord) *map[string]string { return &r.Tags },
|
||||
utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers_ec2_snapshot
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestSnapshotRecordTagsMirror(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapID := createTestSnapshot(t, svc, store, "vol-tagmirror", 10, nil)
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(snapID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
cfg, err := svc.getSnapshotConfig(snapID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "yes", cfg.Tags["keep"])
|
||||
assert.Equal(t, "v", cfg.Tags["drop"])
|
||||
|
||||
// Value-mismatched delete is a no-op; matched delete removes.
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(snapID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
cfg, err = svc.getSnapshotConfig(snapID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "yes", cfg.Tags["keep"])
|
||||
_, ok := cfg.Tags["drop"]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSnapshotRecordTagsMirror_CrossTenantNoMutation(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapID := createTestSnapshot(t, svc, store, "vol-tenantguard", 10, map[string]string{"orig": "1"})
|
||||
|
||||
// A different account tagging this snapshot must not mutate the record.
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(snapID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("evil"), Value: aws.String("1")}},
|
||||
}, "999999999999"))
|
||||
|
||||
cfg, err := svc.getSnapshotConfig(snapID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"orig": "1"}, cfg.Tags)
|
||||
}
|
||||
|
||||
func TestSnapshotRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("snap-missing"), aws.String("vol-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testAccountID))
|
||||
}
|
||||
@@ -740,3 +740,52 @@ func (s *SnapshotServiceImpl) volumeHasSnapshots(volumeID string) (bool, error)
|
||||
|
||||
return len(snapshots) > 0, nil
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning snapshot metadata so
|
||||
// DescribeSnapshots observes tags added after create. Non-snap ids, snapshots
|
||||
// absent from this store, and snapshots the caller does not own are skipped.
|
||||
func (s *SnapshotServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorSnapshotTags(input.Resources, accountID, utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning snapshot metadata with
|
||||
// AWS-faithful delete semantics.
|
||||
func (s *SnapshotServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorSnapshotTags(input.Resources, accountID, utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
// mirrorSnapshotTags read-modify-writes SnapshotConfig.Tags for each snap- id.
|
||||
// metadata.json lives at a global ID-keyed path, so the mutation is gated on
|
||||
// the caller owning the snapshot (OwnerID match); mismatch or absence no-ops.
|
||||
func (s *SnapshotServiceImpl) mirrorSnapshotTags(resources []*string, accountID string, mut func(map[string]string)) error {
|
||||
for _, res := range resources {
|
||||
if res == nil || !strings.HasPrefix(*res, "snap-") {
|
||||
continue
|
||||
}
|
||||
cfg, err := ReadSnapshotConfig(s.store, s.config.Predastore.Bucket, *res)
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if cfg.OwnerID != accountID {
|
||||
slog.Debug("mirrorSnapshotTags: skipping snapshot not owned by caller", "snapshotId", *res)
|
||||
continue
|
||||
}
|
||||
if cfg.Tags == nil {
|
||||
cfg.Tags = map[string]string{}
|
||||
}
|
||||
mut(cfg.Tags)
|
||||
if err := s.putSnapshotConfig(*res, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,21 @@ func getResourceType(resourceID string) string {
|
||||
if strings.HasPrefix(resourceID, "eigw-") {
|
||||
return "egress-only-internet-gateway"
|
||||
}
|
||||
if strings.HasPrefix(resourceID, "eni-") {
|
||||
return "network-interface"
|
||||
}
|
||||
if strings.HasPrefix(resourceID, "eipalloc-") {
|
||||
return "elastic-ip"
|
||||
}
|
||||
if strings.HasPrefix(resourceID, "nat-") {
|
||||
return "natgateway"
|
||||
}
|
||||
if strings.HasPrefix(resourceID, "key-") {
|
||||
return "key-pair"
|
||||
}
|
||||
if strings.HasPrefix(resourceID, "pg-") {
|
||||
return "placement-group"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
||||
@@ -439,6 +439,11 @@ func TestGetResourceType(t *testing.T) {
|
||||
{"rtb-abc123", "route-table"},
|
||||
{"igw-abc123", "internet-gateway"},
|
||||
{"eigw-abc123", "egress-only-internet-gateway"},
|
||||
{"eni-abc123", "network-interface"},
|
||||
{"eipalloc-abc123", "elastic-ip"},
|
||||
{"nat-abc123", "natgateway"},
|
||||
{"key-abc123", "key-pair"},
|
||||
{"pg-abc123", "placement-group"},
|
||||
{"unknown-abc123", "unknown"},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers_ec2_volume
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVolumeRecordTagsMirror(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
seedVolume(t, svc, "vol-tagmirror0001", "available", "")
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("vol-tagmirror0001")},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testVolAccountID))
|
||||
|
||||
cfg, err := svc.GetVolumeConfig("vol-tagmirror0001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "yes", cfg.VolumeMetadata.Tags["keep"])
|
||||
assert.Equal(t, "v", cfg.VolumeMetadata.Tags["drop"])
|
||||
|
||||
// Value-mismatched delete is a no-op; matched delete removes.
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String("vol-tagmirror0001")},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testVolAccountID))
|
||||
|
||||
cfg, err = svc.GetVolumeConfig("vol-tagmirror0001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "yes", cfg.VolumeMetadata.Tags["keep"])
|
||||
_, ok := cfg.VolumeMetadata.Tags["drop"]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestVolumeRecordTagsMirror_CrossTenantNoMutation(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
seedVolume(t, svc, "vol-tenantguard01", "available", "")
|
||||
|
||||
// A different account tagging this volume must not mutate the record.
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("vol-tenantguard01")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("evil"), Value: aws.String("1")}},
|
||||
}, "999999999999"))
|
||||
|
||||
cfg, err := svc.GetVolumeConfig("vol-tenantguard01")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cfg.VolumeMetadata.Tags)
|
||||
}
|
||||
|
||||
func TestVolumeRecordTagsMirror_UnownedNoError(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String("vol-missing000001"), aws.String("snap-other")},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
|
||||
}, testVolAccountID))
|
||||
}
|
||||
@@ -162,6 +162,8 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID
|
||||
slog.Info("CreateVolume", "volumeId", volumeID, "size", size, "type", volumeType,
|
||||
"az", *input.AvailabilityZone, "snapshotId", snapshotID)
|
||||
|
||||
tags := utils.ExtractTags(input.TagSpecifications, "volume")
|
||||
|
||||
// Volume size in bytes for viperblock
|
||||
sizeGiB := utils.SafeInt64ToUint64(size)
|
||||
volumeSizeBytes := sizeGiB * 1024 * 1024 * 1024
|
||||
@@ -178,6 +180,7 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID
|
||||
VolumeType: volumeType,
|
||||
IOPS: iops,
|
||||
SnapshotID: snapshotID,
|
||||
Tags: tags,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -246,6 +249,7 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID
|
||||
CreateTime: aws.Time(now),
|
||||
Iops: aws.Int64(int64(iops)),
|
||||
Encrypted: aws.Bool(mkey != nil),
|
||||
Tags: utils.MapToEC2Tags(tags),
|
||||
}
|
||||
|
||||
if snapshotID != "" {
|
||||
@@ -1603,3 +1607,52 @@ func (s *VolumeServiceImpl) volumeHasSnapshotsKV(volumeID string) (bool, error)
|
||||
|
||||
return len(snapshots) > 0, nil
|
||||
}
|
||||
|
||||
// ApplyRecordTags mirrors CreateTags into the owning volume config so
|
||||
// DescribeVolumes observes tags added after create. Non-vol ids, volumes
|
||||
// absent from this store, and volumes the caller does not own are skipped.
|
||||
func (s *VolumeServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorVolumeTags(input.Resources, accountID, utils.MergeTagsMut(input))
|
||||
}
|
||||
|
||||
// RemoveRecordTags mirrors DeleteTags into the owning volume config with
|
||||
// AWS-faithful delete semantics.
|
||||
func (s *VolumeServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID string) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return s.mirrorVolumeTags(input.Resources, accountID, utils.RemoveTagsMut(input))
|
||||
}
|
||||
|
||||
// mirrorVolumeTags read-modify-writes VolumeMetadata.Tags for each vol- id.
|
||||
// config.json lives at a global ID-keyed path, so the mutation is gated on the
|
||||
// caller owning the volume (TenantID match); mismatch or absence is a no-op.
|
||||
func (s *VolumeServiceImpl) mirrorVolumeTags(resources []*string, accountID string, mut func(map[string]string)) error {
|
||||
for _, res := range resources {
|
||||
if res == nil || !strings.HasPrefix(*res, "vol-") {
|
||||
continue
|
||||
}
|
||||
cfg, err := s.GetVolumeConfig(*res)
|
||||
if err != nil {
|
||||
if err.Error() == awserrors.ErrorInvalidVolumeNotFound {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if cfg.VolumeMetadata.TenantID != accountID {
|
||||
slog.Debug("mirrorVolumeTags: skipping volume not owned by caller", "volumeId", *res)
|
||||
continue
|
||||
}
|
||||
if cfg.VolumeMetadata.Tags == nil {
|
||||
cfg.VolumeMetadata.Tags = map[string]string{}
|
||||
}
|
||||
mut(cfg.VolumeMetadata.Tags)
|
||||
if err := s.putVolumeConfig(*res, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -798,13 +798,7 @@ func (s *VPCServiceImpl) ApplyRecordTags(input *ec2.CreateTagsInput, accountID s
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
merge := func(tags map[string]string) {
|
||||
for _, t := range input.Tags {
|
||||
if t.Key != nil && t.Value != nil {
|
||||
tags[*t.Key] = *t.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
merge := utils.MergeTagsMut(input)
|
||||
for _, res := range input.Resources {
|
||||
if res == nil {
|
||||
continue
|
||||
@@ -823,24 +817,7 @@ func (s *VPCServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
remove := func(tags map[string]string) {
|
||||
if len(input.Tags) == 0 {
|
||||
for k := range tags {
|
||||
delete(tags, k)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, t := range input.Tags {
|
||||
if t.Key == nil {
|
||||
continue
|
||||
}
|
||||
if t.Value == nil {
|
||||
delete(tags, *t.Key)
|
||||
} else if cur, ok := tags[*t.Key]; ok && cur == *t.Value {
|
||||
delete(tags, *t.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
remove := utils.RemoveTagsMut(input)
|
||||
for _, res := range input.Resources {
|
||||
if res == nil {
|
||||
continue
|
||||
@@ -852,50 +829,38 @@ func (s *VPCServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRecordTags applies mut to the tag map of the subnet- or vpc-scoped
|
||||
// record identified by resourceID. Other resource ids are a no-op.
|
||||
// updateRecordTags applies mut to the tag map of the subnet-, vpc-, sg-, or
|
||||
// eni-scoped record identified by resourceID. Other resource ids are a no-op.
|
||||
func (s *VPCServiceImpl) updateRecordTags(accountID, resourceID string, mut func(map[string]string)) error {
|
||||
switch {
|
||||
case strings.HasPrefix(resourceID, "subnet-"):
|
||||
return updateKVRecordTags(s.subnetKV, accountID, resourceID, func(r *SubnetRecord) {
|
||||
return utils.UpdateKVRecordTags(s.subnetKV, accountID, resourceID, func(r *SubnetRecord) {
|
||||
if r.Tags == nil {
|
||||
r.Tags = map[string]string{}
|
||||
}
|
||||
mut(r.Tags)
|
||||
})
|
||||
case strings.HasPrefix(resourceID, "vpc-"):
|
||||
return updateKVRecordTags(s.vpcKV, accountID, resourceID, func(r *VPCRecord) {
|
||||
return utils.UpdateKVRecordTags(s.vpcKV, accountID, resourceID, func(r *VPCRecord) {
|
||||
if r.Tags == nil {
|
||||
r.Tags = map[string]string{}
|
||||
}
|
||||
mut(r.Tags)
|
||||
})
|
||||
case strings.HasPrefix(resourceID, "sg-"):
|
||||
return utils.UpdateKVRecordTags(s.sgKV, accountID, resourceID, func(r *SecurityGroupRecord) {
|
||||
if r.Tags == nil {
|
||||
r.Tags = map[string]string{}
|
||||
}
|
||||
mut(r.Tags)
|
||||
})
|
||||
case strings.HasPrefix(resourceID, "eni-"):
|
||||
return utils.UpdateKVRecordTags(s.eniKV, accountID, resourceID, func(r *ENIRecord) {
|
||||
if r.Tags == nil {
|
||||
r.Tags = map[string]string{}
|
||||
}
|
||||
mut(r.Tags)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateKVRecordTags read-modify-writes a typed KV record, applying mut. A
|
||||
// resource absent from this store is skipped (its tags live elsewhere).
|
||||
func updateKVRecordTags[R any](kv nats.KeyValue, accountID, resourceID string, mut func(*R)) error {
|
||||
key := utils.AccountKey(accountID, resourceID)
|
||||
entry, err := kv.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
var rec R
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
mut(&rec)
|
||||
data, err := json.Marshal(&rec)
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := kv.Put(key, data); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1828,6 +1828,81 @@ func TestRemoveRecordTags_Subnet(t *testing.T) {
|
||||
assert.Equal(t, "", findTag(out.Subnets[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestApplyRecordTags_SecurityGroupTagFilteredDescribe(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.3.0.0/16")
|
||||
sgID := createTestSG(t, svc, vpcID, "mirror-sg")
|
||||
|
||||
err := svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(sgID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("env"), Value: aws.String("prod")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("tag:env"), Values: []*string{aws.String("prod")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.SecurityGroups, 1)
|
||||
assert.Equal(t, sgID, *out.SecurityGroups[0].GroupId)
|
||||
assert.Equal(t, "prod", findTag(out.SecurityGroups[0].Tags, "env"))
|
||||
}
|
||||
|
||||
func TestRemoveRecordTags_SecurityGroup(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.4.0.0/16")
|
||||
sgID := createTestSG(t, svc, vpcID, "mirror-sg-del")
|
||||
|
||||
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(sgID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("yes")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
require.NoError(t, svc.RemoveRecordTags(&ec2.DeleteTagsInput{
|
||||
Resources: []*string{aws.String(sgID)},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
},
|
||||
}, testAccountID))
|
||||
|
||||
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
|
||||
GroupIds: []*string{aws.String(sgID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.SecurityGroups, 1)
|
||||
assert.Equal(t, "yes", findTag(out.SecurityGroups[0].Tags, "keep"))
|
||||
assert.Equal(t, "", findTag(out.SecurityGroups[0].Tags, "drop"))
|
||||
}
|
||||
|
||||
func TestApplyRecordTags_ENITagFilteredDescribe(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.5.0.0/16")
|
||||
subnetID := createTestSubnet(t, svc, vpcID, "10.5.1.0/24")
|
||||
eniID := createTestENI(t, svc, subnetID)
|
||||
|
||||
err := svc.ApplyRecordTags(&ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(eniID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("Name"), Value: aws.String("primary")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("tag:Name"), Values: []*string{aws.String("primary")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eniID, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
assert.Equal(t, "primary", findTag(out.NetworkInterfaces[0].TagSet, "Name"))
|
||||
}
|
||||
|
||||
func TestApplyRecordTags_UnknownResourceNoError(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
// Absent subnet + non-VPC-owned resource id: both skipped without error.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// MergeTagsMut returns a mutator that merges the CreateTagsInput tags into a
|
||||
// record's tag map. Tags with a nil key or value are skipped.
|
||||
func MergeTagsMut(input *ec2.CreateTagsInput) func(map[string]string) {
|
||||
return func(tags map[string]string) {
|
||||
for _, t := range input.Tags {
|
||||
if t.Key != nil && t.Value != nil {
|
||||
tags[*t.Key] = *t.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveTagsMut returns a mutator with AWS-faithful DeleteTags semantics:
|
||||
// empty input.Tags clears all tags; a tag with a value deletes only on a
|
||||
// value match, a nil value deletes unconditionally.
|
||||
func RemoveTagsMut(input *ec2.DeleteTagsInput) func(map[string]string) {
|
||||
return func(tags map[string]string) {
|
||||
if len(input.Tags) == 0 {
|
||||
for k := range tags {
|
||||
delete(tags, k)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, t := range input.Tags {
|
||||
if t.Key == nil {
|
||||
continue
|
||||
}
|
||||
if t.Value == nil {
|
||||
delete(tags, *t.Key)
|
||||
} else if cur, ok := tags[*t.Key]; ok && cur == *t.Value {
|
||||
delete(tags, *t.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateKVRecordTags read-modify-writes a typed account-scoped KV record,
|
||||
// applying mut. A resource absent from this store is skipped (its tags live
|
||||
// elsewhere).
|
||||
func UpdateKVRecordTags[R any](kv nats.KeyValue, accountID, resourceID string, mut func(*R)) error {
|
||||
key := AccountKey(accountID, resourceID)
|
||||
entry, err := kv.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
slog.Error("UpdateKVRecordTags: KV read failed", "key", key, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
var rec R
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
slog.Error("UpdateKVRecordTags: record unmarshal failed", "key", key, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
mut(&rec)
|
||||
data, err := json.Marshal(&rec)
|
||||
if err != nil {
|
||||
slog.Error("UpdateKVRecordTags: record marshal failed", "key", key, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := kv.Put(key, data); err != nil {
|
||||
slog.Error("UpdateKVRecordTags: KV write failed", "key", key, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MirrorKVRecordTags applies mut to the tag map of every resource in
|
||||
// resources carrying prefix, via UpdateKVRecordTags on the service's own KV
|
||||
// handle. tagsOf returns the address of the record's tag map so a nil map can
|
||||
// be initialized in place. Resource ids without prefix are skipped.
|
||||
func MirrorKVRecordTags[R any](kv nats.KeyValue, accountID, prefix string, resources []*string, tagsOf func(*R) *map[string]string, mut func(map[string]string)) error {
|
||||
for _, res := range resources {
|
||||
if res == nil || !strings.HasPrefix(*res, prefix) {
|
||||
continue
|
||||
}
|
||||
if err := UpdateKVRecordTags(kv, accountID, *res, func(r *R) {
|
||||
tp := tagsOf(r)
|
||||
if *tp == nil {
|
||||
*tp = map[string]string{}
|
||||
}
|
||||
mut(*tp)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type tagMirrorRecord struct {
|
||||
Name string `json:"name"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
func TestMergeTagsMut(t *testing.T) {
|
||||
mut := MergeTagsMut(&ec2.CreateTagsInput{
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("a"), Value: aws.String("1")},
|
||||
{Key: aws.String("b"), Value: aws.String("2")},
|
||||
{Key: nil, Value: aws.String("skip")},
|
||||
{Key: aws.String("skip"), Value: nil},
|
||||
},
|
||||
})
|
||||
tags := map[string]string{"a": "old", "keep": "x"}
|
||||
mut(tags)
|
||||
assert.Equal(t, map[string]string{"a": "1", "b": "2", "keep": "x"}, tags)
|
||||
}
|
||||
|
||||
func TestRemoveTagsMut(t *testing.T) {
|
||||
base := func() map[string]string {
|
||||
return map[string]string{"a": "1", "b": "2", "c": "3"}
|
||||
}
|
||||
|
||||
t.Run("empty input clears all", func(t *testing.T) {
|
||||
tags := base()
|
||||
RemoveTagsMut(&ec2.DeleteTagsInput{})(tags)
|
||||
assert.Empty(t, tags)
|
||||
})
|
||||
|
||||
t.Run("value match deletes, mismatch keeps", func(t *testing.T) {
|
||||
tags := base()
|
||||
RemoveTagsMut(&ec2.DeleteTagsInput{Tags: []*ec2.Tag{
|
||||
{Key: aws.String("a"), Value: aws.String("1")},
|
||||
{Key: aws.String("b"), Value: aws.String("wrong")},
|
||||
}})(tags)
|
||||
assert.Equal(t, map[string]string{"b": "2", "c": "3"}, tags)
|
||||
})
|
||||
|
||||
t.Run("nil value deletes unconditionally, nil key skipped", func(t *testing.T) {
|
||||
tags := base()
|
||||
RemoveTagsMut(&ec2.DeleteTagsInput{Tags: []*ec2.Tag{
|
||||
{Key: aws.String("c"), Value: nil},
|
||||
{Key: nil, Value: aws.String("1")},
|
||||
}})(tags)
|
||||
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, tags)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateKVRecordTags(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
rec, err := json.Marshal(&tagMirrorRecord{Name: "r1", Tags: map[string]string{"a": "1"}})
|
||||
require.NoError(t, err)
|
||||
kv := testutil.SeedKV(t, js, "tagmirror-test", map[string][]byte{
|
||||
AccountKey("acct", "res-1"): rec,
|
||||
AccountKey("acct", "res-bad"): []byte("not-json"),
|
||||
})
|
||||
|
||||
t.Run("mutates and persists", func(t *testing.T) {
|
||||
require.NoError(t, UpdateKVRecordTags(kv, "acct", "res-1", func(r *tagMirrorRecord) {
|
||||
r.Tags["b"] = "2"
|
||||
}))
|
||||
entry, err := kv.Get(AccountKey("acct", "res-1"))
|
||||
require.NoError(t, err)
|
||||
var got tagMirrorRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &got))
|
||||
assert.Equal(t, "r1", got.Name)
|
||||
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, got.Tags)
|
||||
})
|
||||
|
||||
t.Run("absent key is a no-op", func(t *testing.T) {
|
||||
require.NoError(t, UpdateKVRecordTags(kv, "acct", "res-missing", func(r *tagMirrorRecord) {
|
||||
t.Fatal("mutator must not run for absent record")
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("corrupt record returns internal error", func(t *testing.T) {
|
||||
err := UpdateKVRecordTags(kv, "acct", "res-bad", func(r *tagMirrorRecord) {})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMirrorKVRecordTags(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
withTags, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"drop": "v", "keep": "yes"}})
|
||||
require.NoError(t, err)
|
||||
nilTags, err := json.Marshal(&tagMirrorRecord{})
|
||||
require.NoError(t, err)
|
||||
kv := testutil.SeedKV(t, js, "tagmirror-mirror-test", map[string][]byte{
|
||||
AccountKey("acct", "vol-1"): withTags,
|
||||
AccountKey("acct", "vol-2"): nilTags,
|
||||
})
|
||||
|
||||
tagsOf := func(r *tagMirrorRecord) *map[string]string { return &r.Tags }
|
||||
|
||||
readTags := func(t *testing.T, id string) map[string]string {
|
||||
entry, err := kv.Get(AccountKey("acct", id))
|
||||
require.NoError(t, err)
|
||||
var got tagMirrorRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &got))
|
||||
return got.Tags
|
||||
}
|
||||
|
||||
resources := []*string{
|
||||
aws.String("vol-1"),
|
||||
aws.String("vol-2"),
|
||||
aws.String("snap-other"),
|
||||
aws.String("vol-absent"),
|
||||
nil,
|
||||
}
|
||||
|
||||
require.NoError(t, MirrorKVRecordTags(kv, "acct", "vol-", resources, tagsOf,
|
||||
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
|
||||
{Key: aws.String("added"), Value: aws.String("1")},
|
||||
}})))
|
||||
assert.Equal(t, map[string]string{"drop": "v", "keep": "yes", "added": "1"}, readTags(t, "vol-1"))
|
||||
assert.Equal(t, map[string]string{"added": "1"}, readTags(t, "vol-2"), "nil tag map initialized in place")
|
||||
|
||||
require.NoError(t, MirrorKVRecordTags(kv, "acct", "vol-", resources, tagsOf,
|
||||
RemoveTagsMut(&ec2.DeleteTagsInput{Tags: []*ec2.Tag{
|
||||
{Key: aws.String("drop"), Value: aws.String("v")},
|
||||
{Key: aws.String("keep"), Value: aws.String("wrong")},
|
||||
}})))
|
||||
assert.Equal(t, map[string]string{"keep": "yes", "added": "1"}, readTags(t, "vol-1"))
|
||||
|
||||
// snap-other carries the wrong prefix, so a record stored under that id
|
||||
// must never be touched even though it exists in this bucket.
|
||||
otherRec, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"x": "y"}})
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(AccountKey("acct", "snap-other"), otherRec)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, MirrorKVRecordTags(kv, "acct", "vol-", resources, tagsOf,
|
||||
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
|
||||
{Key: aws.String("added"), Value: aws.String("2")},
|
||||
}})))
|
||||
assert.Equal(t, map[string]string{"x": "y"}, readTags(t, "snap-other"))
|
||||
}
|
||||
Reference in New Issue
Block a user