Elixir Advent Calendar 2014 21日目。
PostgreSQLに接続する際に使用するpsql
コマンドが利用している、.pgpass
と.pg_service.conf
をElixirで読み込んで接続情報を取得する方法を書きたいと思います。
このような事をやりたかった理由は、Elixirのプロジェクト内に接続情報を保持したくなかったのと、psqlと同じ情報を利用したかったためです。
やりたい事
-
.pg_service.conf
に記述されているサービス名を引数に渡すと、.pgpass
/.pg_service.conf
から接続情報を取り出す。
準備
$HOMEに.pgpass
と.pg_service.conf
を作成しときます。
設定内容は以下の通り。
# hostname:port:database:username:password
*:*:admindb:*:passwd_admin
*:*:userdb:*:passwd_user
[srv_user]
host=awssrv.example.com
port=5432
dbname=userdb
user=admin
[srv_admin]
host=awssrv.example.com
port=5432
dbname=admindb
user=admin
前提条件
- 接続情報に対してデフォルト値を設けていないので、
.pg_service.conf
にはpassword
以外のすべての項目を設定する。 - マクロを使うのは
.pgpass
の情報を取得する部分のみ。 -
.pgpass
のpasswordには":"は入っていないこと。(エスケープに対応してないです。)
使用するライブラリ
.pg_service.conf
はini形式なので、読み込みにeconfigを使用します。
コードを書いてみる
.pg_service.conf読み込み部分
.pg_service.conf
に設定情報が無い場合、Application.get_env
でmix.exs
から取得してます。
defmodule Pg.Service do
def init do
:econfig.register_config(:pg_service, [config_file], [])
:econfig.subscribe(:pg_service)
end
def connection_data(dbtype) do
case :econfig.get_value(:pg_service, dbtype) do
[] ->
Application.get_env(:define_on_compile_phase, List.to_atom dbtype)
_ ->
[
hostname: :econfig.get_value(:pg_service, dbtype, 'host'),
username: :econfig.get_value(:pg_service, dbtype, 'user'),
password: :econfig.get_value(:pg_service, dbtype, 'password'),
database: :econfig.get_value(:pg_service, dbtype, 'dbname'),
port: List.to_integer(:econfig.get_value(:pg_service, dbtype, 'port')),
]
end
end
defp config_file do
String.to_char_list(System.user_home) ++ '/.pg_service.conf'
end
end
.pgpassのパスワード取得のパターンマッチングに使用するStruct
defmodule Pg.PassStruct do
defstruct [hostname: nil, port: nil, database: nil, username: nil, password: nil]
end
.pgpass読み込み部分
このファイルの中には、Pg.ReadPgpass
とPg.Password
の二つのmoduleが含まれています。
ファイルを分けなかった理由は、コンパイル時にPg.Password
の内部でマクロが実行されPg.ReadPgpass
に依存している為です。
(ファイルを分割したら、コンパイルエラーになります。)
マクロを使用しているのは、Pg.Password
moduleで、
Pg.PassStructを使ってパターンマッチング出来るget
関数を定義して、マッチするとパスワードを返す様になってます。
defmodule Pg.ReadPgpass do
def get do
{:ok, bin} = File.read(config_file)
bin |> String.split("\n") |> resolution
end
defp config_file do
String.to_char_list(System.user_home) ++ '/.pgpass'
end
defp resolution(config_arr), do: resolution(config_arr, [])
defp resolution([], acc), do: acc
defp resolution([""|t], acc), do: resolution(t, acc)
defp resolution([<<"#", _rest::binary>>|t], acc), do: resolution(t, acc)
defp resolution([h|t], acc) do
ltuple = String.split(h, ":") |> List.to_tuple
{_, r} = {ltuple, %Pg.PassStruct{}} |>
set_hostname |>
set_port |>
set_database |>
set_username |>
set_password
resolution(t, [r|acc])
end
defp set_hostname({t, r}) do
case (t |> elem 0) do
"*" -> {t, r}
v -> {t, Map.put(r, :hostname, v |> String.to_char_list)}
end
end
defp set_port({t, r}) do
case (t |> elem 1) do
"*" -> {t, r}
v -> {t, Map.put(r, :port, v |> String.to_integer)}
end
end
defp set_database({t, r}) do
case (t |> elem 2) do
"*" -> {t, r}
v -> {t, Map.put(r, :database, v |> String.to_char_list)}
end
end
defp set_username({t, r}) do
case (t |> elem 3) do
"*" -> {t, r}
v -> {t, Map.put(r, :username, v |> String.to_char_list)}
end
end
defp set_password({t, r}) do
case (t |> elem 4) do
"*" -> {t, r}
v -> {t, Map.put(r, :password, v |> String.to_char_list)}
end
end
end
defmodule Pg.Password do
list = Pg.ReadPgpass.get
def list_to_struct(list) do
%Pg.PassStruct{
hostname: list[:hostname],
port: list[:port],
database: list[:database],
username: list[:username],
}
end
Enum.each list, fn(pgpass) ->
opt = Keyword.new([hostname: pgpass.hostname, port: pgpass.port, database: pgpass.database, username: pgpass.username])
opt = Enum.reject opt, fn(y) ->
case y do
{_, nil} -> true
_ -> false
end
end
password = pgpass.password
def get(%Pg.PassStruct{unquote_splicing(opt)}=_val) do
unquote(password)
end
end
end
動作確認
% iex -S mix
Erlang/OTP 17 [erts-6.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]
Interactive Elixir (1.1.0-dev) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Pg.Service.init
true
iex(2)> connection_info = Pg.Service.connection_data("srv_admin")
[hostname: 'awssrv.example.com', username: 'admin', password: :undefined,
database: 'admindb', port: 5432]
iex(3)> password = connection_info |> Pg.Password.list_to_struct |> Pg.Password.get
'passwd_admin'
iex(4)> Keyword.put connection_info, :password, password
[password: 'passwd_admin', hostname: 'awssrv.example.com', username: 'admin',
database: 'admindb', port: 5432]
iex(5)>
まとめ
- 気をつけないといけないのは、
.pgpass
を更新してもコンパイルしたモジュールは古いパスワードを返します。
更新した場合は、mix do clean, compile
を行う必要があります。 - Stage環境/本番環境できっちり
.pgpass
と.pg_service.conf
を使い分けている場合、本番環境でコンパイルしないといけません。Stage環境で作成したモジュールを本番に持って行っても、.pgpass
の情報はStage環境のママです。
あと同じ考え方で、AWSのキー/証明書等をを取り込ませたりする事も出来ると思います。
もっと最適化したいな。
明日は @keithseahus さんです。