実行中ECSタスクがあっても問答無用でcdk destroyする

## 背景
Fargate環境をcdkで構築する時、ECSタスクが実行中だとcdk destroyコマンドだけではリソースが削除できず面倒です。

こんな感じでエラーになる。
```sh
$ cdk destroy 
Are you sure you want to delete: {Stack名} (y/n)? y
{Stack名}: destroying... [1/2]
....
Resource handler returned message: "The specified capacity provider is in use and cannot be removed. (Service: AmazonECS; Status Code: 400; Error Code: ResourceInUseException; Request ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx; Proxy: null)" (RequestToken: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, HandlerErrorCode: nul
l)
....
```

コンソールからECSタスクのみ停止 or 削除するか「削除を再試行」から「このスタック全体を強制削除」を実行する必要があります。

この操作をcdk cliに取り込もうとする動きはあるようですが、cdk cli v2.160.0時点ではまだ実装されていないようなので自前で実装してみようと思います。
https://github.com/aws/aws-cdk/issues/30344

## 概要（方針）
1. スタック名、削除したいcdk appを指定  
2. ECSクラスターを取得  
3. ECSサービスを削除し、ECSタスクを停止  
4. `cdk destroy --all --force` を実行  

下記のように使用する想定です。
どのディレクトリで実行しても`--cdk-app-root`ディレクトリのcdk appに対して実行します。
```sh
go run main.go \
  --stack {スタック名} \       # 紐づくECSを取得するためにしていする。destroy時は`--all`を使用する。
  --profile {プロファイル名} \  # 省略可
  --cdk-app-root {cdk appディレクトリパス} \ 
  --cdk-app-path bin/{cdk app名}
```

Goはv1.23.0を使用し、cdkの実装はTypeScriptを想定します。
TypeScriptを想定しているくせにCLIツールはGoで作るのかよというツッコミはなしでお願いします。

Goで書きたかったんです。
でもcdkはTypeScriptで書きたかったんです。

## コードの解説

### main

- メイン処理
- コマンド引数を受け取り、必要なものが指定されていなければ即終了

```go
var (
	stackName  = flag.String("stack", "", "CloudFormation stack name (required)")
	profile    = flag.String("profile", "", "AWS CLI profile name (optional)")
	cdkAppPath = flag.String("cdk-app-path", "", "Full path to the CDK app entry file, e.g. /path/to/bin/app.ts (required)")
	cdkAppRoot = flag.String("cdk-app-root", ".", "CDK project root path (where cdk.json is). Defaults to current directory.")
)

func main() {
	flag.Parse()

	// スタック名とアプリパスがなければ終了
	if *stackName == "" {
		log.Fatal("Error: --stack を指定してください。")
	}
  if *cdkAppRoot == "" {
    log.Fatal("Error: --cdk-app-root を指定してください。")
  }
	if *cdkAppPath == "" {
		log.Fatal("Error: --cdk-app-path を指定してください。")
	}

	// AWS Config ロード
	ctx := context.Background()
	cfg, err := loadAWSConfig(ctx, *profile)
	if err != nil {
		log.Fatalf("failed to load AWS config: %v", err)
	}

	// ECSクラスター名をCloudFormationから取得
	clusterName, err := getEcsClusterNameFromStack(ctx, cfg, *stackName)
	if err != nil {
		log.Fatalf("Failed to get ECS cluster name: %v", err)
	}

	// クラスターがあればECSサービス削除・タスク停止
	if clusterName != "" {
		if err := deleteEcsServices(ctx, cfg, clusterName); err != nil {
			log.Fatalf("Failed to delete ECS services: %v", err)
		}
		if err := stopRemainingTasks(ctx, cfg, clusterName); err != nil {
			log.Fatalf("Failed to stop tasks: %v", err)
		}
	}

	// cdk destroy コマンド実行
	if err := runCdkDestroy(*profile, *cdkAppRoot, *cdkAppPath); err != nil {
		log.Fatalf("Failed to run cdk destroy: %v", err)
	}
	log.Println("All done.")
}
```


