ぽよメモ

レガシーシステム考古学専攻

Moleculeを使う際にTestinfraからAnsible Factsを参照する

概要

 Ansible公式のテストフレームワークであるMoleculeは,Ansibleによる構築の後に,デフォルトではTestinfraによるテストを実行します. Testinfraにはhost.ansible.get_variables()というAPIがあり,これを介してAnsibleの変数を取得できるように見えます.が,実際にはinventoryやhost_vars,group_varsに記載されているもの以外の値を取ることができません.

 ここでは,Testinfraによるテストコードから,gather_factsによって収集される変数やRoleのdefaultsやvars内で定義されている変数を参照するハックを紹介します.ただし,これは完全にAnsibleの挙動と同じであるとは言えないかもしれないという点に注意してください.

環境

  • macOS 10.15.2 Catalina
  • Python 3.7.5
  • Ansible 2.9.2
  • Molecule 2.22
  • Testinfra 3.4.0

ここでは単体のRoleのテストのみを取り扱います.Roleのディレクトリの構造は以下の通りです.

$ tree -L 2 /path/to/role
/path/to/role
├── LICENSE
├── Pipfile
├── Pipfile.lock
├── README.md
├── defaults
│   └── main.yml
├── meta
│   └── main.yml
├── molecule
│   └── default
├── tasks
│   └── main.yml
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

Testinfraにおける問題点

 TestinfraはAnsibleと完全に統合されているわけではないため,構築の実行中に使用される変数を取得することができません.これにより何が起こるかというと,複数のプラットフォームに跨ったテストを書くときにifを大量に並べることになります.

テストコード中では以下のようにOSの情報を取得できます*1

def test_hoge(host):
    dist = host.system_info.distribution
    if dist == 'debian':
        # some action
    elif dist == 'ubuntu':
        # some action
    elif dist == 'darwin':
        # some action
    elif dist == 'centos':
        # some action

正直めちゃくちゃ面倒臭い……インストールするパッケージのバージョンチェックなども,このままではバージョンをハードコードすることになり,Role内の値を変更するときにテストコード内も全て変更したりする必要が出てきます.

 さて,ここで救世主のようにみえるTestinfraのAPIHost.ansible.get_variables()の実際の実装をみてみましょう.

https://github.com/philpep/testinfra/blob/3.4.0/testinfra/utils/ansible_runner.py#L172-L189

ansible-inventoryコマンドを叩いてインベントリの情報を収集している以外は過去のバージョンとの互換性を保つためのコードのようです.つまり,get_variables()とは言いつつも,実際にはインベントリに記述されている情報しか取っていません

既存のアプローチ

いくつかのIssueで議論されています.

github.com

github.com

これらの中でも直接YAMLとして読み込んだり,include_varssetupを使用した方法が紹介されていますが,これらは誤った用法によりうまく変数を解釈できていません.

  • YAMLとして読み取る
    • これは論外
  • include_varsを使う
    • 一見うまく動作するように思えるが,これも実際には変数の解釈をしない
    • 例) hoge: "{{ ansible_python_interpreter }}"のようなYAMLをロードしても{"hoge": "{{ ansible_python_interpreter }}"}が返ってくるだけ
  • include_vars + setup + ansible.template.Templar

Testinfraから変数を参照する

 ようやく本題です.変数を取得する方法は雑にいうとAnsibleのinclude_varssetupモジュールを使い,自分でファイル読み込みの優先順位を定義して順に読み取っていきます.読み込んだ変数を優先度順に ansible.template.Templar で解決していきます.
 Testinfraが内部で採用しているテストフレームワークであるpytestの作法に則って,fixtureとして実装します.

setup

まず,setupモジュールを使用してAnsibleのFactsを収集します.

import pytest

@pytest.fixture(scope='module')
def ansible_facts(host):
    return host.ansible("setup", "gather_subset=min")["ansible_facts"]

これはそれなりに重いので,

  1. gather_subsetfilterなどの引数を使って必要な変数のみ取得*2
  2. fixtureのスコープをmoduleにすることで,複数回の取得を防ぐ

