Pythonista3+Shortcuts+Blinkを使ってiPadからEC2インスタンスをワンタップで起動+ssh接続する

# やったこと
iPadからEC2インスタンスをワンタップで起動・停止できるようにした。
![FA76BEA7-D24F-4336-81B9-940547017CF8.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/85f32c49-c620-4f5b-d0f8-e518b0a7331f.gif)

(gif対応のため、インスタンス立ち上げ時間を大幅にトリミングしています。実際には40秒ほどかかります。)


全体のコードはこちら

https://github.com/shabaraba/pythonista3_ec2_controller

# 動機
EC2に開発環境を置いており、iPadからssh接続して開発を行っている。
が、**毎回ログイン面倒くさい**。
認証から入らないとだし、何よりawsのwebページはiPadからだとスクロールするのに癖がある。
awsのアプリでもEC2を起動したいだけなのに、
`services->ec2->Instances->instance->Actions->start`
と、かなり深い。

あと、立ち上げて実際にrunningとなり、ssh接続できるようになるのを毎回確認しないといけないのが面倒くさかったので、そこら辺を自動化したかった。


# 環境・使用アプリ
- iPad pro 2世代
- Pythonista3
- blink
- ショートカットapp

# 前提
Blinkでec2に接続できるよう、公開鍵の設定は済ませていること

# 手順
開発手順は下記の通り。

1. Blinkの設定から、x Callback Urlを有効にして、キーを取得
1. awsで、ec2を操作できるユーザを作成し、キーを取得
1. pythonista3でec2を操作するプログラムを書く
1. ショートカットアプリで組み立てる


## Blink設定
Blinkを開いた状態で`cmd`+`,`押下で`Settings`を開く。
`X Callback Url`を`On`にして、`URL key`をひかえる。

![99AC5360-04E7-43A6-B7C3-5848EDCAAE70.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/b8d8ef01-2cd1-ed44-8c22-69098a6d7a5a.jpeg)
![CD7115E1-92C4-425C-B9D7-37F7F7190D0B.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/5d548706-5f55-3f6b-9ff5-d90a31ae3a0c.jpeg)


## aws設定
### 操作用のポリシーを作成
EC2を操作するユーザに付与するポリシーを作成する。

`IAM -> ポリシー -> ポリシーの作成`
EC2の情報/状態取得と、開始/停止操作の権限が必要なので、ポリシーは下記の通り。

```json
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:StartInstances",
                "ec2:StopInstances",
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        }
    ]
}
```

適当な名前（ここでは`EC2_start_stop_policy`）を付けて保存する。