### AWS Configをロードする関数
- `profile` がある場合のみ設定を反映してConfigをロード
```go
func loadAWSConfig(ctx context.Context, profile string) (aws.Config, error) {
	opts := []func(*config.LoadOptions) error{}
	if profile != "" {
		opts = append(opts, config.WithSharedConfigProfile(profile))
	}
	return config.LoadDefaultConfig(ctx, opts...)
}
```


### ECSクラスター名をCloudFormationから取得
- `ListStackResources` から ECS::Cluster のリソースを探して、クラスター名を返す
```go
func getEcsClusterNameFromStack(ctx context.Context, cfg aws.Config, stackName string) (string, error) {
	cfnClient := cfn.NewFromConfig(cfg)
	res, err := cfnClient.ListStackResources(ctx, &cfn.ListStackResourcesInput{
		StackName: &stackName,
	})
	if err != nil {
		return "", err
	}

	for _, r := range res.StackResourceSummaries {
		if r.ResourceType != nil && *r.ResourceType == "AWS::ECS::Cluster" {
			return *r.PhysicalResourceId, nil
		}
	}
	return "", nil
}
```

### ECSサービスを削除する関数

- まず `DesiredCount=0` にしてサービスを停止
- その後サービスを削除
```go
func deleteEcsServices(ctx context.Context, cfg aws.Config, clusterName string) error {
	ecsClient := ecs.NewFromConfig(cfg)

	listOut, err := ecsClient.ListServices(ctx, &ecs.ListServicesInput{
		Cluster: &clusterName,
	})
	if err != nil {
		return fmt.Errorf("ListServices error: %w", err)
	}

	for _, svcArn := range listOut.ServiceArns {
		svcName := arnToName(svcArn)
		// DesiredCountを0にしてタスクを落とす
		_, err := ecsClient.UpdateService(ctx, &ecs.UpdateServiceInput{
			Cluster:      &clusterName,
			Service:      &svcName,
			DesiredCount: aws.Int32(0),
		})
		// 安定するまで待機してから削除
		_ = waitForServiceStable(ctx, ecsClient, clusterName, svcName)
		_, _ = ecsClient.DeleteService(ctx, &ecs.DeleteServiceInput{
			Cluster: &clusterName,
			Service: &svcName,
			Force:   aws.Bool(true),
		})
	}
	return nil
}
```


### 残っているタスクを停止する関数

- 残っているRunningタスクを `StopTask` で停止
- これで`cdk destroy`が通るはず
```go
func stopRemainingTasks(ctx context.Context, cfg aws.Config, clusterName string) error {
	ecsClient := ecs.NewFromConfig(cfg)

	listOut, err := ecsClient.ListTasks(ctx, &ecs.ListTasksInput{
		Cluster:       &clusterName,
		DesiredStatus: ecstypes.DesiredStatusRunning,
	})
	if err != nil {
		return fmt.Errorf("ListTasks error: %w", err)
	}

	for _, taskArn := range listOut.TaskArns {
		_, _ = ecsClient.StopTask(ctx, &ecs.StopTaskInput{
			Cluster: &clusterName,
			Task:    &taskArn,
			Reason:  aws.String("Cleanup before destroy"),
		})
	}
	return nil
}
```

### サービスがSTABLEになるまで待機する関数

- `ecs.NewServicesStableWaiter` でサービスが安定状態になるまで待つ関数
```go
func waitForServiceStable(ctx context.Context, ecsClient *ecs.Client, clusterName, serviceName string) error {
	svcWaiter := ecs.NewServicesStableWaiter(ecsClient)
	input := &ecs.DescribeServicesInput{
		Cluster:  &clusterName,
		Services: []string{serviceName},
	}
	maxWait := 10 * time.Minute
	return svcWaiter.Wait(ctx, input, maxWait)
}
```

### cdk destroyを実行する関数
- `--app` に `ts-node` でCDKのエントリファイルを指定し、`cdk destroy --all --force` を実行
- TyepScript以外をつかう場合はここを修正

