Elixir
ElixirDay 21

マクロを使ってPostgreSQLの接続情報を取得する。

More than 3 years have passed since last update.

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を作成しときます。
設定内容は以下の通り。

.pgpass
# hostname:port:database:username:password
*:*:admindb:*:passwd_admin
*:*:userdb:*:passwd_user
.pg_service.conf
[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_envmix.exsから取得してます。

lib/pg/service.ex
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

lib/pg/pass_struct.ex
defmodule Pg.PassStruct do
  defstruct [hostname: nil, port: nil, database: nil, username: nil, password: nil]
end

.pgpass読み込み部分

このファイルの中には、Pg.ReadPgpassPg.Passwordの二つのmoduleが含まれています。
ファイルを分けなかった理由は、コンパイル時にPg.Passwordの内部でマクロが実行されPg.ReadPgpassに依存している為です。
(ファイルを分割したら、コンパイルエラーになります。)

マクロを使用しているのは、Pg.Password moduleで、
Pg.PassStructを使ってパターンマッチング出来るget関数を定義して、マッチするとパスワードを返す様になってます。

lib/pg/read_pgpass.ex
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
% 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 さんです。