OpenStack Horizon Programming
はじめに
こんにちは、インフラ本部の大山裕泰です。今回は OpenStack Horizon の機能を拡張し、オリジナルのダッシュボードを作成する方法について紹介します。
Horizon は OpenStack が管理する各種リソース (仮想/物理マシン、ネットワーク、セキュリティグループなど) を操作するダッシュボード機能を WEB UI で提供しています。また Horizon は OpenStack が提供するリソースだけではなく、サードパーティのリソースも統一した UI で扱える機能拡張の仕組みも提供しています。今回はこの仕組みを利用して、オリジナルのリソース管理システムを Horizon に追加する手法を紹介します。
何が嬉しいか
OpenStack のリッチな API に魅せられて、開発者はついフルスクラッチで OpenStack と連携するアプリケーション (主に Web) を書きがちです。最近は OpenStack の SDK が豊富なこともあり、OpenStack と連携したアプリケーションの開発も比較的容易に実施できます。しかしこれらを利用して OpenStack 連携する Web アプリをフルスクラッチで書く場合、Horizon と重複した多くの機能の再実装が発生する可能性が多分にあり、そのために多大な開発コストを投じて Horizon とよく似たモノが出来上がり、加えてそれを自分たち「だけ」でメンテし続けなければならなくなります。
既に何らかのシステムリソースを統合して管理する堅牢なダッシュボードがあれば、そこに API を介して OpenStack で管理するリソースを操作する機能を追加するのもいいですが、そうでなければ Horizon を拡張する手法を選択するのが合理的です。Horizon の機能拡張であれば、追加で管理したいシステムリソースを管理するモデルと、どうやって表示させ、どのように操作させるかといったような、本当に欲しい機能の処理だけ実装することで Horizon に外部のリソース管理機構を統合させることができるようになります。
また Horizon はこうした機能拡張をするためのフレームワークを提供しており、MVC モデルの Web フレームワークを触ったことのある人であれば簡単に拡張機能の開発を行うことができます(正確には Horizon 自体がこのフレームワークを利用して開発されています)。
Horizon フレームワークの全体構成
実際に手を動かして作る前に、Horizon の全体像について簡単に見て行きます。Horizon は Django のフレームワークをベースとしていますが、その上に Horizon 自体のフレームワークのレイヤが存在し、Horizon に統合されいてる各リソースはこの上に "ダッシュボード(Dashboard)" という単位で個別に実装されています。
更に各ダッシュボードのページは、以下に示す階層構造によって構造化されています。
それぞれ Dashboard > Panel > TabGroup > Tab > Table という入れ子構造になっており、上位が下位に対して1対nの関係(一つのダッシュボードに対して一つ以上の Panel を持つことができる)になっています。また Tab が一つだけの場合、TabGroup や Tab が省略されるケースもあります。
上記の構成要素を実際の Dashboard の表示に当てはめたものが以下の図になります。以下では各構成要素を色枠で示しました。
TableAction と RowAction については、それぞれテーブル全体への処理と、テーブルの各データへの処理で使い分けます。例えば、カラムの追加であったり、テーブルのソート、データのインポート/エクスポートといったように、カラム全体に関わる処理を行う場合には TableAction に処理を記述します。逆に、テーブルの特定のカラムの更新・削除などの処理を実施する場合には RowAction に処理を記述します。
それでは Horizon フレームワークを使ってオリジナルのリソースを管理するダッシュボード機能を拡張する例を見て行きます。
作り方
以下では Horizon の拡張を実際にやってみたい方向けに、DevStack による OpenStack のテスト開発環境を用いて、オリジナルのリソースを管理するダッシュボード機能を追加する具体的な方法について紹介します。
スケルトンダッシュボードの作成
ここでは、何もないスケルトンのダッシュボードを作ります。まずは DevStack で OpenStack の実行環境を構築します。DevStack が入る環境 をご用意いただき、以下な感じで DevStack をインストールしてみてください。
1 2 3 4 |
$ git clone https://git.openstack.org/openstack-dev/devstack $ cd devstack $ git checkout stable/juno $ ./stack.sh |
後述するサンプルプログラムを正常に動作させるために、念のため stable/juno ブランチで Devstack を構築してください。
次にスケルトンのダッシュボードを作成します。以下な感じでスケルトンプログラムを展開してみてください。
1 2 3 4 |
$ wget https://labs.gree.jp/blog/wp-content/uploads/2015/04/skelton.zip $ unzip skelton.zip $ cp -r openstack_dashboard /opt/stack/horizon $ sudo service apache2 restart |
apache の再起動後、サーバにアクセスすると何もないダッシュボード "Mydashboard" が追加されていれば成功です。以降では、ここにオリジナルのリソース管理システムを追加する手法を解説してゆきます。尚、ここまでの操作の裏側の詳細を知りたい方は OpenStack Horizon ドキュメントの チュートリアル を参照してみてください。
オリジナル機能の追加
さて、ここからが本編の本題になります。Horizon のチュートリアルには、上記のスケルトンページを表示するところまでの解説は書いてありますが、それ以上の内容について言及されたドキュメントは皆無なため、実際に Horizon のコードを読んで動作を理解する必要がでてきます。今回は、オリジナルの拡張機能を実装する上で特によく使われる手法について紹介して行きたいと思います。
インストール
Horizon の機能拡張の手法について説明する上で、こちらで作成しました (ダミーの) ストレージ管理システムを用います。このシステムは、ユーザが (ダミー) のボリュームを作成・削除できる機能を提供しており、これらの機能を拡張した Horizon のダッシュボード上から以下のように行えます。
こちらの環境も skelton 同様、以下の手順でインストールできます。
1 2 3 4 |
$ wget https://labs.gree.jp/blog/wp-content/uploads/2015/04/dummy_manager.zip $ unzip dummy_manager.zip $ cp -r openstack_dashboard /opt/stack/horizon $ sudo service apache2 restart |
Apache 再起動後に Horizon にアクセスすると、Mydashboard に Dummy_Manager が追加されていると思います。右上の Create Data ボタンからストレージ名とストレージサイズを指定し "作成" ボタンをクリックするとテーブルにデータが追加されます。
また、追加されたテーブルの右脇のボタン "Remove Data" を押すと、データが削除されます。
以下では、ここで作成した Horizon 拡張 "Dummy_Manager" で利用している Horizon フレームワークの様々な手法について紹介します。
テーブルのデータの設定
まずはテーブルにオリジナルのデータを表示させる方法を紹介します。
Dummy_Manager では、ユーザが作成した (ダミーの) データモデルを取得し、それを Horizon のデータテーブルに格納し表示させています。具体的には dummy_manager/test_service.py の TestService を通して DummyData オブジェクトを取得し、当該オブジェクトのパラメータを Horizon の DataTable に格納してゆきます。
それでは、どのようにしてこうした処理を実施しているかの実装をみてゆきます。まず Mydashboard に Dummy_Manager を追加した処理ですが、mydashboard 以下の dashboard.py で定義している horizon.Dashboard を継承したクラスのクラス変数 panels に追加したパネルを追加します。default_panel パラメータでは "Mydashborad" ボタンが押された際に選択されるパネルを指定できます。各パラメータでは、Panel の実装が置かれているディレクトリ名を指定します。
1 2 3 4 5 |
class Mydashboard(horizon.Dashboard): name = _("Mydashboard") slug = "mydashboard" panels = ('skelton','dummy_manager') # Add your panels here. default_panel = 'dummy_manager' # Specify the slug of the dashboard's default panel. |
次に、選択されたパネルの中身ですが、ここでは TabGroup 及び Tab を省略し、ページに直接 Table を表示させています。Dummy_Manager のトップページビューは dummy_manager/views.py で定義しています。
1 2 3 4 5 6 |
class IndexView(tables.DataTableView): table_class = project_tables.DataSummaryTable template_name = 'mydashboard/dummy_manager/index.html' def get_data(self): return test_service.TestService().list() |
ここでは、ページに表示させるテーブルの実装 (データテーブル) の指定 (table_class パラメータ) と、表示させるデータのモデルの指定 (get_data メソッド) を行っています。ここで指定されたデータがデータテーブルでよしなに加工されて表示されます。尚、ここで指定している test_service というのはオリジナルの実装で、冒頭で紹介しましたデータオブジェクト (DummyData) からデータを取得したり、データを格納したりするサービスオブジェクトになります。こいつはサードパーティのサービスを擬似的に表現しているもので Horizon フレームワークとも関係ないため詳細な説明は割愛します。
次にデータテーブル側での処理について見て行きます。
テーブルのデータ表示
データテーブルに何をどのように表示させるかは、以下の tables.py の DataSummaryTable クラスで定義しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def _check_status(data_id): return True # This is dummy processing def get_status(data): ret = 'NG' if _check_status(data.id): ret = 'OK' return ret class DataSummaryTable(tables.DataTable): status = tables.Column(get_status, verbose_name= _("Status")) name = tables.Column('name', verbose_name= _("Name")) size = tables.Column('size', verbose_name= _("Size")) |
前節でも紹介した通り、ここではステータス、名前、サイズをそれぞれここで指定しています。Column クラスの第一引数にはメソッドも指定でき、メソッド (ないし高階関数) を指定した場合にはデータオブジェクト (この場合 DummyData) が引数に渡され、当該メソッドの返戻値が表示されます。
アクションについて
冒頭でも説明したように Horizon は、テーブルに対するアクションとして「テーブルアクション」と「ロー(Row)アクション」の2種類を用意しています。それぞれ、テーブルデータ全体に対する処理とテーブルの個々のデータに対する処理として使い分けます。Dummy_Manager ではそれぞれ "Create Data" ボタンによるデータオブジェクトの追加処理がテーブルアクションに、"Remove Data" ボタンによる対象データオブジェクトの削除処理がローアクションに対応します。以下ではそれぞれの実装について見て行きます。
まずアクションの指定ですが、それぞれ先ほど紹介したデータテーブル DataSummaryTable で定義しているメタクラス (Meta) のパラメータ (table_actions, row_actions) で指定します。
1 2 3 4 5 6 7 8 9 10 |
class DataSummaryTable(tables.DataTable): status = tables.Column(get_status, verbose_name= _("Status")) name = tables.Column('name', verbose_name= _("Name")) size = tables.Column('size', verbose_name= _("Size")) class Meta: name = "data_summary" verbose_name = _("DataSummary") table_actions = (CreateData,) row_actions = (RemoveData,) |
Horizon が用意しているアクションの形式には様々な種類がありますが、ここではそれぞれリンクボタンを表示しページ遷移を行う LinkAction を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class CreateData(tables.LinkAction): name = "create" verbose_name = _("Create Data") url = "horizon:mydashboard:dummy_manager:create" classes = ("ajax-modal",) icon = "plus" class RemoveData(tables.LinkAction): name = "delete" verbose_name = _("Remove Data") url = "horizon:mydashboard:dummy_manager:delete" classes = ("ajax-modal",) icon = "plus" |
両方ともほぼ同じ処理を行っており、それぞれ指定した URL に対して画面遷移を実施します。ただし、classes パラメータで "ajax-modal" を指定しているため、画面遷移する代わりに画面のモーダルビューが表示されます。url パラメータは遷移先画面のリンク先 URL を指定します。ここでは urls.py で定義した URL パターンの name パラメータで指定した値を設定しています。このパラメータが django の URL ディスパッチャの仕組みに渡って URL に変換してくれます。また、ここに相対パスを指定した場合にはダッシュボードの URL (/mydashboard) 以降のパスが入ります。
モーダルフォームの表示
テーブルアクション (CraeteData) を実行すると、views.py の CreateView で生成した画面が表示されます。CreateView は Horizon の ModalFormView クラスを継承しており、これはその名の通りモーダルフォームを表示させるオブジェクトになります。表示させるビューの中身 (レイアウト) については、template_name パラメータでテンプレート html ファイルを指定します。
1 2 3 4 |
class CreateView(forms.ModalFormView): form_class = project_forms.CreateForm template_name = 'mydashboard/dummy_manager/create.html' success_url = reverse_lazy("horizon:mydashboard:dummy_manager:index") |
template_name パラメータで指定したファイル名は当該パネルのディレクトリ (mydashboard/dummy_manager) の "templates" ディレクトリ以下の相対パスになります。なお、当該 HTTP リクエストが XMLHttpRequest によって生成された ajax リクエストだった場合、Horizon は template_name パラメータで指定されたファイル名に '_' のプレフィックスが付いたファイル (CreateView の場合 "_create.html") が読まれます。以下は ModalFormView が mix-in しているクラスにおけるテンプレートファイル名を取得するメソッド "get_template_names" の実装になります。
1 2 3 4 5 6 7 8 9 10 11 12 |
class ModalFormMixin(object): def get_template_names(self): if self.request.is_ajax(): if not hasattr(self, "ajax_template_name"): # Transform standard template name to ajax name (leading "_") bits = list(os.path.split(self.template_name)) bits[1] = "".join(("_", bits[1])) self.ajax_template_name = os.path.join(*bits) template = self.ajax_template_name else: template = self.template_name return template |
なので ModalFormView を利用する開発者は 2 種類のテンプレートファイルを用意しておく必要があります。
フォームの値設定と取得方法
表示させたフォームに入力フィールドを設定し、設定した入力フィールドから入力値を取得する方法を紹介します。
CreateView のクラス変数 form_class で指定した forms.py の CreateForm にて 2 つの入力フィールド (CharField, IntegerField) を指定しており、それぞれ名前が示す通り文字列入力と数値入力フィールドを表します。
1 2 3 |
class CreateForm(forms.SelfHandlingForm): name = forms.CharField(max_length=255, label=_("Name")) size = forms.IntegerField(label=_("Size [GB]")) |
開発者が上記のように SelfHandlingForm を継承したクラスのクラス変数でこれらの入力フィールドのクラス変数パラメータを設定し、ビューに Horizon が用意したフォームの入力フィールドを表示するテンプレート 'horizon/common/_form_fields.html' を呼び出せば、Horizon 側がよしなに入力フィールドを表示してくれます。
また入力フィールドに設定した値の取得ですが、同じく CreateForm に定義した handle メソッドによって当該 URL への POST リクエスト処理をハンドリングします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class CreateForm(forms.SelfHandlingForm): name = forms.CharField(max_length=255, label=_("Name")) size = forms.CharField(label=_("Size [GB]")) def handle(self, request, data): service = test_service.TestService() service.add(name=data['name'], size=data['size']) service.save() messages.success(request, _('Successfully to create (dummy) data object')) return True |
ここでは、(模擬)外部サービス "test_service" に対してデータの追加リクエストを発行し、画面にデータが作成された旨のメッセージを表示する処理を記述しています。handle メソッドが True を返した場合、CreateView で指定した success_url パラメータで指定した URL への転送を行い、False を返した場合には行われずに同じフォームが表示されます。
まとめ
ここまでで説明した内容を駆使することで、Horizon にサードパーティのシステムと連携した何らかの機構を作成できると思います。ここでは dummy_manager に実装したうちのごく一部しか解説しませんでしたが、興味があれば動かしながら実装を見てどんな手法が使われているか確認してみてください。同じような Horizon 拡張を作る際に役立つはずです。