```go
func runCdkDestroy(profile, cdkAppRoot, cdkAppPath string) error {
	args := []string{"destroy", "--all", "--force"}
	if profile != "" {
		args = append(args, "--profile", profile)
	}

	appArg := fmt.Sprintf("npx ts-node %s", cdkAppPath)
	args = append(args, "--app", appArg)

	cmd := exec.Command("cdk", args...)
	cmd.Dir = cdkAppRoot
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}
```

### ARNの末尾から名前を取り出すユーティリティ関数
- ARNの末尾だけ取得する関数
```go
func arnToName(arn string) string {
	parts := strings.Split(arn, "/")
	return parts[len(parts)-1]
}
```

## コード全体

以下がすべてまとめたコードです。

```go
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	cfn "github.com/aws/aws-sdk-go-v2/service/cloudformation"
	"github.com/aws/aws-sdk-go-v2/service/ecs"
	ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types"
)

var (
	stackName  = flag.String("stack", "", "CloudFormation stack name (required)")
	profile    = flag.String("profile", "", "AWS CLI profile name (optional)")
	cdkAppPath = flag.String("cdk-app-path", "", "Full path to the CDK app entry file, e.g. /path/to/bin/app.ts (required)")
	cdkAppRoot = flag.String("cdk-app-root", ".", "CDK project root path (where cdk.json is). Defaults to current directory.")
)

func main() {
	flag.Parse()

	if *stackName == "" {
		log.Fatal("Error: --stack を指定してください。")
	}
	if *cdkAppRoot == "" {
		log.Fatal("Error: --cdk-app-root を指定してください。")
	}
	if *cdkAppPath == "" {
		log.Fatal("Error: --cdk-app-path を指定してください。")
	}

	ctx := context.Background()

	// AWS Config をロード (profile のみ反映、region 引数は省略)
	cfg, err := loadAWSConfig(ctx, *profile)
	if err != nil {
		log.Fatalf("failed to load AWS config: %v", err)
	}

	// ECS クラスター名の取得
	clusterName, err := getEcsClusterNameFromStack(ctx, cfg, *stackName)
	if err != nil {
		log.Fatalf("Failed to get ECS cluster name: %v", err)
	}
	if clusterName == "" {
		log.Printf("No ECS::Cluster in stack: %s", *stackName)
	} else {
		// ECSサービスを停止・削除
		if err := deleteEcsServices(ctx, cfg, clusterName); err != nil {
			log.Fatalf("Failed to delete ECS services: %v", err)
		}
		// タスクを停止
		if err := stopRemainingTasks(ctx, cfg, clusterName); err != nil {
			log.Fatalf("Failed to stop tasks: %v", err)
		}
	}

	// コマンド実行
	if err := runCdkDestroy(*profile, *cdkAppRoot, *cdkAppPath); err != nil {
		log.Fatalf("Failed to run cdk destroy: %v", err)
	}
	log.Println("All done.")
}

// AWS Config ロード
func loadAWSConfig(ctx context.Context, profile string) (aws.Config, error) {
	opts := []func(*config.LoadOptions) error{}
	if profile != "" {
		opts = append(opts, config.WithSharedConfigProfile(profile))
	}
	return config.LoadDefaultConfig(ctx, opts...)
}

// CloudFormation から ECS Cluster名を取得
func getEcsClusterNameFromStack(ctx context.Context, cfg aws.Config, stackName string) (string, error) {
	cfnClient := cfn.NewFromConfig(cfg)
	res, err := cfnClient.ListStackResources(ctx, &cfn.ListStackResourcesInput{
		StackName: &stackName,
	})
	if err != nil {
		return "", err
	}

	for _, r := range res.StackResourceSummaries {
		if r.ResourceType != nil && *r.ResourceType == "AWS::ECS::Cluster" {
			return *r.PhysicalResourceId, nil
		}
	}
	return "", nil
}

// ECSサービスを停止（DesiredCount=0）→ 削除
func deleteEcsServices(ctx context.Context, cfg aws.Config, clusterName string) error {
	ecsClient := ecs.NewFromConfig(cfg)

	listOut, err := ecsClient.ListServices(ctx, &ecs.ListServicesInput{
		Cluster: &clusterName,
	})
	if err != nil {
		return fmt.Errorf("ListServices error: %w", err)
	}

	if len(listOut.ServiceArns) == 0 {
		log.Printf("No ECS services found in cluster: %s", clusterName)
		return nil
	}

	for _, svcArn := range listOut.ServiceArns {
		svcName := arnToName(svcArn)
		log.Printf("[Service: %s] Setting desired count to 0...", svcName)

		_, err := ecsClient.UpdateService(ctx, &ecs.UpdateServiceInput{
			Cluster:      &clusterName,
			Service:      &svcName,
			DesiredCount: aws.Int32(0),
		})
		if err != nil {
			log.Printf("Failed to update service(%s) desiredCount=0: %v", svcName, err)
			continue
		}

		if err := waitForServiceStable(ctx, ecsClient, clusterName, svcName); err != nil {
			log.Printf("waitForServiceStable failed for service(%s): %v", svcName, err)
		}

		log.Printf("[Service: %s] Deleting...", svcName)
		_, err = ecsClient.DeleteService(ctx, &ecs.DeleteServiceInput{
			Cluster: &clusterName,
			Service: &svcName,
			Force:   aws.Bool(true),
		})
		if err != nil {
			log.Printf("Failed to delete service(%s): %v", svcName, err)
		}
	}
	return nil
}

// クラスターに残っているタスクを停止
func stopRemainingTasks(ctx context.Context, cfg aws.Config, clusterName string) error {
	ecsClient := ecs.NewFromConfig(cfg)

	listOut, err := ecsClient.ListTasks(ctx, &ecs.ListTasksInput{
		Cluster:       &clusterName,
		DesiredStatus: ecstypes.DesiredStatusRunning,
	})
	if err != nil {
		return fmt.Errorf("ListTasks error: %w", err)
	}
	if len(listOut.TaskArns) == 0 {
		log.Printf("No running tasks in cluster: %s", clusterName)
		return nil
	}

	for _, taskArn := range listOut.TaskArns {
		taskName := arnToName(taskArn)
		log.Printf("[Task: %s] Stopping...", taskName)
		_, err := ecsClient.StopTask(ctx, &ecs.StopTaskInput{
			Cluster: &clusterName,
			Task:    &taskArn,
			Reason:  aws.String("Cleanup before destroy"),
		})
		if err != nil {
			log.Printf("Failed to stop task(%s): %v", taskName, err)
		}
	}
	return nil
}

// コマンド実行
func runCdkDestroy(profile, cdkAppRoot, cdkAppPath string) error {
	args := []string{"destroy", "--all", "--force"}
	if profile != "" {
		args = append(args, "--profile", profile)
	}

	// --app 引数
	appArg := fmt.Sprintf("npx ts-node %s", cdkAppPath)
	args = append(args, "--app", appArg)

	log.Printf("Executing: cdk %s", strings.Join(args, " "))

	cmd := exec.Command("cdk", args...)
	cmd.Dir = cdkAppRoot
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

// ARN末尾からリソース名を取り出す
func arnToName(arn string) string {
	parts := strings.Split(arn, "/")
	return parts[len(parts)-1]
}

// サービスが STABLE になるまで待機
func waitForServiceStable(ctx context.Context, ecsClient *ecs.Client, clusterName, serviceName string) error {
	svcWaiter := ecs.NewServicesStableWaiter(ecsClient)
	input := &ecs.DescribeServicesInput{
		Cluster:  &clusterName,
		Services: []string{serviceName},
	}
	maxWait := 10 * time.Minute
	return svcWaiter.Wait(ctx, input, maxWait)
}

```

## まとめ
局所的で汎用性のないツールですが、スタックのサイズが大きくなるとdestroyコマンド実行後しばらく待った後コンソールから再削除するようなことがなくなるので楽になりました。
処理速度が早くなるわけではないですが、コーヒーブレイクできるので自分的にはよしとしています。

ついでに誰かのなにかの参考になれば幸いです。