From 08c529a59629f0c3c1c3f9464e4964925bef7415 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Wed, 8 Oct 2025 10:35:42 +0200 Subject: [PATCH 1/7] add service filtering flags and update internal aws to handle it --- cmd/kosli/root.go | 8 +- cmd/kosli/snapshotECS.go | 47 +++++-- internal/aws/aws.go | 206 ++++++++++++++++++++--------- internal/aws/aws_test.go | 83 ++++++++++-- internal/filters/resourceFilter.go | 5 + 5 files changed, 269 insertions(+), 80 deletions(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 2700b963a..4fa59e82b 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -169,9 +169,13 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, ecsClusterFlag = "The name of the ECS cluster." ecsClustersFlag = "[optional] The comma-separated list of ECS cluster names to snapshot. Can't be used together with --exclude or --exclude-regex." ecsClustersRegexFlag = "[optional] The comma-separated list of ECS cluster name regex patterns to snapshot. Can't be used together with --exclude or --exclude-regex." - ecsExcludeClustersFlag = "[optional] The comma-separated list of ECS cluster names to exclude. Can't be used together with --exclude or --exclude-regex." + ecsExcludeClustersFlag = "[optional] The comma-separated list of ECS cluster names to exclude. Can't be used together with --clusters or --clusters-regex." ecsExcludeClustersRegexFlag = "[optional] The comma-separated list of ECS cluster name regex patterns to exclude. Can't be used together with --clusters or --clusters-regex." - ecsServiceFlag = "[optional] The name of the ECS service." + ecsServiceFlag = "[deprecated] The name of the ECS service. Use --services instead." + ecsServicesFlag = "[optional] The comma-separated list of ECS service names to snapshot. Can't be used together with --exclude-services or --exclude-services-regex." + ecsServicesRegexFlag = "[optional] The comma-separated list of ECS service name regex patterns to snapshot. Can't be used together with --exclude-services or --exclude-services-regex." + ecsExcludeServicesFlag = "[optional] The comma-separated list of ECS service names to exclude. Can't be used together with --services or --services-regex." + ecsExcludeServicesRegexFlag = "[optional] The comma-separated list of ECS service name regex patterns to exclude. Can't be used together with --services or --services-regex." kubeconfigFlag = "[defaulted] The kubeconfig path for the target cluster." namespacesFlag = "[optional] The comma separated list of namespaces names to report artifacts info from. Can't be used together with --exclude-namespaces or --exclude-namespaces-regex." excludeNamespacesFlag = "[optional] The comma separated list of namespaces names to exclude from reporting artifacts info from. Requires cluster-wide read permissions for pods and namespaces. Can't be used together with --namespaces or --namespaces-regex." diff --git a/cmd/kosli/snapshotECS.go b/cmd/kosli/snapshotECS.go index 4ab636cc6..8ae1abbf0 100644 --- a/cmd/kosli/snapshotECS.go +++ b/cmd/kosli/snapshotECS.go @@ -57,7 +57,8 @@ kosli snapshot ecs yourEnvironmentName \ ` type snapshotECSOptions struct { - filter *filters.ResourceFilterOptions + clustersFilter *filters.ResourceFilterOptions + serviceFilter *filters.ResourceFilterOptions serviceName string awsStaticCreds *aws.AWSStaticCreds } @@ -65,7 +66,8 @@ type snapshotECSOptions struct { func newSnapshotECSCmd(out io.Writer) *cobra.Command { o := new(snapshotECSOptions) o.awsStaticCreds = new(aws.AWSStaticCreds) - o.filter = new(filters.ResourceFilterOptions) + o.clustersFilter = new(filters.ResourceFilterOptions) + o.serviceFilter = new(filters.ResourceFilterOptions) cmd := &cobra.Command{ Use: "ecs ENVIRONMENT-NAME", Short: snapshotECSShortDesc, @@ -94,6 +96,30 @@ func newSnapshotECSCmd(out io.Writer) *cobra.Command { if err != nil { return err } + // service filtering flags + // Include flags vs exclude flags mutual exclusion + err = MuXRequiredFlags(cmd, []string{"services", "exclude-services"}, false) + if err != nil { + return err + } + err = MuXRequiredFlags(cmd, []string{"services", "exclude-services-regex"}, false) + if err != nil { + return err + } + err = MuXRequiredFlags(cmd, []string{"services-regex", "exclude-services"}, false) + if err != nil { + return err + } + err = MuXRequiredFlags(cmd, []string{"services-regex", "exclude-services-regex"}, false) + if err != nil { + return err + } + + // Deprecated flag vs new flags mutual exclusion + err = MuXRequiredFlags(cmd, []string{"service-name", "services", "services-regex", "exclude-services", "exclude-services-regex"}, false) + if err != nil { + return err + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -101,12 +127,17 @@ func newSnapshotECSCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringSliceVar(&o.filter.IncludeNames, "clusters", []string{}, ecsClustersFlag) - cmd.Flags().StringSliceVar(&o.filter.IncludeNamesRegex, "clusters-regex", []string{}, ecsClustersRegexFlag) - cmd.Flags().StringSliceVar(&o.filter.ExcludeNames, "exclude", []string{}, ecsExcludeClustersFlag) - cmd.Flags().StringSliceVar(&o.filter.ExcludeNamesRegex, "exclude-regex", []string{}, ecsExcludeClustersRegexFlag) + cmd.Flags().StringSliceVar(&o.clustersFilter.IncludeNames, "clusters", []string{}, ecsClustersFlag) + cmd.Flags().StringSliceVar(&o.clustersFilter.IncludeNamesRegex, "clusters-regex", []string{}, ecsClustersRegexFlag) + cmd.Flags().StringSliceVar(&o.clustersFilter.ExcludeNames, "exclude", []string{}, ecsExcludeClustersFlag) + cmd.Flags().StringSliceVar(&o.clustersFilter.ExcludeNamesRegex, "exclude-regex", []string{}, ecsExcludeClustersRegexFlag) + + cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNames, "services", []string{}, ecsServicesFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.IncludeNamesRegex, "services-regex", []string{}, ecsServicesRegexFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNames, "exclude-services", []string{}, ecsExcludeServicesFlag) + cmd.Flags().StringSliceVar(&o.serviceFilter.ExcludeNamesRegex, "exclude-services-regex", []string{}, ecsExcludeServicesRegexFlag) - cmd.Flags().StringSliceVarP(&o.filter.IncludeNames, "cluster", "C", []string{}, ecsClusterFlag) + cmd.Flags().StringSliceVarP(&o.clustersFilter.IncludeNames, "cluster", "C", []string{}, ecsClusterFlag) cmd.Flags().StringVarP(&o.serviceName, "service-name", "s", "", ecsServiceFlag) addAWSAuthFlags(cmd, o.awsStaticCreds) addDryRunFlag(cmd) @@ -126,7 +157,7 @@ func (o *snapshotECSOptions) run(args []string) error { envName := args[0] url := fmt.Sprintf("%s/api/v2/environments/%s/%s/report/ECS", global.Host, global.Org, envName) - tasksData, err := o.awsStaticCreds.GetEcsTasksData(o.filter) + tasksData, err := o.awsStaticCreds.GetEcsTasksData(o.clustersFilter, o.serviceFilter) if err != nil { return err } diff --git a/internal/aws/aws.go b/internal/aws/aws.go index b2ce8b15b..3ffbfcbe9 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -420,8 +420,8 @@ func downloadFileFromBucket(downloader *s3manager.Downloader, dirName, key, buck } // getFilteredECSClusters fetches a filtered set of ECS clusters recursively (50 at a time) and returns a list of ecs Clusters -func getFilteredECSClusters(client *ecs.Client, nextToken *string, allClusters *[]ecsTypes.Cluster, - filter *filters.ResourceFilterOptions) (*[]ecsTypes.Cluster, error) { +func getFilteredECSClusters(client *ecs.Client, allClusters *[]ecsTypes.Cluster, + clusterFilter *filters.ResourceFilterOptions, nextToken *string) (*[]ecsTypes.Cluster, error) { params := &ecs.ListClustersInput{} if nextToken != nil { params.NextToken = nextToken @@ -437,12 +437,11 @@ func getFilteredECSClusters(client *ecs.Client, nextToken *string, allClusters * return allClusters, err } - if len(filter.IncludeNames) == 0 && len(filter.IncludeNamesRegex) == 0 && - len(filter.ExcludeNames) == 0 && len(filter.ExcludeNamesRegex) == 0 { + if !clusterFilter.IsSet() { *allClusters = append(*allClusters, describeClustersOutput.Clusters...) } else { for _, c := range describeClustersOutput.Clusters { - include, err := filter.ShouldInclude(*c.ClusterName) + include, err := clusterFilter.ShouldInclude(*c.ClusterName) if err != nil { return allClusters, err } @@ -453,7 +452,7 @@ func getFilteredECSClusters(client *ecs.Client, nextToken *string, allClusters * } if listClustersOutput.NextToken != nil { - _, err := getFilteredECSClusters(client, listClustersOutput.NextToken, allClusters, filter) + _, err := getFilteredECSClusters(client, allClusters, clusterFilter, listClustersOutput.NextToken) if err != nil { return allClusters, err } @@ -462,108 +461,193 @@ func getFilteredECSClusters(client *ecs.Client, nextToken *string, allClusters * } // GetEcsTasksData returns a list of tasks data for an ECS cluster or service -func (staticCreds *AWSStaticCreds) GetEcsTasksData(filter *filters.ResourceFilterOptions) ([]*EcsTaskData, error) { +func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter *filters.ResourceFilterOptions) ([]*EcsTaskData, error) { allTasksData := []*EcsTaskData{} client, err := staticCreds.NewECSClient() if err != nil { return allTasksData, err } - filteredClusters, err := getFilteredECSClusters(client, nil, &[]ecsTypes.Cluster{}, filter) + filteredClusters, err := getFilteredECSClusters(client, &[]ecsTypes.Cluster{}, clusterFilter, nil) if err != nil { return allTasksData, err } var ( wg sync.WaitGroup - mutex = &sync.Mutex{} + mutex sync.Mutex ) - - // run concurrently - errs := make(chan error, 1) // Buffered only for the first error - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Make sure it's called to release resources even if no errors + errChan := make(chan error, len(*filteredClusters)) for _, cluster := range *filteredClusters { wg.Add(1) - go func(cluster string) { + go func(clusterName string) { defer wg.Done() - // Check if any error occurred in any other gorouties: - select { - case <-ctx.Done(): - return // Error somewhere, terminate - default: // Default is must to avoid blocking + + filteredServices, err := getFilteredECSServicesInCluster(client, clusterName, &[]ecsTypes.Service{}, serviceFilter, nil) + if err != nil { + errChan <- err + return } - tasksData, err := getTasksDataInCluster(client, cluster) + tasksData, err := getTasksDataInClusterService(client, clusterName, filteredServices, nil) if err != nil { - // Non-blocking send of error - select { - case errs <- err: - default: - } - cancel() // send cancel signal to goroutines + errChan <- err return } + + // Safely append to shared allTasksData mutex.Lock() allTasksData = append(allTasksData, tasksData...) mutex.Unlock() - }(*cluster.ClusterName) } wg.Wait() - // Return (first) error, if any: - if ctx.Err() != nil { - return allTasksData, <-errs + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return allTasksData, err + } } return allTasksData, nil } -func getTasksDataInCluster(client *ecs.Client, cluster string) ([]*EcsTaskData, error) { - tasksData := []*EcsTaskData{} - listInput := &ecs.ListTasksInput{ +// getFilteredECSServicesInCluster fetches a filtered set of ECS services recursively (100 at a time) and returns a list of ecs Services +func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServices *[]ecsTypes.Service, serviceFilter *filters.ResourceFilterOptions, nextToken *string) (*[]ecsTypes.Service, error) { + listInput := &ecs.ListServicesInput{ Cluster: aws.String(cluster), } - descriptionInput := &ecs.DescribeTasksInput{ - Cluster: aws.String(cluster), + if nextToken != nil { + listInput.NextToken = nextToken + } + listServicesOutput, err := client.ListServices(context.TODO(), listInput) + if err != nil { + return allServices, err } - list, err := client.ListTasks(context.Background(), listInput) + fmt.Println("listServicesOutput.ServiceArns", listServicesOutput.ServiceArns) + + describeServicesOutput, err := client.DescribeServices(context.TODO(), &ecs.DescribeServicesInput{Cluster: aws.String(cluster), Services: listServicesOutput.ServiceArns}) if err != nil { - return tasksData, err + return allServices, err + } + + if !serviceFilter.IsSet() { + *allServices = append(*allServices, describeServicesOutput.Services...) + } else { + for _, s := range describeServicesOutput.Services { + include, err := serviceFilter.ShouldInclude(*s.ServiceName) + if err != nil { + return allServices, err + } + if include { + *allServices = append(*allServices, s) + } + } } - tasks := list.TaskArns - if len(tasks) > 0 { - descriptionInput.Tasks = tasks - result, err := client.DescribeTasks(context.Background(), descriptionInput) + if listServicesOutput.NextToken != nil { + _, err := getFilteredECSServicesInCluster(client, cluster, allServices, serviceFilter, listServicesOutput.NextToken) if err != nil { - return tasksData, err + return allServices, err } + } + return allServices, nil +} - for _, taskDesc := range result.Tasks { - digests := make(map[string]string) - if *taskDesc.LastStatus == "RUNNING" { - for _, container := range taskDesc.Containers { - imageName := container.Image - if imageName == nil { - // some images like AWS Guard Duty don't get an image name from AWS - // so we default to the container name - imageName = container.Name - } - if container.ImageDigest != nil { - digests[*imageName] = strings.TrimPrefix(*container.ImageDigest, "sha256:") - } else if strings.Contains(*imageName, "@sha256:") { - digests[*imageName] = strings.Split(*imageName, "@sha256:")[1] - } else { - digests[*imageName] = "" +// getTasksDataInClusterService fetches a filtered set of ECS tasks recursively (100 at a time) and returns a list of ecs Tasks +func getTasksDataInClusterService(client *ecs.Client, clusterName string, filteredServices *[]ecsTypes.Service, nextToken *string) ([]*EcsTaskData, error) { + tasksData := []*EcsTaskData{} + var mutex sync.Mutex + var wg sync.WaitGroup + errChan := make(chan error, len(*filteredServices)) + + for _, service := range *filteredServices { + wg.Add(1) + go func(svc ecsTypes.Service) { + defer wg.Done() + + listInput := &ecs.ListTasksInput{ + Cluster: aws.String(clusterName), + ServiceName: svc.ServiceName, + } + if nextToken != nil { + listInput.NextToken = nextToken + } + descriptionInput := &ecs.DescribeTasksInput{ + Cluster: aws.String(clusterName), + } + + listTasksOutput, err := client.ListTasks(context.Background(), listInput) + if err != nil { + errChan <- err + return + } + tasks := listTasksOutput.TaskArns + + if len(tasks) > 0 { + descriptionInput.Tasks = tasks + result, err := client.DescribeTasks(context.Background(), descriptionInput) + if err != nil { + errChan <- err + return + } + + serviceTasksData := []*EcsTaskData{} + for _, taskDesc := range result.Tasks { + digests := make(map[string]string) + if *taskDesc.LastStatus == "RUNNING" { + for _, container := range taskDesc.Containers { + imageName := container.Image + if imageName == nil { + // some images like AWS Guard Duty don't get an image name from AWS + // so we default to the container name + imageName = container.Name + } + if container.ImageDigest != nil { + digests[*imageName] = strings.TrimPrefix(*container.ImageDigest, "sha256:") + } else if strings.Contains(*imageName, "@sha256:") { + digests[*imageName] = strings.Split(*imageName, "@sha256:")[1] + } else { + digests[*imageName] = "" + } + } + data := NewEcsTaskData(*taskDesc.TaskArn, clusterName, digests, *taskDesc.StartedAt) + serviceTasksData = append(serviceTasksData, data) } } - data := NewEcsTaskData(*taskDesc.TaskArn, cluster, digests, *taskDesc.StartedAt) - tasksData = append(tasksData, data) + + // Safely append to shared tasksData + mutex.Lock() + tasksData = append(tasksData, serviceTasksData...) + mutex.Unlock() } + + // Handle pagination for this service's tasks + if listTasksOutput.NextToken != nil { + additionalTasksData, err := getTasksDataInClusterService(client, clusterName, &[]ecsTypes.Service{svc}, listTasksOutput.NextToken) + if err != nil { + errChan <- err + return + } + mutex.Lock() + tasksData = append(tasksData, additionalTasksData...) + mutex.Unlock() + } + }(service) + } + + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return tasksData, err } } diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index a3193b7f7..6b83b6aae 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -529,7 +529,8 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { name string requireEnvVars bool // indicates that a test case needs real credentials from env vars creds *AWSStaticCreds - filter *filters.ResourceFilterOptions + clusterFilter *filters.ResourceFilterOptions + serviceFilter *filters.ResourceFilterOptions minNumberOfArtifacts int wantErr bool }{ @@ -540,15 +541,23 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { AccessKeyID: "ssss", SecretAccessKey: "ssss", }, - filter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, - wantErr: true, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + wantErr: true, + }, + { + name: "can get ECS data without any filters", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + minNumberOfArtifacts: 1, + requireEnvVars: true, }, { name: "can get ECS data with cluster name alone", creds: &AWSStaticCreds{ Region: "eu-central-1", }, - filter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, minNumberOfArtifacts: 1, requireEnvVars: true, }, @@ -557,7 +566,7 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { creds: &AWSStaticCreds{ Region: "ap-south-1", }, - filter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, minNumberOfArtifacts: 0, requireEnvVars: true, }, @@ -566,7 +575,7 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { creds: &AWSStaticCreds{ Region: "eu-central-1", }, - filter: &filters.ResourceFilterOptions{ExcludeNames: []string{"slackapp"}}, + clusterFilter: &filters.ResourceFilterOptions{ExcludeNames: []string{"slackapp"}}, minNumberOfArtifacts: 1, requireEnvVars: true, }, @@ -575,7 +584,7 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { creds: &AWSStaticCreds{ Region: "eu-central-1", }, - filter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"slack.*"}}, + clusterFilter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"slack.*"}}, minNumberOfArtifacts: 1, requireEnvVars: true, }, @@ -584,14 +593,70 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { creds: &AWSStaticCreds{ Region: "eu-central-1", }, - filter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^merk.*"}}, + clusterFilter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^merk.*"}}, minNumberOfArtifacts: 1, requireEnvVars: true, }, + { + name: "can get ECS data with service names", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + serviceFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"kosli"}}, + minNumberOfArtifacts: 1, + requireEnvVars: true, + }, + { + name: "can get ECS data with service names regex", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + serviceFilter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^kos.*"}}, + minNumberOfArtifacts: 1, + requireEnvVars: true, + }, + { + name: "can get ECS data with service names regex", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + serviceFilter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^kos.*"}}, + minNumberOfArtifacts: 1, + requireEnvVars: true, + }, + { + name: "can get ECS data with exclude service names", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + serviceFilter: &filters.ResourceFilterOptions{ExcludeNames: []string{"kosli"}}, + minNumberOfArtifacts: 0, + requireEnvVars: true, + }, + { + name: "can get ECS data with exclude service names regex", + creds: &AWSStaticCreds{ + Region: "eu-central-1", + }, + clusterFilter: &filters.ResourceFilterOptions{IncludeNames: []string{"merkely"}}, + serviceFilter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"^kos.*"}}, + minNumberOfArtifacts: 0, + requireEnvVars: true, + }, } { suite.Suite.Run(t.name, func() { skipOrSetCreds(suite.Suite.T(), t.requireEnvVars, t.creds) - data, err := t.creds.GetEcsTasksData(t.filter) + if t.clusterFilter == nil { + t.clusterFilter = new(filters.ResourceFilterOptions) + } + if t.serviceFilter == nil { + t.serviceFilter = new(filters.ResourceFilterOptions) + } + data, err := t.creds.GetEcsTasksData(t.clusterFilter, t.serviceFilter) require.False(suite.Suite.T(), (err != nil) != t.wantErr, "GetEcsTasksData() error = %v, wantErr %v", err, t.wantErr) if !t.wantErr { diff --git a/internal/filters/resourceFilter.go b/internal/filters/resourceFilter.go index 9a8260b51..39668fbd5 100644 --- a/internal/filters/resourceFilter.go +++ b/internal/filters/resourceFilter.go @@ -13,6 +13,11 @@ type ResourceFilterOptions struct { ExcludeNamesRegex []string } +// IsSet checks if the filter options are set +func (filter *ResourceFilterOptions) IsSet() bool { + return len(filter.IncludeNames) > 0 || len(filter.IncludeNamesRegex) > 0 || len(filter.ExcludeNames) > 0 || len(filter.ExcludeNamesRegex) > 0 +} + // ShouldInclude checks if a name should be included or not according to the filter options // the filter should only be used for one operation (include, exclude) func (filter *ResourceFilterOptions) ShouldInclude(name string) (bool, error) { From 1f73a56fb0da2fd69b6733eb21fe987db91c4420 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Wed, 8 Oct 2025 10:58:25 +0200 Subject: [PATCH 2/7] update help and documentation --- cmd/kosli/snapshotECS.go | 106 ++++++++++++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/cmd/kosli/snapshotECS.go b/cmd/kosli/snapshotECS.go index 8ae1abbf0..7e8f73890 100644 --- a/cmd/kosli/snapshotECS.go +++ b/cmd/kosli/snapshotECS.go @@ -13,47 +13,105 @@ import ( const snapshotECSShortDesc = `Report a snapshot of running containers in one or more AWS ECS cluster(s) to Kosli. ` const snapshotECSLongDesc = snapshotECSShortDesc + ` -Skip ^--clusters^ and ^--clusters-regex^ to report all clusters in a given AWS account. Or use ^--exclude^ and/or ^--exclude-regex^ to report all clusters excluding some. -The reported data includes container image digests and creation timestamps.` + awsAuthDesc +Skip all filtering flags to report everything running in all clusters in a given AWS account. + +Use ^--clusters^ and/or ^--clusters-regex^ OR ^--exclude^ and/or ^--exclude-regex^ to filter the clusters to snapshot. +You can also filter the services within a cluster using ^--services^ and/or ^--services-regex^. Or use ^--exclude-services^ and/or ^--exclude-services-regex^ to exclude some services. +Note that service filtering is applied to all clusters being snapshot. + +All filtering options are case-sensitive. + +The reported data includes cluster and service names, container image digests and creation timestamps.` + awsAuthDesc const snapshotECSExample = ` -# report what is running in an entire AWS ECS cluster: -export AWS_REGION=yourAWSRegion -export AWS_ACCESS_KEY_ID=yourAWSAccessKeyID -export AWS_SECRET_ACCESS_KEY=yourAWSSecretAccessKey +###### Authentication to AWS ###### + +# authentication to AWS using flags kosli snapshot ecs yourEnvironmentName \ - --clusters yourECSClusterName \ + --aws-key-id yourAWSAccessKeyID \ + --aws-secret-key yourAWSSecretAccessKey \ + --aws-region yourAWSRegion \ --api-token yourAPIToken \ --org yourOrgName -# report what is running in a specific AWS ECS service within a cluster: -export AWS_REGION=yourAWSRegion +# authentication to AWS using env variables + export AWS_ACCESS_KEY_ID=yourAWSAccessKeyID export AWS_SECRET_ACCESS_KEY=yourAWSSecretAccessKey +export AWS_REGION=yourAWSRegion kosli snapshot ecs yourEnvironmentName \ - --clusters yourECSClusterName \ - --service-name yourECSServiceName \ --api-token yourAPIToken \ --org yourOrgName -# report what is running in all ECS clusters in an AWS account (AWS auth provided in flags): -kosli snapshot ecs yourEnvironmentName \ - --aws-key-id yourAWSAccessKeyID \ - --aws-secret-key yourAWSSecretAccessKey \ - --aws-region yourAWSRegion \ - --api-token yourAPIToken \ - --org yourOrgName +###### reporting everything running in all clusters in a given AWS account ###### -# report what is running in all ECS clusters in an AWS account except for clusters with names matching given regex patterns: -kosli snapshot ecs yourEnvironmentName \ - --aws-key-id yourAWSAccessKeyID \ - --aws-secret-key yourAWSSecretAccessKey \ - --aws-region yourAWSRegion \ - --exclude-regex "those-names.*" \ +kosli snapshot ecs my-env \ --api-token yourAPIToken \ --org yourOrgName + +###### filtering which ECS clusters to snapshot ###### + +########### including clusters + +# include clusters matching a name in the AWS account +kosli snapshot ecs my-env --clusters my-cluster ... + +# include clusters matching a pattern in the AWS account +kosli snapshot ecs my-env --clusters-regex "my-cluster-*" ... + +# include clusters matching a list of names in the AWS account +kosli snapshot ecs my-env --clusters my-cluster1,my-cluster2 ... + +########### excluding clusters + +# exclude clusters matching a name in the AWS account +kosli snapshot ecs my-env --exclude my-cluster ... + +# exclude clusters matching a pattern in the AWS account +kosli snapshot ecs my-env --exclude-regex "my-cluster-*" ... + +# exclude clusters matching a list of names in the AWS account +kosli snapshot ecs my-env --exclude my-cluster1,my-cluster2 ... + + + +###### filtering which ECS services to snapshot ###### + +########### including services + +# include Services matching a name in one cluster +kosli snapshot ecs my-env --clusters my-cluster --services backend-app ... + +# include Services matching a pattern in one cluster +kosli snapshot ecs my-env --clusters my-cluster --services-regex "backend-*" ... + +# include production Services only (by naming convention) in all clusters in the AWS account +kosli snapshot ecs my-env --services-regex "*-prod-*" ... + +# include Services matching a name in all clusters in the AWS account +kosli snapshot ecs my-env --services backend-app ... + +# include Services matching a list of names in all clusters in the AWS account +kosli snapshot ecs my-env --services backend-app,frontend-app ... + +########### excluding services + +# exclude Services matching a pattern in one cluster +kosli snapshot ecs my-env --clusters my-cluster --exclude-services-regex "backend-*" ... + +# exclude Production services only (by naming convention) in all clusters in the AWS account +kosli snapshot ecs my-env --exclude-services-regex "*-prod-*" ... + +# exclude Services matching a name in one cluster +kosli snapshot ecs my-env --clusters my-cluster --exclude-services backend-app ... + +# exclude Services matching a name in all clusters in the AWS account +kosli snapshot ecs my-env --exclude-services backend-app ... + +# exclude Services matching a list of names in all clusters in the AWS account +kosli snapshot ecs my-env --exclude-services backend-app,frontend-app ... ` type snapshotECSOptions struct { From 5e231eabdadbd16db4cacb5bf21371732bb55d19 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Wed, 8 Oct 2025 11:01:38 +0200 Subject: [PATCH 3/7] correct comment --- internal/aws/aws.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 3ffbfcbe9..69e7a6a03 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -516,7 +516,7 @@ func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter return allTasksData, nil } -// getFilteredECSServicesInCluster fetches a filtered set of ECS services recursively (100 at a time) and returns a list of ecs Services +// getFilteredECSServicesInCluster fetches a filtered set of ECS services recursively (10 at a time) and returns a list of ecs Services func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServices *[]ecsTypes.Service, serviceFilter *filters.ResourceFilterOptions, nextToken *string) (*[]ecsTypes.Service, error) { listInput := &ecs.ListServicesInput{ Cluster: aws.String(cluster), From f524a8ce16fc332056536fbd6851b09e41cac597 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Wed, 8 Oct 2025 11:41:24 +0200 Subject: [PATCH 4/7] add logging of what will be scanned and improve some errors context --- cmd/kosli/snapshotECS.go | 2 +- internal/aws/aws.go | 48 ++++++++++++++++++++++++++-------------- internal/aws/aws_test.go | 2 +- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/cmd/kosli/snapshotECS.go b/cmd/kosli/snapshotECS.go index 7e8f73890..836fd3899 100644 --- a/cmd/kosli/snapshotECS.go +++ b/cmd/kosli/snapshotECS.go @@ -215,7 +215,7 @@ func (o *snapshotECSOptions) run(args []string) error { envName := args[0] url := fmt.Sprintf("%s/api/v2/environments/%s/%s/report/ECS", global.Host, global.Org, envName) - tasksData, err := o.awsStaticCreds.GetEcsTasksData(o.clustersFilter, o.serviceFilter) + tasksData, err := o.awsStaticCreds.GetEcsTasksData(o.clustersFilter, o.serviceFilter, logger) if err != nil { return err } diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 69e7a6a03..143ffd1b1 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -421,7 +421,7 @@ func downloadFileFromBucket(downloader *s3manager.Downloader, dirName, key, buck // getFilteredECSClusters fetches a filtered set of ECS clusters recursively (50 at a time) and returns a list of ecs Clusters func getFilteredECSClusters(client *ecs.Client, allClusters *[]ecsTypes.Cluster, - clusterFilter *filters.ResourceFilterOptions, nextToken *string) (*[]ecsTypes.Cluster, error) { + clusterFilter *filters.ResourceFilterOptions, nextToken *string, logger *logger.Logger) (*[]ecsTypes.Cluster, error) { params := &ecs.ListClustersInput{} if nextToken != nil { params.NextToken = nextToken @@ -438,6 +438,7 @@ func getFilteredECSClusters(client *ecs.Client, allClusters *[]ecsTypes.Cluster, } if !clusterFilter.IsSet() { + logger.Info("all ECS clusters in the AWS account will be scanned") *allClusters = append(*allClusters, describeClustersOutput.Clusters...) } else { for _, c := range describeClustersOutput.Clusters { @@ -452,25 +453,32 @@ func getFilteredECSClusters(client *ecs.Client, allClusters *[]ecsTypes.Cluster, } if listClustersOutput.NextToken != nil { - _, err := getFilteredECSClusters(client, allClusters, clusterFilter, listClustersOutput.NextToken) + _, err := getFilteredECSClusters(client, allClusters, clusterFilter, listClustersOutput.NextToken, logger) if err != nil { return allClusters, err } } + if clusterFilter.IsSet() { + clusterNames := make([]string, len(*allClusters)) + for i, cluster := range *allClusters { + clusterNames[i] = *cluster.ClusterName + } + logger.Info("the following ECS clusters will be scanned: %v", clusterNames) + } return allClusters, nil } // GetEcsTasksData returns a list of tasks data for an ECS cluster or service -func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter *filters.ResourceFilterOptions) ([]*EcsTaskData, error) { +func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter *filters.ResourceFilterOptions, logger *logger.Logger) ([]*EcsTaskData, error) { allTasksData := []*EcsTaskData{} client, err := staticCreds.NewECSClient() if err != nil { - return allTasksData, err + return allTasksData, fmt.Errorf("failed to create ECS client: %w", err) } - filteredClusters, err := getFilteredECSClusters(client, &[]ecsTypes.Cluster{}, clusterFilter, nil) + filteredClusters, err := getFilteredECSClusters(client, &[]ecsTypes.Cluster{}, clusterFilter, nil, logger) if err != nil { - return allTasksData, err + return allTasksData, fmt.Errorf("failed to filter ECS clusters: %w", err) } var ( @@ -484,15 +492,15 @@ func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter go func(clusterName string) { defer wg.Done() - filteredServices, err := getFilteredECSServicesInCluster(client, clusterName, &[]ecsTypes.Service{}, serviceFilter, nil) + filteredServices, err := getFilteredECSServicesInCluster(client, clusterName, &[]ecsTypes.Service{}, serviceFilter, nil, logger) if err != nil { - errChan <- err + errChan <- fmt.Errorf("failed to filter ECS services in cluster %s: %w", clusterName, err) return } - tasksData, err := getTasksDataInClusterService(client, clusterName, filteredServices, nil) + tasksData, err := getTasksDataInClusterService(client, clusterName, filteredServices, nil, logger) if err != nil { - errChan <- err + errChan <- fmt.Errorf("failed to get tasks data in cluster %s: %w", clusterName, err) return } @@ -517,7 +525,8 @@ func (staticCreds *AWSStaticCreds) GetEcsTasksData(clusterFilter, serviceFilter } // getFilteredECSServicesInCluster fetches a filtered set of ECS services recursively (10 at a time) and returns a list of ecs Services -func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServices *[]ecsTypes.Service, serviceFilter *filters.ResourceFilterOptions, nextToken *string) (*[]ecsTypes.Service, error) { +func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServices *[]ecsTypes.Service, serviceFilter *filters.ResourceFilterOptions, + nextToken *string, logger *logger.Logger) (*[]ecsTypes.Service, error) { listInput := &ecs.ListServicesInput{ Cluster: aws.String(cluster), } @@ -529,14 +538,13 @@ func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServ return allServices, err } - fmt.Println("listServicesOutput.ServiceArns", listServicesOutput.ServiceArns) - describeServicesOutput, err := client.DescribeServices(context.TODO(), &ecs.DescribeServicesInput{Cluster: aws.String(cluster), Services: listServicesOutput.ServiceArns}) if err != nil { return allServices, err } if !serviceFilter.IsSet() { + logger.Info("all ECS services in cluster [%s] will be scanned", cluster) *allServices = append(*allServices, describeServicesOutput.Services...) } else { for _, s := range describeServicesOutput.Services { @@ -551,16 +559,23 @@ func getFilteredECSServicesInCluster(client *ecs.Client, cluster string, allServ } if listServicesOutput.NextToken != nil { - _, err := getFilteredECSServicesInCluster(client, cluster, allServices, serviceFilter, listServicesOutput.NextToken) + _, err := getFilteredECSServicesInCluster(client, cluster, allServices, serviceFilter, listServicesOutput.NextToken, logger) if err != nil { return allServices, err } } + if serviceFilter.IsSet() { + serviceNames := make([]string, len(*allServices)) + for i, service := range *allServices { + serviceNames[i] = *service.ServiceName + } + logger.Info("the following ECS services in cluster [%s] will be scanned: %v", cluster, serviceNames) + } return allServices, nil } // getTasksDataInClusterService fetches a filtered set of ECS tasks recursively (100 at a time) and returns a list of ecs Tasks -func getTasksDataInClusterService(client *ecs.Client, clusterName string, filteredServices *[]ecsTypes.Service, nextToken *string) ([]*EcsTaskData, error) { +func getTasksDataInClusterService(client *ecs.Client, clusterName string, filteredServices *[]ecsTypes.Service, nextToken *string, logger *logger.Logger) ([]*EcsTaskData, error) { tasksData := []*EcsTaskData{} var mutex sync.Mutex var wg sync.WaitGroup @@ -571,6 +586,7 @@ func getTasksDataInClusterService(client *ecs.Client, clusterName string, filter go func(svc ecsTypes.Service) { defer wg.Done() + logger.Debug("scanning ECS tasks in service [%s] in cluster [%s]", *svc.ServiceName, clusterName) listInput := &ecs.ListTasksInput{ Cluster: aws.String(clusterName), ServiceName: svc.ServiceName, @@ -629,7 +645,7 @@ func getTasksDataInClusterService(client *ecs.Client, clusterName string, filter // Handle pagination for this service's tasks if listTasksOutput.NextToken != nil { - additionalTasksData, err := getTasksDataInClusterService(client, clusterName, &[]ecsTypes.Service{svc}, listTasksOutput.NextToken) + additionalTasksData, err := getTasksDataInClusterService(client, clusterName, &[]ecsTypes.Service{svc}, listTasksOutput.NextToken, logger) if err != nil { errChan <- err return diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 6b83b6aae..2f112464a 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -656,7 +656,7 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { if t.serviceFilter == nil { t.serviceFilter = new(filters.ResourceFilterOptions) } - data, err := t.creds.GetEcsTasksData(t.clusterFilter, t.serviceFilter) + data, err := t.creds.GetEcsTasksData(t.clusterFilter, t.serviceFilter, logger.NewStandardLogger()) require.False(suite.Suite.T(), (err != nil) != t.wantErr, "GetEcsTasksData() error = %v, wantErr %v", err, t.wantErr) if !t.wantErr { From 9073ac0868df38e0fa5a221238a41aae2e2740e0 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Wed, 8 Oct 2025 11:45:00 +0200 Subject: [PATCH 5/7] include cluster and service names in ECS tasks data --- internal/aws/aws.go | 10 ++++++---- internal/aws/aws_test.go | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 143ffd1b1..112626157 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -35,6 +35,7 @@ type EcsEnvRequest struct { type EcsTaskData struct { TaskArn string `json:"taskArn"` Cluster string `json:"cluster,omitempty"` + Service string `json:"service,omitempty"` Digests map[string]string `json:"digests"` StartedAt int64 `json:"creationTimestamp"` } @@ -62,10 +63,11 @@ type LambdaData struct { } // NewEcsTaskData creates a NewEcsTaskData object from an ECS task -func NewEcsTaskData(taskArn, cluster string, digests map[string]string, startedAt time.Time) *EcsTaskData { +func NewEcsTaskData(taskArn, clusterName, serviceName string, digests map[string]string, startedAt time.Time) *EcsTaskData { return &EcsTaskData{ - TaskArn: taskArn, - // Cluster: cluster, + TaskArn: taskArn, + Cluster: clusterName, + Service: serviceName, Digests: digests, StartedAt: startedAt.Unix(), } @@ -632,7 +634,7 @@ func getTasksDataInClusterService(client *ecs.Client, clusterName string, filter digests[*imageName] = "" } } - data := NewEcsTaskData(*taskDesc.TaskArn, clusterName, digests, *taskDesc.StartedAt) + data := NewEcsTaskData(*taskDesc.TaskArn, clusterName, *svc.ServiceName, digests, *taskDesc.StartedAt) serviceTasksData = append(serviceTasksData, data) } } diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 2f112464a..7d226325b 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -78,15 +78,17 @@ func (suite *AWSTestSuite) TestDecodeLambdaFingerprint() { func (suite *AWSTestSuite) TestNewEcsTaskData() { taskARN := "" cluster := "foo" + service := "bar" digests := map[string]string{} time := time.Now() expected := &EcsTaskData{ TaskArn: taskARN, - Cluster: "", + Cluster: cluster, + Service: service, Digests: digests, StartedAt: time.Unix(), } - got := NewEcsTaskData(taskARN, cluster, digests, time) + got := NewEcsTaskData(taskARN, cluster, service, digests, time) require.Equal(suite.Suite.T(), expected, got) } From 0352be48072dc9f3dae007fa4a44d66691499ef6 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Thu, 9 Oct 2025 14:02:44 +0200 Subject: [PATCH 6/7] fix json keys --- internal/aws/aws.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 112626157..02db3400a 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -34,8 +34,8 @@ type EcsEnvRequest struct { // EcsTaskData represents the harvested ECS task data type EcsTaskData struct { TaskArn string `json:"taskArn"` - Cluster string `json:"cluster,omitempty"` - Service string `json:"service,omitempty"` + Cluster string `json:"cluster_name,omitempty"` + Service string `json:"service_name,omitempty"` Digests map[string]string `json:"digests"` StartedAt int64 `json:"creationTimestamp"` } From 79359735b69145020f5d67c7c35fb1b5c5ba1bb2 Mon Sep 17 00:00:00 2001 From: Sami Alajrami Date: Fri, 10 Oct 2025 11:08:41 +0200 Subject: [PATCH 7/7] add command tests --- cmd/kosli/snapshotECS_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cmd/kosli/snapshotECS_test.go b/cmd/kosli/snapshotECS_test.go index 587ab804b..4b360f74d 100644 --- a/cmd/kosli/snapshotECS_test.go +++ b/cmd/kosli/snapshotECS_test.go @@ -71,6 +71,30 @@ func (suite *SnapshotECSTestSuite) TestSnapshotECSCmd() { cmd: fmt.Sprintf(`snapshot ecs %s --clusters sss --exclude-regex sss %s`, suite.envName, suite.defaultKosliArguments), golden: "Error: only one of --cluster, --clusters, --exclude-regex is allowed\n", }, + { + wantError: true, + name: "snapshot ECS fails if --services and --exclude-services-regex are set", + cmd: fmt.Sprintf(`snapshot ecs %s --services sss --exclude-services-regex sss %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services, --exclude-services-regex is allowed\n", + }, + { + wantError: true, + name: "snapshot ECS fails if --services and --exclude-services are set", + cmd: fmt.Sprintf(`snapshot ecs %s --services sss --exclude-services sss %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services, --exclude-services is allowed\n", + }, + { + wantError: true, + name: "snapshot ECS fails if --services-regex and --exclude-services-regex are set", + cmd: fmt.Sprintf(`snapshot ecs %s --services-regex sss --exclude-services-regex sss %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services-regex, --exclude-services-regex is allowed\n", + }, + { + wantError: true, + name: "snapshot ECS fails if --services-regex and --exclude-services are set", + cmd: fmt.Sprintf(`snapshot ecs %s --services-regex sss --exclude-services sss %s`, suite.envName, suite.defaultKosliArguments), + golden: "Error: only one of --services-regex, --exclude-services is allowed\n", + }, { name: "snapshot ECS works if no filtering flags are used", cmd: fmt.Sprintf(`snapshot ecs %s %s`, suite.envName, suite.defaultKosliArguments), @@ -87,6 +111,14 @@ func (suite *SnapshotECSTestSuite) TestSnapshotECSCmd() { }, goldenRegex: "\\[\\d+\\] containers were reported to environment snapshot-ecs-env\n", }, + { + name: "snapshot ECS works with --clusters and --services", + cmd: fmt.Sprintf(`snapshot ecs %s %s --clusters merkely --services kosli`, suite.envName, suite.defaultKosliArguments), + additionalConfig: snapshotECSTestConfig{ + requireAuthToBeSet: true, + }, + goldenRegex: "\\[\\d+\\] containers were reported to environment snapshot-ecs-env\n", + }, } for _, t := range tests {