diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 229dd00..af779d7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,9 +22,12 @@ jobs: - name: Run go vet run: go vet ./... + working-directory: ./src - name: Run go build run: go build . + working-directory: ./src - name: Run go test run: go test -v ./... + working-directory: ./src diff --git a/docker-compose.yml b/docker-compose.yml index 2a68818..79dfb2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,6 @@ services: AWS_DEFAULT_REGION: ap-northeast-1 AWS_DEFAULT_OUTPUT: json volumes: - - ./:/src/hansel + - ./src:/src/hansel working_dir: /src/hansel command: "go run main.go" diff --git a/main.go b/main.go deleted file mode 100644 index 9aba360..0000000 --- a/main.go +++ /dev/null @@ -1,260 +0,0 @@ -package main - -import ( - "encoding/json" - "hansel/config" - "log" - "os" - "os/exec" - "os/signal" - "syscall" - "time" - - "github.com/bwmarrin/discordgo" -) - -// ServerStatusResponse EC2起動後のステータス確認レスポンス -type ServerStatusResponse struct { - PublicIP string `json:"publicip"` -} - -// StartResponse EC2起動指示時のレスポンス -type StartResponse struct { - StartingInstances []InstanceStatus `json:"StartingInstances"` -} - -// StopResponse EC2停止指示時のレスポンス -type StopResponse struct { - StoppingInstances []InstanceStatus `json:"StoppingInstances"` -} - -// InstanceStatus EC2指示時の共通レスポンス -type InstanceStatus struct { - InstanceID string `json:"InstanceId"` - CurrentState struct { - Code int `json:"Code"` - Name string `json:"Name"` - } `json:"CurrentState"` - PreviousState struct { - Code int `json:"Code"` - Name string `json:"Name"` - } `json:"PreviousState"` -} - -// TargetChannel Botがメッセージを投稿するDiscordチャンネル -type TargetChannel struct { - s *discordgo.Session - event *discordgo.MessageCreate -} - -func (tc *TargetChannel) messageSend(message string) error { - // コマンドが投稿されたチャンネル - targetChannel, err := tc.s.State.Channel(tc.event.ChannelID) - if err != nil { - log.Println("チャンネルの取得に失敗 :", err) - return err - } - - // Botからメッセージ投稿 - if _, err := tc.s.ChannelMessageSend(targetChannel.ID, message); err != nil { - log.Println("チャンネルメッセージの送信に失敗 :", err) - return err - } - return nil -} - -// getIPAddress インスタンスのIPアドレス取得 -func getIPAddress() (string, error) { - statusOutputJSON, err := exec.Command("aws", "ec2", "describe-instances", "--instance-ids", os.Getenv("INSTANCE_ID"), "--query", "Reservations[].Instances[].{publicip:PublicIpAddress}").Output() - if err != nil { - log.Println("IPアドレス取得時、コマンド実行に失敗 : ", err) - return "", err - } - - ssResponse := []ServerStatusResponse{} - if err := json.Unmarshal(statusOutputJSON, &ssResponse); err != nil { - log.Println("IPアドレス取得時のレスポンスに異常 :", err) - return "", err - } - - ipaddress := ssResponse[0].PublicIP - if ipaddress != "" { - log.Println("IPアドレス : ", ipaddress) - } - - return ipaddress, nil -} - -func receive(s *discordgo.Session, event *discordgo.MessageCreate) { - targetChannel := TargetChannel{ - s: s, - event: event, - } - - messages, err := config.GetConfig() - if err != nil { - log.Fatalln(err) - } - - if event.Content == messages.StartTriggerMessage { - // 起動時 - log.Println("開始 : インスタンス起動...") - targetChannel.messageSend("インスタンスの起動コマンドを検知") - - outputJSON, err := exec.Command("aws", "ec2", "start-instances", "--instance-ids", os.Getenv("INSTANCE_ID")).Output() - if err != nil { - log.Println("起動に失敗した :", err) - targetChannel.messageSend("インスタンスの起動に失敗") - return - } - - startResponse := StartResponse{} - if err := json.Unmarshal(outputJSON, &startResponse); err != nil { - log.Println("起動時のレスポンスに異常 :", err) - targetChannel.messageSend("インスタンスの起動に失敗") - return - } - currentState := startResponse.StartingInstances[0].CurrentState.Name - if currentState == "running" { - log.Println("既に起動している") - targetChannel.messageSend("インスタンスは起動済み") - return - } - - previousState := startResponse.StartingInstances[0].PreviousState.Name - if currentState == "pending" && previousState == "pending" { - log.Println("起動処理実行中") - targetChannel.messageSend("インスタンスは既に起動準備中") - return - } - - // 開始待ち - if _, err := exec.Command("aws", "ec2", "wait", "instance-running", "--instance-ids", os.Getenv("INSTANCE_ID")).Output(); err != nil { - log.Println("起動待ちに失敗した :", err) - targetChannel.messageSend("インスタンスの起動状態不明 再度のコマンド入力を要求") - return - } - - log.Println("正常終了 : インスタンス起動") - targetChannel.messageSend("インスタンスの起動に成功") - - // IPアドレス通知 - log.Println("IPアドレス取得待機中...") - time.Sleep(time.Second) - - ipaddress, err := getIPAddress() - if err != nil { - targetChannel.messageSend("IPアドレスの取得に失敗") - return - } - - targetChannel.messageSend("今回のIPアドレス : " + ipaddress) - - } else if event.Content == messages.HibernateTriggerMessage { - // 停止時 - log.Println("開始 : インスタンス停止...") - targetChannel.messageSend("インスタンスの停止コマンドを検知") - - outputJSON, err := exec.Command("aws", "ec2", "stop-instances", "--instance-ids", os.Getenv("INSTANCE_ID")).Output() - if err != nil { - log.Println("停止に失敗した :", err) - targetChannel.messageSend("インスタンスの停止に失敗") - return - } - - stopResponse := StopResponse{} - if err := json.Unmarshal(outputJSON, &stopResponse); err != nil { - log.Println("停止時のレスポンスに異常 :", err) - targetChannel.messageSend("インスタンスの停止に失敗") - return - } - - currentState := stopResponse.StoppingInstances[0].CurrentState.Name - if currentState == "stopped" { - log.Println("既に停止している") - targetChannel.messageSend("インスタンスは停止済み") - return - } - - previousState := stopResponse.StoppingInstances[0].PreviousState.Name - if currentState == "stopping" && previousState == "stopping" { - log.Println("停止処理実行中") - targetChannel.messageSend("インスタンスは既に停止準備中") - return - } - - // 停止待ち - if _, err := exec.Command("aws", "ec2", "wait", "instance-stopped", "--instance-ids", os.Getenv("INSTANCE_ID")).Output(); err != nil { - log.Println("停止待ちに失敗した :", err) - targetChannel.messageSend("インスタンスの停止状態不明 再度のコマンド入力を要求") - return - } - - log.Println("正常終了 : インスタンス停止") - targetChannel.messageSend("インスタンスの停止に成功") - - } else if event.Content == messages.GetStatusTriggerMessage { - // 起動状態の確認(IPアドレスの取得) - log.Println("開始 : インスタンスステータス確認") - targetChannel.messageSend("インスタンスの確認コマンドを検知") - - ipaddress, err := getIPAddress() - if err != nil { - targetChannel.messageSend("インスタンスの確認に失敗") - return - } - - if ipaddress != "" { - targetChannel.messageSend("インスタンスは起動済み :" + ipaddress) - } else { - targetChannel.messageSend("インスタンスは未起動") - } - - } -} - -func runDiscordBot(session *discordgo.Session) error { - session.Token = "Bot " + os.Getenv("BOT_ID") - - session.AddHandler(receive) - err := session.Open() - if err != nil { - log.Println("Failed : Start Bot") - return err - } - log.Println("Succeeded : Start Bot") - - return nil -} - -func main() { - session, err := discordgo.New() - if err != nil { - panic(err) - } - - err = runDiscordBot(session) - if err != nil { - panic(err) - } - - //goland:noinspection GoUnhandledErrorResult - defer session.Close() - - // 終了を待機 - signalChan := make(chan os.Signal, 1) - signal.Notify( - signalChan, - os.Interrupt, - os.Kill, - syscall.SIGHUP, - syscall.SIGQUIT, - syscall.SIGTERM, - ) - - select { - case <-signalChan: - log.Println("bye") - return - } -} diff --git a/src/aws/client.go b/src/aws/client.go new file mode 100644 index 0000000..96f9e4c --- /dev/null +++ b/src/aws/client.go @@ -0,0 +1,126 @@ +package aws + +import ( + "encoding/json" + "log" + "os" + "os/exec" +) + +// ServerStatusResponse EC2起動後のステータス確認レスポンス +type ServerStatusResponse struct { + PublicIP string `json:"publicip"` +} + +// StartResponse EC2起動指示時のレスポンス +type StartResponse struct { + StartingInstances []InstanceStatus `json:"StartingInstances"` +} + +// StopResponse EC2停止指示時のレスポンス +type StopResponse struct { + StoppingInstances []InstanceStatus `json:"StoppingInstances"` +} + +// InstanceStatus EC2指示時の共通レスポンス +type InstanceStatus struct { + InstanceID string `json:"InstanceId"` + CurrentState struct { + Code int `json:"Code"` + Name string `json:"Name"` + } `json:"CurrentState"` + PreviousState struct { + Code int `json:"Code"` + Name string `json:"Name"` + } `json:"PreviousState"` +} + +type Client interface { + GetIPAddress() (string, error) + StartInstance() error + StopInstance() error +} + +type EC2Client struct{} + +func NewEC2Client() Client { + return EC2Client{} +} + +// GetIPAddress インスタンスのIPアドレス取得 +func (c EC2Client) GetIPAddress() (string, error) { + statusOutputJSON, err := exec.Command("aws", "ec2", "describe-instances", "--instance-ids", os.Getenv("INSTANCE_ID"), "--query", "Reservations[].Instances[].{publicip:PublicIpAddress}").Output() + if err != nil { + return "", WrapError(err, ErrFailedGetIpAddress) + } + + ssResponse := []ServerStatusResponse{} + if err := json.Unmarshal(statusOutputJSON, &ssResponse); err != nil { + return "", WrapError(err, ErrInvalidResponseGetIpAddress) + } + + ipaddress := ssResponse[0].PublicIP + if ipaddress != "" { + log.Println("IPアドレス : ", ipaddress) + } + + return ipaddress, nil +} + +func (c EC2Client) StartInstance() error { + outputJSON, err := exec.Command("aws", "ec2", "start-instances", "--instance-ids", os.Getenv("INSTANCE_ID")).Output() + if err != nil { + return WrapError(err, ErrFailedStartInstance) + } + + startResponse := StartResponse{} + if err := json.Unmarshal(outputJSON, &startResponse); err != nil { + return WrapError(err, ErrInvalidResponseStartInstance) + } + + currentState := startResponse.StartingInstances[0].CurrentState.Name + if currentState == "running" { + return WrapError(nil, ErrInstanceAlreadyStarted) + } + + previousState := startResponse.StartingInstances[0].PreviousState.Name + if currentState == "pending" && previousState == "pending" { + return WrapError(nil, ErrStartingInstance) + } + + // 開始待ち + if _, err := exec.Command("aws", "ec2", "wait", "instance-running", "--instance-ids", os.Getenv("INSTANCE_ID")).Output(); err != nil { + return WrapError(err, ErrFailedWaitStartInstance) + } + + return nil +} + +func (c EC2Client) StopInstance() error { + outputJSON, err := exec.Command("aws", "ec2", "stop-instances", "--instance-ids", os.Getenv("INSTANCE_ID")).Output() + if err != nil { + return WrapError(err, ErrFailedStopInstance) + } + + stopResponse := StopResponse{} + if err := json.Unmarshal(outputJSON, &stopResponse); err != nil { + return WrapError(err, ErrInvalidResponseStopInstance) + } + + currentState := stopResponse.StoppingInstances[0].CurrentState.Name + if currentState == "stopped" { + return WrapError(nil, ErrInstanceAlreadyStopped) + } + + previousState := stopResponse.StoppingInstances[0].PreviousState.Name + if currentState == "stopping" && previousState == "stopping" { + return WrapError(nil, ErrStoppingInstance) + } + + // 停止待ち + if _, err := exec.Command("aws", "ec2", "wait", "instance-stopped", "--instance-ids", os.Getenv("INSTANCE_ID")).Output(); err != nil { + return WrapError(err, ErrFailedWaitStopInstance) + } + + return nil +} diff --git a/src/aws/error.go b/src/aws/error.go new file mode 100644 index 0000000..7ba867e --- /dev/null +++ b/src/aws/error.go @@ -0,0 +1,41 @@ +package aws + +import "fmt" + +var ( + ErrFailedGetIpAddress error = &StatusError{message: "IPアドレス取得時、コマンド実行に失敗 :"} + ErrFailedStartInstance error = &StatusError{message: "起動に失敗した :"} + ErrFailedStopInstance error = &StatusError{message: "停止に失敗した :"} + ErrFailedWaitStartInstance error = &StatusError{message: "起動待ちに失敗した"} + ErrFailedWaitStopInstance error = &StatusError{message: "停止待ちに失敗した :"} + ErrInstanceAlreadyStarted error = &StatusError{message: "既に起動している"} + ErrInstanceAlreadyStopped error = &StatusError{message: "既に停止している"} + ErrInvalidResponseGetIpAddress error = &StatusError{message: "IPアドレス取得時のレスポンスに異常 :"} + ErrInvalidResponseStartInstance error = &StatusError{message: "起動時のレスポンスに異常 :"} + ErrInvalidResponseStopInstance error = &StatusError{message: "停止時のレスポンスに異常 :"} + ErrStartingInstance error = &StatusError{message: "起動処理実行中"} + ErrStoppingInstance error = &StatusError{message: "停止処理実行中"} +) + +type StatusError struct { + base error + message string +} + +func (e *StatusError) Error() string { + baseMessage := "" + if e.base != nil { + baseMessage = e.base.Error() + } + return fmt.Sprintf("%s %s", e.message, baseMessage) +} + +func (e *StatusError) Unwrap() error { + return e.base +} + +func WrapError(err error, statusError error) error { + e := statusError.(*StatusError) + e.base = err + return e +} diff --git a/config/config.go b/src/config/config.go similarity index 100% rename from config/config.go rename to src/config/config.go diff --git a/src/discord/bot-receive.go b/src/discord/bot-receive.go new file mode 100644 index 0000000..0c06d26 --- /dev/null +++ b/src/discord/bot-receive.go @@ -0,0 +1,160 @@ +package discord + +import ( + "errors" + "hansel/aws" + "hansel/config" + "log" + "time" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) receive(s *discordgo.Session, event *discordgo.MessageCreate) { + messages, err := config.GetConfig() + if err != nil { + log.Fatalln(err) + } + + if event.Content == messages.StartTriggerMessage { + // 起動時 + log.Println("開始 : インスタンス起動...") + _, err := s.ChannelMessageSend(event.ChannelID, "インスタンスの起動コマンドを検知") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + err = b.awsClient.StartInstance() + if err != nil { + log.Println(err) + + errDiscordMsg := "" + if errors.Is(err, aws.ErrFailedStartInstance) { + errDiscordMsg = "インスタンスの起動に失敗" + } else if errors.Is(err, aws.ErrInvalidResponseStartInstance) { + errDiscordMsg = "インスタンスの起動に失敗" + } else if errors.Is(err, aws.ErrInstanceAlreadyStarted) { + errDiscordMsg = "インスタンスは起動済み" + } else if errors.Is(err, aws.ErrStartingInstance) { + errDiscordMsg = "インスタンスは既に起動準備中" + } else if errors.Is(err, aws.ErrFailedWaitStartInstance) { + errDiscordMsg = "インスタンスの起動状態不明 再度のコマンド入力を要求" + } + + _, err := s.ChannelMessageSend(event.ChannelID, errDiscordMsg) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + return + } + + log.Println("正常終了 : インスタンス起動") + _, err = s.ChannelMessageSend(event.ChannelID, "インスタンスの起動に成功") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + // IPアドレス通知 + log.Println("IPアドレス取得待機中...") + time.Sleep(time.Second) + + ipaddress, err := b.awsClient.GetIPAddress() + if err != nil { + log.Println(err) + + errDiscordMsg := "IPアドレスの取得に失敗" + _, err := s.ChannelMessageSend(event.ChannelID, errDiscordMsg) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + return + } + + _, err = s.ChannelMessageSend(event.ChannelID, "今回のIPアドレス : "+ipaddress) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + } else if event.Content == messages.HibernateTriggerMessage { + // 停止時 + log.Println("開始 : インスタンス停止...") + _, err := s.ChannelMessageSend(event.ChannelID, "インスタンスの停止コマンドを検知") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + err = b.awsClient.StopInstance() + if err != nil { + log.Println(err) + + errDiscordMsg := "" + if errors.Is(err, aws.ErrFailedStopInstance) { + errDiscordMsg = "インスタンスの停止に失敗" + } else if errors.Is(err, aws.ErrInvalidResponseStopInstance) { + errDiscordMsg = "インスタンスの停止に失敗" + } else if errors.Is(err, aws.ErrInstanceAlreadyStopped) { + errDiscordMsg = "インスタンスは停止済み" + } else if errors.Is(err, aws.ErrStoppingInstance) { + errDiscordMsg = "インスタンスは既に停止準備中" + } else if errors.Is(err, aws.ErrFailedWaitStopInstance) { + errDiscordMsg = "インスタンスの停止状態不明 再度のコマンド入力を要求" + } + + _, err := s.ChannelMessageSend(event.ChannelID, errDiscordMsg) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + return + } + + log.Println("正常終了 : インスタンス停止") + _, err = s.ChannelMessageSend(event.ChannelID, "インスタンスの停止に成功") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + } else if event.Content == messages.GetStatusTriggerMessage { + // 起動状態の確認(IPアドレスの取得) + log.Println("開始 : インスタンスステータス確認") + _, err := s.ChannelMessageSend(event.ChannelID, "インスタンスの確認コマンドを検知") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + + ipaddress, err := b.awsClient.GetIPAddress() + if err != nil { + log.Println(err) + + errDiscordMsg := "インスタンスの確認に失敗" + _, err := s.ChannelMessageSend(event.ChannelID, errDiscordMsg) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + return + } + + if ipaddress != "" { + _, err := s.ChannelMessageSend(event.ChannelID, "インスタンスは起動済み :"+ipaddress) + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + } else { + _, err := s.ChannelMessageSend(event.ChannelID, "インスタンスは未起動") + if err != nil { + log.Println("チャンネルメッセージの送信に失敗 :", err) + return + } + } + } +} diff --git a/src/discord/bot.go b/src/discord/bot.go new file mode 100644 index 0000000..c7967ec --- /dev/null +++ b/src/discord/bot.go @@ -0,0 +1,43 @@ +package discord + +import ( + "hansel/aws" + "log" + "os" + + "github.com/bwmarrin/discordgo" +) + +type Bot struct { + session *discordgo.Session + awsClient aws.Client +} + +func NewBot() (*Bot, error) { + session, err := discordgo.New("Bot " + os.Getenv("BOT_ID")) + if err != nil { + return &Bot{}, nil + } + + return &Bot{ + session: session, + awsClient: aws.NewEC2Client(), + }, nil +} + +func (b *Bot) Start() error { + b.session.AddHandler(b.receive) + + err := b.session.Open() + if err != nil { + log.Println("Failed : Start Bot") + return err + } + + log.Println("Succeeded : Start Bot") + return nil +} + +func (b *Bot) Stop() error { + return b.session.Close() +} diff --git a/go.mod b/src/go.mod similarity index 100% rename from go.mod rename to src/go.mod diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..9fbd5a1 --- /dev/null +++ b/src/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "hansel/discord" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + bot, err := discord.NewBot() + if err != nil { + log.Fatalln(err) + return + } + + err = bot.Start() + if err != nil { + log.Fatalln(err) + return + } + + defer bot.Stop() + + // 終了を待機 + signalChan := make(chan os.Signal, 1) + signal.Notify( + signalChan, + os.Interrupt, + os.Kill, + syscall.SIGHUP, + syscall.SIGQUIT, + syscall.SIGTERM, + ) + + select { + case <-signalChan: + log.Println("bye") + return + } +}