Zenn
Closed33

Go でCLIツールを作る

snakasnaka

aws ecs execute-command を実行するだけの CLI ツールを作ってみる

名前

  • ecs-exec-sh とする

要件

  • 起動中の cluster / service / container を指定して aws ecs execute-command で、該当コンテナでshell (/bin/sh) を起動する
snakasnaka

プロジェクトディレクトリを用意する

$ mkdir ecs-exec-sh
$ cd ecs-exec-sh/

go.mod を用意する

$ go mod init github.com/snaka/ecs-exec-sh
go: creating new go.mod: module ecs-exec-sh

cobra-cli で雛形を用意する

$ cobra-cli init . --license MIT
Your Cobra application is ready at
/Users/snaka/projects/ecs-exec-sh

とりあえず git リポジトリを初期化しておく

$ git init
$ git commit --allow-empty -m 'Initial commit'

snakasnaka

go install 用にディレクトリ構成を調整 (必要?)

cmd/ecs-exec-sh のようにコマンド名でディレクトリを作って、そこに main.go を移動する

.
├── LICENSE
├── Makefile
├── cmd
│   ├── ecs-exec-sh
│   │   └── main.go
│   └── root.go
├── go.mod
└── go.sum
snakasnaka

サブコマンド list を追加する

AWS アカウントで実行されている ECS Cluster, Service, Container の一覧を出力するサブコマンドとして list を追加する

cobra-cli add list

cmd/list.go が追加される

snakasnaka

cmd/list.go の実装

AI にお願いして出してもらった実装は以下

AIが出力した参考実装
cmd/list.go
package cmd

import (
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/ 1 ecs"
	"github.com/spf13/cobra"
)

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "ECS Cluster, Service, Container の一覧を表示",
	Run: func(cmd *cobra.Command, args []string) {
		sess, err := session.NewSession()
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}

		svc := ecs.New(sess)

		// Cluster 一覧取得
		result, err := svc.ListClusters(&ecs.ListClustersInput{})
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}

		for _, clusterArn := range result.ClusterArns {
			fmt.Println("Cluster:", *clusterArn)

			// Service 一覧取得
			serviceResult, err := svc.ListServices(&ecs.ListServicesInput{
				Cluster: clusterArn,
			})
			if err != nil {
				fmt.Fprintln(os.Stderr, err)
				os.Exit(1)
			}

			for _, serviceArn := range serviceResult.ServiceArns {
				fmt.Println("  Service:", *serviceArn)

				// Task 一覧取得
				taskResult, err := svc.ListTasks(&ecs.ListTasksInput{
					Cluster:     clusterArn,
					ServiceName: serviceArn,
				})
				if err != nil {
					fmt.Fprintln(os.Stderr, err)
					os.Exit(1)
				}

				for _, taskArn := range taskResult.TaskArns {
					// Task 詳細取得
					taskDetail, err := svc.DescribeTasks(&ecs.DescribeTasksInput{
						Cluster: clusterArn,
						Tasks:   []*string{taskArn},
					})
					if err != nil {
						fmt.Fprintln(os.Stderr, err)
						os.Exit(1)
					}

					for _, task := range taskDetail.Tasks {
						for _, container := range task.Containers {
							fmt.Println("    Container:", *container.Name)
						}
					}
				}
			}
		}
	},
}

func init() {
	rootCmd.AddCommand(listCmd)
}

これを足がかりとして実装を進める

snakasnaka

とりあえず動くバージョン

cmd/list.go
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd

import (
	"context"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/ecs"
	ecsShell "github.com/snaka/ecs-exec-sh/ecs"
	"github.com/spf13/cobra"
)

// listCmd represents the list command
var listCmd = &cobra.Command{
	Use:   "list",
	Short: "A brief description of your command",
	Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
	Run: func(cmd *cobra.Command, args []string) {
		// Load AWS Config
		cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion("ap-northeast-1"))
		if err != nil {
			fmt.Println("Error loading AWS config:", err)
			os.Exit(1)
		}

		// Create ECS Client
		svc := ecs.NewFromConfig(cfg)

		// Run List
		if err := ecsShell.RunList(svc); err != nil {
			fmt.Println("Error running list:", err)
			os.Exit(1)
		}
	},
}

