golangでリモートサーバーのphpのバッチを実行する

こんばんは。
XTechグループ Advent Calendar 2020の11日目の担当は、エキサイトのテクノロジー事業部でエンジニアをしている中です。ブログを担当しています。

##はじめに

手動でバッチを実行することが最近よくあります。
定期バッチとちがって、移行の時にしか使わない、初回サービスにしか使わない、たまにしか使わないバッチみたいなやつです。

たまにしか使わない＝不定期に実行しているバッチについて、もっと簡単にできないかということで、golangでバッチを実行するバッチを作りました。
※たまにしか使わないやつを自動化したい気持ちや、管理画面化したい気持ちもありますが、、、
今ある資産をうまく利用する形でやる方法なので、スマートにする場合は管理画面化してください。

##そもそも

![スクリーンショット 2020-12-09 19.31.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892644/2ae8512a-7c1e-0e3c-5455-2c54c2e7919e.png)
手動でバッチ実行するようなやつに限って引継ぎがうまくいかないケースって多くないですか？
「エンジニアの人がよしなにやっていた」みたいなことをビジネス側から聞いて、その通りにやってもうまくいかない。。。
謎テクノロジーすぎて、、、ってことが多いと思います。
そもそも引継ぎせず、誰でもできるようにするのが技術だと思います。

##もっと簡単に

![スクリーンショット 2020-12-09 19.47.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892644/6bc49c56-3c0b-295e-2858-b8b0005b05bd.png)

golangでまとめました。
※そんな珍しいことはないと思いますが。。。

##想定ケース
サーバーに接続して、該当のバッチを実行するまえにファイルを用意して、そのファイルの中身を呼び出して処理をする。(user一覧を使って、メールの一括送信するみたいなケース)

##フレームワーク

使っているフレームワークは以下です。

 - github.com/spf13/cobra(cli)
 - golang.org/x/crypto/ssh(sshで接続する時に使う)
 - github.com/pkg/sftp(sshで接続して、ファイル転送する時に使う)

##ディレクトリ構成

```
.
├── Dockerfile
├── README.md
├── all_hidden.txt
├── cmd
│   ├── job-test.go
│   └── root.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── id_ed25519
└── main.go
```

##実装

簡単に処理の流れを。

 - 引数でユーザ、秘密鍵、接続先を
 - sshの設定
 - sftpでファイルアップロード
 - sshで接続
 - コマンド発行(バッチ実行)
 - ssh 終了

ロジックのメインであるroot.go、job-test.goは以下を見てください。

 - root.go

```go
package cmd

import (
	"fmt"
	"golang.org/x/crypto/ssh"
	"io"
	"io/ioutil"
	"log"
	"os"
	"github.com/spf13/cobra"
)

var cfgFile string

type Config struct {
	Debug bool
}

var config Config

func NewCmdRoot() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "go-cmd",
		Short: "Command line tool golang",
		Run: func(cmd *cobra.Command, args []string) {
			if len(args) == 0 {
				cmd.HelpFunc()(cmd, args)
			}
		},
	}

	cobra.OnInitialize(initConfig)
	cmd.AddCommand(NewManuelJobRunTest())
	return cmd
}

func Execute() {
	cmd := NewCmdRoot()
	if err := cmd.Execute(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

func initConfig() {
}

func init() {
}

func ce(err error, successMessage string, errorMessage string) {
	if err != nil {
		log.Panicf("%s error: %v", errorMessage, err)
		return
	}
	log.Printf(successMessage)
	return
}


func write(w io.WriteCloser, command string) error {
	_, err := w.Write([]byte(command + "\n"))
	return err
}

func readUntil(r io.Reader, matchingByte []byte) (*string, error) {
	var buf [64 * 1024]byte
	var t int
	for {
		n, err := r.Read(buf[t:])
		if err != nil {
			return nil, err
		}
		t += n
		if isMatch(buf[:t], t, matchingByte) {
			stringResult := string(buf[:t])
			return &stringResult, nil
		}
	}
}

func isMatch(bytes []byte, t int, matchingBytes []byte) bool {
	if t >= len(matchingBytes) {
		for i := 0; i < len(matchingBytes); i++ {
			if bytes[t-len(matchingBytes)+i] != matchingBytes[i] {
				return false
			}
		}
		return true
	}
	return false
}

func sshConfigSetting(userID string, privateKey string, iP string) *ssh.ClientConfig {

	log.Printf("UserID: %v", userID)
	log.Printf("PrivateKey: %v", privateKey)
	log.Printf("IP: %v", iP)

	var auth []ssh.AuthMethod
	key, err := ioutil.ReadFile(privateKey)
	ce(err, "private key read Success", "private key read fatal")

	signer, err := ssh.ParsePrivateKey(key)
	ce(err, "signer read Success", "signer read fatal")

	auth = append(auth, ssh.PublicKeys(signer))

	// set ssh config.
	sshConfig := &ssh.ClientConfig{
		User:            userID,
		Auth:            auth,
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	return sshConfig
}

```

 - job-test.go

