こんにちは、滝澤です。

いくつかのプロジェクトでタスクランナーFabric 2を使う機会がありました。少しですが知見が溜まったので紹介します。 また、Fabric 1.xを利用していた方は互換性も気になると思いますでのその点についても紹介します。

記事が長くなったので3編に分けます。

本記事は中編の「Invokeの使い方」になります。

なお、執筆時点(2018年11月21日)での最新バージョンはFabric 2.4.0、Invoke 1.2.0です。 動作確認はPython 3.7.1にて行っています。

Invokeについて

Invokeはローカルでシェルコマンドを実行するPythonライブラリです。 タスクランナーとして利用できます。

前編でも述べましたが、Fabric 2はタスクランナーInvokeのSSHラッパーとして動作します。 タスクの定義方法はInvokeとほぼ同じですので、まずInvokeの使い方を確認してみましょう。

Invokeのインストール

pipコマンドでインストールできます。

$ pip install invoke

タスクランナーInvoke

タスク実行

タスクの定義方法を紹介します。 例として、与えられたシェルコマンドを単純に実行するタスクshを定義してみます。

まず、tasks.pyという名前のファイルを作成します。 invokeパッケージからtaskモジュールをインポートします。

from invoke import task

タスク名shの関数を定義し、1つ目の引数にはInvokeのコンテキストの変数としてcを指定し、2つ目以降の引数にはinvokeコマンドで渡される引数を指定します。さらに、タスクとして認識させるためにデコレーター@taskを付けます。 run()の引数にシェルで実行するコマンドを指定します。

@task
def sh(c, command):
    """Execute a shell command."""
    c.run(command)

tasks.pyファイルと同じディレクトリにおいて、invokeコマンドを実行します。invokeコマンドの基本的な使い方は次の通りです。

invoke タスク名 [引数...]

ここでタスク名shと引数のdateを指定してinvokeコマンドを実行してみます。

$ invoke sh 'date'
Tue Nov 20 17:30:22 JST 2018

dateコマンドの実行結果が出力されます。

ここでもう一つ関数名の長いタスクを定義してみます。

@task
def print_date(c):
    """Print date."""
    c.run("date")

invokeコマンドで--listオプションを付けて実行するとタスク一覧が表示されます。

$ invoke --list
Available tasks:

  print-date   Print date.
  sh           Execute a shell command.

ここで定義したタスクの関数名print_dateとタスク名print-dateが異なることがわかります。 "_"が"-"に置き換わっています。

同じ名前に戻したい場合は設定ファイルを用意します。 設定ファイルの配置方法や記述方法にはいくつかあるのですが、ここでは同じ場所にinvoke.yamlという名前のファイルを用意し、次のようにtasks.auto_dash_namesの値をfalseにします。

---
tasks:
  auto_dash_names: false

再び実行すると、タスク名がprint_dateになり、定義したタスクの関数名と一致するようになりました。

$ invoke --list
Available tasks:

  print_date   Print date.
  sh           Execute a shell command.

公式ドキュメント:

コマンド実行結果Resultの利用

コマンドの実行結果の出力をprint()関数で出力してみます。 run()自体での出力を抑制するためにhide=Trueあるいはhide="both"オプションを追加します。 run()の実行結果をresultに格納し、標準出力をresult.stdoutから取得します。なお、出力には改行コードも含まれるため、strip()で取り除いています。

@task
def sh(c, command):
    """Execute a shell command."""
    result = c.run(command, hide=True)
    print(result.stdout.strip())

再びinvokeコマンドを実行してみます。

$ invoke sh 'date'
Tue Nov 20 17:48:55 JST 2018

print()関数で実行結果を出力できました。

なお、実行コマンドそのものも出力したい場合はecho=Trueオプションを追加するか、invokeコマンドのオプションとして--echoを追加するかしてください。ただし、hide=Trueオプションはこの設定を上書きするため、hide="both"などに書き換える必要があります。

$ invoke --echo sh 'date'
date
Tue Nov 20 17:48:55 JST 2018

公式ドキュメント:

エラーへの対応

エラーが発生したときの動きを見てみましょう。

引数にdateと入力すべきところを誤ってdeteと入力してしまいました。

$ invoke sh 'dete'
Encountered a bad command exit code!

Command: 'dete'

Exit code: 127

Stdout:



Stderr:

/bin/bash: dete: command not found

コマンドの終了コードが0ではないため、エラーが発生してタスクが中断されました。

ここで、エラー処理を追加してみます。

エラー発生時に中断しないようにrun()warn=Trueオプションを追加します。 終了コードの判断には終了コードが0のときに真になるresult.okを利用します。ちなみに、result.failedはこの逆です。 標準エラーはresult.stderrで取得できます。

@task
def sh(c, command):
    """Execute a shell command."""
    result = c.run(command, hide=True, warn=True)
    if result.ok:
        print(result.stdout.strip())
    else:
        print("ERROR: {}".format(result.stderr.strip()))

再び実行すると次のようになり、エラーを処理することができました。

$ invoke sh 'dete'
ERROR: /bin/bash: dete: command not found

公式ドキュメント:

複数のコマンドの実行

複数のコマンドの実行について確認してみます。

