Fabric 2 の with cd 周りの問題点と対応方法

# はじめに

これまではインフラの自動化に Python 2.7 + Fabric 1 系を使っていたが、もう Python 2系のサポートも切れるし、Python3 + Fabric 2 をメインにするために実際に Fabric 2 を使ってみた。
その中で作業ディレクトリ周りの問題にちょっとハマったので自分でどう対処したかを書きます。

## どこへ行った？

Fabric 1 系の場合、`with cd('...')` として、with句内のコマンドを特定ディレクトリに移動した後に利用するという方法が利用できる。 しかし、Fabric 2のドキュメントを読んでも、この機能が見つからない。
どこに行ったのかと思っていたら Fabric が Wrap している invoke ライブラリの方に機能がある。
`with cd('...')` , あるいは `with prefix('cd ...')` のような方法で実行できる。

```python
from invoke import task

@task
def sample(conn):
    with conn.prefix('cd /tmp'):
        conn.run('pwd')  # /tmp が表示される
```

Fabric 2 はinvoke の ssh版ライブラリみたいな形の位置づけになっているので、 Fabric 1系にあったものが無くなっている場合、invoke ライブラリを探してみるのが良い。


## それだけじゃなくて

これで解消できる問題もあるが、リダイレクトファイル出力のパス・パーミッションは解決できない。 
以下のようなコードの場合はリダイレクト先には `cd` での設定が適用されず `test.txt` はログインユーザーのホームディレクトリに出力される。 リダイレクトは `cat << EOF > filename` や `echo ... > filename` などでセットアップをしていると使いたくなることがあり、そこでハマってしまう。
また `conn.sudo` で実行した場合、そのリダイレクト実行権限はあくまでログインユーザーのものであるため、設定ファイルなどを出力したい場合に出力先に権限がなくてエラーとなる。

```python
@task
def sample(conn):
    with conn.prefix('cd /tmp'):
        conn.run('echo HOGE > test.txt')
```

さらに、`cd` と `sudo` を組み合わせた場合、コマンドは単なる `&&` での連結となってしまうため `cd` が有効にならない。 これは執筆時点で [Open Issue](https://github.com/pyinvoke/invoke/issues/459) となっており、まだ修正は行われていない。 

```python
@task
def sample(conn):
    with conn.cd('/tmp'):
        conn.sudo('pwd') # ホームディレクトリのパスが出力される
```

## 対処方法

対処方法としては [先の問題](https://github.com/pyinvoke/invoke/issues/459)  のやり取りでもあるように、別のプロセスを立ち上げてその中で完結させてしまえばよい。具体的に書くと以下のようになる。

```python
@task
def sample(conn):
    conn.sudo('/bin/bash -c "cd /tmp && echo HOGE > test.txt"')
 ```

が、これを逐一書くのは面倒くさいので、簡単なコマンドラッパークラスを書くことで対処した。
具体的には以下のようなコードになる。

```python
class FabContext:
    def __init__(self, conn, directory=None, sudo=False, exports=None, **default_kwargs):
        self.conn = conn
        self.directory = directory
        self.sudo = sudo
        self.exports = exports if exports is not None else {}
        self.default_kwargs = default_kwargs


    def __enter__(self):
        return self


    def __exit__(self, exc_type, exc_value, traceback):
        pass
    

    def cd(self, path, *pathes):
        ''' 現在のパスからの相対パスで利用可能な Context を返す '''
        if self.directory is None:
            directory = os.path.join(path, *pathes)
        else:
            relative_path = os.path.join(path, *pathes)
            directory = os.path.abspath(os.path.join(self.directory, relative_path))
        return FabContext(self.conn, directory=directory, sudo=self.sudo)
    

    def sudo_as(self, sudo):
        ''' sudo オプションを変更した context を返す '''
        return FabContext(self.conn, directory=self.directory, sudo=sudo)


    def _escape(self, command):
        return command.replace('"', '\\"').replace('$', '\\$')


    def _exports_command(self):
        return ' && '.join([
            'export {}={}'.format(k, v) for (k, v) in self.exports.items()
        ])

    def run(self, command, **kwargs):
        ''' 設定されているコンテキストで command を実行する '''
        # 環境変数とディレクトリを注入
        if self.directory is None:
            tokens = [ self._exports_command(), self._escape(command) ]
        else:
            tokens = [ self._exports_command(), 'cd ' + self.directory, self._escape(command) ]

        # 最終的なコマンドを生成
        tokens = [ token for token in tokens if len(token) > 0 ]
        real_command = '/bin/bash -c "{}"'.format(' && '.join(tokens))
        
        # 引数の更新
        real_kwargs = self.default_kwargs.copy()
        for (k, v) in kwargs.items():
            real_kwargs[k] = v

        if self.sudo:
            return self.conn.sudo(real_command, **real_kwargs)
        else:
            return self.conn.run(real_command, **real_kwargs)
```

使い方は以下の通り。

```python
@task
def sample(conn):
    # ラッパークラスを生成する場合
    exports = dict(HOGE='HOGE1')
    with FabContext(conn, '/tmp', exports=exports) as fab:
        fab.run('echo HOGE=$HOGE > run.txt')  # HOGE=HOGE1 が /tmp/run.txt に出力
        fab.run('ls -al run.txt')

    exports = dict(HOGE='HOGE2')
    with FabContext(conn, '/tmp', sudo=True, exports=exports) as fab:
        fab.run('echo HOGE=$HOGE > sudo.txt') # HOGE=HOGE2 が /tmp/sudo.txt に出力
        fab.run('ls -al run.txt')

    # ラッパークラスのネストをwith句として使う場合
    with FabContext(conn, '/usr') as fab1:
        with fab1.cd('local') as fab2:
            fab2.run('...')  # サブディレクトリ内でコマンドを実行
        with fab1.sudo_as(True) as fab2:
            fab2.run('...')  # sudo で実行
```


invokeでは `conn.run` と `conn.sudo` を使い分けているが、個人的にはこれもコンテキストで指定できた方がうれしいのでコンテキスト内で切り替えられるようにしている。


## 最後に

shellをスクリプトに置き換えて気軽に自動化したかっただけなのに、新しく使い方を覚えるよりこのあたりをいかに楽に書くかの方が時間がかかりました……
上記コードは私の必要とする範疇のエスケープなどしか効かせていませんが、同じような悩みを抱えている人の参考になれば幸いです。