### 操作用のIAMユーザ作成
pythonista3用のユーザを作成し、[先ほど](#操作用のポリシーを作成)作成したポリシーを付与する。

`IAM -> ユーザー -> ユーザーを追加`

![8D25E6B2-0899-411E-8499-799AB44662EB.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/5fd527c8-a503-9ec7-9280-a1b2f5980d3e.jpeg)
![99070FFF-DC79-43B6-9CDC-B7446EF9A17C.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/fee75a44-85d9-5031-8d63-950ee45fa152.jpeg)

作成したら、アクセスキーと秘密キーを取得し、ひかえる。
`作成したユーザー -> 認証情報 -> アクセスキーの作成`

![ECF43C36-B0C8-4B96-BCA3-18BE73F5454A.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/2bf31f93-7d0a-b8db-4675-3b4720642aae.jpeg)


## pythonista3でコーディング
### StaShにてパッケージインストール
pythonでawsを操作するパッケージであるboto3を利用する。
デフォルトのままではインストールされていないので、StaSh経由でインストールする。

```sh
pip install boto3 botocore jmespath
```

boto3だけでは依存パッケージがインストールされなかったので、必要なものも一緒に入れる。
もし過不足あれば都度`pip install`

参考: StaShの入れ方

https://qiita.com/maboy/items/cef5dee13d5b2e9ac843

### boto3
下記記事を参考にした。

https://qiita.com/usop4/items/5289c07aeb5ea4593432#awsのインスタンスを操作

Blinkの起動について、ショートカットappにBlinkがなかったが、URLスキーマ対応されていたので、pythonから直接起動する。
ただし、後述する癖あり（不具合？仕様？URLスキーマまわり明るくないので分からない）。

```python
import sys
import console
import time
import urllib.parse

import webbrowser
import boto3

import config

        
class AwsController():
    instance_id = config.instance_id
    aws_access_key_id = config.aws_access_key_id
    aws_secret_access_key = config.aws_secret_access_key
    region_name = config.region_name
    
    def __init__(self) -> None:
        self.instances = [self.instance_id]
        self.ec2 = boto3.client(
            'ec2',
            aws_access_key_id=self.aws_access_key_id,
            aws_secret_access_key=self.aws_secret_access_key,
            region_name=self.region_name
        )
    
    def start(self) -> None:
        self.ec2.start_instances(InstanceIds=self.instances)

    def stop(self) -> None:
        self.ec2.stop_instances(InstanceIds=self.instances)

    def fetch_instance_status(self) -> str:
        return \
            self.ec2.describe_instances(InstanceIds=self.instances)\
            ['Reservations'][0]\
            ['Instances'][0]\
            ['State']['Name']
 
    def fetch_instance_domain(self) -> str:
        return \
            self.ec2.describe_instances(InstanceIds=self.instances)\
            ['Reservations'][0]\
            ['Instances'][0]\
            ['NetworkInterfaces'][0]\
            ['Association']['PublicDnsName']

    def wait_until_running(self) -> bool:
        while self.fetch_instance_status() != 'running':
            print('waiting...')
            time.sleep(10)
        time.sleep(10)
        print('run!')
        return True


class BlinkController (object):
    BLINK_KEY = config.BLINK_KEY

    def connect_to(self, user_name, domain, ssh_key_name) -> None:
        print(
            'blinkshell://run?key=' + BlinkController.BLINK_KEY + 
            '&cmd=' + 
            urllib.parse.quote(
                'ssh -v -i "' + ssh_key_name + '" ' + 
                user_name + '@' + domain
            )
        )
        webbrowser.open(
            'blinkshell://run?key=' + BlinkController.BLINK_KEY + 
            '&cmd=' + 
            urllib.parse.quote(
                'ssh -v -i "' + ssh_key_name + '" ' + 
                user_name + '@' + domain
            )
        )


def main() -> None:
    aws_controller = AwsController()
    instance_status = aws_controller.fetch_instance_status()
    if instance_status == 'running':
        console.alert('now RUNNING','STOP?', 'yes', hide_cancel_button=False)
        aws_controller.stop()
    elif instance_status == 'stopped':
        console.alert('now STOPPED','RUN?', 'yes', hide_cancel_button=False)
        aws_controller.start()
        aws_controller.wait_until_running()

        blink_controller = BlinkController()
        blink_controller.connect_to(
            user_name=config.user_name,
            domain=aws_controller.fetch_instance_domain(),
            ssh_key_name=config.ssh_key_name
        )
    else:
        print('now stopping or pending. please exec later.')
    return


if __name__ == '__main__':
    main()
```

#### コード概要
1つのスクリプトで、EC2をトグルしたかったのでこの書き方になっている（が、分けた方が良かったと思う）。

停止操作は見たままなので省略。
開始操作の手順は下記の通り。

1. EC2の情報を取得、停止済みなら開始するか尋ねる
1. EC2に開始指令を出す
1. 該当インスタンスのステータスをポーリングし、'running'が返ってくるまで待つ
1. 'running'が返ってきたら、少し待ってから再度インスタンスの情報を取得し、パブリックドメイン名など、ssh接続に必要な情報を取得
    1. runningとなった直後はdnsが確立していないのか（？）ssh接続できないことが多々あったので、余裕を持たせる
1. Blinkをsshコマンド付きでurlスキーマ経由で起動する

書いたコードはpythonista3のurlスキーマ経由でショートカットappから起動するため、
`設定 -> shortcuts -> Pythonista URL -> Copy URL`
で控えておく。

### ショートカット

ショートカットappを初めて使ったので、変なことをしてるかも。

![A0344707-7637-4945-BE1B-4C004CF3165F.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1505764/259b9ee7-4b86-2180-458a-192a1d4f0b6e.png)

Blinkについて、先にも少し述べたがurlスキーマからコマンド付きで実行する場合の留意点。
あらかじめBlinkを立ち上げておかないと、urlスキーマの`cmd`以降が無視されることがあるようで、sshコマンドがうまく実行されなかった。
そのため、ショートカットappで、pythonista3のスクリプトを起動した直後にBlinkを起動するようにした。
ただしその場合、フォーカスがBlinkに飛んでしまい、pythonスクリプトのアラートが確認できなくなるので、さらにPythonistaを画面に表示するようにして対応した。

ちなみに、

1. Blinkを開く
1. pythonista3のurlスキーマを開く

でも良さそうに見えるが、`1.`後手動でショートカットappに戻らないと`2.`が実施されなかったので断念。


# 終わりに
出来上がったのが[こちら](#やったこと)。
dockに入れておいて、

1. 開発したくなったらショートカットポチっ
1. お供のコーヒーを淹れる
1. 戻ってくるとssh接続済み
1. 開発やめるときはdockの同ショートカットぽち

なので、とても楽チンになった:relaxed:

## ちなみに
本当はアイコンを見るだけでインスタンスが起動中かどうかわかるようにしたかったが、ショートカットappには動的にアイコンを切り替える機能はないよう。
macを持っていないので、凝ったアプリは作れない。断念。
iPadOS15で、swift playgroundでアプリを作成できるようになるとのことなので、もしそっちでアイコンを切り替えられるなら嬉しい。

## ちなみに2
下記の記事のように、EIPを利用せず、インスタンス起動時に毎回route53のホストゾーンにサブドメインを登録するlambdaを使っているが、
`インスタンス起動 -> lambda実行 -> dns反映`
と、接続までさらに時間がかかってしまうので、今回はパブリックドメインで接続することにした。

https://qiita.com/keys/items/43adf968d366e80f0003

もしEIPを利用しない独自ドメインでsshしたい場合は、ポリシーにroute53の状態チェックも追加し、route53にドメインが追加されたされたことを確認する処理が必要となる。