等で多少早くなります(たぶん).

 このansible_factsというfixtureは単にいつも使っているFacts*3がそのまま返ってきます.

include_vars

 include_varsを使って各変数定義ファイルを読んでいきます.

from ansible.template import Templar
from ansible.parsing.dataloader import DataLoader

@pytest.fixture(scope='module')
def ansible_vars(host, ansible_facts):
    # リストの後にいくほど優先度が高い
    # host.system_infoを利用すれば,OSごとに読み取るvarsを変更している場合にも対応可能
    var_files = [
        "../../defaults/main.yml",
        host.ansible.get_variables(),
        "../../vars/main.yml",
    ]
    templar = Templar(loader=DataLoader())
    # 優先度順に読んでいく
    all_vars = {}
    all_vars.update(ansible_facts)
    for f in var_files:
        if isinstance(f, str):
            var_from_f = host.ansible(
                "include_vars", f"file={f}")["ansible_facts"]
        else:
            var_from_f = f
        # 変数の出現順に埋め込みを解決
        for key, val in var_from_f.items():
            templar.available_variables = all_vars
            all_vars[key] = templar.template(val)
    return all_vars

ディクショナリの順序が保証されているPython 3.6以降でしかうまく動作しない気はしています.

 templar.template()は引数にディクショナリも取ることができるのですが,その際,解決すべき変数がそのディクショナリ内に含まれている場合にうまく動作しません. 例えば以下のようなファイルを読み込み,そのまま渡すとうまく解決することができずにエラーになります.

---
hoge: "hoge"
hogefuga: "{{ hoge }}fuga"

そのため,まずはhogeという変数を解決してTemplarの持つ変数のディクショナリを更新し,次にhogefugaを解決するようにしました.

 

テストで使う

 実際のテストコードへの実装は非常に容易です.例えばインストールされているべきパッケージの一覧をinstalled_packagesとして定義しているなら,以下のようにします.

def test_hoge(host, ansible_vars):
    expected_packages = ansible_vars.get("installed_packages")
    for expected_pkg in expected_packages:
        actual_pkg = host.package(expected_pkg)
        assert actual_pkg.is_installed

メリット

  • 煩わしいOSの判別を一部不要にできる可能性がある
  • Role側がデフォルト値を変えただけなどの場合に,テストを手動で修正する必要が無くなる

デメリット

  • そもそもRoleのデフォルト値が間違っていたりするとテストの意味がなくなる
  • setupが重いため,内容次第ではテストが遅くなる可能性がある
  • 完全にansible-playbookコマンドによる実行と同等とはいえず,また,内部のAPIを直接参照しているため,今後も互換性が担保されるかわからない

現時点での制限

  • Role中のタスクでset_factしたりregisterしている変数は参照できない
  • 変数の優先順位を人が解決する必要がある

などなど.現時点での実装があまり賢いとは思っていないので,もしより良い方法を知っている人がおられれば教えていただけると幸いです.


Ansibleの冪等性について深く考えたことはありますか?

はじめに

 僕は今,修士論文の執筆に向けて構成管理ツールと冪等性についての論文を複数読んでいます.それらを踏まえて,一般によく見られるAnsibleへの誤解についての自分の見解,そして2019年現在,構成管理ツールについてどういった研究がされているかを簡単に述べていきたいと思います.

冪等性

 そもそも冪等性とは「ある操作を一回行っても,複数回行っても,結果が変わらない」という概念です.例えば,

  • 例えば1や0と任意の自然数のかけ算
    •  N \times 1 = N \times 1 \times 1 = N \times 1 \times 1 \times 1= \cdots
    •  N \times 0 = N \times 0 \times 0 = N \times 0 \times 0 \times 0= \cdots
  • 絶対値の計算
    •  abs(x) = abs(abs(x)) = abs(abs(abs(x))) = \cdots

などが冪等性を持った処理に当たります.

Ansibleにおける冪等性

 さて,Ansibleのモジュールを使用した操作はよく冪等であると言われています.ここで,Ansibleにおける「冪等」とは何でしょうか?Ansibleの用語集*1を引いてみます.