```go
package cmd

import (
	"fmt"
	"github.com/pkg/sftp"
	"github.com/spf13/cobra"
	"golang.org/x/crypto/ssh"
	"io"
	"io/ioutil"
	"log"
)

type getManuelJobRunTestOptions struct {
	UserID     string
	PrivateKey string
	IP         string
	FileName   string
}

func NewManuelJobRunTest() *cobra.Command {
	var (
		o = &getManuelJobRunTestOptions{}
	)
	cmd := &cobra.Command{
		Use:   "job-test",
		Short: "job-test",
		Run: func(cmd *cobra.Command, args []string) {
			runManuelJobRunTest(o)
		},
	}

	cmd.Flags().StringVarP(&o.UserID, "user_id", "u", "", "user_id")
	cmd.Flags().StringVarP(&o.PrivateKey, "private_key", "p", "", "private_key")
	cmd.Flags().StringVarP(&o.IP, "ip", "i", "", "ip")
	return cmd
}

func runManuelJobRunTest(opt *getManuelJobRunTestOptions) {

	// SSH connect.
	client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", opt.IP), sshConfigSetting(opt.UserID,opt.PrivateKey,opt.IP))
	ce(err, "SSH Connect Success", "SSH connect fatal")
	defer client.Close()

	// sftp file upload
	data, err := ioutil.ReadFile("all_hidden.txt")
	ce(err, "ReadFile Success", "ReadFile fatal")

	sftpClient, err := sftp.NewClient(client)
	ce(err, "sftp connect Success", "sftp connect fatal")
	defer sftpClient.Close()

	file, err := sftpClient.Create("/var/home/naka-sho/work/all_hidden.txt")
	ce(err, "sftpClient Success", "sftpClient fatal")
	defer file.Close()

	_, err = file.Write(data)
	ce(err, "sftp upload Success", "sftp upload fatal")

	session, err := client.NewSession()
	ce(err, "new session Success", "new session fatal")
	defer session.Close()

	modes := ssh.TerminalModes{
		ssh.ECHO:          0,     //disable echoing
		ssh.TTY_OP_ISPEED: 14400, //input speed = 14.4kbaud
		ssh.TTY_OP_OSPEED: 14400, //output speed = 14.4kbaud
	}

	if err := session.RequestPty("xterm", 80, 40, modes); err != nil {
		log.Panic(err)
	}

	w, err := session.StdinPipe()
	if err != nil {
		log.Panic(err)
	}
	r, err := session.StdoutPipe()
	if err != nil {
		log.Panic(err)
	}
	if err := session.Start("/bin/sh"); err != nil {
		log.Fatal(err)
	}

	commandJobRunTest(w, r)

	if err := session.Wait();err != nil {
		log.Panic(err)
	}
}

func commandJobRunTest(w io.WriteCloser, r io.Reader) {
	var escapePrompt = []byte{'$', ' '}
	//var escapePromptSharp = []byte{'#', ' '}

	readUntil(r, escapePrompt) //ignore the shell output

	// SSH command run
	write(w, "whoami")
	out, err := readUntil(r, escapePrompt)
	ce(err, fmt.Sprintf("whoami: %s\n", *out), "whoami fatal")

	write(w, "cd /var/home/naka-sho")
	out, err = readUntil(r, escapePrompt)
	ce(err, "cd:ok", "cd fatal")

	write(w, "php test.php")
	out, err = readUntil(r, escapePrompt)
	ce(err, fmt.Sprintf("report: %s\n", *out), "exec fatal")

	// SSH exit
	write(w, "exit")
}

```

commandJobRunTestにコマンドを書いていけば実行できます。

##実行

実行するテストバッチ

```php
<?php
var_dump(file_get_contents("./work/all_hidden.txt"));
exit;
```

実行コマンド

```sh
go run main.go job-test --user_id naka-sho--ip XXXXXXXXXX --private_key id_ed25519
```

##実行結果

```
2020/12/09 20:19:37 UserID: naka-sho
2020/12/09 20:19:37 PrivateKey: id_ed25519
2020/12/09 20:19:37 IP: XXXXXXXXXX
2020/12/09 20:19:37 private key read Success
2020/12/09 20:19:37 signer read Success
2020/12/09 20:19:37 SSH Connect Success
2020/12/09 20:19:37 ReadFile Success
2020/12/09 20:19:37 sftp connect Success
2020/12/09 20:19:37 sftpClient Success
2020/12/09 20:19:37 sftp upload Success
2020/12/09 20:19:37 new session Success
2020/12/09 20:19:38 whoami: naka-sho
$
2020/12/09 20:19:38 cd:ok
2020/12/09 20:19:38 report: string(77) "textファイルの中身
textファイルの中身
textファイルの中身"
$"
```

##メリット
 - 手動でバッチを実行するのは、基本的に毎回変わらないコマンドなので、まとまったのが非常に嬉しい。
 - コードを見れば、実際のコマンドもわかる。
 - コードの中で文字列の結合など自由度にできるので、事前準備が大変なバッチであればあるほど楽。

##デメリット
 - オレオレコードになりやすい。golangなので、シンプルで素な実装をしたけど、人によっては嫌われるかも。。。
 - 途中で[y]を入力するできない。※そもそもそんな実装にしないでほしい。実行するだけにしましょう。
 - エラーハンドリングがめんどくさかったので全部panicにした。
 - トランザクション的なことはないので、途中で接続切れたら、途中までの処理になる、

##最終的に

やっぱり管理画面にしたいと思いました。

今ある資産を利用しつつ、管理画面化するためにjenkins使いました。
[go build main.go]でバイナリファイルを作成し、jenkinsから実行することでjenkinsに依存しないバッチの実行にしました。
簡単に説明すると、jenkinsの文字列からファイルの中身を設定し、それをファイルとして作成し、sftpで送ってバッチ実行。
接続先は固定化し、ユーザ名、秘密鍵も文字列で入力する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892644/1eb42fa5-bc2a-fcd4-a04d-e4154f16e0bd.png)

これはgoというより、そもそもよかったです。

jenkinsから実行することで、管理画面っぽくなり、使いやすくなりました。
手間はかかりますが、誰からでも実行できる環境を作る技術が重要だと思います。

**エンジニアだから、コンソール使ってコマンド使えばいいじゃんって発想は、引き継ぎ漏れやオペレーションミスにつながります。**

イージーな仕組みではなく、シンプルな仕組みにしていきましょう。