Go でCLIツールを作る

とりあえず動くようになったコード

参考
こっちも参考にしてた
リリース関連はあとで

半日程度で、既存のシェルスクリプトで実装されていたツールをベースに、実用的な CLI ツールをバイナリ配布できる形で実装することができた。

aws ecs execute-command
を実行するだけの CLI ツールを作ってみる
名前
-
ecs-exec-sh
とする
要件
- 起動中の cluster / service / container を指定して
aws ecs execute-command
で、該当コンテナでshell (/bin/sh
) を起動する

プロジェクトディレクトリを用意する
$ 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'

go install
用にディレクトリ構成を調整 (必要?)
cmd/ecs-exec-sh
のようにコマンド名でディレクトリを作って、そこに main.go
を移動する
.
├── LICENSE
├── Makefile
├── cmd
│ ├── ecs-exec-sh
│ │ └── main.go
│ └── root.go
├── go.mod
└── go.sum

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

cmd/list.go の実装
AI にお願いして出してもらった実装は以下
AIが出力した参考実装
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)
}
これを足がかりとして実装を進める

実装の参考

とりあえず動くバージョン
/*
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
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
}

exec
を作成する
サブコマンド サブコマンド exec
は指定された Cluster, Service, Container で /bin/sh
を実行する
cobra-cli add exec
cmd/exec.go
が追加される

フラグを用意する
以下のようなフラグを受け取りたい
--cluster
--service
--container

以下のように、init()
に実装を追加する
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

参考

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

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
にすることで動くようになったが、上記の本来の解消方法がよくわからない...

exec
サブコマンドの本体ロジックは以下のようになった
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
}

インストールに失敗する
別端末で 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.

go.mod から replace 取り除いた

権限が不足している?
環境によって以下のエラーが出る
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
権限を利用しているのは以下の箇所

Endpoint を利用しているところ session-manager-plugin
を呼び出している
AWS CLI のこのあたりを踏襲していそう

endpoint
は必須ではないのでは?

ここで言う endpoint
ってこれかな?

aws cli 側で endpoint の値を出力してみる
parsed_globals.region: None
client.meta.endpoint_url: https://ecs.ap-northeast-1.amazonaws.com

aws cli の client.meta
を定義してる箇所はこれかな?

client
生成の際に ClientEndpointBridge
が関わっていそう

ClientEndpointBridge

とりあえず Issue 立てた

速攻で直してもらえた 🎉

イケてる感じにしたい

promptkit を入れる
go get github.com/erikgeiser/promptkit@v0.9.0

こんな感じでクラスター選択する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
}

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