次のタスクはディレクトリを移動してコマンドを実行することを期待して定義したタスクです。

@task
def cd(c):
    """Execute cd()."""
    c.run("cd /opt/project")
    c.run("pwd")

これを実行すると次のような出力が得られます。

$ invoke cd
/home/foo/fabric

/opt/projectディレクトリに移動してからコマンドを実行したかったのですが、期待したとおりに動いていないことがわかります。 これは、それぞれのrun()が独立して実行され、状態を維持していないためです。

期待した動作を行わせるためには次のように実行するコマンドを"&&"で結合します。

@task
def cd(c):
    """Execute cd()."""
    c.run("cd /opt/project && pwd")

cd()

コマンド実行前にディレクトリを移動したいだけの場合は、コンテキスト内でディレクトリを変更するcd()を使うことで実行できます。

@task
def cd(c):
    """Execute cd()."""
    with c.cd("/opt/project"):
        c.run("pwd")

これを実行すると次のようになります。

$ invoke cd
/opt/project

実際にどのようなコマンドが実行されているかを確認するために、--echoオプションを付けて実行してみます。

$ invoke --echo cd
cd /opt/project && pwd
/opt/project

"cd /opt/project &&"が実行コマンドのプレフィックスとして付与されていることがわかります。

似たようなものにprefix()があります。これは次のように記述して、run()を実行する前にprefix()で指定したコマンドを実行するものです。

@task
def cd(c):
    """Execute cd()."""
    with c.prefix("cd /opt/project"):
        c.run("pwd")

実行してみると、次のようにコマンドが"&&"で結合されていることがわかります。  

$ invoke --echo cd
cd /opt/project && pwd
/opt/project

公式ドキュメント:

sudo()

タスクの処理の中でsudoコマンド経由でコマンドを実行したいときがあります。 この例として、whoamiコマンドを実行するタスクを用意してみました。 sudoコマンド経由でコマンドを実行するためにはrun()の代わりにsudo()を利用します。

@task
def whoami(c):
    """Execute whoami."""
    result = c.run("whoami", hide="both")
    print("run: {}".format(result.stdout.strip()))
    result = c.sudo("whoami", hide="both")
    print("sudo: {}".format(result.stdout.strip()))

sudoのパスワードを入力するために--prompt-for-sudo-passwordオプションを指定してinvokeコマンドを実行します。パスワードなしにsudoが実行できる場合はこのオプションは不要です。

$ invoke --prompt-for-sudo-password whoami
Desired 'sudo.password' config value:
run: foo
sudo: root

また、設定ファイル内にパラメータsudo.passwordとそのパスワードを設定することによりパスワードの入力を省略することもできます。

---
sudo:
  password: 'wveF}bWNYp4Wsu6m'

--echoオプションを付けて実行すると次のようになります。

$ invoke --echo whoami
whoami
run: foo
sudo -S -p '[sudo] password: ' whoami
sudo: root

sudoコマンドが実行されていることがわかります。 sudo()で指定したコマンドの前に単に"sudo -S -p '[sudo] password: '"が付与されているだけであることをよく覚えておいてください。ここが嵌まりどころになります。

公式ドキュメント:

sudo()による複数コマンドの実行

一つのsudo()で複数のコマンドを実行することを考えてみましょう。 この例ではwhoamiコマンドを2回実行します。出力としてはrootが2回表示されることを期待しています。

@task
def whoami(c):
    """Execute whoami."""
    result = c.sudo("whoami && whoami", hide="both")
    print(result.stdout.strip())

実行されたコマンドを確認するために--echoオプションを付けて実行してみます。

$ invoke --echo whoami
sudo -S -p '[sudo] password: ' whoami && whoami
root
foo

1つ目は"root"で2つ目は実行時のユーザー"foo"が出力され、期待した通りには動作していないことがわかります。 ここで、実行されたコマンドに着目すると"sudo -S -p '[sudo] password: ' whoami"というコマンドと"whoami"というコマンドが実行されたことがわかります。

この動作は先に紹介したcd()prefix()にも当てはまり、sudo()は期待した通りには実行されません。

期待した動作を行わせるためには、実行したいコマンドにbash -cを付与してsudo()に渡すとよいでしょう。

@task
def whoami(c):
    """Execute whoami."""
    result = c.sudo('bash -c "whoami && whoami"', hide="both")
    print(result.stdout.strip())

実行してみると、期待した動作になります。

$ invoke --echo whoami
sudo -S -p '[sudo] password: ' bash -c "whoami && whoami"
root
root

また、sudo()内でリダイレクトでファイル入出力を行う場合は、ファイルのオープンそのものは実行時のユーザーで行われるため、ファイルやディレクトリの権限によりアクセスできないことがあります。 このようなことを防ぐために、同様にbash -cを付与するとよいでしょう。

@task
def whoami(c):
    """Execute whoami."""
    c.sudo('bash -c "whoami > /opt/project/data/w.dat"', hide="both")
$ invoke --echo whoami
sudo -S -p '[sudo] password: ' bash -c "whoami > /opt/project/data/w.dat"

以上でInvokeの基本的な使い方を紹介しました。

公式ドキュメント:

目次

参考サイト

株式会社ハートビーツのインフラエンジニアから、ちょっとした情報をお届けします。