An operation is idempotent if the result of performing it once is exactly the same as the result of performing it repeatedly without any intervening actions.

訳すなら,「一度実行した結果が,介入する操作無しで繰り返し実行した結果とまったく同じならば,操作は冪等である」という感じでしょうか*2.うーんなんとなくいいかんじに思えますね!(?

 しかしこの「まったく同じ」というのは何を要求するのでしょうか.例えばlineinfileモジュールを用いて,あるファイルにある行が一文あるということを宣言したとします.

- lineinfile:
    path: /path/to/hoge
    line: "Some line"

実行したとき,必ず一度はその存在を確認するためにファイルにアクセスする必要があります.すると,その行がある場合も無い場合も,Linuxではそのファイル(ここでは/path/to/hoge)のatime,つまりアクセス日時が更新されてしまいます.また,(オフに設定しない限り)実行の度に対象のホストへログも書き込まれます.これは「冪等性」を満たすのでしょうか?

冪等性は主観によって決まる

 おそらくほとんどのケースでは,atimeの更新やsyslogへの書き込みのような,求める操作に伴う暗黙的な副作用は問題にならないでしょう.この場合には「(当人から見て)冪等性は満たされている」と言えるかと思います.しかし逆に,(あるのか知りませんが)atimeが重要で容易に更新されて欲しくないケースにおいてはどうでしょうか?もしかすると,「(当人から見て)冪等で無い」と考えるかも知れません.

 このように,構成管理ツールにおける冪等性というものは,実際にはその結果を観測した人の主観に左右されます.冪等なモジュールであるからといって,得られる結果が完全に何もかも実行前と同じとは限らないということに留意した上で,こうしたツールと付き合っていく必要があるでしょう.

 このような必ずしも何もかもが完全に同一とはならない現実のシステムにおいて,「冪等」という概念をそのまま適用することは非常に難しいと言わざるを得ません.あまりしっくり来る定義を見つける事ができなかったのですが,天才*3から教えてもらったRFC7231の4.2.2*4がなかなかしっくりくるなと思ったので紹介します.ここではGETやPUTなどのHTTPリクエストについて,どのような場合にそのリクエストを「冪等」と見なすかを述べています.

Like the definition of safe, the idempotent property only applies to what has been requested by the user; a server is free to log each request separately, retain a revision control history, or implement other non-idempotent side effects for each idempotent request.

つまり,「冪等」と判定する要素は,ユーザが要求した属性についてのみであり,それに伴うサーバサイドでの副作用(例えば各リクエストについてログを取るなど)は感知しないということのようです.Infrastructure as Codeの文脈において考えてみても,各ツールはユーザが要求した属性(例えばファイルの存在とか)についてのみ冪等であることを保証できるように努力し,それに伴う副作用(サーバサイドにログが残る,atimeが更新される…etc)については保証しない,という考え方をすれば,比較的まともな「Ansibleにおける冪等性」への理解を得られるかと思います.

モジュールの冪等性

 現状構成管理ツールのあるモジュールによる操作が,(他の何らかの操作の介入を受けなかったとしても)完全に冪等性を保証することは知る限りでは証明されていません*5.つまり,Ansibleによる操作が完全に冪等であることを,誰も保証してくれません.もちろんIssueには冪等性(idempotence)に関するものも多くあり,努力はされていますが,最終的には自分で検証してなんとかするしかありません.
 このような現状なので,当然どのモジュールのドキュメントにも,「これは冪等なモジュールです」とは書いていません.Issueを漁っていると,過去に「冪等かどうかドキュメントに書いてくれ」というものが上がっていました.

github.com

これに関連したIssueコメントは以下です.

github.com

非常に短いメモが残されているだけなので,実際のその場での議論などは分かりませんが,

  • 冪等でないモジュールもある
  • でもそれらのために多大な労力を払って冪等かどうかを表示するのはコストが高すぎる
  • 今のところ何もしない

というような決着を迎えたようです.以降これに関連したIssueは探しましたが見つからず,Ansibleの方向性として冪等性のしっかりとした証明は目指さず,あくまで努力目標であるということが伺えます*6.活躍するドメインから考えても,これはあくまで実務上のツールであり,理想を追い求める物ではないので,この判断は正しいことだと思います.

 とはいえ,冪等な操作を売りにしているツールにしては,そのやり方でみんな納得しているのか?という気持ちがあります.

Playbookの冪等性

 全てのタスクが冪等であったとしても,複数のタスク・ロールを束ねたときに,全体を通して冪等になるとは限りません.例えばあるミドルウェアをインストールするというロールと,そのミドルウェアをアンインストールするというロールを同時に使用すれば,毎回インストール→アンインストールを繰り返すことになり,冪等性が保たれなくなります*7

 こうした事象を避けるためには,

  • 一つのRoleの責務を小さくかつ明確にすること
  • Ansible GalaxyなどでインストールしたRoleの中身に一度は目を通しておくこと

などが重要かと思います.

構成管理ツールについての研究の今

 それなりの数に目を通したのですが,ちゃんと研究になっているものとしては以下の3種類に分類できると考えました.現状この分野の研究はほぼPuppetのものに限定されており,Ansibleは構成管理ツールとしての紹介がされているのみです.

冪等であるかどうかをテストする系

 2013年にChefのCookbookを対象に,冪等性検証フレームワークを提唱した論文です.LXCを使用して実際にCookbookを実行するのですが,ただ二回実行するというような方法ではなく,STGという状態遷移グラフを構築して様々な実行パターンを検証します.状態の変化の検知にはOhai*8を使用していたようです.

Testing Idempotence for Infrastructure as Code | SpringerLink

2016年に上の論文の結果を受けてPuppetでの冪等性検証フレームワークの提唱をしたのが下記の論文です.Ohaiを参考にした上で,straceやptraceを使って任意のファイルの変更まで追いかけたのが特徴です.

Asserting reliable convergence for configuration management scripts

結果の可視可もよく出来ています.

citac.github.io

同じく2016年にPuppet向けの冪等性検証フレームワークとしてRehearsalというものを提唱した論文です.これは単にPuppetコードを実行するのではなく,Puppetの各リソースを彼らが提唱したファイル操作をするための小さなDSLに置き換え,Puppetの非決定的な実行順序による操作のコンフリクトを検証できます.

Rehearsal: a configuration verification tool for puppet

難しいのでしっかりとは読み込めていませんが,そもそも構成管理ツールのある操作は任意のファイルへの操作であると考えると,これは割と面白いアプローチかと思いました.

品質モデルを提唱する系

 2016年にMSRというリポジトリマイニングの国際会議にて提唱された,Puppetコードのコードスメル*9に関する研究です.いわゆるバッドプラクティスを検知して「匂う」コードを見つけようというものです.

Does your configuration code smell?

下記の論文では,Puppetコードの品質モデルを提唱しています.実際に開発者らへのアンケートからモデルを定義し,自社・他社のPuppetエンジニアによる品質の分類と,品質モデルによる分類を比べて,ある程度正確な品質モデルを定義する事が出来たと述べられています.

How good is your puppet? An empirically defined and validated quality model for puppet - IEEE Conference Publication

セキュリティ系

 以下の論文も又コードスメルの検出なのですが,内容がよりセキュリティに寄っています.

2019.icse-conferences.org

以下のブログに簡単なまとめがあります.

jagijagijag1.qrunch.io

IaCに関する研究はブルーオーシャン

 ここでは構成管理ツールに関する研究に絞っていますが,IaC(というかDevOps)という分野自体が若く,研究はあまり進んでいません.実務と結びついた領域であるために,研究者が手を出しにくい分野なのかもしれません.出すなら今ですよ!(?

 特にそのツールや概念に詳しい研究者を集めることは容易ではなく,GitHubなどから開発者らにメールを送ってアンケートに答えてもらう,など,泥臭いことが多々行われています.その回答率も芳しくなく,結果の信頼性としてもかなり微妙と言わざるを得ません.Ansibleはユーザコミュニティも活発で,実務で使用しているプロフェッショナルが多数います.研究にはもってこいなのでは? 🤔

まとめ

  • Ansibleに限らず,構成管理ツールにおける冪等性というものは,使用者の主観によって左右される
  • 今のところ,Ansibleモジュールが真に冪等であることは証明されていない
  • モジュールが冪等だから!と適当にやっていると容易に冪等性を失う
  • IaCは実務に関わる部分が大きく,研究者にはなじみが薄いため,論文を書き放題
    • ツールへの造詣が深いエンジニアの意見を使って論文が書けるのは企業の強み
    • 是非いかがですか?

*1:https://docs.ansible.com/ansible/2.9/reference_appendices/glossary.html?highlight=idempotency

*2:ニュアンス違ったらすいません

*3:弊ラボのドクターです

*4:tools.ietf.org

*5:これは研究者の怠慢であるかもしれませんね

*6:続報があるとか何か知っておられる方いらっしゃれば教えてください

*7:こういうのをAnsibleのdry-run時に検知する方法とかないんでしょうか?

*8:github.com

*9:qiita.com

deanishe/awgoを使って簡単にAlfred Workflowを作る

はじめに

 これはGo3 Advent Calendar 2019,7日目の記事です.

qiita.com

Alfred Workflowとは?

 macOS向けの多機能ランチャーであるAlfredの自動化のためのプラットフォームです.Alfredとは,簡単に言ってしまうとmacOSのSpotlightの機能強化版です.

www.alfredapp.com

 過去に以下のようなものを作りました.

poyo.hatenablog.jp

poyo.hatenablog.jp

poyo.hatenablog.jp

最後の一つはAlfredのUIを一切使っていないので少し毛色が違いますが,上2つの様な,

  1. 何らかのトリガーとなるコマンドに対して
  2. 選択肢を提示し
  3. ユーザの選択によって何かアクションを起こす

というものを作りたくなったときに便利に使えます.なお,Alfred本体は無料なのですが,それとは別にPowerPackと呼ばれる拡張パッケージを購入することで使えるようになります.現在のメジャーバージョンでのみ使えるSingle License(25ユーロ),永久ライセンスのMega Supporter(45ユーロ)という二つの形態のライセンスがあるのでお好きな方を購入してください.

www.alfredapp.com

Alfred Workflowの仕組み

 様々な形態のWorkflowがありますが,基本的には用意されているコンポーネントGUIで繋いでいき,あるコンポーネントから別のコンポーネントへ,文字列を受け渡して様々なアクションを起こします.

 自作のスクリプト/バイナリを使ってAlfredのUIを使用したい場合,Script Filterというコンポーネント内でそのスクリプト/バイナリを実行し,所定の形式を持つJSON*1を標準出力へprintします.そのJSONをAlfredが読み取り,良い感じにUIを表示して,選択されたオブジェクト(の中の,次のコンポーネントへ渡す引数となる文字列)を接続されているコンポーネントへ受け渡します.

 実行できるアクションには様々な物があり,

  • ブラウザで検索
  • URLを開く
  • 任意のアプリケーションで開く
  • 任意のスクリプトを実行する

などなど,多数のアクションが用意されています*2.こうしたアクション以外にも,クリップボードへコピーしたり,通知をだしたりも出来ます.便利ですね.

Alfred WorkflowとGo

 Alfred Workflowで動かすアプリケーションをGoで書くというアプローチは,数年前から行われています.

techblog.kayac.com

blog.tsub.me

kyokomi.hatenablog.com

これらのサイトでも述べられていますが,

  • シングルバイナリになるので簡単に配布して簡単に使える

という部分が最も大きいです.全てをシェルスクリプトで書ける猛者ならまだしも,どんなモジュールが入っているか分からないPythonPerlで頑張るよりも簡単かつ安全かと思います.

github.com/deanishe/awgo

 今までは自分の手で書いたstructをjson.Marshal()していましたが,便利なライブラリが開発されていました.

github.com

Alfred3および4用のライブラリで,どちらでも使うことが出来ます*3

 また,表示するアイテムの出力だけでなく,様々な機能のサポートが含まれています.長くなるので今回は紹介しませんが,一通り調べてみたので機会があればそれについても記事を書きたいと思っています.

チュートリアル

以下の環境で実行しています.

  • macOS 10.15
  • Go 1.13
  • Alfred 4.0.6
  • github.com/deanishe/awgo v0.21.0

あまり良い題材を思い浮かばなかったので,今回はこのAlfred Workflowで使った依存パッケージのライセンスを閲覧できるAlfred Workflowを作ってみようと思います.ビルドしたバイナリを配布する場合,使用した各モジュールのライセンスに則って著作権表示等が必要となります*4

f:id:pudding_info:20191206182837g:plain
選択したモジュールのLICENSEを表示

リポジトリはこちら

github.com

1. 空のWorkflowを作る

左下の+からBlank Workflowを選択すると新しいWorkflowを作成するウィンドウが開きます.

f:id:pudding_info:20191205224816p:plain
Blank Workflow

Bundle idはとにかく一意な値であれば良いので,自分の持っているドメイン等があればそこから適当な値を自分で考えれば良いと思います.ここで記述した内容は後からでも変更可能なので,あまり迷う必要はありません.

2. 実行するアクションを並べる

 依存パッケージとライセンスの取得には以下のパッケージを利用させて頂きます.

github.com

これは出力方式としてJSONを選択することができ,その場合各モジュールの名前とLICENSEファイルの中身を取得できます.閲覧のためにはその中身を一旦ファイルに出力する必要があるので,以下の様な構成とします.

  1. モジュール名とライセンスファイルの中身から表示するアイテムを作成するスクリプトを実行
  2. 選択したモジュールのライセンスが文字列で渡されるので,これをWorkflow内のテンポラリディレクトリに出力
  3. そのファイルを規定のプログラムで開く

加えて,Modifierを使ったときの例も示したいので,1の後にCommand + Enterを押すことでそのモジュールのURLをブラウザで開く,というタスクも追加します.

コンポーネントは,右クリックして出てきたメニューから選択して作成できます.各コンポーネント間はドラッグで紐付けることが出来ます.虫のマークはデバッグコンポーネントで,そのコンポーネント間でやりとりされる引数や変数を見ることが出来ます.

f:id:pudding_info:20191206163051p:plain
コンポーネント一覧
コンポーネントの設定はそのコンポーネントをダブルクリックで開けます.

f:id:pudding_info:20191205230549p:plain
Script Filterの設定

f:id:pudding_info:20191205230746p:plain
Write Text Fileの設定

Open File,Open URLコンポーネントはデフォルトのままです.Command+Enterでアイテムを選択したときにOpen URLへ動作を分岐させる方法は,コンポーネント間の接続(エッジ)をダブルクリックすると出てくるメニューから,使用するModifierにチェックを入れるだけです.他のModifierもあるので,更に様々な操作を行うことが出来ます.

3. info.plistを手に入れる

次に,左にあるWorkflow一覧から,このWorkflowを選び,オプションメニューからFinderで開くを選択します.

f:id:pudding_info:20191206163352p:plain
Open in Finder

info.plistというファイルがあると思うので,それを次に作るGoのプロジェクト内にコピーします.このファイルを単体で編集するのは骨が折れるので,基本的にコンポーネントの操作等はAlfredのUI上で行い,それをその都度Goのプロジェクトへコピーするようにしています.

4. Goでプログラムを書く

 まずはプロジェクトを作り,deanishe/awgoをインストールします.

$ mkdir sample-alfred-workflow
$ cd sample-alfred-workflow
$ go mod init
$ go get github.com/deanishe/awgo

Songmu/gocreditsが吐くJSONは以下の様なフォーマットなのでそれに合わせてstructを作ります.

{
  "Licenses": [
    {
      "Name": "モジュール名",
      "URL": "URL",
      "FilePath": "ファイルパス",
      "Content": "LICENSEの中身"
    }
  ]
}

license.goとして以下の内容を記述します.特に難しいところは無いはずです.

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
)

// 各モジュールごとのLicenseを表すstruct
type license struct {
    Name     string
    URL      string
    FilePath string
    Content  string
}

// モジュールごとのライセンスを全て内包するstruct
type licenseJson struct {
    Licenses []license
}

// 与えられたパス名からライセンスを読み取り,ライセンスの一覧を返す
func getLicenses(path string) ([]license, error) {
    contents, err := ioutil.ReadFile("./credits.json")
    if err != nil {
        return nil, fmt.Errorf("%s does not exists.\n", path)
    }
    var licenses licenseJson
    if err = json.Unmarshal(contents, &licenses); err != nil {
        return nil, fmt.Errorf("Failed to parse %s.\n", path)
    }
    return licenses.Licenses, nil
}

ようやくdeanishe/awgoを使ってメインのロジックを書いていきます.とは言っても,この程度のAlfred Workflowならば非常に簡単になります.

package main

import (
    "fmt"
    "os"
    "strings"

    aw "github.com/deanishe/awgo"
)

var (
    // このプロジェクト全体で参照するaw.Workflow
    wf *aw.Workflow
)

func init() {
    // 初期化時にインスタンスを生成する
    wf = aw.New()
}

func run() {
    licenses, err := getLicenses("./credits.json")
    if err != nil {
        // wf.Fatal~~は,エラーメッセージを出力して終了させる
        wf.FatalError(err)
    }
    for _, c := range licenses {
        // ループを回して一つずつItemを作っていく
        // Itemの持つ各メソッドはItem自身を返すのでメソッドチェーンで書ける
        item := wf.NewItem(c.Name).
            // Argは選択されたときに次のコンポーネントへ渡す引数となる文字列
            Arg(c.Content).
            Subtitle(strings.Split(c.Content, "\n")[0]).
            // Valid(true)としないと選択できないので注意
            Valid(true)
        // そのアイテムがCommand+Enterで選択されたときの動作を変更する
        item.Cmd().
            Arg(c.URL).
            Subtitle(fmt.Sprintf("Open %s in browser", c.URL)).
            Valid(true)
    }
    // 与えられた文字列でアイテムをフィルタリングする
    args := os.Args
    if len(args) > 1 {
        // https://godoc.org/github.com/deanishe/awgo/fuzzy
        wf.Filter(args[1])
    }
    // 最終的に表示すべきアイテムが無かったときに表示するエラー文
    wf.WarnEmpty("No credits were found.", "Try different query.")
    // 標準出力へ最終的なJSONをプリントする
    wf.SendFeedback()
}

func main() {
    // 内部でpanic等をうまくハンドリングしてくれる
    wf.Run(run)
}

wf.NewItem()を呼び出した時点で表示するアイテム一覧の中にそのアイテムが追加されるため,どこかにappendしたりする操作は不要です.

6. パッケージングする

 ググっているとAlfredのUI上からExportしないとAlfred Workflowとしては動作しないというように言われていますが,実は.alfredworkflowというファイルの実態は単なるzipファイルです.そのため,単に必要なファイルを全てzipでアーカイブにして拡張子を変えるだけで済みます.今回は以下の様なMakefileを用意しました.

SHELL := /bin/bash

PLIST=info.plist
CREDITS=credits.json
EXEC_BIN=sample-alfred-workflow
DIST_FILE=sample.alfredworkflow
GO_SRCS=$(shell find -f . \( -name \*.go \))

all: $(DIST_FILE)

$(CREDITS): go.sum
  gocredits -json . > $(CREDITS)

$(EXEC_BIN): $(GO_SRCS)
  go build -o $(EXEC_BIN) .

$(DIST_FILE): $(EXEC_BIN) $(CREDITS) $(PLIST)
  zip -r $(DIST_FILE) $(PLIST) $(CREDITS) $(EXEC_BIN)

単にmakeとするだけでsample.alfredworkflowが生成されます.

$ make
# 関連付けされているのでAlfredが開いてインストールできるはず
$ open sample.alfredworkflow

7. CI/CDする

 GitHub Actionsが正式リリースになっているのでこれを使いましょう..github/workflows/release.yamlとして保存します.

name: Release
on:
  push:
    tags:
      - "v*"
jobs:
  release:
    runs-on: macos-latest
    steps:
      - name: Checkout source codes
        uses: actions/checkout@v1
        with:
          fetch-depth: 1

      - name: Setup Go environment
        uses: actions/setup-go@v1
        with:
          go-version: 1.13

      - name: Restore cache if available
        uses: actions/cache@v1
        id: cache
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-

      - name: download modules
        if: steps.cache.outputs.cache-hit != 'true'
        run: go mod download

      - name: Build
        run: make

      - name: Create new release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: Release ${{ github.ref }}
          draft: false
          prerelease: false

      - name: upload release asset
        id: upload-release-asset
        uses: actions/upload-release-asset@v1.0.1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ steps.create_release.outputs.upload_url }}
          asset_path: ./sample.alfredworkflow
          asset_name: sample.alfredworkflow
          asset_content_type: application/zip