func init() {
	rootCmd.AddCommand(listCmd)

	// Here you will define your flags and configuration settings.

	// Cobra supports Persistent Flags which will work for this command
	// and all subcommands, e.g.:
	// listCmd.PersistentFlags().String("foo", "", "A help for foo")

	// Cobra supports local flags which will only run when this command
	// is called directly, e.g.:
	// listCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
ecs/ecs.go
// ecs/ecs.go
package ecs

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/service/ecs"
)

// li 1 st コマンドの実行関数
func RunList(svc *ecs.Client) error {
	// Cluster 一覧取得
	result, err := svc.ListClusters(context.TODO(), &ecs.ListClustersInput{})
	if err != nil {
		return err
	}

	for _, clusterArn := range result.ClusterArns {
		fmt.Println("Cluster:", clusterArn)

		// Service 一覧取得
		serviceResult, err := svc.ListServices(context.TODO(), &ecs.ListServicesInput{
			Cluster: &clusterArn,
		})
		if err != nil {
			return err
		}

		for _, serviceArn := range serviceResult.ServiceArns {
			fmt.Println("  Service:", serviceArn)

			// Task 一覧取得
			taskResult, err := svc.ListTasks(context.TODO(), &ecs.ListTasksInput{
				Cluster:     &clusterArn,
				ServiceName: &serviceArn,
			})
			if err != nil {
				return err
			}

			containerNames := make(map[string]struct{})

			for _, taskArn := range taskResult.TaskArns {
				// Task 詳細取得
				taskDetail, err := svc.DescribeTasks(context.TODO(), &ecs.DescribeTasksInput{
					Cluster: &clusterArn,
					Tasks:   []string{taskArn},
				})
				if err != nil {
					return err
				}

				for _, task := range taskDetail.Tasks {
					for _, container := range task.Containers {
						containerNames[*container.Name] = struct{}{}
					}
				}
			}

			fmt.Println("    Containers:")
			for containerName := range containerNames {
				fmt.Println("      -", containerName)
			}
		}
	}

	return nil
}

snakasnaka

サブコマンド exec を作成する

サブコマンド exec は指定された Cluster, Service, Container で /bin/sh を実行する

cobra-cli add exec

cmd/exec.go が追加される

snakasnaka

フラグを用意する

以下のようなフラグを受け取りたい

  • --cluster
  • --service
  • --container
snakasnaka

以下のように、init() に実装を追加する

cmd/exec.go
var clusterName, serviceName, containerName string

func init() {
	rootCmd.AddCommand(execCmd)

	execCmd.Flags().StringVarP(&clusterName, "cluster", "c", "", "cluster name")
	execCmd.Flags().StringVarP(&serviceName, "service", "s", "", "service name")
	execCmd.Flags().StringVarP(&containerName, "container", "C", "", "container name")

	execCmd.MarkFlagRequired("cluster")
	execCmd.MarkFlagRequired("service")
	execCmd.MarkFlagRequired("container")
}

ecs-exec-sh help exec の結果は以下のようになる

Usage:
  ecs-exec-sh exec [flags]

Flags:
  -c, --cluster string     cluster name
  -C, --container string   container name
  -h, --help               help for exec
  -s, --service string     service name
snakasnaka

どうも /bin/sh を使うには SessionManager Plugin を利用する必要がありそう
そのあたりをいい感じにしてくれるライブラリが ecsta らしいので、これを呼び出す形にしてみる

https://github.com/fujiwara/ecsta

snakasnaka

ecsta の v0.6.x だと以下のエラーで build 失敗してた

go/pkg/mod/github.com/fujiwara/ecsta@v0.6.1/cp.go:54:12: pattern assets/tncl-aarch64-linux-musl: no matching files found

