From a813d42b3b1a093418076f5ba85d2ad23ff149c4 Mon Sep 17 00:00:00 2001 From: sakisv Date: Thu, 28 May 2026 12:44:48 +0300 Subject: [PATCH 1/3] Get ecs clusters and services --- aws/ecs.go | 97 +++++++++++++++++++++++++++++++++ aws/types.go | 4 ++ common/awsservicename_string.go | 5 +- common/resourcetype_string.go | 6 +- common/types.go | 3 + go.mod | 1 + go.sum | 2 + 7 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 aws/ecs.go diff --git a/aws/ecs.go b/aws/ecs.go new file mode 100644 index 0000000..62768eb --- /dev/null +++ b/aws/ecs.go @@ -0,0 +1,97 @@ +package aws + +import ( + "context" + "log" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/noclickops/common" +) + +type ECSClient interface { + ListClusters(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) + ListServices(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) +} + +type NoclickopsECSClient struct { + Client ECSClient + ClientMeta +} + +type NoclickopsECSService struct { + Clients []NoclickopsECSClient + common.ServiceMeta +} + +func NewECSServiceFromConfigs(cfg []awssdk.Config, meta common.ServiceMeta) NoclickopsECSService { + service := NoclickopsECSService{ServiceMeta: meta} + for _, c := range cfg { + service.Clients = append(service.Clients, NoclickopsECSClient{ + Client: ecs.NewFromConfig(c), + ClientMeta: ClientMeta{Region: c.Region}, + }) + } + return service +} + +func (s *NoclickopsECSService) GetAllResources() []common.Resource { + return s.GetClustersAndServices() +} + +func (s *NoclickopsECSService) GetClustersAndServices() []common.Resource { + var resources []common.Resource + for _, rc := range s.Clients { + var clusterNextToken *string = nil + for { + clusterRes, err := rc.Client.ListClusters(context.TODO(), &ecs.ListClustersInput{ + NextToken: clusterNextToken, + }) + if err != nil { + log.Printf("warning: %v", err) + break + } + + for _, clusterArn := range clusterRes.ClusterArns { + // ecs cluster arn format + // arn:${Partition}:ecs:${Region}:${Account}:cluster/${ClusterName} + parts := strings.Split(clusterArn, "/") + clusterName := parts[len(parts)-1] + resources = append(resources, common.Resource{Arn: clusterArn, TerraformID: clusterName, ResourceType: common.ECS_cluster, Region: rc.Region}) + + var nextToken *string = nil + for { + res, err := rc.Client.ListServices(context.TODO(), &ecs.ListServicesInput{ + Cluster: awssdk.String(clusterArn), + NextToken: nextToken, + }) + if err != nil { + log.Printf("warning: %v", err) + break + } + + for _, serviceArn := range res.ServiceArns { + // ecs service arn format + // arn:${Partition}:ecs:${Region}:${Account}:service/${ClusterName}/${ServiceName} + // + // tf import: cluster-name/service-name + tfId := strings.SplitN(serviceArn, ":service/", 2)[1] + resources = append(resources, common.Resource{Arn: serviceArn, TerraformID: tfId, ResourceType: common.ECS_service, Region: rc.Region}) + } + + if res.NextToken == nil { + break + } + nextToken = res.NextToken + } + } + + if clusterRes.NextToken == nil { + break + } + clusterNextToken = clusterRes.NextToken + } + } + return resources +} diff --git a/aws/types.go b/aws/types.go index d0e1920..db453be 100644 --- a/aws/types.go +++ b/aws/types.go @@ -29,6 +29,7 @@ var SERVICES = map[common.AWSServiceName]common.ServiceMeta{ common.CloudwatchLogs: {Global: false, ServiceName: "cloudwatchlogs"}, common.Dynamodb: {Global: false, ServiceName: "dynamodb"}, common.Elasticache: {Global: false, ServiceName: "elasticache"}, + common.ECS: {Global: false, ServiceName: "ecs"}, // not included in the switch/case in `NewclickopsServiceFromConfigs` // because it doesn't follow the same invocation pattern. @@ -103,6 +104,9 @@ func NewNoclickopsServiceFromConfigs(service common.AWSServiceName, configs []aw case common.Elasticache: c := NewElasticacheServiceFromConfigs(configs, meta) return &c + case common.ECS: + c := NewECSServiceFromConfigs(configs, meta) + return &c case common.IdentityStore: ssoMeta := SERVICES[common.SSOAdmin] ssoClient := NewSSOAdminServiceFromConfigs(configs[:1], ssoMeta) diff --git a/common/awsservicename_string.go b/common/awsservicename_string.go index 82aca46..33823bd 100644 --- a/common/awsservicename_string.go +++ b/common/awsservicename_string.go @@ -27,11 +27,12 @@ func _() { _ = x[CloudwatchLogs-16] _ = x[Dynamodb-17] _ = x[Elasticache-18] + _ = x[ECS-19] } -const _AWSServiceName_name = "Route53IAMEKSSSMEC2RDSSSOAdminIdentityStoreSNSS3CloudFrontELBELBV2ASGLambdaResourceGroupsTaggingAPICloudwatchLogsDynamodbElasticache" +const _AWSServiceName_name = "Route53IAMEKSSSMEC2RDSSSOAdminIdentityStoreSNSS3CloudFrontELBELBV2ASGLambdaResourceGroupsTaggingAPICloudwatchLogsDynamodbElasticacheECS" -var _AWSServiceName_index = [...]uint8{0, 7, 10, 13, 16, 19, 22, 30, 43, 46, 48, 58, 61, 66, 69, 75, 99, 113, 121, 132} +var _AWSServiceName_index = [...]uint8{0, 7, 10, 13, 16, 19, 22, 30, 43, 46, 48, 58, 61, 66, 69, 75, 99, 113, 121, 132, 135} func (i AWSServiceName) String() string { idx := int(i) - 0 diff --git a/common/resourcetype_string.go b/common/resourcetype_string.go index 213c9f0..c9a5543 100644 --- a/common/resourcetype_string.go +++ b/common/resourcetype_string.go @@ -41,11 +41,13 @@ func _() { _ = x[Cloudwatchlogs_log_group-30] _ = x[Dynamodb_table-31] _ = x[Elasticache_cluster-32] + _ = x[ECS_cluster-33] + _ = x[ECS_service-34] } -const _ResourceType_name = "Route53_zoneRoute53_recordIAM_policyIAM_userIAM_groupSSM_parameterSecurity_groupSecurity_group_ruleEKS_clusterEKS_node_groupSSOAdmin_permission_setIdentitystore_userIdentitystore_groupInstanceEipDB_instanceRDS_clusterSNS_topicSNS_subscriptionS3_bucketCloudFront_distributionELB_load_balancerELBV2_load_balancerVPCInternet_gatewayNAT_gatewaySubnetVPC_endpointLambda_functionAutoscaling_groupCloudwatchlogs_log_groupDynamodb_tableElasticache_cluster" +const _ResourceType_name = "Route53_zoneRoute53_recordIAM_policyIAM_userIAM_groupSSM_parameterSecurity_groupSecurity_group_ruleEKS_clusterEKS_node_groupSSOAdmin_permission_setIdentitystore_userIdentitystore_groupInstanceEipDB_instanceRDS_clusterSNS_topicSNS_subscriptionS3_bucketCloudFront_distributionELB_load_balancerELBV2_load_balancerVPCInternet_gatewayNAT_gatewaySubnetVPC_endpointLambda_functionAutoscaling_groupCloudwatchlogs_log_groupDynamodb_tableElasticache_clusterECS_clusterECS_service" -var _ResourceType_index = [...]uint16{0, 12, 26, 36, 44, 53, 66, 80, 99, 110, 124, 147, 165, 184, 192, 195, 206, 217, 226, 242, 251, 274, 291, 310, 313, 329, 340, 346, 358, 373, 390, 414, 428, 447} +var _ResourceType_index = [...]uint16{0, 12, 26, 36, 44, 53, 66, 80, 99, 110, 124, 147, 165, 184, 192, 195, 206, 217, 226, 242, 251, 274, 291, 310, 313, 329, 340, 346, 358, 373, 390, 414, 428, 447, 458, 469} func (i ResourceType) String() string { idx := int(i) - 0 diff --git a/common/types.go b/common/types.go index c4ed0fa..b88edf9 100644 --- a/common/types.go +++ b/common/types.go @@ -39,6 +39,8 @@ const ( Cloudwatchlogs_log_group Dynamodb_table Elasticache_cluster + ECS_cluster + ECS_service ) func (r ResourceType) MarshalJSON() ([]byte, error) { @@ -68,6 +70,7 @@ const ( CloudwatchLogs Dynamodb Elasticache + ECS ) type Resource struct { diff --git a/go.mod b/go.mod index 15b7436..9127b4c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.74.0 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.4 github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.0 + github.com/aws/aws-sdk-go-v2/service/ecs v1.81.0 github.com/aws/aws-sdk-go-v2/service/eks v1.82.1 github.com/aws/aws-sdk-go-v2/service/elasticache v1.52.2 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.33.24 diff --git a/go.sum b/go.sum index 9966a80..279f2d2 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.4 h1:0E3bfw1Va3vfCrmtATvKRnG github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.4/go.mod h1:dFPU89qDDGgQbXyzQ5ZY6zcjjKPVW+1M63axOw887JE= github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.0 h1:qTozRFl2YFFU2HJGl7ZAywlRQvBnAN591gbAFT5bE0s= github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.0/go.mod h1:E1pnYwWFZ8N3REmeN9Fe/Zipbpps4HJj8DQGNnLUMYc= +github.com/aws/aws-sdk-go-v2/service/ecs v1.81.0 h1:2Sp9EwK7giQpJnQ54k0zdUh6aykmmbpEurEEygr104c= +github.com/aws/aws-sdk-go-v2/service/ecs v1.81.0/go.mod h1:TIKZ9zIFS6W2k9FeW+r5sGVnlxp+aUt9oQ/St3Suj1o= github.com/aws/aws-sdk-go-v2/service/eks v1.82.1 h1:xTzXiQ8Q6U4ACdMNSCm72zd4Ds7QxhgVLqt5x8GXLBM= github.com/aws/aws-sdk-go-v2/service/eks v1.82.1/go.mod h1:jjcGpziR11RTrr3JIgXg/Nn8GSwK3WOz2z1v/RqEBUI= github.com/aws/aws-sdk-go-v2/service/elasticache v1.52.2 h1:5wbCUfyxXcjIqesyVfJBBJs0bDMyejthtHyy48mfZCI= From d9558c3d36717b359277345ac65a1d73c9f18027 Mon Sep 17 00:00:00 2001 From: sakisv Date: Thu, 28 May 2026 12:45:15 +0300 Subject: [PATCH 2/3] Add tests for ecs --- aws/ecs_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++ aws/helpers_test.go | 14 ++++++ 2 files changed, 132 insertions(+) create mode 100644 aws/ecs_test.go diff --git a/aws/ecs_test.go b/aws/ecs_test.go new file mode 100644 index 0000000..56f4346 --- /dev/null +++ b/aws/ecs_test.go @@ -0,0 +1,118 @@ +package aws_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/google/go-cmp/cmp" + "github.com/noclickops/aws" + "github.com/noclickops/common" +) + +func getMockedECSService(mock *mockECSClient) aws.NoclickopsECSService { + return aws.NoclickopsECSService{ + Clients: []aws.NoclickopsECSClient{ + { + Client: mock, + ClientMeta: aws.ClientMeta{Region: "eu-west-1"}, + }, + }, + ServiceMeta: common.ServiceMeta{Global: false, ServiceName: "ecs"}, + } +} + +func TestGetECSClustersAndServices_PaginationFollowed(t *testing.T) { + listClustersCallCount := 0 + listServicesCallCount := 0 + mock := &mockECSClient{ + listClustersFn: func(_ context.Context, params *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + listClustersCallCount++ + if listClustersCallCount == 1 { + return &ecs.ListClustersOutput{ + NextToken: ptr("next-cluster"), + ClusterArns: []string{"arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-1"}, + }, nil + } + if params.NextToken == nil || *params.NextToken != "next-cluster" { + return nil, fmt.Errorf("wrong NextToken, expected 'next-cluster' got '%v'", params.NextToken) + } + return &ecs.ListClustersOutput{ + ClusterArns: []string{"arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-2"}, + }, nil + }, + listServicesFn: func(_ context.Context, params *ecs.ListServicesInput, _ ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + listServicesCallCount++ + if listServicesCallCount == 1 { + return &ecs.ListServicesOutput{ + NextToken: ptr("next-service"), + ServiceArns: []string{"arn:aws:ecs:eu-west-1:123456789012:service/cluster-1/service-1"}, + }, nil + } + if listServicesCallCount == 2 { + if params.NextToken == nil || *params.NextToken != "next-service" { + return nil, fmt.Errorf("wrong NextToken, expected 'next-service' got '%v'", params.NextToken) + } + return &ecs.ListServicesOutput{ + ServiceArns: []string{"arn:aws:ecs:eu-west-1:123456789012:service/cluster-1/service-2"}, + }, nil + } + return &ecs.ListServicesOutput{ + ServiceArns: []string{"arn:aws:ecs:eu-west-1:123456789012:service/cluster-2/service-3"}, + }, nil + }, + } + service := getMockedECSService(mock) + got := service.GetClustersAndServices() + expected := []common.Resource{ + {Arn: "arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-1", TerraformID: "cluster-1", ResourceType: common.ECS_cluster, Region: "eu-west-1"}, + {Arn: "arn:aws:ecs:eu-west-1:123456789012:service/cluster-1/service-1", TerraformID: "cluster-1/service-1", ResourceType: common.ECS_service, Region: "eu-west-1"}, + {Arn: "arn:aws:ecs:eu-west-1:123456789012:service/cluster-1/service-2", TerraformID: "cluster-1/service-2", ResourceType: common.ECS_service, Region: "eu-west-1"}, + {Arn: "arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-2", TerraformID: "cluster-2", ResourceType: common.ECS_cluster, Region: "eu-west-1"}, + {Arn: "arn:aws:ecs:eu-west-1:123456789012:service/cluster-2/service-3", TerraformID: "cluster-2/service-3", ResourceType: common.ECS_service, Region: "eu-west-1"}, + } + if diff := cmp.Diff(got, expected); diff != "" { + t.Errorf("expected %v, got %v", expected, got) + } + if listClustersCallCount != 2 { + t.Errorf("expected 2 calls to ListClusters, got %d", listClustersCallCount) + } + if listServicesCallCount != 3 { + t.Errorf("expected 3 calls to ListServices, got %d", listServicesCallCount) + } +} + +func TestGetECSClustersAndServices_ListClustersErrorIsSkipped(t *testing.T) { + mock := &mockECSClient{ + listClustersFn: func(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return nil, fmt.Errorf("some error") + }, + } + service := getMockedECSService(mock) + got := service.GetClustersAndServices() + if len(got) != 0 { + t.Errorf("expected no resources on error, got %v", got) + } +} + +func TestGetECSClustersAndServices_ListServicesErrorIsSkipped(t *testing.T) { + mock := &mockECSClient{ + listClustersFn: func(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return &ecs.ListClustersOutput{ + ClusterArns: []string{"arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-1"}, + }, nil + }, + listServicesFn: func(_ context.Context, _ *ecs.ListServicesInput, _ ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + return nil, fmt.Errorf("some error") + }, + } + service := getMockedECSService(mock) + got := service.GetClustersAndServices() + expected := []common.Resource{ + {Arn: "arn:aws:ecs:eu-west-1:123456789012:cluster/cluster-1", TerraformID: "cluster-1", ResourceType: common.ECS_cluster, Region: "eu-west-1"}, + } + if diff := cmp.Diff(got, expected); diff != "" { + t.Errorf("expected %v, got %v", expected, got) + } +} diff --git a/aws/helpers_test.go b/aws/helpers_test.go index 21a5a47..2550510 100644 --- a/aws/helpers_test.go +++ b/aws/helpers_test.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/elasticache" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" @@ -216,6 +217,19 @@ func (m *mockDynamodbClient) ListTables(ctx context.Context, params *dynamodb.Li return m.listTablesFn(ctx, params, optFns...) } +type mockECSClient struct { + listClustersFn func(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) + listServicesFn func(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) +} + +func (m *mockECSClient) ListClusters(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return m.listClustersFn(ctx, params, optFns...) +} + +func (m *mockECSClient) ListServices(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + return m.listServicesFn(ctx, params, optFns...) +} + type mockElasticacheClient struct { describeCacheClustersFn func(ctx context.Context, params *elasticache.DescribeCacheClustersInput, optFns ...func(*elasticache.Options)) (*elasticache.DescribeCacheClustersOutput, error) } From c5143bc8fcba9559a838f0a8147322fce7508a1a Mon Sep 17 00:00:00 2001 From: sakisv Date: Thu, 28 May 2026 12:47:44 +0300 Subject: [PATCH 3/3] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a6c57d8..20fd7cb 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ exist in your statefiles, and prints the missing ones in a json format for ease | autoscaling | autoscaling_group | | lambda | lambda_function | | dynamodb | dynamodb_table | -| cloudwatchlogs | cloudwatchlogs_log_group | +| cloudwatchlogs | cloudwatch_log_group | | elasticache | elasticache_cluster | +| ecs | ecs_cluster, ecs_service | ## How to use