Current section
Files
Jump to
Current section
Files
lib/smoke_test.ex
defmodule SmokeTest.Test do
defstruct [:id, :task, :timeout, :result]
end
defmodule SmokeTest do
@moduledoc """
A configurable Plug middleware to quickly find errors that might prevent a
deployment. It can also be used as an application health-check.
## Usage
SmokeTest can be easily configured for Plug or Phoenix servers. The only
options you need to provide is the OTP application that contains your
smoketest configuration.
### Plug
For pure Plug applications, simply use `Plug.Router.forward/2` on the
path you'd like to use for smoke-testing.
```
defmodule ExamplePlugWeb.Router do
use Plug.Router
forward "/ping", to: SmokeTest, init_opts: [otp_app: :example]
end
```
### Phoenix
For Phoenix applications, adding SmokeTest to your router is also a one-liner.
You can pass Smoketest into [`Phoenix.Router.forward/4`](https://hexdocs.pm/phoenix/Phoenix.Router.html#forward/4), choosing
whatever route you prefer for smoke testing.
```
defmodule ExamplePhoenixWeb.Router do
use ExampleWeb, :router
forward "/ping", SmokeTest, [otp_app: :example]
end
```
### Configuration
Configuration can be provided as part of your application config, or as a
keyword list in the plug itself. Options from your application config will
be overriden by explicit plug configuration.
Configuration values are expected to resolved at compile time.
### Registering Tests
Tests should be provided as a map with the following properties:
- `id` the id of the test.
- `test` either a three item tuple of `{ Module, :fun, [args]}` or an
anonymous function.
- `timeout` (optional) the amount of time (in ms) the test has to complete.
Defaults to 1000.
Each test should return a two-item tuple of `{:ok, term}` or `{:error, reason}`.
Items that don't fulfill this spec are marked as a failure.
```
tests: [
# Test "db" calls a Module.function(args), with a timeout of 5000 ms
%{ id: "db", test: {Module, :function, [args]}, timeout: 5000} ,
# Test "cluster" calls an anonymous function, with a timeout of 1500ms
%{ id: "cluster", test: fn -> :net_adm.names() end, timeout: 1500 },
# Test "other" calls another module, and uses the default timeout of 1000
%{ id: "other", test: {Other.Module, :function, [args]} },
]
```
Inlined into your configuration:
```
# application configuration
config :example, SmokeTest,
tests: [
%{ id: "db", test: {Module, :function, [args]}, timeout: 5000} ,
%{ id: "cluster", test: fn -> :net_adm.names() end, timeout: 1500 },
%{ id: "other", test: {Other.Module, :function, [args]} },
]
# plug configuration
forward "/ping", SmokeTest, [
otp_app: :example,
tests: [
%{ id: "db", test: {Module, :function, [args]}, timeout: 5000} ,
%{ id: "cluster", test: fn -> :net_adm.names() end, timeout: 1500 },
%{ id: "other", test: {Other.Module, :function, [args]} },
]
]
```
### Additional Configuration options
#### Response Status
The status to return on success and failure is also configurable. Success
defaults to 200, and failure defaults to 503
```
# application configuration
config :example, Smoketest,
success_status: 201,
failure_status: 500
# plug configuration
forward "/ping", SmokeTest, [
otp_app: :example,
success_status: 201,
failure_status: 500
]
```
#### Json Encoder
A json encoder is required to diplay output. A `Poison` adapter is provided by
default, and need not be configured if present in your deps. See `SmokeTest.Adapters.JSONEncoder.Poison`
for more information.
To explicitly configure your own adapter, add the following to your `SmokeTest`
configuration:
```
# application config
config :example, SmokeTest,
json_encoder: SmokeTest.Adapters.JSONEncoder.Poison
# plug config
forward "/ping", SmokeTest, [
otp_app: :example,
json_encoder: SmokeTest.Adapters.JSONEncoder.Poison
]
```
## Example JSON Output
Here's a quick example output of a failed smoketest.
```
{
"app":"example",
"status":"failures",
"version":"0.0.1",
"timeouts":[
{
"id": "cluster"
"result": "No response",
"timeout": 15000
}
],
"failures":[
{
"id":"DB"
"timeout":5000,
"result":"Argument Error"
}
],
}
```
And a successful one
```
{
"app":"example",
"status":"ok",
"version":"0.0.1",
}
```
## Encoders
The `Poison` JSON encoder is included with this module. If present, the
module will automatically include the adapter for you. See `SmokeTest.Adapters.JSONEncoder`
and `SmokeTest.Adapters.JSONEncoder.Poison` for more details.
"""
import Plug.Conn
alias SmokeTest.Adapters.JSONEncoder
@behaviour Plug
@default_encoder if Code.ensure_loaded?(Poison), do: JSONEncoder.Poison, else: nil
def init(opts \\ [])
def init([]) do
raise(otp_app_error())
end
def init(opts) do
otp_app = get_or_throw(opts, :otp_app, otp_app_error())
config = Application.get_env(otp_app, __MODULE__, [])
merged = Keyword.merge(config, opts)
encoder =
[{:json_encoder, @default_encoder}]
|> Keyword.merge(merged)
|> get_or_throw(:json_encoder, decoder_error())
{:ok, version} = :application.get_key(otp_app, :vsn)
merged
|> Keyword.put(:version, List.to_string(version))
|> Keyword.put(:json_encoder, encoder)
end
defp get_or_throw(opts, key, error) do
case Keyword.get(opts, key) do
nil -> throw(error)
otherwise -> otherwise
end
end
def call(conn, options) do
{app, options} = Keyword.pop(options, :otp_app)
{version, options} = Keyword.pop(options, :version)
{encoder, options} = Keyword.pop(options, :json_encoder)
{tests, options} = Keyword.pop(options, :tests, [])
{success_status, options} = Keyword.pop(options, :success_status, 200)
{failure_status, _options} = Keyword.pop(options, :failure_status, 503)
results =
tests
|> Enum.map(&run_test/1)
|> Enum.map(&await_tests/1)
|> Enum.group_by(
fn
%SmokeTest.Test{result: {:ok, _}} -> :ok
%SmokeTest.Test{result: {:timeout, _}} -> :timeouts
%SmokeTest.Test{result: {:error, _}} -> :failures
_ -> :failures
end,
fn
%{result: {_, result}} = test ->
%{id: test.id, result: result, timeout: test.timeout}
test ->
%{id: test.id, result: inspect(test.result), timeout: test.timeout}
end
)
|> Map.delete(:ok)
{status_code, status_text} = format_status(results, success_status, failure_status)
body =
Map.merge(results, %{
status: status_text,
app: app,
version: version
})
conn
|> put_resp_content_type("application/json")
|> send_resp(status_code, encoder.encode!(body))
end
defp format_status(%{failures: _}, _, status), do: {status, "failures"}
defp format_status(%{timeouts: _}, _, status), do: {status, "failures"}
defp format_status(_, status, _), do: {status, "ok"}
defp run_test(%{id: id, test: fun, timeout: timeout}) when is_function(fun) do
%SmokeTest.Test{id: id, task: Task.async(fun), timeout: timeout}
end
defp run_test(%{id: id, test: {mod, fun, args}, timeout: timeout}) do
%SmokeTest.Test{id: id, task: Task.async(mod, fun, args), timeout: timeout}
end
defp run_test(%{id: id, test: fun}) do
run_test(%{id: id, test: fun, timeout: 1000})
end
defp await_tests(test) do
result =
case Task.yield(test.task, test.timeout) || Task.shutdown(test.task) do
{:ok, result} -> result
{:exit, reason} -> {:error, reason}
nil -> {:timeout, "No response"}
end
Map.put(test, :result, result)
end
# Error messages
defp decoder_error do
"No JSON encoder specified in configuration, or plug options; Default JSON encoder is unavailable. Please include a JSON encoder adapter. See hexdocs for details."
end
defp otp_app_error do
"No OTP app specified in application config or plug options. Please specify an application holding the SmokeTest configuration."
end
end