xerions/conform

Name: conform

Owner: xerions

Description: Easy release configuration for Elixir apps!

Created: 2015-09-23 10:50:01.0

Updated: 2016-01-15 07:23:08.0

Pushed: 2016-05-11 07:49:59.0

Homepage: null

Size: 215

Language: Elixir

GitHub Committers

UserMost Recent Commit# Commits

Other Committers

UserEmailMost Recent Commit# Commits

README

Conform

Master

The definition of conform is “Adapt or conform oneself to new or different conditions”. As this library is used to adapt your application to its deployed environment, I think it's rather fitting. It's also a play on the word configuration, and the fact that Conform uses an init-style configuration, maintained in a .conf file.

Conform is a library for Elixir applications. Its original intended use is in exrm as means of providing a simplified configuration file for deployed releases, but is flexible enough to work for any use case where you want init-style configuration translated to Elixir/Erlang terms. It is inspired directly by basho/cuttlefish, and in fact uses its .conf parser. Beyond that, you can look at conform as a reduced (but growing!) implementation of cuttlefish in Elixir.

Usage

You can use Conform either via its API, which is simple and easy to pick up, or by building the escript and running that via the command line.

Running the escript's help, you'll see how it's used:

conform --help
orm - Translate the provided .conf file to a .config file using the given schema
---
e: conform --conf foo.conf --schema foo.schema.exs [options]

ons:
filename <name>:    Names the output file <name>.config
output-dir <path>:  Outputs the .config file to <path>/<sys|name>.config
config <config>:    Merges the translated configuration over the top of
                    <config> before output
 | --help:          Prints this help

Conform also provides some mix tasks for generating and viewing configuration:

There are additional options for these tasks, use mix help <task> to view their documentation.

Conf files and Schema files

The conform .conf file looks something like the following:

oose the logging level for the console backend.
lowed values: info, error
r.handlers.console.level = info

ecify the path to the error log for the file backend
r.handlers.file.error = /var/log/error.log

ecify the path to the console log for the file backend
r.handlers.file.info = /var/log/console.log

mote database hosts
p.db.hosts = 127.0.0.1:8000, 127.0.0.2:8001

st some atom.
p.some_val = foo

termine the type of thing.
all:  use everything
some: use a few things
none: use nothing
lowed values: all, some, none
p.another_val = all

mplex data types with wildcard support
lex_list.first.username = "username1"
lex_list.first.age = 20
lex_list.second.username = "username2"
lex_list.second.age = 40

Short and sweet, and most importantly, easy for sysadmins and users to understand and modify. The real power of conform though is when you dig into the schema file. It allows you to define documentation, mappings between friendly setting names and specific application settings in the underlying sys.config, define validation of values via datatype specifications, provide default values, and transform simplified values from the .conf into something more meaningful to your application using translation functions.

A schema is basically a single data structure. A keyword list, containing two top-level properties, mappings, and translations. Before we dive in, here's the schema for the .conf file above:


ppings: [
"lager.handlers.console.level": [
  doc: """
  Choose the logging level for the console backend.
  """,
  to: "lager.handlers",
  datatype: [enum: [:info, :error]],
  default: :info
],
"lager.handlers.file.error": [
  doc: """
  Specify the path to the error log for the file backend
  """,
  to: "lager.handlers",
  datatype: :binary,
  default: "/var/log/error.log"
],
"lager.handlers.file.info": [
  doc: """
  Specify the path to the console log for the file backend
  """,
  to: "lager.handlers",
  datatype: :binary,
  default: "/var/log/console.log"
],
"myapp.db.hosts": [
  doc: "Remote database hosts",
  to: "myapp.db.hosts",
  datatype: [list: :ip],
  default: [{"127.0.0.1", "8001"}]
],
"myapp.some_val": [
  doc:      "Just some atom.",
  to:       "myapp.some_val",
  datatype: :atom,
  default:  :foo
],
"my_app.complex_list.*": [
  to: "my_app.complex_list",
  datatype: [:complex],
  default: []
],
"my_app.complex_list.*.type": [
  to: "my_app.complex_list",
  datatype: :atom,
  default:  :undefined
],
"my_app.complex_list.*.age": [
  to: "my_app.complex_list",
  datatype: :integer,
  default: 30
]


anslations: [
"myapp.another_val": fn val ->
  case _mapping, val do
    :all  -> {:on, [debug: true, tracing: true]}
    :some -> {:on, [debug: true]}
    :none -> {:off, []}
    _     -> {:off, []}
  end
end,
"lager.handlers.console.level": fn
  _mapping, level, nil when level in [:info, :error] ->
      [lager_console_backend: level]
  _mapping, level, acc when level in [:info, :error] ->
      acc ++ [lager_console_backend: level]
  _, level, _ ->
    IO.puts("Unsupported console logging level: #{level}")
    exit(1)
end,
"lager.handlers.file.error": fn
  _, path, nil ->
    [lager_file_backend: [file: path, level: :error]]
  _, path, acc ->
    acc ++ [lager_file_backend: [file: path, level: :error]]
end,
"lager.handlers.file.info": fn
  _, path, nil ->
    [lager_file_backend: [file: path, level: :info]]
  _, path, acc ->
    acc ++ [lager_file_backend: [file: path, level: :info]]
end,
"my_app.complex_list.*": fn _, {key, value_map}, acc ->
[[name: key,
  username: value_map[:username],
  age:  value_map[:age]
 ] | acc]
end


This looks pretty daunting, but I've provided mix tasks to help you generate the schema from your existing config.exs file. Once you've gotten the schema tightened up though, you'll start to understand why it's worth a little extra effort up front. So schemas consist of two types of things: mappings and translations. Mappings are defined by four properties:

After a mapping is parsed according to its schema definition, if a translation function with an arity of 2 or 3 exists for that mapping, the function is called with the following parameters: mapping and value, and optionally accumulator, if you provide a translation function with an arity of 3. The mapping parameter is basically what you would expect – the mapping for the setting associated with the currently executing translation. The value is of course the value you are translating. accumulator is a bit different, but works the way it sounds. If you provide multiple mappings with the same to path, then translations for those mappings will receive an accumulated value for that config setting. In the example above, you can see I defined multiple mappings that all had different names, but pointed to the same underlying field. Each translation handles both the case where the accumulator is nil, or already contains a list of values. Let's take a look at what the final output of the combined .conf and schema files will look like.

ger, [
andlers, [
{lager_console_backend, info},
{lager_file_backend, [{file, "var/log/error.log"},    {level, error}]},
{lager_file_backend, [{file, "/var/log/console.log"}, {level, info}]}
]},
app, [
nother_val, {on, [{debug, true}, {tracing, true}]}},
ome_val, foo},
b, [{hosts, [{"127.0.0.1", "8001"}]}]},
omplex_list: [first: %{age: 20, username: "username1"},
              second: %{age: 40, username: "username2"}]]