これでタグを付けると以下の様にリリースされます🎉

f:id:pudding_info:20191206181142p:plain
First release!

macOSのセキュリティ

 Catalinaでは,署名されていない,インターネットからダウンロードしたバイナリの実行をデフォルトでブロックします.そのため,この方法でAlfred WorkflowをGitHub Releasesからダウンロードすると以下の様に警告を吐かれます.

f:id:pudding_info:20191206181331p:plain
警告その1

システム環境設定 > セキュリティとプライバシーから実行を許可して再度実行するとまた警告が出ます.

f:id:pudding_info:20191206181521p:plain
警告その2

ここで「開く」を選択すると,ようやく次からAlfred Workflowを使用できます.
別にマルウェアなどは仕込んでいないので安心してください……

deanishe/awgoの微妙なところ

  • 何もかもstructなのでテストしづらい
    • aw.Workflowがあらゆるビジネスロジックを担っている巨大structなので剥がしづらい
    • 小さいInterfaceを自分で定義していくのがいいかも

  • グローバルなaw.Workflowを使い回す設計なのでテストしづらい
    • wf.Run(fn func())の中で実行することを想定しているので,この設計以外で使えない

  • wf.SendFeedback()がDIの機構を持たないので出力をテストしづらい
    • 直接stdoutに書き込んでいて外側からインジェクション出来ない
    • wf.Feedback.MarshalJSON()で生成されるJSONをテストするだけで凌ぐ…?

  • wf.NewItem()がgoroutine safeじゃない
    • 内部的には全てのアイテムはwf.Feedbackが管理しており,wf.NewItem()wf.Feedback.NewItem()を呼んでいる
    • FeedbackのItemsに単に作ったItemをappendしており,goroutineで普通に叩くと壊れる https://github.com/deanishe/awgo/blob/master/feedback.go#L421
    • 普通にaw.NewItem()みたいなのとwf.AddItem()みたいなのがあれば十分だったと思う

  • wf.NewItem()が返すItemがデフォルトでvalid=falseになっている
    • valid=falseなオブジェクトはUIに表示できるが選択できない(エラーメッセージとかを表示するのに使う)
    • デフォルトがfalseなのはAlfredの文脈的には直感に反する
    • 作者の意見:github.com

紹介しきれていない機能

リポジトリに上がっている参考実装がかなりためになります.https://github.com/deanishe/awgo/tree/master/_examples

などなど

まとめ

  • Alfred WorkflowをGoで書くならdeanishe/awgoは機能が豊富で便利
  • GitHub ActionsでCI/CDも出来る
  • macOS Catalinaのセキュリティ機能はかなりおせっかい
  • deanishe/awgoは癖が強いのでテストはちょっと色々考える必要がある

皆さんの面白いAlfred Workflowをお待ちしています.


*1:www.alfredapp.com

*2:www.alfredapp.com

*3:Alfred2まではJSONではなくXMLを使って居ました.これはdeprecatedになっています.Alfred3および4はJSON形式に対応しています https://www.alfredapp.com/help/workflows/inputs/script-filter/xml/

*4:加えてGoの自体のライセンス(BSD3条項ライセンス)も含める必要があります.これ知らなかったので,今後気をつけます…