が、 v0.5.1 にすることで動くようになったが、上記の本来の解消方法がよくわからない...

snakasnaka

exec サブコマンドの本体ロジックは以下のようになった

ecs/exec.go
package ecs

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/ecs"
	"github.com/fujiwara/ecsta"
)

func ExecuteCommand(clusterName, serviceName, containerName, command string) error {
    svc, err := Client()
    if err != nil {
        return fmt.Errorf("failed to create ECS client: %w", err)
    }

    ctx := context.TODO()

    // List tasks
    listTasksOutput, err := svc.ListTasks(ctx, &ecs.ListTasksInput{
        Cluster:     aws.String(clusterName),
        ServiceName: aws.String(serviceName),
    })
    if err != nil {
        return fmt.Errorf("failed to list tasks: %w", err)
    }

    // Get task ID from the list
    taskID := listTasksOutput.TaskArns[0]

    fmt.Printf("Task: %s\n", taskID)

    ecstaApp, err := newEcsta(ctx, "ap-northeast-1", clusterName)
    if err != nil {
        return fmt.Errorf("failed to create Ecsta: %w", err)
    }

    // Execute command
    return ecstaApp.RunExec(ctx,&ecsta.ExecOption{
        ID: taskID,
        Service: &serviceName,
        Container: containerName,
        Command: command,
    })
}

func newEcsta(ctx context.Context, region, cluster string) (*ecsta.Ecsta, error) {
    app, err := ecsta.New(ctx, region, cluster)
    if err != nil {
        return nil, fmt.Errorf("failed to create Ecsta, %w", err)
    }
    return app, nil
}
snakasnaka

インストールに失敗する

別端末で go install するとエラーになった

$ go install github.com/snaka/ecs-exec-sh/cmd/ecs-exec-sh@latest
go: downloading github.com/snaka/ecs-exec-sh v0.0.0-20241201150040-98168b26051e
go: github.com/snaka/ecs-exec-sh/cmd/ecs-exec-sh@latest (in github.com/snaka/ecs-exec-sh@v0.0.0-20241201150040-98168b26051e):
        The go.mod file for the module providing named packages contains one or
        more replace directives. It must not contain directives that would cause
        it to be interpreted differently than if it were the main module.
snakasnaka

権限が不足している?

環境によって以下のエラーが出る

User: arn:aws:iam::xxxxxxxx:user/foo is not authorized to perform: ecs:DiscoverPollEndpoint on resource: * because no identity-based policy allows the ecs:DiscoverPollEndpoint action

権限を利用しているのは以下の箇所

https://github.com/fujiwara/ecsta/blob/3ee1c4121f6a29eb1efbbc38147036b8b5397bf5/ecsta.go#L285-L293

snakasnaka

aws cli 側で endpoint の値を出力してみる

parsed_globals.region:  None
client.meta.endpoint_url:  https://ecs.ap-northeast-1.amazonaws.com
snakasnaka

promptkit を入れる

https://github.com/erikgeiser/promptkit/tree/main

 go get github.com/erikgeiser/promptkit@v0.9.0
snakasnaka

こんな感じでクラスター選択するUIができる

func selectCluster(ctx context.Context, svc *ecs.Client) (string, error) {
	clusters, err := svc.ListClusters(ctx, &ecs.ListClustersInput{})
	if err != nil {
		return "", fmt.Errorf("failed to list clusters: %w", err)
	}

	clusterNames := make([]string, 0, len(clusters.ClusterArns))
	for _, c := range clusters.ClusterArns {
		clusterNames = append(clusterNames, extractName(c))
	}

	// selection
	sp := selection.New("Select cluster:", clusterNames)
	choice, err := sp.RunPrompt()
	if err != nil {
		return "", fmt.Errorf("failed to select cluster: %w", err)
	}
	return choice, nil
}
snakasnaka

region を config から取得する

デフォルトの region は aws config 時の設定から取り出したい

このスクラップは4ヶ月前にクローズされました
ログインするとコメントできます