diff --git a/go.mod b/go.mod index 4fbfc9e8..729097b9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( git.arvancloud.ir/arvancloud/cdn-go-sdk v0.12.1 github.com/aliyun/alibaba-cloud-sdk-go v1.62.560 - github.com/aws/aws-sdk-go v1.45.19 + github.com/aws/aws-sdk-go v1.55.5 github.com/cloudflare/cloudflare-go v0.77.0 github.com/digitalocean/godo v1.102.1 github.com/fastly/go-fastly/v3 v3.12.0 diff --git a/go.sum b/go.sum index 417e5307..685d1e8e 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.45.19 h1:+4yXWhldhCVXWFOQRF99ZTJ92t4DtoHROZIbN7Ujk/U= -github.com/aws/aws-sdk-go v1.45.19/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -708,7 +708,6 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -782,7 +781,6 @@ golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXct golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= @@ -798,7 +796,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= diff --git a/pkg/providers/aws/aws.go b/pkg/providers/aws/aws.go index 762f5a43..3545e123 100644 --- a/pkg/providers/aws/aws.go +++ b/pkg/providers/aws/aws.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ecs" "github.com/aws/aws-sdk-go/service/eks" + "github.com/aws/aws-sdk-go/service/elasticache" "github.com/aws/aws-sdk-go/service/elb" "github.com/aws/aws-sdk-go/service/elbv2" "github.com/aws/aws-sdk-go/service/lambda" @@ -30,7 +31,7 @@ import ( sliceutil "github.com/projectdiscovery/utils/slice" ) -var Services = []string{"ec2", "instance", "route53", "s3", "ecs", "eks", "lambda", "apigateway", "apigatewayv2", "alb", "elb", "lightsail", "cloudfront", "rds"} +var Services = []string{"ec2", "instance", "route53", "s3", "ecs", "eks", "lambda", "apigateway", "apigatewayv2", "alb", "elb", "lightsail", "cloudfront", "rds", "elasticache"} type ProviderOptions struct { Id string @@ -101,22 +102,23 @@ func (p *ProviderOptions) ParseOptionBlock(block schema.OptionBlock) error { // Provider is a data provider for aws API type Provider struct { - options *ProviderOptions - ec2Client *ec2.EC2 - route53Client *route53.Route53 - s3Client *s3.S3 - ecsClient *ecs.ECS - eksClient *eks.EKS - lambdaClient *lambda.Lambda - apiGateway *apigateway.APIGateway - apiGatewayV2 *apigatewayv2.ApiGatewayV2 - albClient *elbv2.ELBV2 - elbClient *elb.ELB - lightsailClient *lightsail.Lightsail - cloudFrontClient *cloudfront.CloudFront - rdsClient *rds.RDS - regions *ec2.DescribeRegionsOutput - session *session.Session + options *ProviderOptions + ec2Client *ec2.EC2 + route53Client *route53.Route53 + s3Client *s3.S3 + ecsClient *ecs.ECS + eksClient *eks.EKS + lambdaClient *lambda.Lambda + apiGateway *apigateway.APIGateway + apiGatewayV2 *apigatewayv2.ApiGatewayV2 + albClient *elbv2.ELBV2 + elbClient *elb.ELB + lightsailClient *lightsail.Lightsail + cloudFrontClient *cloudfront.CloudFront + rdsClient *rds.RDS + elastiCacheClient *elasticache.ElastiCache + regions *ec2.DescribeRegionsOutput + session *session.Session } // New creates a new provider client for aws API @@ -396,6 +398,9 @@ func (p *Provider) initServices(sess *session.Session) { if services.Has("rds") { p.rdsClient = rds.New(sess) } + if services.Has("elasticache") { + p.elastiCacheClient = elasticache.New(sess) + } } const providerName = "aws" @@ -503,6 +508,10 @@ func (p *Provider) Resources(ctx context.Context) (*schema.Resources, error) { rdsProvider := &rdsProvider{rdsClient: p.rdsClient, options: *p.options, session: p.session, regions: p.regions} assignWorker(rdsProvider.GetResource) } + if p.elastiCacheClient != nil { + elastiCacheProvider := &elastiCacheProvider{elastiCacheClient: p.elastiCacheClient, options: *p.options, session: p.session, regions: p.regions} + assignWorker(elastiCacheProvider.GetResource) + } go func() { workersWaitGroup.Wait() diff --git a/pkg/providers/aws/elasticache.go b/pkg/providers/aws/elasticache.go new file mode 100644 index 00000000..f7850ad5 --- /dev/null +++ b/pkg/providers/aws/elasticache.go @@ -0,0 +1,226 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials/stscreds" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/pkg/errors" + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/projectdiscovery/gologger" +) + +// elastiCacheProvider is a provider for AWS ElastiCache API. +type elastiCacheProvider struct { + options ProviderOptions + elastiCacheClient *elasticache.ElastiCache + session *session.Session + regions *ec2.DescribeRegionsOutput +} + +func (ep *elastiCacheProvider) name() string { + return "elasticache" +} + +// GetResource returns all the resources in the store for a provider. +func (ep *elastiCacheProvider) GetResource(ctx context.Context) (*schema.Resources, error) { + list := schema.NewResources() + var wg sync.WaitGroup + var mu sync.Mutex + var errs []error + + for _, region := range ep.regions.Regions { + for _, client := range ep.getElastiCacheClients(region.RegionName) { + wg.Add(1) + + go func(client *elasticache.ElastiCache) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("panic in elasticache provider: %v", r)) + mu.Unlock() + } + }() + + resources, err := ep.listElastiCacheResources(client) + mu.Lock() + defer mu.Unlock() + if resources != nil { + list.Merge(resources) + } + if err != nil { + errs = append(errs, err) + } + }(client) + } + } + wg.Wait() + if len(errs) > 0 && len(list.Items) == 0 { + return nil, fmt.Errorf("elasticache: all workers failed: %v", errs) + } + if len(errs) > 0 { + gologger.Warning().Msgf("elasticache: some listings failed: %v", errs) + } + return list, nil +} + +func (ep *elastiCacheProvider) listElastiCacheResources(client *elasticache.ElastiCache) (*schema.Resources, error) { + list := schema.NewResources() + appendEndpoint := func(endpoint *elasticache.Endpoint, metadata map[string]string) { + if endpoint == nil || aws.StringValue(endpoint.Address) == "" { + return + } + list.Append(&schema.Resource{ + ID: ep.options.Id, + Provider: providerName, + DNSName: aws.StringValue(endpoint.Address), + Public: true, + Service: ep.name(), + Metadata: metadata, + }) + } + + err := client.DescribeReplicationGroupsPages(&elasticache.DescribeReplicationGroupsInput{}, func(page *elasticache.DescribeReplicationGroupsOutput, _ bool) bool { + for _, group := range page.ReplicationGroups { + var metadata map[string]string + if ep.options.ExtendedMetadata { + metadata = getReplicationGroupMetadata(group) + } + appendEndpoint(group.ConfigurationEndpoint, metadata) + for _, nodeGroup := range group.NodeGroups { + appendEndpoint(nodeGroup.PrimaryEndpoint, metadata) + appendEndpoint(nodeGroup.ReaderEndpoint, metadata) + for _, member := range nodeGroup.NodeGroupMembers { + appendEndpoint(member.ReadEndpoint, metadata) + } + } + } + return true + }) + if err != nil { + return list, errors.Wrap(err, "could not describe elasticache replication groups") + } + + // Node endpoints are only returned when ShowCacheNodeInfo is set; Memcached + // clients connect to them directly, so they are reachable endpoints too. + err = client.DescribeCacheClustersPages(&elasticache.DescribeCacheClustersInput{ShowCacheNodeInfo: aws.Bool(true)}, func(page *elasticache.DescribeCacheClustersOutput, _ bool) bool { + for _, cluster := range page.CacheClusters { + var metadata map[string]string + if ep.options.ExtendedMetadata { + metadata = getCacheClusterMetadata(cluster) + } + appendEndpoint(cluster.ConfigurationEndpoint, metadata) + for _, node := range cluster.CacheNodes { + appendEndpoint(node.Endpoint, metadata) + } + } + return true + }) + if err != nil { + return list, errors.Wrap(err, "could not describe elasticache cache clusters") + } + + // Serverless caches are not available in every region, so a failure here + // must not discard the clusters already found. + _ = client.DescribeServerlessCachesPages(&elasticache.DescribeServerlessCachesInput{}, func(page *elasticache.DescribeServerlessCachesOutput, _ bool) bool { + for _, cache := range page.ServerlessCaches { + var metadata map[string]string + if ep.options.ExtendedMetadata { + metadata = getServerlessCacheMetadata(cache) + } + appendEndpoint(cache.Endpoint, metadata) + appendEndpoint(cache.ReaderEndpoint, metadata) + } + return true + }) + + return list, nil +} + +func getReplicationGroupMetadata(group *elasticache.ReplicationGroup) map[string]string { + metadata := make(map[string]string) + schema.AddMetadata(metadata, "replication_group_id", group.ReplicationGroupId) + schema.AddMetadata(metadata, "arn", group.ARN) + schema.AddMetadata(metadata, "status", group.Status) + schema.AddMetadata(metadata, "cache_node_type", group.CacheNodeType) + schema.AddMetadata(metadata, "cluster_mode", group.ClusterMode) + if group.TransitEncryptionEnabled != nil { + metadata["transit_encryption_enabled"] = fmt.Sprintf("%t", *group.TransitEncryptionEnabled) + } + if group.AuthTokenEnabled != nil { + metadata["auth_token_enabled"] = fmt.Sprintf("%t", *group.AuthTokenEnabled) + } + return metadata +} + +func getCacheClusterMetadata(cluster *elasticache.CacheCluster) map[string]string { + metadata := make(map[string]string) + schema.AddMetadata(metadata, "cache_cluster_id", cluster.CacheClusterId) + schema.AddMetadata(metadata, "arn", cluster.ARN) + schema.AddMetadata(metadata, "replication_group_id", cluster.ReplicationGroupId) + schema.AddMetadata(metadata, "engine", cluster.Engine) + schema.AddMetadata(metadata, "engine_version", cluster.EngineVersion) + schema.AddMetadata(metadata, "status", cluster.CacheClusterStatus) + schema.AddMetadata(metadata, "cache_node_type", cluster.CacheNodeType) + schema.AddMetadata(metadata, "availability_zone", cluster.PreferredAvailabilityZone) + if cluster.TransitEncryptionEnabled != nil { + metadata["transit_encryption_enabled"] = fmt.Sprintf("%t", *cluster.TransitEncryptionEnabled) + } + if cluster.CacheClusterCreateTime != nil { + metadata["created_at"] = cluster.CacheClusterCreateTime.Format(time.RFC3339) + } + return metadata +} + +func getServerlessCacheMetadata(cache *elasticache.ServerlessCache) map[string]string { + metadata := make(map[string]string) + schema.AddMetadata(metadata, "serverless_cache_name", cache.ServerlessCacheName) + schema.AddMetadata(metadata, "arn", cache.ARN) + schema.AddMetadata(metadata, "engine", cache.Engine) + schema.AddMetadata(metadata, "engine_version", cache.FullEngineVersion) + schema.AddMetadata(metadata, "status", cache.Status) + if len(cache.SecurityGroupIds) > 0 { + metadata["security_group_ids"] = strings.Join(aws.StringValueSlice(cache.SecurityGroupIds), ",") + } + if cache.CreateTime != nil { + metadata["created_at"] = cache.CreateTime.Format(time.RFC3339) + } + return metadata +} + +func (ep *elastiCacheProvider) getElastiCacheClients(region *string) []*elasticache.ElastiCache { + clients := make([]*elasticache.ElastiCache, 0) + + clients = append(clients, elasticache.New( + ep.session, + aws.NewConfig().WithRegion(aws.StringValue(region)), + )) + + if ep.options.AssumeRoleName == "" || len(ep.options.AccountIds) < 1 { + return clients + } + + for _, accountId := range ep.options.AccountIds { + roleARN := fmt.Sprintf("arn:aws:iam::%s:role/%s", accountId, ep.options.AssumeRoleName) + creds := stscreds.NewCredentials(ep.session, roleARN) + + assumeSession, err := session.NewSession(&aws.Config{ + Region: region, + Credentials: creds, + }) + if err != nil { + continue + } + + clients = append(clients, elasticache.New(assumeSession)) + } + return clients +} diff --git a/pkg/providers/aws/elasticache_test.go b/pkg/providers/aws/elasticache_test.go new file mode 100644 index 00000000..550ba055 --- /dev/null +++ b/pkg/providers/aws/elasticache_test.go @@ -0,0 +1,253 @@ +package aws + +import ( + "context" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var elastiCacheResponses = map[string]string{ + "DescribeReplicationGroups": ` + + + + redis-rg + available + + +
redis-rg.abc123.ng.0001.use1.cache.amazonaws.com
6379
+
redis-rg-ro.abc123.ng.0001.use1.cache.amazonaws.com
6379
+ + + redis-rg-001 +
redis-rg-001.abc123.0001.use1.cache.amazonaws.com
6379
+
+
+
+
+
+ + redis-cluster-mode +
clustercfg.redis-cluster-mode.abc123.use1.cache.amazonaws.com
6379
+
+
+
+
`, + "DescribeCacheClusters": ` + + + + redis-rg-001 + redis-rg + redis + + 0001
redis-rg-001.abc123.0001.use1.cache.amazonaws.com
6379
+
+
+ + memcached + memcached +
memcached.abc123.cfg.use1.cache.amazonaws.com
11211
+ + 0001
memcached.abc123.0001.use1.cache.amazonaws.com
11211
+ 0002 +
+
+
+
+
`, + "DescribeServerlessCaches": ` + + + + valkey-serverless + valkey +
valkey-serverless-abc123.serverless.use1.cache.amazonaws.com
6379
+
valkey-serverless-abc123.serverless.use1.cache.amazonaws.com
6380
+
+
+
+
`, +} + +func newTestElastiCacheClient(t *testing.T, serverlessStatus, cacheStatus int) *elasticache.ElastiCache { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + action := r.Form.Get("Action") + if action == "DescribeCacheClusters" { + assert.Equal(t, "true", r.Form.Get("ShowCacheNodeInfo")) + } + w.Header().Set("Content-Type", "text/xml") + if action == "DescribeCacheClusters" && cacheStatus != http.StatusOK { + w.WriteHeader(cacheStatus) + _, _ = w.Write([]byte(`AccessDenieddenied`)) + return + } + if action == "DescribeServerlessCaches" && serverlessStatus != http.StatusOK { + w.WriteHeader(serverlessStatus) + _, _ = w.Write([]byte(`InvalidParameterValuenot supported`)) + return + } + body, ok := elastiCacheResponses[action] + require.True(t, ok, "unexpected action %s", action) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(server.URL), + Credentials: credentials.NewStaticCredentials("test", "test", ""), + MaxRetries: aws.Int(0), + }) + require.NoError(t, err) + return elasticache.New(sess) +} + +func dnsNames(t *testing.T, provider *elastiCacheProvider, client *elasticache.ElastiCache) []string { + t.Helper() + resources, err := provider.listElastiCacheResources(client) + require.NoError(t, err) + + var names []string + for _, item := range resources.Items { + assert.Equal(t, "elasticache", item.Service) + names = append(names, item.DNSName) + } + sort.Strings(names) + return names +} + +func TestListElastiCacheResources(t *testing.T) { + provider := &elastiCacheProvider{options: ProviderOptions{Id: "test"}} + + assert.Equal(t, []string{ + "clustercfg.redis-cluster-mode.abc123.use1.cache.amazonaws.com", + "memcached.abc123.0001.use1.cache.amazonaws.com", + "memcached.abc123.cfg.use1.cache.amazonaws.com", + "redis-rg-001.abc123.0001.use1.cache.amazonaws.com", + "redis-rg-ro.abc123.ng.0001.use1.cache.amazonaws.com", + "redis-rg.abc123.ng.0001.use1.cache.amazonaws.com", + "valkey-serverless-abc123.serverless.use1.cache.amazonaws.com", + }, dnsNames(t, provider, newTestElastiCacheClient(t, http.StatusOK, http.StatusOK))) +} + +func TestListElastiCacheResources_ServerlessUnsupported(t *testing.T) { + provider := &elastiCacheProvider{options: ProviderOptions{Id: "test"}} + + names := dnsNames(t, provider, newTestElastiCacheClient(t, http.StatusBadRequest, http.StatusOK)) + assert.Len(t, names, 6, "a serverless API failure must not drop cluster endpoints") + assert.NotContains(t, names, "valkey-serverless-abc123.serverless.use1.cache.amazonaws.com") +} + +func TestListElastiCacheResourcesKeepsEarlierReplicationGroupPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + w.Header().Set("Content-Type", "text/xml") + if r.Form.Get("Action") == "DescribeReplicationGroups" && r.Form.Get("Marker") == "next" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`InternalErrorpage failed`)) + return + } + if r.Form.Get("Action") != "DescribeReplicationGroups" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`AccessDenieddenied`)) + return + } + _, _ = w.Write([]byte(` + + next + + + redis-rg + +
redis-rg.abc123.ng.0001.use1.cache.amazonaws.com
6379
+
+
+
+
+
`)) + })) + t.Cleanup(server.Close) + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(server.URL), + Credentials: credentials.NewStaticCredentials("test", "test", ""), + MaxRetries: aws.Int(0), + }) + require.NoError(t, err) + + provider := &elastiCacheProvider{options: ProviderOptions{Id: "test"}} + resources, err := provider.listElastiCacheResources(elasticache.New(sess)) + require.Error(t, err) + require.NotNil(t, resources) + var names []string + for _, item := range resources.Items { + names = append(names, item.DNSName) + } + assert.Equal(t, []string{"redis-rg.abc123.ng.0001.use1.cache.amazonaws.com"}, names) +} + +func TestListElastiCacheResourcesKeepsReplicationGroups(t *testing.T) { + provider := &elastiCacheProvider{options: ProviderOptions{Id: "test"}} + resources, err := provider.listElastiCacheResources(newTestElastiCacheClient(t, http.StatusOK, http.StatusForbidden)) + require.Error(t, err) + + var names []string + for _, item := range resources.Items { + names = append(names, item.DNSName) + } + assert.Contains(t, names, "redis-rg.abc123.ng.0001.use1.cache.amazonaws.com") + assert.NotContains(t, names, "memcached.abc123.cfg.use1.cache.amazonaws.com") +} + +func TestListElastiCacheResources_ExtendedMetadata(t *testing.T) { + provider := &elastiCacheProvider{options: ProviderOptions{Id: "test", ExtendedMetadata: true}} + + resources, err := provider.listElastiCacheResources(newTestElastiCacheClient(t, http.StatusOK, http.StatusOK)) + require.NoError(t, err) + + byName := map[string]map[string]string{} + for _, item := range resources.Items { + byName[item.DNSName] = item.Metadata + } + assert.Equal(t, "redis-rg", byName["redis-rg.abc123.ng.0001.use1.cache.amazonaws.com"]["replication_group_id"]) + assert.Equal(t, "memcached", byName["memcached.abc123.cfg.use1.cache.amazonaws.com"]["engine"]) + assert.Equal(t, "valkey-serverless", byName["valkey-serverless-abc123.serverless.use1.cache.amazonaws.com"]["serverless_cache_name"]) +} + +func TestElastiCacheGetResourceReportsListingErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`AccessDenieddenied`)) + })) + t.Cleanup(server.Close) + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(server.URL), + Credentials: credentials.NewStaticCredentials("test", "test", ""), + MaxRetries: aws.Int(0), + }) + require.NoError(t, err) + + provider := &elastiCacheProvider{ + options: ProviderOptions{Id: "test"}, + session: sess, + regions: &ec2.DescribeRegionsOutput{Regions: []*ec2.Region{{RegionName: aws.String("us-east-1")}, {RegionName: aws.String("eu-west-1")}}}, + } + _, err = provider.GetResource(context.Background()) + require.Error(t, err, "a denied listing in every region must not look like an empty account") +}