As you can see, if your sysadmins had to work with the above, versus the .conf, it would be quite prone to mistakes, and much harder to understand, particularly with the lack of comments or documentation.

If you are using exrm and need to import any applications from the your_app/deps, you can update your you_app.schema.exs with the import:


import: [
    :my_app_dep1,
    :my_app_dep2
],

mappings: [
    ...
    ...
    ...
],

translations: [
    ...
    ...
    ...
]

Will be created archive with the myapp.schema.ez name in the your release which will contain the my_app_dep1 and my_app_dep2 applications. During the sys.config will be generated by conform script, the applications from the archive will be loaded and you can use any public API from these applications in the translations. Conform also allows to use a schema with imports without exrm. There is the special conform.archive mix task that takes one parameter - path of the schema:

conform.archive myapp/config/myapp.schema.exs

Conform will collect dependencies which are pointed in the import: [....] and compress them to the myapp/config/myapp.schema.ez archive. After this you can use the conform script as always:

conform.new --conf myapp/config/myapp.conf --schema myapp/config/myapp.schema.exs

I've also provided mix tasks to handle generating your initial .conf and .schema.exs files, which includes the default options, and the documentation. The end result is an easy to maintain configuration file for your users, and ideally, a powerful tool for managing your own configuration as well.

Custom data types

Conform provides ability to use custom data types in your schemas:


mappings: [
  "myapp.val1": [
    doc: "Provide some documentation for val1",
    to: "myapp.val1",
    datatype: MyModule1,
    default: 100
  ],
  "myapp.val2": [
    doc: "Provide some documentation for val2",
    to: "myapp.val2",
    datatype: [{MyModule2, [:dev, :prod, :test]}],
    default: :dev
  ]
],

translations: [
   ...
   ...
   ...
]

Where MyModule1 and MyModule2 must be modules which implement the Conform.Type behaviour:

odule MyModule1 do
e Conform.Type

You can return a string representing the documentation you wish to use,
or false to use what is provided in the schema, under :doc. If you return
documentation here, it will be appended to whatever is contained in :doc
f to_doc(values) do
"Document your custom type here"
d

Equivalent to the translation function in the schema
f translate(mapping, val, _acc) do
val
d

Since conform only parses values as binaries, use this function to
convert to your desired datatype. This is called prior to `translate/3`
Return {:ok, val} or {:error, reason}
f parse_datatype(_key, val) when is_list(val) do
{:ok, List.to_atom(val)}
d
f parse_datatype(_key, val) do
{:ok, val}
d


Rationale

Conform is a library for Elixir applications, specifically in the release phase. Elixir already offers a convenient configuration mechanism via config/config.exs, but it has downsides:

Conform is intended to fix these problems in the following way:

I'm glad to hear from anyone using this on what problems they are having, if any, and any ideas you may have. Feel free to open issues on the tracker or come find me in #elixir-lang on freenode.

License

The .conf parser in conf_parse.peg is licensed under Apache 2.0, per Basho. The rest of this project is licensed under the MIT license. Use as you see fit.


This work is supported by the National Institutes of Health's National Center for Advancing Translational Sciences, Grant Number U24TR002306. This work is solely the responsibility of the creators and does not necessarily represent the official views of the National Institutes of Health.