Compare commits
122 commits
Author | SHA1 | Date | |
---|---|---|---|
3fb227f5d9 | |||
d74c8e3cb3 | |||
fad27f142e | |||
b96516405e | |||
a191077cc4 | |||
de29cb2b51 | |||
d9fe59958e | |||
07c41153f5 | |||
dfe52b6e74 | |||
c5a337ca4a | |||
c440a88bdc | |||
4ad256acdd | |||
de160bd70c | |||
11b8444b96 | |||
60664ff5cc | |||
d8e7e0c3a0 | |||
e43c31997e | |||
f739d4b7e1 | |||
cd489acd63 | |||
09433f8e26 | |||
adc2453081 | |||
cac6f82746 | |||
8cf2027f28 | |||
638badbba8 | |||
a701a200b0 | |||
b11b37078e | |||
7ffec1bed6 | |||
19f4a9b387 | |||
c016c5a8ae | |||
59ae3a59cf | |||
331827d6b4 | |||
c6b7c98930 | |||
08976af06c | |||
69a32accd4 | |||
0dd15ad75e | |||
6bf61d35f1 | |||
ec239d1e57 | |||
bbc6f05d20 | |||
82e4b461eb | |||
384257a33f | |||
d4d38b7b34 | |||
57e0f63ee8 | |||
6eb6fd6a1f | |||
90c60759ca | |||
886e032c05 | |||
3545d01262 | |||
e1e796e234 | |||
d7d16fd925 | |||
0c9137cfe9 | |||
204dd61258 | |||
51d100898c | |||
b6e2337dab | |||
304fd0bcd4 | |||
1008da98a4 | |||
5d8be9ae42 | |||
2be599db1b | |||
e32b2a868d | |||
6009a19705 | |||
055b0ca2d5 | |||
b8b5623a2f | |||
4766d48dcb | |||
db3abee0fb | |||
99aaa5d57d | |||
4ce0d89652 | |||
d78dca56f6 | |||
989d48c46d | |||
27858f1474 | |||
7093ae3517 | |||
6e63fa3961 | |||
fe61ed4e1a | |||
7e5ec8c11c | |||
ac3440d512 | |||
bbf213825b | |||
5a411f54ee | |||
f3c49db9db | |||
c6f84bb1c5 | |||
5c70adaaff | |||
35dce07e77 | |||
4747c50673 | |||
b35d678050 | |||
8791e1228d | |||
5d491362d2 | |||
2e9f41c00d | |||
3ed6b64e80 | |||
2b79e3cb10 | |||
25fb114808 | |||
c672e2c4f4 | |||
7e2f0f2182 | |||
41ac07b8f1 | |||
0d186e834a | |||
306a0d7935 | |||
2736fdbbd1 | |||
e48f26dd85 | |||
b59af873e8 | |||
5cfb9a4a7d | |||
52632fe88e | |||
d95e23d727 | |||
25c9003193 | |||
ccdfce9850 | |||
d57e6a0b7f | |||
5ec7cf267a | |||
48bc11159a | |||
3383898512 | |||
ca1a344057 | |||
3312d02642 | |||
e4224a0daf | |||
49c30c4dde | |||
3ebb9843da | |||
f192b9fd5f | |||
76f2a299f8 | |||
37a064c507 | |||
8358d2de28 | |||
c142bdbc1a | |||
545384b149 | |||
b269855cb0 | |||
a381ca4b34 | |||
87bc601fb7 | |||
c32dc0a22c | |||
2d2cbad0a9 | |||
9c2aaa62f3 | |||
fb11032695 | |||
8f759bf0db |
58 changed files with 6977 additions and 330 deletions
34
.check.exs
Normal file
34
.check.exs
Normal file
|
@ -0,0 +1,34 @@
|
|||
[
|
||||
## don't run tools concurrently
|
||||
# parallel: false,
|
||||
|
||||
## don't print info about skipped tools
|
||||
# skipped: false,
|
||||
|
||||
## always run tools in fix mode (put it in ~/.check.exs locally, not in project config)
|
||||
# fix: true,
|
||||
|
||||
## don't retry automatically even if last run resulted in failures
|
||||
# retry: false,
|
||||
|
||||
## list of tools (see `mix check` docs for a list of default curated tools)
|
||||
tools: [
|
||||
## curated tools may be disabled (e.g. the check for compilation warnings)
|
||||
# {:compiler, false},
|
||||
|
||||
## ...or have command & args adjusted (e.g. enable skip comments for sobelow)
|
||||
{:sobelow, false},
|
||||
|
||||
## ...or reordered (e.g. to see output from dialyzer before others)
|
||||
# {:dialyzer, order: -1},
|
||||
|
||||
## ...or reconfigured (e.g. disable parallel execution of ex_unit in umbrella)
|
||||
# {:ex_unit, umbrella: [parallel: false]},
|
||||
|
||||
## custom new tools may be added (Mix tasks or arbitrary commands)
|
||||
# {:my_task, "mix my_task", env: %{"MIX_ENV" => "prod"}},
|
||||
# {:my_tool, ["my_tool", "arg with spaces"]}
|
||||
{:spark_formatter, "mix spark.formatter --check"},
|
||||
{:spark_cheat_sheets, "mix spark.cheat_sheets --check"}
|
||||
]
|
||||
]
|
5
.dialyzer_ignore.exs
Normal file
5
.dialyzer_ignore.exs
Normal file
|
@ -0,0 +1,5 @@
|
|||
[
|
||||
{"lib/wayfarer/server/proxy.ex", :unknown_function},
|
||||
{"lib/wayfarer/target/check.ex", :unknown_function},
|
||||
{"test/support/http_request.ex", :unknown_function}
|
||||
]
|
17
.doctor.exs
Normal file
17
.doctor.exs
Normal file
|
@ -0,0 +1,17 @@
|
|||
%Doctor.Config{
|
||||
ignore_modules: [
|
||||
~r/^Inspect\./,
|
||||
~r/^Support\./
|
||||
],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 40,
|
||||
min_module_spec_coverage: 0,
|
||||
min_overall_doc_coverage: 50,
|
||||
min_overall_spec_coverage: 0,
|
||||
min_overall_moduledoc_coverage: 100,
|
||||
exception_moduledoc_required: true,
|
||||
raise: false,
|
||||
reporter: Doctor.Reporters.Full,
|
||||
struct_type_spec_required: true,
|
||||
umbrella: false
|
||||
}
|
29
.drone.yml
29
.drone.yml
|
@ -49,7 +49,7 @@ steps:
|
|||
- .rebar3
|
||||
|
||||
- name: install dependencies
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
pull: "always"
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
|
@ -122,7 +122,7 @@ steps:
|
|||
- .rebar3
|
||||
|
||||
- name: mix compile
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -135,7 +135,7 @@ steps:
|
|||
- asdf mix compile --warnings-as-errors
|
||||
|
||||
- name: mix test
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -148,7 +148,7 @@ steps:
|
|||
- asdf mix test
|
||||
|
||||
- name: mix credo
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -161,7 +161,7 @@ steps:
|
|||
- asdf mix credo --strict
|
||||
|
||||
- name: mix hex.audit
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -174,7 +174,7 @@ steps:
|
|||
- asdf mix hex.audit
|
||||
|
||||
- name: mix format
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -187,7 +187,7 @@ steps:
|
|||
- asdf mix format --check-formatted
|
||||
|
||||
- name: mix deps.unlock
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -200,7 +200,7 @@ steps:
|
|||
- asdf mix deps.unlock --check-unused
|
||||
|
||||
- name: mix doctor
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -213,7 +213,7 @@ steps:
|
|||
- asdf mix doctor --full
|
||||
|
||||
- name: mix dialyzer
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -226,7 +226,7 @@ steps:
|
|||
- asdf mix dialyzer
|
||||
|
||||
- name: mix git_ops.check_message
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
|
@ -240,7 +240,7 @@ steps:
|
|||
- asdf mix git_ops.check_message .last_commit_message
|
||||
|
||||
- name: mix git_ops.release
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
|
@ -280,7 +280,7 @@ steps:
|
|||
- fi
|
||||
|
||||
- name: build artifacts
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
@ -327,7 +327,7 @@ steps:
|
|||
settings:
|
||||
api_key:
|
||||
from_secret: DRONE_TOKEN
|
||||
base_url: https://code.harton.nz
|
||||
base_url: https://harton.dev
|
||||
files: artifacts/*.tar.gz
|
||||
checksum: sha256
|
||||
|
||||
|
@ -351,12 +351,11 @@ steps:
|
|||
commands:
|
||||
- mc alias set store $${S3_ENDPOINT} $${ACCESS_KEY} $${SECRET_KEY}
|
||||
- mc mb -p store/docs.harton.nz
|
||||
- mc anonymous set download store/docs.harton.nz
|
||||
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
|
||||
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}
|
||||
|
||||
- name: hex release
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
image: harton.dev/james/asdf_container:latest
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
|
|
@ -1,4 +1,57 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
spark_locals_without_parens = [
|
||||
algorithm: 1,
|
||||
certfile: 1,
|
||||
check: 0,
|
||||
check: 1,
|
||||
check: 2,
|
||||
cipher_suite: 1,
|
||||
config: 0,
|
||||
config: 1,
|
||||
config: 2,
|
||||
connect_timeout: 1,
|
||||
health_checks: 0,
|
||||
health_checks: 1,
|
||||
host_patterns: 0,
|
||||
host_patterns: 1,
|
||||
hostname: 1,
|
||||
http: 2,
|
||||
http: 3,
|
||||
http_1_options: 1,
|
||||
http_2_options: 1,
|
||||
https: 2,
|
||||
https: 3,
|
||||
interval: 1,
|
||||
keyfile: 1,
|
||||
listeners: 0,
|
||||
listeners: 1,
|
||||
method: 1,
|
||||
name: 1,
|
||||
path: 1,
|
||||
pattern: 1,
|
||||
pattern: 2,
|
||||
plug: 1,
|
||||
plug: 2,
|
||||
response_timeout: 1,
|
||||
scheme: 1,
|
||||
success_codes: 1,
|
||||
targets: 0,
|
||||
targets: 1,
|
||||
thousand_island_options: 1,
|
||||
threshold: 1,
|
||||
transport: 1,
|
||||
websocket_options: 1,
|
||||
ws: 2,
|
||||
ws: 3,
|
||||
wss: 2,
|
||||
wss: 3
|
||||
]
|
||||
|
||||
[
|
||||
import_deps: [:spark],
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
|
||||
plugins: [Spark.Formatter],
|
||||
locals_without_parens: spark_locals_without_parens,
|
||||
export: [
|
||||
locals_without_parens: spark_locals_without_parens
|
||||
]
|
||||
]
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
erlang 26.1.2
|
||||
elixir 1.15.6
|
||||
erlang 27.0.1
|
||||
elixir 1.17.2
|
||||
hey 0.1.4
|
||||
|
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cSpell.words": ["ntoa"]
|
||||
}
|
62
CHANGELOG.md
62
CHANGELOG.md
|
@ -5,7 +5,65 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
|
|||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v0.2.0](https://code.harton.nz/james/smokestack/compare/v0.1.0...v0.2.0) (2023-10-14)
|
||||
## [v0.6.1](https://harton.dev/james/wayfarer/compare/v0.6.0...v0.6.1) (2024-08-27)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Proxy: don't crash for responses with no body.
|
||||
|
||||
## [v0.6.0](https://harton.dev/james/wayfarer/compare/v0.5.0...v0.6.0) (2024-08-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Add request telemetry (#114)
|
||||
|
||||
## [v0.5.0](https://harton.dev/james/wayfarer/compare/v0.4.0...v0.5.0) (2024-08-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Add support for proxying websockets.
|
||||
|
||||
## [v0.4.1](https://harton.dev/james/wayfarer/compare/v0.4.0...v0.4.1) (2024-06-23)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* incorrect handling of Mint stream failure.
|
||||
|
||||
## [v0.4.0](https://harton.dev/james/wayfarer/compare/v0.3.0...v0.4.0) (2023-11-19)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* add proxying. (#7)
|
||||
|
||||
## [v0.3.0](https://harton.dev/james/wayfarer/compare/v0.2.0...v0.3.0) (2023-10-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Target: Add healthy-checking HTTP targets.
|
||||
|
||||
### Improvements:
|
||||
|
||||
* Listener: Register listeners with scheme, address and port.
|
||||
|
||||
## [v0.2.0](https://harton.dev/james/wayfarer/compare/v0.1.0...v0.2.0) (2023-10-14)
|
||||
|
||||
|
||||
|
||||
|
@ -14,7 +72,7 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
|
|||
|
||||
* Add ability to start and stop HTTP listeners. (#1)
|
||||
|
||||
## [v0.1.0](https://code.harton.nz/james/smokestack/compare/v0.1.0...v0.1.0) (2023-10-13)
|
||||
## [v0.1.0](https://harton.dev/james/wayfarer/compare/v0.1.0...v0.1.0) (2023-10-13)
|
||||
|
||||
|
||||
|
||||
|
|
21
README.md
21
README.md
|
@ -1,6 +1,6 @@
|
|||
# Wayfarer
|
||||
|
||||
[![Build Status](https://drone.harton.nz/api/badges/bivouac/wayfarer/status.svg?ref=refs/heads/main)](https://drone.harton.nz/bivouac/wayfarer)
|
||||
[![Build Status](https://drone.harton.dev/api/badges/james/wayfarer/status.svg?ref=refs/heads/main)](https://drone.harton.dev/james/wayfarer)
|
||||
[![Hippocratic License HL3-FULL](https://img.shields.io/static/v1?label=Hippocratic%20License&message=HL3-FULL&labelColor=5e2751&color=bc8c3d)](https://firstdonoharm.dev/version/3/0/full.html)
|
||||
|
||||
Wayfarer is a runtime-configurable HTTP reverse proxy using
|
||||
|
@ -9,22 +9,31 @@ Wayfarer is a runtime-configurable HTTP reverse proxy using
|
|||
|
||||
## Status
|
||||
|
||||
Wayfarer is yet to handle it's first HTTP request. Please hold.
|
||||
Wayfarer is able to proxy HTTP/1, HTTP/2 and WebSocket requests. There are
|
||||
probably still edge cases and bugs, but it can be used.
|
||||
|
||||
## Installation
|
||||
|
||||
Wayfarer is not yet available on Hex, so you will need to add it as a Git
|
||||
dependency in your app:
|
||||
Wayfarer is [available in Hex](https://hex.pm/packages/wayfarer), the package
|
||||
can be installed by adding `wayfarer` to your list of dependencies in `mix.exs`:
|
||||
|
||||
```elixir
|
||||
def deps do
|
||||
[
|
||||
{:wayfarer, git: "https://code.harton.nz/bivouac/wayfarer.git", tag: "v0.1.0"}
|
||||
{:wayfarer, "~> 0.4.1"}
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
Documentation for `main` is always available on [my docs site](https://docs.harton.nz/bivouac/wayfarer/Wayfarer.html).
|
||||
Documentation for the latest release can be found on
|
||||
[HexDocs](https://hexdocs.pm/wayfarer) and for the `main` branch on
|
||||
[docs.harton.nz](https://docs.harton.nz/james/wayfarer).
|
||||
|
||||
## Github Mirror
|
||||
|
||||
This repository is mirrored [on Github](https://github.com/jimsynz/wayfarer)
|
||||
from it's primary location [on my Forgejo instance](https://harton.dev/james/wayfarer).
|
||||
Feel free to raise issues and open PRs on Github.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -3,10 +3,14 @@ import Config
|
|||
config :git_ops,
|
||||
mix_project: Mix.Project.get!(),
|
||||
changelog_file: "CHANGELOG.md",
|
||||
repository_url: "https://code.harton.nz/james/smokestack",
|
||||
repository_url: "https://harton.dev/james/wayfarer",
|
||||
manage_mix_version?: true,
|
||||
version_tag_prefix: "v",
|
||||
manage_readme_version: "README.md"
|
||||
|
||||
config :spark, :formatter, remove_parens?: true
|
||||
|
||||
config :wayfarer,
|
||||
start_listeners?: config_env() != :test
|
||||
start_listener_supervisor?: config_env() != :test,
|
||||
start_server_supervisor?: config_env() != :test,
|
||||
start_target_supervisor?: config_env() != :test
|
||||
|
|
1240
documentation/dsls/DSL:-Wayfarer.md
Normal file
1240
documentation/dsls/DSL:-Wayfarer.md
Normal file
File diff suppressed because one or more lines are too long
|
@ -2,4 +2,6 @@ defmodule Wayfarer do
|
|||
@moduledoc """
|
||||
Documentation for `Wayfarer`.
|
||||
"""
|
||||
|
||||
use Spark.Dsl, default_extensions: [extensions: Wayfarer.Dsl]
|
||||
end
|
||||
|
|
|
@ -7,13 +7,17 @@ defmodule Wayfarer.Application do
|
|||
@spec start(any, any) :: {:error, any} | {:ok, pid}
|
||||
def start(_type, _args) do
|
||||
[]
|
||||
|> start_listeners?()
|
||||
|> maybe_add_child(Wayfarer.Target.Supervisor, :start_target_supervisor?)
|
||||
|> maybe_add_child(Wayfarer.Listener.Supervisor, :start_listener_supervisor?)
|
||||
|> maybe_add_child(Wayfarer.Server.Supervisor, :start_server_supervisor?)
|
||||
|> Supervisor.start_link(strategy: :one_for_one, name: Wayfarer.Supervisor)
|
||||
end
|
||||
|
||||
defp start_listeners?(children) do
|
||||
if Application.get_env(:wayfarer, :start_listeners?, true) do
|
||||
Enum.concat(children, [Wayfarer.Listener.Supervisor])
|
||||
defp maybe_add_child(children, child_spec, option) do
|
||||
:wayfarer
|
||||
|> Application.get_env(option, true)
|
||||
|> if do
|
||||
Enum.concat(children, [child_spec])
|
||||
else
|
||||
children
|
||||
end
|
||||
|
|
22
lib/wayfarer/dsl.ex
Normal file
22
lib/wayfarer/dsl.ex
Normal file
|
@ -0,0 +1,22 @@
|
|||
defmodule Wayfarer.Dsl do
|
||||
alias Spark.Dsl.{Extension, Section}
|
||||
alias Wayfarer.Dsl.{Config, Transformer}
|
||||
|
||||
@sections [
|
||||
%Section{
|
||||
name: :wayfarer,
|
||||
top_level?: true,
|
||||
entities: Config.entities()
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
The Wayfarer DSL for defining static proxy configurations.
|
||||
|
||||
## DSL options
|
||||
|
||||
#{Extension.doc(@sections)}
|
||||
"""
|
||||
|
||||
use Extension, sections: @sections, transformers: [Transformer]
|
||||
end
|
69
lib/wayfarer/dsl/config.ex
Normal file
69
lib/wayfarer/dsl/config.ex
Normal file
|
@ -0,0 +1,69 @@
|
|||
defmodule Wayfarer.Dsl.Config do
|
||||
@moduledoc """
|
||||
The struct for storing configurations as generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Dsl.{HealthChecks, HostPatterns, Listeners, Targets}
|
||||
|
||||
defstruct __identifier__: nil,
|
||||
health_checks: HealthChecks.init(),
|
||||
host_patterns: HostPatterns.init(),
|
||||
listeners: Listeners.init(),
|
||||
name: nil,
|
||||
targets: Targets.init()
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
__identifier__: any,
|
||||
health_checks: HealthChecks.t(),
|
||||
host_patterns: HostPatterns.t(),
|
||||
listeners: Listeners.t(),
|
||||
name: nil | String.t(),
|
||||
targets: Targets.t()
|
||||
}
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :config,
|
||||
target: __MODULE__,
|
||||
schema: [
|
||||
name: [
|
||||
type: :string,
|
||||
required: false
|
||||
]
|
||||
],
|
||||
entities: [
|
||||
health_checks: HealthChecks.entities(),
|
||||
host_patterns: HostPatterns.entities(),
|
||||
listeners: Listeners.entities(),
|
||||
targets: Targets.entities()
|
||||
],
|
||||
singleton_entity_keys: [:health_checks, :host_patterns, :listeners, :targets],
|
||||
args: [{:optional, :name, nil}],
|
||||
transform: {__MODULE__, :transform, []}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec transform(t) :: {:ok, t} | {:error, any}
|
||||
def transform(config) do
|
||||
with :ok <- verify_at_least_one_listener(config),
|
||||
:ok <- verify_at_least_one_target(config) do
|
||||
{:ok, config}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_at_least_one_listener(config) when config.listeners.listeners == [],
|
||||
do: {:error, "Must provide at least one listener to accept incoming connections."}
|
||||
|
||||
defp verify_at_least_one_listener(_config), do: :ok
|
||||
|
||||
defp verify_at_least_one_target(config) when config.targets.targets == [],
|
||||
do: {:error, "Must provide at least one target to send requests to."}
|
||||
|
||||
defp verify_at_least_one_target(_config), do: :ok
|
||||
end
|
168
lib/wayfarer/dsl/health_check.ex
Normal file
168
lib/wayfarer/dsl/health_check.ex
Normal file
|
@ -0,0 +1,168 @@
|
|||
defmodule Wayfarer.Dsl.HealthCheck do
|
||||
@moduledoc """
|
||||
A struct for storing a health-check generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
|
||||
@defaults [
|
||||
connect_timeout: :timer.seconds(5),
|
||||
interval: :timer.seconds(30),
|
||||
method: :get,
|
||||
path: "/",
|
||||
response_timeout: 500,
|
||||
success_codes: [200..299],
|
||||
threshold: 3
|
||||
]
|
||||
|
||||
defstruct connect_timeout: @defaults[:connect_timeout],
|
||||
hostname: nil,
|
||||
interval: @defaults[:interval],
|
||||
method: @defaults[:method],
|
||||
name: nil,
|
||||
path: @defaults[:path],
|
||||
response_timeout: @defaults[:response_timeout],
|
||||
success_codes: @defaults[:success_codes],
|
||||
threshold: @defaults[:threshold]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
connect_timeout: pos_integer,
|
||||
hostname: nil | String.t(),
|
||||
interval: pos_integer,
|
||||
method: :get | :head | :port | :put | :delete | :connect | :options | :trace | :patch,
|
||||
name: nil | String.t(),
|
||||
path: String.t(),
|
||||
response_timeout: pos_integer,
|
||||
success_codes: [Range.t(100, 599)],
|
||||
threshold: pos_integer
|
||||
}
|
||||
|
||||
@schema [
|
||||
name: [
|
||||
type: {:or, [:string, nil]},
|
||||
required: false,
|
||||
doc: "A unique name for the health check."
|
||||
],
|
||||
method: [
|
||||
type: {:in, [:get, :head, :post, :put, :delete, :connect, :options, :trace, :patch]},
|
||||
required: false,
|
||||
default: @defaults[:method],
|
||||
doc: "The HTTP method to use for the request."
|
||||
],
|
||||
connect_timeout: [
|
||||
type: :pos_integer,
|
||||
required: false,
|
||||
default: @defaults[:connect_timeout],
|
||||
doc: "Connection timeout in milliseconds"
|
||||
],
|
||||
response_timeout: [
|
||||
type: :pos_integer,
|
||||
required: false,
|
||||
default: @defaults[:response_timeout],
|
||||
doc: "Response timeout in milliseconds"
|
||||
],
|
||||
hostname: [
|
||||
type: {:or, [nil, :string]},
|
||||
required: false,
|
||||
doc: "The HTTP hostname to use when sending the request. Defaults to the IP address."
|
||||
],
|
||||
interval: [
|
||||
type: :pos_integer,
|
||||
required: false,
|
||||
default: @defaults[:interval],
|
||||
doc: "Interval in milliseconds"
|
||||
],
|
||||
threshold: [
|
||||
type: :pos_integer,
|
||||
required: false,
|
||||
default: @defaults[:threshold],
|
||||
doc: "Success threshold"
|
||||
],
|
||||
path: [
|
||||
type: :string,
|
||||
required: false,
|
||||
default: @defaults[:path],
|
||||
doc: "Path"
|
||||
],
|
||||
success_codes: [
|
||||
type: {:wrap_list, {:or, [{:struct, Range}, {:in, 100..500}]}},
|
||||
required: false,
|
||||
default: @defaults[:success_codes],
|
||||
doc: "HTTP status codes which are considered successful."
|
||||
]
|
||||
]
|
||||
|
||||
@doc false
|
||||
@spec default :: t
|
||||
def default, do: struct(__MODULE__, @defaults)
|
||||
|
||||
@doc false
|
||||
@spec to_options(t) :: keyword()
|
||||
def to_options(check) do
|
||||
check
|
||||
|> Map.from_struct()
|
||||
|> Enum.to_list()
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :check,
|
||||
target: __MODULE__,
|
||||
schema: @schema,
|
||||
args: [{:optional, :name, nil}],
|
||||
transform: {__MODULE__, :transform, []}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec schema :: NimbleOptions.schema()
|
||||
def schema, do: @schema
|
||||
|
||||
@doc false
|
||||
@spec transform(t) :: {:ok, t} | {:error, any}
|
||||
def transform(check) do
|
||||
with {:ok, success_codes} <- transform_success_codes(check.success_codes) do
|
||||
maybe_set_name(%{check | success_codes: success_codes})
|
||||
end
|
||||
end
|
||||
|
||||
defguardp is_valid_status_code?(code) when is_integer(code) and code >= 100 and code <= 599
|
||||
|
||||
defguardp is_valid_range?(range)
|
||||
when is_struct(range, Range) and is_valid_status_code?(range.first) and
|
||||
is_valid_status_code?(range.last)
|
||||
|
||||
defp transform_success_codes(range) when is_valid_range?(range), do: {:ok, [range]}
|
||||
defp transform_success_codes([]), do: {:ok, []}
|
||||
|
||||
defp transform_success_codes([head | tail]) when is_valid_range?(head) do
|
||||
with {:ok, tail} <- transform_success_codes(tail) do
|
||||
{:ok, [head | tail]}
|
||||
end
|
||||
end
|
||||
|
||||
defp transform_success_codes([head | tail])
|
||||
when is_integer(head) and is_valid_status_code?(head) do
|
||||
with {:ok, tail} <- transform_success_codes(tail) do
|
||||
{:ok, [head..head | tail]}
|
||||
end
|
||||
end
|
||||
|
||||
defp transform_success_codes([range | _]),
|
||||
do: {:error, "Value `#{inspect(range)}` is not valid. Must be a range between 100..599"}
|
||||
|
||||
defp maybe_set_name(check) when is_binary(check.name), do: {:ok, check}
|
||||
|
||||
defp maybe_set_name(check) do
|
||||
name =
|
||||
check.success_codes
|
||||
|> Enum.map_join(",", &"#{&1.first}..#{&1.last}")
|
||||
|> then(&"#{check.path}:[#{&1}]")
|
||||
|
||||
{:ok, %{check | name: name}}
|
||||
end
|
||||
end
|
30
lib/wayfarer/dsl/health_checks.ex
Normal file
30
lib/wayfarer/dsl/health_checks.ex
Normal file
|
@ -0,0 +1,30 @@
|
|||
defmodule Wayfarer.Dsl.HealthChecks do
|
||||
@moduledoc """
|
||||
A struct for storing a group of health-checks generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Dsl.HealthCheck
|
||||
|
||||
defstruct health_checks: []
|
||||
|
||||
@type t :: %__MODULE__{health_checks: [HealthCheck.t()]}
|
||||
|
||||
@doc false
|
||||
@spec init :: t
|
||||
def init, do: %__MODULE__{health_checks: []}
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :health_checks,
|
||||
target: __MODULE__,
|
||||
entities: [
|
||||
health_checks: HealthCheck.entities()
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
44
lib/wayfarer/dsl/host_pattern.ex
Normal file
44
lib/wayfarer/dsl/host_pattern.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
defmodule Wayfarer.Dsl.HostPattern do
|
||||
@moduledoc """
|
||||
A struct for storing a host pattern generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
|
||||
defstruct pattern: nil
|
||||
|
||||
@type t :: %__MODULE__{pattern: String.t()}
|
||||
|
||||
# This is not a rigorous check, it's just enough to do for now.
|
||||
@pattern_regex ~r/^(\*\.)?([a-zA-Z0-9-]+\.)*([a-zA-Z0-9-]+)\.?$/
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :pattern,
|
||||
target: __MODULE__,
|
||||
schema: [
|
||||
pattern: [
|
||||
type: :string,
|
||||
required: true,
|
||||
doc: "A hostname matching pattern."
|
||||
]
|
||||
],
|
||||
args: [:pattern],
|
||||
transform: {__MODULE__, :transform, []}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec transform(t) :: {:ok, t} | {:error, any}
|
||||
def transform(pattern) do
|
||||
if Regex.match?(@pattern_regex, pattern.pattern) do
|
||||
{:ok, pattern}
|
||||
else
|
||||
{:error, "Invalid host pattern: `#{pattern}`"}
|
||||
end
|
||||
end
|
||||
end
|
30
lib/wayfarer/dsl/host_patterns.ex
Normal file
30
lib/wayfarer/dsl/host_patterns.ex
Normal file
|
@ -0,0 +1,30 @@
|
|||
defmodule Wayfarer.Dsl.HostPatterns do
|
||||
@moduledoc """
|
||||
A struct for storing a group of host patterns generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Dsl.HostPattern
|
||||
|
||||
defstruct host_patterns: []
|
||||
|
||||
@type t :: %__MODULE__{host_patterns: []}
|
||||
|
||||
@doc false
|
||||
@spec init :: t
|
||||
def init, do: %__MODULE__{host_patterns: []}
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :host_patterns,
|
||||
target: __MODULE__,
|
||||
entities: [
|
||||
host_patterns: HostPattern.entities()
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
184
lib/wayfarer/dsl/listener.ex
Normal file
184
lib/wayfarer/dsl/listener.ex
Normal file
|
@ -0,0 +1,184 @@
|
|||
defmodule Wayfarer.Dsl.Listener do
|
||||
@moduledoc """
|
||||
A struct for storing a listener generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Utils
|
||||
|
||||
defstruct address: nil,
|
||||
certfile: nil,
|
||||
cipher_suite: nil,
|
||||
http_1_options: [],
|
||||
http_2_options: [],
|
||||
keyfile: nil,
|
||||
name: nil,
|
||||
port: nil,
|
||||
scheme: :http,
|
||||
thousand_island_options: [],
|
||||
uri: nil,
|
||||
websocket_options: []
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
address: IP.Address.t(),
|
||||
certfile: nil | String.t(),
|
||||
cipher_suite: nil | :strong | :compatible,
|
||||
http_1_options: Bandit.http_1_options(),
|
||||
http_2_options: Bandit.http_2_options(),
|
||||
keyfile: nil | String.t(),
|
||||
name: nil | String.t(),
|
||||
port: :inet.port_number(),
|
||||
scheme: :http | :https,
|
||||
thousand_island_options: ThousandIsland.options(),
|
||||
uri: URI.t(),
|
||||
websocket_options: Bandit.websocket_options()
|
||||
}
|
||||
|
||||
@http_schema [
|
||||
scheme: [
|
||||
type: {:in, [:http, :https]},
|
||||
required: true,
|
||||
doc: "The listening protocol."
|
||||
],
|
||||
address: [
|
||||
type: {:or, [{:struct, IP.Address}, :string]},
|
||||
required: true,
|
||||
doc: "The address of the interface to listen on."
|
||||
],
|
||||
name: [
|
||||
type: {:or, [nil, :string]},
|
||||
required: false,
|
||||
doc: "A unique name for the listener (defaults to the URI)."
|
||||
],
|
||||
port: [
|
||||
type: :pos_integer,
|
||||
required: true,
|
||||
doc: "The TCP port on which to listen for incoming connections."
|
||||
],
|
||||
http_1_options: [
|
||||
type: :keyword_list,
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "Options to configure the HTTP/1 stack in Bandit."
|
||||
],
|
||||
http_2_options: [
|
||||
type: :keyword_list,
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "Options to configure the HTTP/2 stack in Bandit."
|
||||
],
|
||||
thousand_island_options: [
|
||||
type: :keyword_list,
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "Possible options to configure a ThousandIsland server."
|
||||
],
|
||||
websocket_options: [
|
||||
type: :keyword_list,
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "Options to configure the WebSocket stack in Bandit."
|
||||
]
|
||||
]
|
||||
|
||||
@https_schema @http_schema ++
|
||||
[
|
||||
certfile: [
|
||||
type: :string,
|
||||
required: false,
|
||||
doc:
|
||||
"The path to a file containing the SSL certificate to use for this listener."
|
||||
],
|
||||
keyfile: [
|
||||
type: :string,
|
||||
required: false,
|
||||
doc: "The path to a file containing the SSL key to use for this listener."
|
||||
],
|
||||
cipher_suite: [
|
||||
type: {:in, [nil, :strong, :compatible]},
|
||||
required: false,
|
||||
doc:
|
||||
"Used to define a pre-selected set of ciphers, as described by `Plug.SSL.configure/1`."
|
||||
]
|
||||
]
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :http,
|
||||
target: __MODULE__,
|
||||
schema: @http_schema,
|
||||
auto_set_fields: [scheme: :http],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []}
|
||||
},
|
||||
%Entity{
|
||||
name: :https,
|
||||
target: __MODULE__,
|
||||
schema: @https_schema,
|
||||
auto_set_fields: [scheme: :https],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec transform(t) :: {:ok, t} | {:error, any}
|
||||
def transform(listener) do
|
||||
with :ok <- validate_cert_and_key(listener),
|
||||
{:ok, listener} <- maybe_parse_address(listener),
|
||||
{:ok, listener} <- set_uri(listener) do
|
||||
maybe_set_name(listener)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec schema :: NimbleOptions.schema()
|
||||
def schema,
|
||||
do:
|
||||
@https_schema
|
||||
|> make_optional(:certfile)
|
||||
|> make_optional(:keyfile)
|
||||
|> make_optional(:cipher_suite)
|
||||
|
||||
defp make_optional(schema, field) do
|
||||
schema
|
||||
|> Keyword.update!(field, fn config ->
|
||||
config
|
||||
|> Keyword.put(:required, false)
|
||||
|> Keyword.update!(:type, &{:or, [nil, &1]})
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_cert_and_key(listener) when listener.scheme == :http, do: :ok
|
||||
|
||||
defp validate_cert_and_key(listener)
|
||||
when is_binary(listener.certfile) and is_binary(listener.keyfile),
|
||||
do: :ok
|
||||
|
||||
defp validate_cert_and_key(_listener),
|
||||
do: {:error, "Both `certfile` and `keyfile` options must be set for an HTTPS listener."}
|
||||
|
||||
defp maybe_parse_address(listener) when is_struct(listener.address, IP.Address),
|
||||
do: {:ok, listener}
|
||||
|
||||
defp maybe_parse_address(listener) when is_binary(listener.address) do
|
||||
with {:ok, address} <- IP.Address.from_string(listener.address) do
|
||||
{:ok, %{listener | address: address}}
|
||||
end
|
||||
end
|
||||
|
||||
defp set_uri(listener) do
|
||||
with {:ok, uri} <- Utils.to_uri(listener.scheme, listener.address, listener.port) do
|
||||
{:ok, %{listener | uri: uri}}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_set_name(listener) when is_binary(listener.name), do: {:ok, listener}
|
||||
defp maybe_set_name(listener), do: {:ok, %{listener | name: to_string(listener.uri)}}
|
||||
end
|
31
lib/wayfarer/dsl/listeners.ex
Normal file
31
lib/wayfarer/dsl/listeners.ex
Normal file
|
@ -0,0 +1,31 @@
|
|||
defmodule Wayfarer.Dsl.Listeners do
|
||||
@moduledoc """
|
||||
A struct for storing a group of listeners generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Dsl.Listener
|
||||
|
||||
defstruct listeners: []
|
||||
|
||||
@type t :: %__MODULE__{listeners: [Listener.t()]}
|
||||
|
||||
@doc false
|
||||
@spec init :: t
|
||||
def init, do: %__MODULE__{listeners: []}
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :listeners,
|
||||
target: __MODULE__,
|
||||
entities: [
|
||||
listeners: Listener.entities()
|
||||
],
|
||||
imports: [IP.Sigil]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
186
lib/wayfarer/dsl/target.ex
Normal file
186
lib/wayfarer/dsl/target.ex
Normal file
|
@ -0,0 +1,186 @@
|
|||
defmodule Wayfarer.Dsl.Target do
|
||||
@moduledoc """
|
||||
A struct for storing a target generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.{Dsl.Entity, Options}
|
||||
alias Wayfarer.Dsl.{HealthCheck, HealthChecks}
|
||||
alias Wayfarer.Utils
|
||||
|
||||
defstruct address: nil,
|
||||
health_checks: HealthChecks.init(),
|
||||
module: nil,
|
||||
name: nil,
|
||||
port: nil,
|
||||
scheme: :http,
|
||||
transport: :auto,
|
||||
uri: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
address: IP.Address.t(),
|
||||
health_checks: HealthChecks.t(),
|
||||
module: nil | module,
|
||||
name: nil | String.t(),
|
||||
port: :inet.port_number(),
|
||||
scheme: :http | :https | :plug | :ws | :wss,
|
||||
transport: :http1 | :http2 | :auto,
|
||||
uri: URI.t()
|
||||
}
|
||||
|
||||
@shared_schema [
|
||||
address: [
|
||||
type: {:or, [{:struct, IP.Address}, :string]},
|
||||
required: true,
|
||||
doc: "The address of the interface to listen on."
|
||||
],
|
||||
name: [
|
||||
type: {:or, [nil, :string]},
|
||||
required: false,
|
||||
doc: "A unique name for the target (defaults to the URI)."
|
||||
],
|
||||
port: [
|
||||
type: :pos_integer,
|
||||
required: true,
|
||||
doc: "The TCP port on which to listen for incoming connections."
|
||||
],
|
||||
transport: [
|
||||
type: {:in, [:http1, :http2, :auto]},
|
||||
required: false,
|
||||
default: :auto,
|
||||
doc: "Which HTTP protocol to use."
|
||||
]
|
||||
]
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :http,
|
||||
target: __MODULE__,
|
||||
schema: @shared_schema,
|
||||
auto_set_fields: [scheme: :http],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []},
|
||||
entities: [health_checks: HealthChecks.entities()],
|
||||
singleton_entity_keys: [:health_checks]
|
||||
},
|
||||
%Entity{
|
||||
name: :https,
|
||||
target: __MODULE__,
|
||||
schema: @shared_schema,
|
||||
auto_set_fields: [scheme: :https],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []},
|
||||
entities: [health_checks: HealthChecks.entities()],
|
||||
singleton_entity_keys: [:health_checks]
|
||||
},
|
||||
%Entity{
|
||||
name: :plug,
|
||||
target: __MODULE__,
|
||||
schema: [
|
||||
module: [
|
||||
type: {:spark_behaviour, Plug},
|
||||
doc: "A plug which can handle requests.",
|
||||
required: true
|
||||
]
|
||||
],
|
||||
auto_set_fields: [scheme: :plug],
|
||||
args: [:module]
|
||||
},
|
||||
%Entity{
|
||||
name: :ws,
|
||||
target: __MODULE__,
|
||||
schema: @shared_schema,
|
||||
auto_set_fields: [scheme: :ws],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []},
|
||||
entities: [health_checks: HealthChecks.entities()],
|
||||
singleton_entity_keys: [:health_checks]
|
||||
},
|
||||
%Entity{
|
||||
name: :wss,
|
||||
target: __MODULE__,
|
||||
schema: @shared_schema,
|
||||
auto_set_fields: [scheme: :wss],
|
||||
args: [:address, :port],
|
||||
imports: [IP.Sigil],
|
||||
transform: {__MODULE__, :transform, []},
|
||||
entities: [health_checks: HealthChecks.entities()],
|
||||
singleton_entity_keys: [:health_checks]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec transform(t) :: {:ok, t} | {:error, any}
|
||||
def transform(target) do
|
||||
with {:ok, target} <- ensure_health_checks(target),
|
||||
{:ok, target} <- maybe_parse_address(target),
|
||||
{:ok, target} <- set_uri(target) do
|
||||
maybe_set_name(target)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def schema do
|
||||
@shared_schema
|
||||
|> Options.Helpers.make_optional!(:address)
|
||||
|> Options.Helpers.make_optional!(:port)
|
||||
|> Keyword.merge(
|
||||
scheme: [
|
||||
type: {:in, [:http, :https, :plug, :ws, :wss]},
|
||||
required: true,
|
||||
doc: "The connection type for the target."
|
||||
],
|
||||
plug: [
|
||||
type: {:behaviour, Plug},
|
||||
required: false,
|
||||
doc: "A plug to use when `scheme` is `:plug`."
|
||||
],
|
||||
health_checks: [
|
||||
type: {:list, {:keyword_list, HealthCheck.schema()}},
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "A list of health check configurations."
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp ensure_health_checks(target) when is_nil(target.health_checks),
|
||||
do: {:ok, %{target | health_checks: HealthChecks.init()}}
|
||||
|
||||
defp ensure_health_checks(target), do: {:ok, target}
|
||||
|
||||
defp maybe_parse_address(target) when is_struct(target.address, IP.Address),
|
||||
do: {:ok, target}
|
||||
|
||||
defp maybe_parse_address(target) when is_binary(target.address) do
|
||||
with {:ok, address} <- IP.Address.from_string(target.address) do
|
||||
{:ok, %{target | address: address}}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_parse_address(target) when is_nil(target.address), do: {:ok, target}
|
||||
|
||||
defp set_uri(target) when target.scheme == :plug do
|
||||
uri = %URI{
|
||||
scheme: "plug",
|
||||
host: inspect(target.module)
|
||||
}
|
||||
|
||||
{:ok, %{target | uri: uri}}
|
||||
end
|
||||
|
||||
defp set_uri(target) do
|
||||
with {:ok, uri} <- Utils.to_uri(target.scheme, target.address, target.port) do
|
||||
{:ok, %{target | uri: uri}}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_set_name(target) when is_binary(target.name), do: {:ok, target}
|
||||
defp maybe_set_name(target), do: {:ok, %{target | name: to_string(target.uri)}}
|
||||
end
|
38
lib/wayfarer/dsl/targets.ex
Normal file
38
lib/wayfarer/dsl/targets.ex
Normal file
|
@ -0,0 +1,38 @@
|
|||
defmodule Wayfarer.Dsl.Targets do
|
||||
@moduledoc """
|
||||
A struct for storing a group of targets generated by the DSL.
|
||||
"""
|
||||
|
||||
alias Spark.Dsl.Entity
|
||||
alias Wayfarer.Dsl.Target
|
||||
|
||||
defstruct algorithm: :round_robin, targets: []
|
||||
|
||||
@type t :: %__MODULE__{algorithm: :round_robin | :sticky, targets: [Target.t()]}
|
||||
|
||||
@doc false
|
||||
@spec init :: t
|
||||
def init, do: %__MODULE__{algorithm: :round_robin, targets: []}
|
||||
|
||||
@doc false
|
||||
@spec entities :: [Entity.t()]
|
||||
def entities do
|
||||
[
|
||||
%Entity{
|
||||
name: :targets,
|
||||
target: __MODULE__,
|
||||
entities: [
|
||||
targets: Target.entities()
|
||||
],
|
||||
imports: [IP.Sigil],
|
||||
schema: [
|
||||
algorithm: [
|
||||
type: {:in, [:round_robin, :sticky]},
|
||||
doc: "The target selection algorithm.",
|
||||
default: :round_robin
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
174
lib/wayfarer/dsl/transformer.ex
Normal file
174
lib/wayfarer/dsl/transformer.ex
Normal file
|
@ -0,0 +1,174 @@
|
|||
defmodule Wayfarer.Dsl.Transformer do
|
||||
@moduledoc """
|
||||
The Transformer for the Wayfarer DSL extension.
|
||||
"""
|
||||
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
alias Wayfarer.Dsl.{Config, HealthCheck}
|
||||
use Transformer
|
||||
|
||||
@doc false
|
||||
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
|
||||
def transform(dsl_state) do
|
||||
with {:ok, listeners} <- get_unique_listeners(dsl_state),
|
||||
{:ok, routing_table} <- build_routing_table(dsl_state),
|
||||
{:ok, targets} <- build_unique_targets(dsl_state) do
|
||||
listeners = Macro.escape(listeners)
|
||||
routing_table = Macro.escape(routing_table)
|
||||
targets = Macro.escape(targets)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.eval(
|
||||
[listeners: listeners, routing_table: routing_table, targets: targets],
|
||||
quote do
|
||||
use Wayfarer.Server,
|
||||
listeners: unquote(listeners),
|
||||
targets: unquote(targets),
|
||||
routing_table: unquote(routing_table)
|
||||
end
|
||||
)
|
||||
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_unique_listeners(dsl_state) do
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:wayfarer])
|
||||
|> Enum.filter(&is_struct(&1, Config))
|
||||
|> Enum.flat_map(& &1.listeners.listeners)
|
||||
|> Enum.group_by(& &1.uri)
|
||||
|> Enum.reduce_while({:ok, []}, fn {uri, listeners_for_uri}, {:ok, all_listeners} ->
|
||||
listeners_for_uri
|
||||
|> Enum.uniq()
|
||||
|> case do
|
||||
[listener] ->
|
||||
{:cont, {:ok, [listener_to_options(listener) | all_listeners]}}
|
||||
|
||||
_listeners ->
|
||||
{:halt,
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: Transformer.get_persisted(dsl_state, :module),
|
||||
path: [:wayfarer],
|
||||
message: "Multiple listeners for #{uri} with differing configurations."
|
||||
)}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_routing_table(dsl_state) do
|
||||
table =
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:wayfarer])
|
||||
|> Enum.filter(&is_struct(&1, Config))
|
||||
|> Enum.flat_map(fn config ->
|
||||
listeners = build_routing_listeners(config)
|
||||
host_patterns = build_routing_host_patterns(config)
|
||||
targets = build_routing_targets(config)
|
||||
|
||||
for listener <- listeners, target <- targets do
|
||||
{listener, target, host_patterns, config.targets.algorithm}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, table}
|
||||
end
|
||||
|
||||
defp build_routing_host_patterns(config) when is_struct(config, Config),
|
||||
do: build_routing_host_patterns(config.host_patterns.host_patterns)
|
||||
|
||||
defp build_routing_host_patterns([]), do: ["*"]
|
||||
defp build_routing_host_patterns(patterns), do: Enum.map(patterns, & &1.pattern)
|
||||
|
||||
defp build_routing_listeners(config) when is_struct(config, Config),
|
||||
do: build_routing_listeners(config.listeners.listeners)
|
||||
|
||||
defp build_routing_listeners(listeners) do
|
||||
Enum.map(listeners, &{&1.scheme, IP.Address.to_tuple(&1.address), &1.port})
|
||||
end
|
||||
|
||||
defp build_routing_targets(config) when is_struct(config, Config),
|
||||
do: build_routing_targets(config.targets.targets)
|
||||
|
||||
defp build_routing_targets(targets) do
|
||||
Enum.map(targets, fn
|
||||
target when target.scheme == :plug ->
|
||||
{target.scheme, target.module}
|
||||
|
||||
target ->
|
||||
{target.scheme, IP.Address.to_tuple(target.address), target.port, target.transport}
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_targets(config) do
|
||||
with {:ok, global_checks} <- build_global_health_checks(config) do
|
||||
targets =
|
||||
config.targets.targets
|
||||
|> Enum.map(fn
|
||||
target when target.scheme == :plug ->
|
||||
target
|
||||
|> Map.from_struct()
|
||||
|> Map.drop([:address, :health_checks, :port, :uri])
|
||||
|> Enum.to_list()
|
||||
|
||||
target when target.scheme in [:http, :https, :ws, :wss] ->
|
||||
health_checks =
|
||||
target.health_checks.health_checks
|
||||
|> Enum.map(&HealthCheck.to_options/1)
|
||||
|> Enum.concat(global_checks)
|
||||
|> Enum.uniq()
|
||||
|> maybe_add_default_health_check()
|
||||
|
||||
target
|
||||
|> Map.from_struct()
|
||||
|> Map.drop([:module, :uri])
|
||||
|> Map.put(:health_checks, health_checks)
|
||||
|> Enum.to_list()
|
||||
end)
|
||||
|
||||
{:ok, targets}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_default_health_check([]) do
|
||||
HealthCheck.default()
|
||||
|> HealthCheck.to_options()
|
||||
|> then(&[&1])
|
||||
end
|
||||
|
||||
defp maybe_add_default_health_check(checks), do: checks
|
||||
|
||||
defp build_unique_targets(dsl_state) do
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:wayfarer])
|
||||
|> Enum.filter(&is_struct(&1, Config))
|
||||
|> Enum.reduce({:ok, []}, fn config, {:ok, targets} ->
|
||||
with {:ok, new_targets} <- build_targets(config) do
|
||||
{:ok, Enum.concat(targets, new_targets)}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, targets} -> {:ok, Enum.uniq(targets)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_global_health_checks(config) when is_list(config.health_checks.health_checks) do
|
||||
checks =
|
||||
config.health_checks.health_checks
|
||||
|> Enum.map(&HealthCheck.to_options/1)
|
||||
|
||||
{:ok, checks}
|
||||
end
|
||||
|
||||
defp build_global_health_checks(_), do: {:ok, []}
|
||||
|
||||
defp listener_to_options(listener) do
|
||||
listener
|
||||
|> Map.from_struct()
|
||||
|> Map.delete(:uri)
|
||||
|> Enum.to_list()
|
||||
end
|
||||
end
|
|
@ -1,23 +1,182 @@
|
|||
defmodule Wayfarer.Listener do
|
||||
# @moduledoc ⬇️⬇️
|
||||
|
||||
use GenServer, restart: :transient
|
||||
require Logger
|
||||
alias Spark.Options
|
||||
import Wayfarer.Utils
|
||||
|
||||
@options_schema [
|
||||
scheme: [
|
||||
type: {:in, [:http, :https]},
|
||||
doc: "The connection protocol.",
|
||||
required: true
|
||||
],
|
||||
port: [
|
||||
type: {:in, 1..0xFFFF},
|
||||
doc: "The TCP port to listen on for connections.",
|
||||
required: true
|
||||
],
|
||||
address: [
|
||||
type: {:struct, IP.Address},
|
||||
doc: "The IP address of an interface to bind to.",
|
||||
required: true
|
||||
],
|
||||
drain_timeout: [
|
||||
type: :pos_integer,
|
||||
doc: "How long to wait for existing connections to complete on shutdown.",
|
||||
required: false,
|
||||
default: :timer.seconds(60)
|
||||
],
|
||||
module: [
|
||||
type: {:behaviour, Wayfarer.Server},
|
||||
doc: "The proxy module this listener is linked to.",
|
||||
required: true
|
||||
],
|
||||
name: [
|
||||
type: {:or, [:string, nil]},
|
||||
doc: "An optional name for the listener.",
|
||||
required: false
|
||||
],
|
||||
keyfile: [
|
||||
type: {:or, [:string, nil]},
|
||||
doc: "The path to the SSL secret key file.",
|
||||
required: false,
|
||||
subsection: "HTTPS Options"
|
||||
],
|
||||
certfile: [
|
||||
type: {:or, [:string, nil]},
|
||||
doc: "The path to the SSL certificate file",
|
||||
required: false,
|
||||
subsection: "HTTPS Options"
|
||||
],
|
||||
cipher_suite: [
|
||||
type: {:in, [nil, :strong, :compatible]},
|
||||
doc:
|
||||
"Used to define a pre-selected set of ciphers, as described by `Plug.SSL.configure/1.`",
|
||||
required: false,
|
||||
subsection: "HTTPS Options"
|
||||
],
|
||||
http_1_options: [
|
||||
type: :keyword_list,
|
||||
doc: "See `t:Bandit.http_1_options/0`.",
|
||||
required: false,
|
||||
subsection: "Protocol-specific Options"
|
||||
],
|
||||
http_2_options: [
|
||||
type: :keyword_list,
|
||||
doc: "See `t:Bandit.http_2_options/0`.",
|
||||
required: false,
|
||||
subsection: "Protocol-specific Options"
|
||||
],
|
||||
websocket_options: [
|
||||
type: :keyword_list,
|
||||
doc: "See `t:Bandit.websocket_options/0`.",
|
||||
required: false,
|
||||
subsection: "Protocol-specific Options"
|
||||
],
|
||||
thousand_island_options: [
|
||||
type: :keyword_list,
|
||||
doc: "See `t:ThousandIsland.options/0`",
|
||||
subsection: "Protocol-specific Options"
|
||||
]
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Manage HTTP listeners.
|
||||
A GenServer which manages the state of each Bandit listener.
|
||||
|
||||
You should not need to create one of these yourself, instead use it via
|
||||
`Wayfarer.Server`.
|
||||
|
||||
## Options
|
||||
|
||||
#{Options.docs(@options_schema)}
|
||||
"""
|
||||
|
||||
alias Wayfarer.Listener.DynamicSupervisor, as: ListenerSupervisor
|
||||
alias Wayfarer.Listener.Registry, as: ListenerRegistry
|
||||
alias Wayfarer.Listener.Server
|
||||
@doc false
|
||||
@spec start_link(keyword) :: GenServer.on_start()
|
||||
def start_link(options), do: GenServer.start_link(__MODULE__, options)
|
||||
|
||||
@doc """
|
||||
Start listener.
|
||||
"""
|
||||
@spec start_listener(Server.options()) :: Supervisor.on_start_child()
|
||||
def start_listener(options),
|
||||
do: DynamicSupervisor.start_child(ListenerSupervisor, {Server, options})
|
||||
@doc false
|
||||
@impl true
|
||||
def init(options) do
|
||||
with {:ok, options} <- validate_options(options),
|
||||
bandit_options <- build_bandit_options(options),
|
||||
{:ok, pid} <- Bandit.start_link(bandit_options),
|
||||
{:ok, {listen_address, listen_port}} <- ThousandIsland.listener_info(pid),
|
||||
{:ok, listen_address} <- IP.Address.from_tuple(listen_address),
|
||||
{:ok, uri} <- to_uri(options[:scheme], listen_address, listen_port) do
|
||||
Logger.info("Started Wayfarer listener on #{uri}")
|
||||
|
||||
@doc """
|
||||
Stop listener
|
||||
"""
|
||||
@spec stop_listener(:inet.socket_address(), :inet.port_number()) :: :ok
|
||||
def stop_listener(ip, port),
|
||||
do: GenServer.stop({:via, Registry, {ListenerRegistry, {ip, port}}}, :normal)
|
||||
{:ok, %{server: pid, name: options[:name], uri: uri}}
|
||||
else
|
||||
:error -> {:stop, "Unable to retrieve listener information."}
|
||||
{:error, reason} -> {:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def terminate(:normal, %{server: server}) do
|
||||
GenServer.stop(server, :normal)
|
||||
end
|
||||
|
||||
defp validate_options(options) do
|
||||
case Keyword.fetch(options, :scheme) do
|
||||
{:ok, :https} ->
|
||||
schema =
|
||||
@options_schema
|
||||
|> Options.Helpers.make_required!(:keyfile)
|
||||
|> Options.Helpers.make_required!(:certfile)
|
||||
|> Options.Helpers.make_required!(:cipher_suite)
|
||||
|
||||
Options.validate(options, schema)
|
||||
|
||||
_ ->
|
||||
Options.validate(options, @options_schema)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_bandit_options(options) do
|
||||
plug_options = %{
|
||||
address: IP.Address.to_tuple(options[:address]),
|
||||
module: options[:module],
|
||||
port: options[:port],
|
||||
scheme: options[:scheme]
|
||||
}
|
||||
|
||||
thousand_island_options =
|
||||
options
|
||||
|> Keyword.get(:thousand_island_options, [])
|
||||
|> Keyword.put_new(:shutdown_timeout, options[:drain_timeout])
|
||||
|
||||
base_options = [
|
||||
ip: IP.Address.to_tuple(options[:address]),
|
||||
otp_app: Application.get_application(options[:module]),
|
||||
plug: {Wayfarer.Server.Plug, plug_options},
|
||||
port: options[:port],
|
||||
scheme: options[:scheme],
|
||||
thousand_island_options: thousand_island_options
|
||||
]
|
||||
|
||||
ssl_options =
|
||||
if options[:scheme] == :https do
|
||||
[
|
||||
certfile: options[:certfile],
|
||||
keyfile: options[:keyfile]
|
||||
]
|
||||
|> maybe_add_option(:cipher_suite, options[:cipher_suite])
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
base_options
|
||||
|> Enum.concat(ssl_options)
|
||||
|> maybe_add_option(:http_1_options, options[:http_1_options])
|
||||
|> maybe_add_option(:http_2_options, options[:http_2_options])
|
||||
|> maybe_add_option(:websocket_options, options[:websocket_options])
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, _key, nil), do: options
|
||||
defp maybe_add_option(options, key, value), do: Keyword.put(options, key, value)
|
||||
end
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
defmodule Wayfarer.Listener.Plug do
|
||||
@moduledoc """
|
||||
Plug pipeline to handle inbound HTTP connections.
|
||||
"""
|
||||
import Plug.Conn
|
||||
@behaviour Plug
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(options) do
|
||||
options
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def call(conn, _opts) do
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(502, "Bad Gateway")
|
||||
end
|
||||
end
|
|
@ -1,86 +0,0 @@
|
|||
defmodule Wayfarer.Listener.Server do
|
||||
@moduledoc """
|
||||
A GenServer which manages the state of each listener.
|
||||
"""
|
||||
|
||||
alias Wayfarer.Listener.Plug
|
||||
alias Wayfarer.Listener.Registry, as: ListenerRegistry
|
||||
|
||||
use GenServer, restart: :transient
|
||||
require Logger
|
||||
|
||||
# How long to wait for connections to drain when shutting down a listener.
|
||||
@drain_timeout :timer.seconds(60)
|
||||
|
||||
@type options :: [
|
||||
scheme: :http | :https,
|
||||
port: :inet.port_number(),
|
||||
ip: :inet.socket_address(),
|
||||
keyfile: binary(),
|
||||
certfile: binary(),
|
||||
otp_app: binary() | atom(),
|
||||
cipher_suite: :strong | :compatible,
|
||||
display_plug: module(),
|
||||
startup_log: Logger.level() | false,
|
||||
thousand_island_options: ThousandIsland.options(),
|
||||
http_1_options: Bandit.http_1_options(),
|
||||
http_2_options: Bandit.http_2_options(),
|
||||
websocket_options: Bandit.websocket_options()
|
||||
]
|
||||
|
||||
@doc false
|
||||
@spec start_link(options) :: GenServer.on_start()
|
||||
def start_link(options), do: GenServer.start_link(__MODULE__, options)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(options) do
|
||||
options =
|
||||
options
|
||||
|> Keyword.put(:plug, Plug)
|
||||
|> Keyword.put(:startup_log, false)
|
||||
|> Keyword.update(
|
||||
:thousand_island_options,
|
||||
[shutdown_timeout: @drain_timeout],
|
||||
&Keyword.put_new(&1, :shutdown_timeout, @drain_timeout)
|
||||
)
|
||||
|
||||
with {:ok, scheme} <- fetch_required_option(options, :scheme),
|
||||
{:ok, pid} <- Bandit.start_link(options),
|
||||
{:ok, %{address: addr, port: port}} <- ThousandIsland.listener_info(pid),
|
||||
{:ok, _pid} <- Registry.register(ListenerRegistry, {addr, port}, pid) do
|
||||
listen_url = listen_url(scheme, addr, port)
|
||||
version = Application.spec(:wayfarer)[:vsn]
|
||||
Logger.info("Started Wayfarer v#{version} listener on #{listen_url}")
|
||||
|
||||
{:ok, %{server: pid, options: options, addr: addr}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def terminate(:normal, %{server: server}) do
|
||||
GenServer.stop(server, :normal)
|
||||
end
|
||||
|
||||
defp fetch_required_option(options, option) do
|
||||
case Keyword.fetch(options, option) do
|
||||
{:ok, value} -> {:ok, value}
|
||||
:error -> {:error, {:required_option, option}}
|
||||
end
|
||||
end
|
||||
|
||||
defp listen_url(scheme, {:local, socket_path}, _), do: "#{scheme}:#{socket_path}"
|
||||
|
||||
defp listen_url(scheme, address, port) when tuple_size(address) == 4 do
|
||||
"#{scheme}://#{:inet.ntoa(address)}:#{port}"
|
||||
|> URI.new!()
|
||||
|> to_string()
|
||||
end
|
||||
|
||||
defp listen_url(scheme, address, port) when tuple_size(address) == 8 do
|
||||
"#{scheme}://[#{:inet.ntoa(address)}]:#{port}"
|
||||
|> URI.new!()
|
||||
|> to_string()
|
||||
end
|
||||
end
|
|
@ -1,20 +1,14 @@
|
|||
defmodule Wayfarer.Listener.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for HTTP listeners.
|
||||
"""
|
||||
@moduledoc false
|
||||
|
||||
use Supervisor
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: Supervisor.on_start()
|
||||
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg)
|
||||
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
def init(_) do
|
||||
[
|
||||
{Registry, keys: :unique, name: Wayfarer.Listener.Registry},
|
||||
{DynamicSupervisor, name: Wayfarer.Listener.DynamicSupervisor}
|
||||
{DynamicSupervisor, name: Wayfarer.Listener.DynamicSupervisor, strategy: :one_for_one}
|
||||
]
|
||||
|> Supervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
|
323
lib/wayfarer/router.ex
Normal file
323
lib/wayfarer/router.ex
Normal file
|
@ -0,0 +1,323 @@
|
|||
defmodule Wayfarer.Router do
|
||||
@moduledoc """
|
||||
Wayfarer's routing is implemented on top of an ETS table to allow for fast
|
||||
querying and easy mutation.
|
||||
|
||||
This module provides a standardised interface to interact with a routing
|
||||
table.
|
||||
"""
|
||||
import Wayfarer.Utils
|
||||
alias Wayfarer.Target.Selector
|
||||
require Selector
|
||||
|
||||
@table_options [
|
||||
:duplicate_bag,
|
||||
:protected,
|
||||
:named_table,
|
||||
read_concurrency: true
|
||||
]
|
||||
|
||||
@type scheme :: :http | :https
|
||||
|
||||
@typedoc """
|
||||
The row format for each route in the table.
|
||||
"""
|
||||
@type route :: {listener, host_pattern, target, algorithm, health}
|
||||
|
||||
@typedoc """
|
||||
Uniquely identifies a listener.
|
||||
"""
|
||||
@type listener :: {scheme, :inet.ip_address(), :socket.port_number()}
|
||||
|
||||
@typedoc """
|
||||
A fully qualified hostname optionally with a leading wildcard.
|
||||
"""
|
||||
@type host_name :: String.t()
|
||||
|
||||
@typedoc """
|
||||
Host patterns are built by splitting hostnames into segments and storing them
|
||||
as tuples. Hosts with a leading wildcard (`*`) segment will have that segment
|
||||
replaced with a `:_`.
|
||||
|
||||
## Examples
|
||||
|
||||
* `www.example.com` becomes `{"com", "example", "www"}`
|
||||
* `*.example.com` becomes `{"com", "example", :_}`
|
||||
"""
|
||||
@type host_pattern :: tuple()
|
||||
|
||||
@typedoc """
|
||||
Uniquely identifies a request target, either a remote http(s) server or a
|
||||
local `Plug`.
|
||||
"""
|
||||
@type target ::
|
||||
{:http | :https | :ws | :wss, :inet.ip_address(), :socket.port_number(),
|
||||
:http1 | :http2 | :auto}
|
||||
| {:plug, module}
|
||||
| {:plug, {module, any}}
|
||||
|
||||
@typedoc """
|
||||
Like `t:target/0` except that it can contain user input for the address portion.
|
||||
"""
|
||||
@type target_input ::
|
||||
{:http | :https | :ws | :wss, Wayfarer.Utils.address_input(), :socket.port_number(),
|
||||
:http1 | :http2 | :auto}
|
||||
| {:plug, module}
|
||||
| {:plug, {module, any}}
|
||||
|
||||
@typedoc """
|
||||
The algorithm used to select which target to forward requests to (when there
|
||||
is more than one matching target).
|
||||
"""
|
||||
@type algorithm :: Selector.algorithm()
|
||||
|
||||
@typedoc """
|
||||
The current health status of the target.
|
||||
"""
|
||||
@type health :: :initial | :healthy | :unhealthy | :draining
|
||||
|
||||
@doc """
|
||||
Create a new, empty routing table.
|
||||
|
||||
This table is protected and owned by the calling process.
|
||||
"""
|
||||
@spec init(module) :: {:ok, :ets.tid()} | {:error, any}
|
||||
def init(name) do
|
||||
{:ok, :ets.new(name, @table_options)}
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add new route to the routing table.
|
||||
|
||||
A route will be added for each host name with it's health state set to `:initial`.
|
||||
|
||||
This should only ever be called by `Wayfarer.Server` directly.
|
||||
"""
|
||||
@spec add_route(:ets.tid(), listener, target_input, [host_name], algorithm) ::
|
||||
:ok | {:error, any}
|
||||
def add_route(table, listener, target, host_names, algorithm) do
|
||||
with {:ok, entries} <- route_to_entries(table, listener, target, host_names, algorithm) do
|
||||
:ets.insert(table, entries)
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add a number of routes into the routing table.
|
||||
"""
|
||||
@spec import_routes(:ets.tid(), [{listener, target_input, [host_name], algorithm}]) :: :ok
|
||||
def import_routes(table, routes) do
|
||||
with {:ok, entries} <- routes_to_entries(table, routes) do
|
||||
:ets.insert(table, entries)
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Remove a listener from the routing table.
|
||||
|
||||
This should only ever be done by `Wayfarer.Server` after it has finished
|
||||
draining connections.
|
||||
"""
|
||||
@spec remove_listener(:ets.tid(), listener) :: :ok
|
||||
def remove_listener(table, listener) do
|
||||
:ets.match_delete(table, {listener, :_, :_, :_, :_})
|
||||
|
||||
:ok
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Remove a target from the routing table.
|
||||
|
||||
This should only ever be done by `Wayfarer.Server` after it has finished
|
||||
draining connections.
|
||||
"""
|
||||
@spec remove_target(:ets.tid(), target) :: :ok
|
||||
def remove_target(table, target) do
|
||||
:ets.match_delete(table, {:_, :_, target, :_, :_})
|
||||
|
||||
:ok
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Change a target's health state.
|
||||
"""
|
||||
@spec update_target_health_status(:ets.tid(), target, health) :: :ok
|
||||
def update_target_health_status(table, {scheme, address, port, transport}, status) do
|
||||
# Match spec generated using:
|
||||
# :ets.fun2ms(fn {listener, host_pattern, {:http, {192, 168, 4, 26}, 80, transport}, algorithm, _} ->
|
||||
# {listener, host_pattern, {:http, {192, 168, 4, 26}, 80, transport}, algorithm, :healthy}
|
||||
# end)
|
||||
|
||||
match_spec = [
|
||||
{{:"$1", :"$2", {scheme, address, port, transport}, :"$3", :_}, [],
|
||||
[{{:"$1", :"$2", {{scheme, {address}, port, transport}}, :"$3", status}}]}
|
||||
]
|
||||
|
||||
:ets.select_replace(table, match_spec)
|
||||
|
||||
:ok
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find healthy targets for a given listener and hostname.
|
||||
"""
|
||||
@spec find_healthy_targets(:ets.tid(), listener, String.t()) ::
|
||||
{:ok, [{target, algorithm}]} | {:error, any}
|
||||
def find_healthy_targets(table, listener, hostname) do
|
||||
# Match spec generated with:
|
||||
# :ets.fun2ms(fn {:listener, {segment, "example", "com"}, target, algorithm, :healthy}
|
||||
# when segment == "www" or segment == :_ ->
|
||||
# {target, algorithm}
|
||||
# end)
|
||||
|
||||
[head | tail] =
|
||||
hostname
|
||||
|> String.trim_trailing(".")
|
||||
|> String.split(".")
|
||||
|
||||
host_match = [:"$1" | tail] |> List.to_tuple()
|
||||
|
||||
match_spec =
|
||||
[
|
||||
{{listener, host_match, :"$2", :"$3", :healthy},
|
||||
[{:orelse, {:==, :"$1", head}, {:==, :"$1", :_}}], [{{:"$2", :"$3"}}]}
|
||||
]
|
||||
|
||||
targets =
|
||||
table
|
||||
|> :ets.select(match_spec)
|
||||
|> Enum.uniq()
|
||||
|
||||
{:ok, targets}
|
||||
rescue
|
||||
e -> {:error, e}
|
||||
end
|
||||
|
||||
defp routes_to_entries(table, routes) do
|
||||
Enum.reduce_while(routes, {:ok, []}, fn {listener, target, host_names, algorithm},
|
||||
{:ok, entries} ->
|
||||
case route_to_entries(table, listener, target, host_names, algorithm) do
|
||||
{:ok, new_entries} -> {:cont, {:ok, Enum.concat(entries, new_entries)}}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp route_to_entries(table, listener, target, host_names, algorithm)
|
||||
when Selector.is_algorithm(algorithm) do
|
||||
with {:ok, listener} <- sanitise_listener(listener),
|
||||
{:ok, target} <- sanitise_target(target),
|
||||
{:ok, patterns} <- host_names_to_pattern(host_names),
|
||||
health_state <- current_health_state(table, target) do
|
||||
entries = Enum.map(patterns, &{listener, &1, target, algorithm, health_state})
|
||||
{:ok, entries}
|
||||
end
|
||||
end
|
||||
|
||||
defp route_to_entries(_table, _listener, _target, _host_names, algorithm),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Value `#{inspect(algorithm)}` is not a valid load balancing algorithm."
|
||||
)}
|
||||
|
||||
defp current_health_state(table, {scheme, address, port, transport}) do
|
||||
# Generated using
|
||||
# :ets.fun2ms(fn {_, _, :target, :_, health} -> health end)
|
||||
|
||||
match_spec = [
|
||||
{{:_, :_, {scheme, address, port, transport}, :_, :"$1"}, [], [:"$1"]}
|
||||
]
|
||||
|
||||
case :ets.select(table, match_spec, 1) do
|
||||
{[health], _} -> health
|
||||
_ -> :initial
|
||||
end
|
||||
end
|
||||
|
||||
defp host_names_to_pattern(host_names) do
|
||||
Enum.reduce_while(host_names, {:ok, []}, fn host_name, {:ok, patterns} ->
|
||||
case host_name_to_pattern(host_name) do
|
||||
{:ok, pattern} -> {:cont, {:ok, [pattern | patterns]}}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp host_name_to_pattern(host_name) when is_binary(host_name) do
|
||||
pattern =
|
||||
host_name
|
||||
|> String.trim_trailing(".")
|
||||
|> String.split(".")
|
||||
|> Enum.reduce({}, fn
|
||||
"*", {} -> {:_}
|
||||
segment, pattern -> Tuple.append(pattern, segment)
|
||||
end)
|
||||
|
||||
{:ok, pattern}
|
||||
end
|
||||
|
||||
defp host_name_to_pattern(host_name) do
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Value `#{inspect(host_name)}` is not a valid host expression."
|
||||
)}
|
||||
end
|
||||
|
||||
defp sanitise_listener({scheme, address, port}) do
|
||||
with {:ok, scheme} <- sanitise_scheme(scheme),
|
||||
{:ok, address} <- sanitise_ip_address(address),
|
||||
{:ok, port} <- sanitise_port(port) do
|
||||
{:ok, {scheme, address, port}}
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitise_listener(listener),
|
||||
do: {:error, ArgumentError.exception(message: "Not a valid listener: `#{inspect(listener)}")}
|
||||
|
||||
defp sanitise_transport(transport) when transport in [:http1, :http2, :auto],
|
||||
do: {:ok, transport}
|
||||
|
||||
defp sanitise_transport(transport),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(message: "Not a valid target transport: `#{inspect(transport)}`")}
|
||||
|
||||
defp sanitise_target({:plug, module}), do: sanitise_target({:plug, module, []})
|
||||
|
||||
defp sanitise_target({:plug, module, _}) do
|
||||
if Spark.implements_behaviour?(module, Plug) do
|
||||
{:ok, {:plug, module}}
|
||||
else
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Module `#{inspect(module)}` does not implement the `Plug` behaviour."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitise_target({scheme, address, port, transport}) do
|
||||
with {:ok, scheme} <- sanitise_scheme(scheme),
|
||||
{:ok, address} <- sanitise_ip_address(address),
|
||||
{:ok, port} <- sanitise_port(port),
|
||||
{:ok, transport} <- sanitise_transport(transport) do
|
||||
{:ok, {scheme, address, port, transport}}
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitise_target(target),
|
||||
do: {:error, ArgumentError.exception(message: "Not a valid target: `#{inspect(target)}")}
|
||||
end
|
228
lib/wayfarer/server.ex
Normal file
228
lib/wayfarer/server.ex
Normal file
|
@ -0,0 +1,228 @@
|
|||
defmodule Wayfarer.Server do
|
||||
alias Spark.Options
|
||||
alias Wayfarer.{Dsl, Listener, Router, Server, Target}
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@callback child_spec(keyword()) :: Supervisor.child_spec()
|
||||
@callback start_link(keyword()) :: GenServer.on_start()
|
||||
|
||||
@scheme_type {:in, [:http, :https, :ws, :wss]}
|
||||
@port_type {:in, 0..0xFFFF}
|
||||
@transport_type {:in, [:http1, :http2, :auto]}
|
||||
@ip_type {:or,
|
||||
[
|
||||
{:tuple, [{:in, 0..0xFF}, {:in, 0..0xFF}, {:in, 0..0xFF}, {:in, 0..0xFF}]},
|
||||
{:tuple,
|
||||
[
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF},
|
||||
{:in, 0..0xFFFF}
|
||||
]},
|
||||
{:struct, IP.Address}
|
||||
]}
|
||||
|
||||
@options_schema [
|
||||
module: [
|
||||
type: {:behaviour, __MODULE__},
|
||||
required: true,
|
||||
doc: "The name of the module which \"uses\" Wayfarer.Server."
|
||||
],
|
||||
targets: [
|
||||
type: {:list, {:keyword_list, Dsl.Target.schema()}},
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "A list of target specifications."
|
||||
],
|
||||
listeners: [
|
||||
type: {:list, {:keyword_list, Dsl.Listener.schema()}},
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "A list of listener specifications."
|
||||
],
|
||||
routing_table: [
|
||||
type:
|
||||
{:list,
|
||||
{:tuple,
|
||||
[
|
||||
{:tuple, [@scheme_type, @ip_type, @port_type]},
|
||||
{:tuple, [@scheme_type, @ip_type, @port_type, @transport_type]},
|
||||
{:list, :string},
|
||||
{:in, [:round_robin, :sticky, :random, :least_connections]}
|
||||
]}},
|
||||
required: false,
|
||||
default: [],
|
||||
doc: "A list of routes to add when the server starts."
|
||||
]
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
A GenServer which manages a proxy.
|
||||
|
||||
An appropriate `child_spec/1` and `start_link/1` are generated when `use
|
||||
Wayfarer.Server` is called.
|
||||
|
||||
You can use this module directly if you are not planning on using the configuration DSL at all.
|
||||
|
||||
## Example
|
||||
|
||||
```elixir
|
||||
defmodule MyProxy do
|
||||
use Wayfarer.Server, targets: [..], listeners: [..], routing_table: [..]
|
||||
end
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
#{Options.docs(@options_schema)}
|
||||
"""
|
||||
|
||||
@type options :: keyword
|
||||
|
||||
@doc false
|
||||
@spec __using__(any) :: Macro.output()
|
||||
defmacro __using__(opts) do
|
||||
quote do
|
||||
@behaviour Server
|
||||
|
||||
@doc false
|
||||
@spec child_spec(keyword) :: Supervisor.child_spec()
|
||||
def child_spec(opts) do
|
||||
opts =
|
||||
unquote(opts)
|
||||
|> Keyword.merge(opts)
|
||||
|> Keyword.put(:module, __MODULE__)
|
||||
|
||||
Server.child_spec(opts)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec start_link(keyword) :: GenServer.on_start()
|
||||
def start_link(opts) do
|
||||
opts =
|
||||
unquote(opts)
|
||||
|> Keyword.merge(opts)
|
||||
|> Keyword.put(:module, __MODULE__)
|
||||
|
||||
Server.start_link(opts)
|
||||
end
|
||||
|
||||
defoverridable child_spec: 1, start_link: 1
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec target_status_change(
|
||||
{module, :http | :https, IP.Address.t(), :socket.port_number(),
|
||||
:http1 | :http2 | :auto},
|
||||
Router.health()
|
||||
) :: :ok
|
||||
def target_status_change({module, scheme, address, port, transport}, status) do
|
||||
GenServer.cast(
|
||||
{:via, Registry, {Wayfarer.Server.Registry, module}},
|
||||
{:target_status_change, scheme, address, port, transport, status}
|
||||
)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec start_link(options) :: GenServer.on_start()
|
||||
def start_link(opts) do
|
||||
case Keyword.fetch(opts, :module) do
|
||||
{:ok, module} ->
|
||||
GenServer.start_link(__MODULE__, opts,
|
||||
name: {:via, Registry, {Wayfarer.Server.Registry, module}}
|
||||
)
|
||||
|
||||
:error ->
|
||||
{:error, "Missing required `module` option."}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(options) :: {:ok, map} | {:stop, any}
|
||||
def init(options) do
|
||||
with {:ok, options} <- Options.validate(options, @options_schema),
|
||||
{:ok, module} <- assert_is_server(options[:module]),
|
||||
listeners <- Keyword.get(options, :listeners, []),
|
||||
targets <- Keyword.get(options, :targets, []),
|
||||
initial_routing_table <- Keyword.get(options, :routing_table, []),
|
||||
{:ok, routing_table} <- Router.init(module),
|
||||
:ok <- Router.import_routes(routing_table, initial_routing_table),
|
||||
state <- %{module: module, routing_table: routing_table},
|
||||
{:ok, state} <- start_listeners(listeners, state),
|
||||
{:ok, state} <- start_targets(targets, state) do
|
||||
{:ok, state}
|
||||
else
|
||||
:error -> raise "unreachable"
|
||||
{:error, reason} -> {:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_cast(any, map) :: {:noreply, map}
|
||||
def handle_cast({:target_status_change, scheme, address, port, transport, status}, state) do
|
||||
Router.update_target_health_status(
|
||||
state.routing_table,
|
||||
{scheme, IP.Address.to_tuple(address), port, transport},
|
||||
status
|
||||
)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp start_listeners(listeners, state) do
|
||||
listeners
|
||||
|> Enum.reduce_while({:ok, state}, fn listener, success ->
|
||||
listener = Keyword.put(listener, :module, state.module)
|
||||
|
||||
case DynamicSupervisor.start_child(Listener.DynamicSupervisor, {Listener, listener}) do
|
||||
{:ok, pid} ->
|
||||
Process.link(pid)
|
||||
{:cont, success}
|
||||
|
||||
{:error, {:already_started, pid}} ->
|
||||
Process.link(pid)
|
||||
{:cont, success}
|
||||
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp start_targets(targets, state) do
|
||||
targets
|
||||
|> Enum.reduce_while({:ok, state}, fn target, success ->
|
||||
target = Keyword.put(target, :module, state.module)
|
||||
|
||||
case DynamicSupervisor.start_child(Target.DynamicSupervisor, {Target, target}) do
|
||||
{:ok, pid} ->
|
||||
Process.link(pid)
|
||||
{:cont, success}
|
||||
|
||||
{:error, {:already_started, pid}} ->
|
||||
Process.link(pid)
|
||||
{:cont, success}
|
||||
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp assert_is_server(module) do
|
||||
if Spark.implements_behaviour?(module, __MODULE__) do
|
||||
{:ok, module}
|
||||
else
|
||||
{:error,
|
||||
"The module `#{inspect(module)}` does not implement the `Wayfarer.Server` behaviour."}
|
||||
end
|
||||
end
|
||||
end
|
110
lib/wayfarer/server/plug.ex
Normal file
110
lib/wayfarer/server/plug.ex
Normal file
|
@ -0,0 +1,110 @@
|
|||
defmodule Wayfarer.Server.Plug do
|
||||
@moduledoc """
|
||||
Plug pipeline to handle inbound HTTP connections.
|
||||
"""
|
||||
|
||||
alias Wayfarer.{Router, Server.Proxy, Target.Selector, Telemetry}
|
||||
require Logger
|
||||
|
||||
import Plug.Conn
|
||||
@behaviour Plug
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(Enumerable.t()) :: map
|
||||
def init(config), do: Map.new(config)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec call(Plug.Conn.t(), map) :: Plug.Conn.t()
|
||||
def call(conn, config) do
|
||||
transport = get_transport(conn)
|
||||
|
||||
conn
|
||||
|> put_private(:wayfarer, %{listener: config, transport: transport})
|
||||
|> Telemetry.request_start()
|
||||
|> do_call(config)
|
||||
end
|
||||
|
||||
defp do_call(conn, config) when is_atom(config.module) do
|
||||
listener = {config.scheme, config.address, config.port}
|
||||
|
||||
with {:ok, targets} <- Router.find_healthy_targets(config.module, listener, conn.host),
|
||||
{:ok, targets, algorithm} <- split_targets_and_algorithms(targets),
|
||||
{:ok, target} <- Selector.choose(conn, targets, algorithm) do
|
||||
conn
|
||||
|> Telemetry.request_routed(target, algorithm)
|
||||
|> do_proxy(target)
|
||||
else
|
||||
:error ->
|
||||
conn
|
||||
|> Telemetry.request_exception(:error, :target_not_found)
|
||||
|> bad_gateway()
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> Telemetry.request_exception(:error, reason)
|
||||
|> internal_error(reason)
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
conn
|
||||
|> Telemetry.request_exception(:exception, exception, __STACKTRACE__)
|
||||
|> internal_error(exception)
|
||||
catch
|
||||
reason ->
|
||||
conn
|
||||
|> Telemetry.request_exception(:throw, reason)
|
||||
|> internal_error(reason)
|
||||
|
||||
kind, reason ->
|
||||
conn
|
||||
|> Telemetry.request_exception(kind, reason)
|
||||
|> internal_error(reason)
|
||||
end
|
||||
|
||||
defp do_call(conn, _config) do
|
||||
conn
|
||||
|> Telemetry.request_exception(:error, :unrecognised_request)
|
||||
|> bad_gateway()
|
||||
end
|
||||
|
||||
defp internal_error(conn, reason) do
|
||||
Logger.error("Internal error when routing proxy request: #{inspect(reason)}")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(500, "Internal Error")
|
||||
end
|
||||
|
||||
defp bad_gateway(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(502, "Bad Gateway")
|
||||
end
|
||||
|
||||
defp do_proxy(conn, {:plug, {module, opts}}), do: module.call(conn, module.init(opts))
|
||||
defp do_proxy(conn, {:plug, module}), do: module.call(conn, module.init([]))
|
||||
defp do_proxy(conn, target), do: Proxy.request(conn, target)
|
||||
|
||||
# This is lazy, but we assume that the algorithm is consistent across all
|
||||
# matching targets.
|
||||
defp split_targets_and_algorithms([]), do: :error
|
||||
defp split_targets_and_algorithms([{target, algorithm}]), do: {:ok, [target], algorithm}
|
||||
|
||||
defp split_targets_and_algorithms([{target, algorithm} | tail]),
|
||||
do: split_targets_and_algorithms(tail, [target], algorithm)
|
||||
|
||||
defp split_targets_and_algorithms([{target, algorithm} | tail], targets, algorithm),
|
||||
do: split_targets_and_algorithms(tail, [target | targets], algorithm)
|
||||
|
||||
defp split_targets_and_algorithms([], targets, algorithm), do: {:ok, targets, algorithm}
|
||||
|
||||
defp get_transport(%{adapter: {Bandit.Adapter, %{transport: %Bandit.HTTP1.Socket{}}}}),
|
||||
do: :http1
|
||||
|
||||
defp get_transport(%{adapter: {Bandit.Adapter, %{transport: %Bandit.HTTP2.Stream{}}}}),
|
||||
do: :http2
|
||||
|
||||
defp get_transport(_), do: :unknown
|
||||
end
|
346
lib/wayfarer/server/proxy.ex
Normal file
346
lib/wayfarer/server/proxy.ex
Normal file
|
@ -0,0 +1,346 @@
|
|||
defmodule Wayfarer.Server.Proxy do
|
||||
@moduledoc """
|
||||
Uses Mint to convert a Plug connection into an outgoing HTTP request to a
|
||||
specific target.
|
||||
"""
|
||||
|
||||
alias Mint.{HTTP, HTTP1, HTTP2}
|
||||
alias Plug.Conn
|
||||
alias Wayfarer.{Router, Target.ActiveConnections, Target.TotalConnections, Telemetry}
|
||||
require Logger
|
||||
|
||||
@connect_timeout 5_000
|
||||
@idle_timeout 5_000
|
||||
|
||||
@doc """
|
||||
Convert the request conn into an HTTP request to the specified target.
|
||||
"""
|
||||
@spec request(Conn.t(), Router.target()) :: Conn.t()
|
||||
def request(conn, target) do
|
||||
with {:ok, mint} <- connect(conn, target),
|
||||
:ok <- ActiveConnections.connect(target),
|
||||
:ok <- TotalConnections.proxy_connect(target) do
|
||||
handle_request(mint, conn, target)
|
||||
else
|
||||
error ->
|
||||
conn
|
||||
|> Telemetry.request_exception(:error, error)
|
||||
|> handle_error(error, target)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_request(mint, conn, {proto, _, _, _}) do
|
||||
if http1?(conn) && connection_wants_upgrade?(conn) && upgrade_is_websocket?(conn) do
|
||||
handle_websocket_request(mint, conn, proto)
|
||||
else
|
||||
handle_http_request(mint, conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_http_request(mint, conn) do
|
||||
with {:ok, mint, req} <- send_request(conn, mint),
|
||||
{:ok, mint, conn} <- stream_request_body(conn, mint, req),
|
||||
{:ok, conn, _mint} <- proxy_responses(conn, mint, req) do
|
||||
conn
|
||||
|> Telemetry.request_stop()
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_websocket_request(mint, conn, proto) do
|
||||
conn
|
||||
|> WebSockAdapter.upgrade(Wayfarer.Server.WebSocketProxy, {mint, conn, proto}, compress: true)
|
||||
|> Telemetry.request_upgraded()
|
||||
end
|
||||
|
||||
defp connect(conn, {:ws, address, port, transport}),
|
||||
do: connect(conn, {:http, address, port, transport})
|
||||
|
||||
defp connect(conn, {:wss, address, port, transport}),
|
||||
do: connect(conn, {:https, address, port, transport})
|
||||
|
||||
defp connect(conn, {scheme, address, port, :http1}) when is_tuple(address),
|
||||
do: HTTP1.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
|
||||
|
||||
defp connect(conn, {scheme, address, port, :http2}) when is_tuple(address),
|
||||
do: HTTP2.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
|
||||
|
||||
defp connect(conn, {scheme, address, port, :auto}) when is_tuple(address),
|
||||
do: HTTP.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
|
||||
|
||||
defp handle_error(conn, {:error, _, reason}, target),
|
||||
do: handle_error(conn, {:error, reason}, target)
|
||||
|
||||
defp handle_error(conn, {:error, reason}, {scheme, address, port, transport}) do
|
||||
Logger.error(fn ->
|
||||
phase = connection_phase(conn)
|
||||
ip = :inet.ntoa(address)
|
||||
|
||||
"Proxy error [phase=#{phase},ip=#{ip},port=#{port},proto=#{scheme},trans=#{transport}]: #{message(reason)}"
|
||||
end)
|
||||
|
||||
if conn.halted || conn.state in [:sent, :chunked, :upgraded] do
|
||||
# Sadly there's not much more we can do here.
|
||||
Conn.halt(conn)
|
||||
else
|
||||
{status, body} = status_and_response(reason)
|
||||
|
||||
conn
|
||||
|> Conn.put_resp_content_type("text/plain")
|
||||
|> Conn.send_resp(status, body)
|
||||
end
|
||||
end
|
||||
|
||||
defp http1?(%{private: %{wayfarer: %{transport: :http1}}}), do: true
|
||||
defp http1?(_), do: false
|
||||
|
||||
defp connection_wants_upgrade?(conn) do
|
||||
case Conn.get_req_header(conn, "connection") do
|
||||
["Upgrade"] ->
|
||||
true
|
||||
|
||||
["upgrade"] ->
|
||||
true
|
||||
|
||||
[] ->
|
||||
false
|
||||
|
||||
maybe ->
|
||||
maybe
|
||||
|> Enum.flat_map(&String.split(&1, ~r/,\s*/))
|
||||
|> Enum.map(fn chunk ->
|
||||
chunk
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
end)
|
||||
|> Enum.member?("upgrade")
|
||||
end
|
||||
end
|
||||
|
||||
defp upgrade_is_websocket?(conn) do
|
||||
case Conn.get_req_header(conn, "upgrade") do
|
||||
["websocket"] ->
|
||||
true
|
||||
|
||||
[] ->
|
||||
false
|
||||
|
||||
maybe ->
|
||||
maybe
|
||||
|> Enum.flat_map(&String.split(&1, ~r/,\s*/))
|
||||
|> Enum.map(fn chunk ->
|
||||
chunk
|
||||
|> String.trim()
|
||||
|> String.split("/")
|
||||
|> hd()
|
||||
|> String.downcase()
|
||||
end)
|
||||
|> Enum.member?("websocket")
|
||||
end
|
||||
end
|
||||
|
||||
defp connection_phase(conn) when conn.state in [:chunked, :upgraded], do: "stream"
|
||||
defp connection_phase(conn) when conn.halted, do: "done"
|
||||
defp connection_phase(conn) when conn.state == :sent, do: "done"
|
||||
defp connection_phase(_conn), do: "init"
|
||||
|
||||
defp message(error) when is_exception(error), do: Exception.message(error)
|
||||
defp message(error) when is_binary(error), do: error
|
||||
defp message(error), do: inspect(error)
|
||||
|
||||
defp status_and_response(:idle_timeout), do: {504, "Gateway Timeout"}
|
||||
defp status_and_response(error) when error.reason == :timeout, do: {504, "Gateway Timeout"}
|
||||
|
||||
defp status_and_response(error) when is_struct(error, Mint.TransportError),
|
||||
do: {502, "Bad Gateway"}
|
||||
|
||||
defp status_and_response(_error), do: {500, "Internal Error"}
|
||||
|
||||
defp proxy_responses(conn, mint, req) do
|
||||
receive do
|
||||
message ->
|
||||
case HTTP.stream(mint, message) do
|
||||
:unknown ->
|
||||
proxy_responses(conn, mint, req)
|
||||
|
||||
{:ok, mint, responses} ->
|
||||
handle_responses(conn, responses, mint, req)
|
||||
|
||||
{:error, _, reason, _} ->
|
||||
{:error, reason}
|
||||
end
|
||||
after
|
||||
@idle_timeout -> {:error, :idle_timeout}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_responses(conn, [], mint, req), do: proxy_responses(conn, mint, req)
|
||||
|
||||
defp handle_responses(conn, [{:status, req, status} | responses], mint, req) do
|
||||
conn
|
||||
|> Conn.put_status(status)
|
||||
|> Telemetry.request_received_status(status)
|
||||
|> handle_responses(responses, mint, req)
|
||||
end
|
||||
|
||||
defp handle_responses(conn, [{:headers, req, headers} | responses], mint, req) do
|
||||
headers
|
||||
|> Enum.reduce(conn, fn {header_name, header_value}, conn ->
|
||||
conn
|
||||
|> Conn.put_resp_header(header_name, header_value)
|
||||
end)
|
||||
|> handle_responses(responses, mint, req)
|
||||
end
|
||||
|
||||
defp handle_responses(conn, [{:data, req, body} | responses], mint, req)
|
||||
when conn.state == :chunked do
|
||||
case Conn.chunk(conn, body) do
|
||||
{:ok, conn} ->
|
||||
body_size = byte_size(body)
|
||||
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|
||||
|> Telemetry.request_resp_body_chunk(body_size)
|
||||
|> handle_responses(responses, mint, req)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, conn, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_responses(conn, [{:data, req, body} | responses], mint, req) do
|
||||
# We need to check here for a content-length or transfer encoding header and
|
||||
# deal with it. This should be refactored out into a proxy state rather
|
||||
# than using the conn as our state.
|
||||
|
||||
body_size = byte_size(body)
|
||||
|
||||
case Conn.get_resp_header(conn, "content-length") do
|
||||
[] ->
|
||||
conn
|
||||
|> Conn.send_chunked(conn.status)
|
||||
|> Telemetry.request_resp_started()
|
||||
|> handle_responses([{:data, req, body} | responses], mint, req)
|
||||
|
||||
[length] ->
|
||||
if String.to_integer(length) == body_size do
|
||||
conn =
|
||||
conn
|
||||
|> Conn.send_resp(conn.status, body)
|
||||
|> Telemetry.request_resp_started()
|
||||
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|
||||
|> Telemetry.request_resp_body_chunk(body_size)
|
||||
|> Conn.halt()
|
||||
|
||||
{:ok, conn, mint}
|
||||
else
|
||||
conn =
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|
||||
|> Telemetry.request_resp_body_chunk(body_size)
|
||||
|> Conn.send_chunked(conn.status)
|
||||
|
||||
handle_responses(conn, [{:data, req, body} | responses], mint, req)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# If the connection is done without sending any body content, then we need to
|
||||
# send the respond and halt the conn.
|
||||
defp handle_responses(conn, [{:done, req} | _], mint, req) when conn.state == :unset do
|
||||
conn =
|
||||
conn
|
||||
|> Conn.send_resp(conn.status, "")
|
||||
|> Conn.halt()
|
||||
|
||||
{:ok, conn, mint}
|
||||
end
|
||||
|
||||
defp handle_responses(conn, [{:done, req} | _], mint, req) when conn.state == :chunked,
|
||||
do: {:ok, Conn.halt(conn), mint}
|
||||
|
||||
defp handle_responses(conn, [{:error, req, reason} | _], _mint, req), do: {:error, conn, reason}
|
||||
|
||||
defp send_request(conn, mint) do
|
||||
request_path =
|
||||
case {conn.request_path, conn.query_string} do
|
||||
{path, nil} -> path
|
||||
{path, ""} -> path
|
||||
{path, query} -> path <> "?" <> query
|
||||
end
|
||||
|
||||
HTTP.request(
|
||||
mint,
|
||||
conn.method,
|
||||
request_path,
|
||||
proxy_headers(conn),
|
||||
:stream
|
||||
)
|
||||
end
|
||||
|
||||
defp stream_request_body(conn, mint, req) do
|
||||
case Conn.read_body(conn) do
|
||||
{:ok, <<>>, conn} ->
|
||||
with {:ok, mint} <- HTTP.stream_request_body(mint, req, :eof) do
|
||||
conn =
|
||||
conn
|
||||
|> Telemetry.set_metrics(%{req_body_bytes: 0, req_body_chunks: 0})
|
||||
|
||||
{:ok, mint, conn}
|
||||
end
|
||||
|
||||
{:ok, chunk, conn} ->
|
||||
with {:ok, mint} <- HTTP.stream_request_body(mint, req, chunk),
|
||||
{:ok, mint} <- HTTP.stream_request_body(mint, req, :eof) do
|
||||
chunk_size = byte_size(chunk)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{req_body_bytes: chunk_size, req_body_chunks: 1})
|
||||
|> Telemetry.request_req_body_chunk(chunk_size)
|
||||
|
||||
{:ok, mint, conn}
|
||||
end
|
||||
|
||||
{:more, chunk, conn} ->
|
||||
with {:ok, mint} <- HTTP.stream_request_body(mint, req, chunk) do
|
||||
chunk_size = byte_size(chunk)
|
||||
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{req_body_bytes: chunk_size, req_body_chunks: 1})
|
||||
|> Telemetry.request_req_body_chunk(chunk_size)
|
||||
|> stream_request_body(mint, req)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, conn, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp proxy_headers(conn) do
|
||||
listener =
|
||||
conn.private.wayfarer.listener
|
||||
|> case do
|
||||
%{address: address, port: port} when tuple_size(address) == 8 ->
|
||||
"[#{:inet.ntoa(address)}]:#{port}"
|
||||
|
||||
%{address: address, port: port} ->
|
||||
"#{:inet.ntoa(address)}:#{port}"
|
||||
end
|
||||
|
||||
client =
|
||||
conn
|
||||
|> Conn.get_peer_data()
|
||||
|> case do
|
||||
%{address: address, port: port} when tuple_size(address) == 8 ->
|
||||
"[#{:inet.ntoa(address)}]:#{port}"
|
||||
|
||||
%{address: address, port: port} ->
|
||||
"#{:inet.ntoa(address)}:#{port}"
|
||||
end
|
||||
|
||||
[
|
||||
{"forwarded", "by=#{listener};for=#{client};host=#{conn.host};proto=#{conn.scheme}"}
|
||||
| conn.req_headers
|
||||
]
|
||||
end
|
||||
end
|
15
lib/wayfarer/server/supervisor.ex
Normal file
15
lib/wayfarer/server/supervisor.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
defmodule Wayfarer.Server.Supervisor do
|
||||
@moduledoc false
|
||||
|
||||
use Supervisor
|
||||
|
||||
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
[
|
||||
{Registry, keys: :unique, name: Wayfarer.Server.Registry}
|
||||
]
|
||||
|> Supervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
end
|
256
lib/wayfarer/server/websocket_proxy.ex
Normal file
256
lib/wayfarer/server/websocket_proxy.ex
Normal file
|
@ -0,0 +1,256 @@
|
|||
defmodule Wayfarer.Server.WebSocketProxy do
|
||||
@moduledoc """
|
||||
When a connection is upgraded to a websocket, we switch from handing via
|
||||
`Plug` to `WebSock` via `WebSockAdapter`.
|
||||
|
||||
The outgoing connection is made using `Mint.WebSocket`.
|
||||
"""
|
||||
|
||||
@behaviour WebSock
|
||||
|
||||
alias Mint.WebSocket
|
||||
alias Plug.Conn
|
||||
alias Wayfarer.Telemetry
|
||||
require Logger
|
||||
|
||||
@default_opts [extensions: [WebSocket.PerMessageDeflate]]
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init({mint, conn, proto}) when proto in [:ws, :wss] do
|
||||
request_path =
|
||||
case {conn.request_path, conn.query_string} do
|
||||
{path, nil} -> path
|
||||
{path, ""} -> path
|
||||
{path, query} -> path <> "?" <> query
|
||||
end
|
||||
|
||||
case WebSocket.upgrade(proto, mint, request_path, proxy_headers(conn), @default_opts) do
|
||||
{:ok, mint, ref} -> {:ok, %{mint: mint, ref: ref, status: :init, buffer: [], conn: conn}}
|
||||
{:error, _mint, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def init({mint, conn, :https}), do: init({mint, conn, :wss})
|
||||
def init({mint, conn, :http}), do: init({mint, conn, :ws})
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_control({frame, [{:opcode, :ping}]}, state) do
|
||||
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, {:ping, frame}),
|
||||
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
|
||||
conn = request_client_frame(state.conn, {:ping, frame})
|
||||
|
||||
{:ok, %{state | websocket: websocket, mint: mint, conn: conn}}
|
||||
else
|
||||
error -> handle_error(error, state)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_control({frame, [{:opcode, frame_type}]}, state) do
|
||||
conn = request_client_frame(state.conn, {frame_type, frame})
|
||||
|
||||
{:ok, %{state | conn: conn}}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_in({payload, [{:opcode, frame_type}]}, state) when state.status == :init do
|
||||
frame = {frame_type, payload}
|
||||
buffer = [frame | state.buffer]
|
||||
conn = request_client_frame(state.conn, frame)
|
||||
|
||||
{:ok, %{state | buffer: buffer, conn: conn}}
|
||||
end
|
||||
|
||||
def handle_in({payload, [{:opcode, frame_type}]}, state) do
|
||||
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, {frame_type, payload}),
|
||||
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
|
||||
conn = request_client_frame(state.conn, {frame_type, payload})
|
||||
|
||||
{:ok, %{state | websocket: websocket, mint: mint, conn: conn}}
|
||||
else
|
||||
error -> handle_error(error, state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info(msg, state) when state.status == :init do
|
||||
with {:ok, mint, result} <- WebSocket.stream(state.mint, msg),
|
||||
{:ok, result} <- handle_upgrade_response(result, state.ref),
|
||||
{:ok, mint, websocket} <- WebSocket.new(mint, state.ref, result.status, result.headers),
|
||||
state <- Map.merge(state, %{status: :connected, websocket: websocket, mint: mint}),
|
||||
{:ok, state} <- empty_buffer(state),
|
||||
{:ok, messages, state} <- decode_frames(result.data, state) do
|
||||
response_for_messages(messages, state)
|
||||
else
|
||||
error -> handle_error(error, state)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(msg, state) when state.status == :connected do
|
||||
with {:ok, mint, result} <- WebSocket.stream(state.mint, msg),
|
||||
{:ok, frames} <- handle_websocket_data(result, state.ref),
|
||||
{:ok, messages, state} <- decode_frames(frames, %{state | mint: mint}) do
|
||||
response_for_messages(messages, state)
|
||||
else
|
||||
error -> handle_error(error, state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def terminate(_reason, _state), do: :ok
|
||||
|
||||
defp handle_error({:error, _, %{reason: reason}, _}, state),
|
||||
do: handle_error({:error, reason}, state)
|
||||
|
||||
defp handle_error({:error, reason, state}, _state),
|
||||
do: handle_error({:error, reason}, state)
|
||||
|
||||
defp handle_error({:error, reason}, state) do
|
||||
Logger.debug(fn ->
|
||||
"Dropping WebSocket connection for reason: #{inspect(reason)}"
|
||||
end)
|
||||
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
defp proxy_headers(conn) do
|
||||
listener =
|
||||
conn.private.wayfarer.listener
|
||||
|> case do
|
||||
%{address: address, port: port} when tuple_size(address) == 8 ->
|
||||
"[#{:inet.ntoa(address)}]:#{port}"
|
||||
|
||||
%{address: address, port: port} ->
|
||||
"#{:inet.ntoa(address)}:#{port}"
|
||||
end
|
||||
|
||||
client =
|
||||
conn
|
||||
|> Conn.get_peer_data()
|
||||
|> case do
|
||||
%{address: address, port: port} when tuple_size(address) == 8 ->
|
||||
"[#{:inet.ntoa(address)}]:#{port}"
|
||||
|
||||
%{address: address, port: port} ->
|
||||
"#{:inet.ntoa(address)}:#{port}"
|
||||
end
|
||||
|
||||
req_headers =
|
||||
conn.req_headers
|
||||
|> Enum.reject(
|
||||
&(elem(&1, 0) in [
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-extensions",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-version"
|
||||
])
|
||||
)
|
||||
|
||||
[
|
||||
{"forwarded", "by=#{listener};for=#{client};host=#{conn.host};proto=#{conn.scheme}"}
|
||||
| req_headers
|
||||
]
|
||||
end
|
||||
|
||||
defp empty_buffer(state) when state.buffer == [], do: {:ok, state}
|
||||
defp empty_buffer(state), do: do_empty_buffer(Enum.reverse(state.buffer), %{state | buffer: []})
|
||||
defp do_empty_buffer([], state), do: {:ok, state}
|
||||
|
||||
defp do_empty_buffer([head | tail], state) do
|
||||
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, head),
|
||||
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
|
||||
conn = request_client_frame(state.conn, head)
|
||||
|
||||
do_empty_buffer(tail, %{state | websocket: websocket, mint: mint, conn: conn})
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_upgrade_response(result, ref), do: handle_upgrade_response(result, ref, %{data: []})
|
||||
defp handle_upgrade_response([{:done, ref}], ref, result), do: {:ok, result}
|
||||
|
||||
defp handle_upgrade_response([{:status, ref, status} | tail], ref, result) do
|
||||
handle_upgrade_response(tail, ref, Map.put(result, :status, status))
|
||||
end
|
||||
|
||||
defp handle_upgrade_response([{:headers, ref, headers} | tail], ref, result) do
|
||||
handle_upgrade_response(tail, ref, Map.put(result, :headers, headers))
|
||||
end
|
||||
|
||||
defp handle_upgrade_response([{:data, ref, data} | tail], ref, result) do
|
||||
result = Map.update!(result, :data, &[data | &1])
|
||||
handle_upgrade_response(tail, ref, result)
|
||||
end
|
||||
|
||||
defp handle_websocket_data(data, ref),
|
||||
do: handle_websocket_data(data, ref, [])
|
||||
|
||||
defp handle_websocket_data([], _ref, messages), do: {:ok, Enum.reverse(messages)}
|
||||
|
||||
defp handle_websocket_data([{:data, ref, data} | tail], ref, messages),
|
||||
do: handle_websocket_data(tail, ref, [data | messages])
|
||||
|
||||
defp decode_frames(frames, state) do
|
||||
frames
|
||||
|> Enum.reduce_while({:ok, [], state}, fn frame, {:ok, messages, state} ->
|
||||
case decode_frame(state, frame) do
|
||||
{:ok, new_messages, state} -> {:cont, {:ok, [new_messages, messages], state}}
|
||||
{:error, reason, state} -> {:halt, {:error, reason, state}}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, messages, state} -> {:ok, List.flatten(messages), state}
|
||||
{:error, reason, state} -> {:error, reason, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_frame(state, frame) do
|
||||
case WebSocket.decode(state.websocket, frame) do
|
||||
{:ok, websocket, frames} when is_list(frames) ->
|
||||
conn = Enum.reduce(frames, state.conn, &request_server_frame(&2, &1))
|
||||
{:ok, frames, %{state | websocket: websocket, conn: conn}}
|
||||
|
||||
{:error, websocket, reason} ->
|
||||
{:error, reason, %{state | websocket: websocket}}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all the frames coming from the target and decide how to respond to
|
||||
# Bandit/WebSock. In the case of encountering a close frame, we terminate the
|
||||
# client websocket with the same code, otherwise we just copy the frames over.
|
||||
defp response_for_messages(messages, state, response \\ [])
|
||||
defp response_for_messages([], state, []), do: {:ok, state}
|
||||
defp response_for_messages([], state, response), do: {:push, Enum.reverse(response), state}
|
||||
|
||||
defp response_for_messages([{:close, code, _} | _], state, response),
|
||||
do: {:stop, :normal, code, Enum.reverse(response), state}
|
||||
|
||||
defp response_for_messages([message | messages], state, response),
|
||||
do: response_for_messages(messages, state, [message | response])
|
||||
|
||||
defp request_client_frame(conn, {frame_type, frame}) do
|
||||
frame_size = byte_size(frame)
|
||||
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{
|
||||
client_frame_bytes: frame_size,
|
||||
client_frame_count: 1
|
||||
})
|
||||
|> Telemetry.request_client_frame(frame_size, frame_type)
|
||||
end
|
||||
|
||||
defp request_server_frame(conn, {frame_type, frame}) do
|
||||
frame_size = byte_size(frame)
|
||||
|
||||
conn
|
||||
|> Telemetry.increment_metrics(%{
|
||||
server_frame_bytes: frame_size,
|
||||
server_frame_count: 1
|
||||
})
|
||||
|> Telemetry.request_server_frame(frame_size, frame_type)
|
||||
end
|
||||
end
|
262
lib/wayfarer/target.ex
Normal file
262
lib/wayfarer/target.ex
Normal file
|
@ -0,0 +1,262 @@
|
|||
defmodule Wayfarer.Target do
|
||||
# @moduledoc ⬇️⬇️
|
||||
|
||||
use GenServer, restart: :transient
|
||||
require Logger
|
||||
alias Spark.Options
|
||||
alias Wayfarer.{Dsl.HealthCheck, Router, Server, Target}
|
||||
import Wayfarer.Utils
|
||||
|
||||
@options_schema [
|
||||
scheme: [
|
||||
type: {:in, [:http, :https, :ws, :wss]},
|
||||
doc: "The connection scheme.",
|
||||
required: true
|
||||
],
|
||||
port: [
|
||||
type: {:in, 1..0xFFFF},
|
||||
doc: "The TCP port to connect to.",
|
||||
required: true
|
||||
],
|
||||
address: [
|
||||
type: {:struct, IP.Address},
|
||||
doc: "The IP address to connect to.",
|
||||
required: true
|
||||
],
|
||||
module: [
|
||||
type: {:behaviour, Wayfarer.Server},
|
||||
doc: "The proxy module this target is linked to.",
|
||||
required: true
|
||||
],
|
||||
name: [
|
||||
type: {:or, [nil, :string]},
|
||||
doc: "An optional name for the target.",
|
||||
required: false
|
||||
],
|
||||
transport: [
|
||||
type: {:in, [:http1, :http2, :auto]},
|
||||
required: false,
|
||||
default: :auto,
|
||||
doc: "The connection protocol."
|
||||
],
|
||||
health_checks: [
|
||||
type: {:list, {:keyword_list, HealthCheck.schema()}},
|
||||
required: false,
|
||||
default: HealthCheck.default() |> HealthCheck.to_options() |> then(&[&1])
|
||||
]
|
||||
]
|
||||
|
||||
@wayfarer_vsn Application.spec(:wayfarer, :vsn) || Mix.Project.config()[:version]
|
||||
@elixir_vsn Application.spec(:elixir, :vsn)
|
||||
@erlang_vsn :erlang.system_info(:otp_release)
|
||||
@mint_vsn Application.spec(:mint, :vsn)
|
||||
|
||||
@default_headers [
|
||||
{"User-Agent",
|
||||
"Wayfarer/#{@wayfarer_vsn} (Elixir #{@elixir_vsn}; Erlang #{@erlang_vsn}) Mint/#{@mint_vsn}"},
|
||||
{"Connection", "close"},
|
||||
{"Accept", "*/*"}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
A GenServer responsible for performing health-checks against HTTP and HTTPS
|
||||
targets.
|
||||
|
||||
You should not need to create one of these yourself, instead use it via
|
||||
`Wayfarer.Server`.
|
||||
|
||||
## Options
|
||||
|
||||
#{Options.docs(@options_schema)}
|
||||
"""
|
||||
|
||||
@type key :: {module, :http | :https, IP.Address.t(), :socket.port_number()}
|
||||
|
||||
@doc false
|
||||
@spec check_failed({key, reference}) :: :ok
|
||||
def check_failed({key, id}),
|
||||
do: GenServer.cast({:via, Registry, {Wayfarer.Target.Registry, key}}, {:check_failed, id})
|
||||
|
||||
@doc false
|
||||
@spec check_passed({key, reference}) :: :ok
|
||||
def check_passed({key, id}),
|
||||
do: GenServer.cast({:via, Registry, {Wayfarer.Target.Registry, key}}, {:check_passed, id})
|
||||
|
||||
@doc "Return the current health status of the target"
|
||||
@spec current_status(pid | key) :: {:ok, Router.health()} | {:error, any}
|
||||
def current_status(pid) when is_pid(pid), do: GenServer.call(pid, :current_status)
|
||||
|
||||
def current_status(key),
|
||||
do: GenServer.call({:via, Registry, {Wayfarer.Target.Registry, key}}, :current_status)
|
||||
|
||||
@doc false
|
||||
@spec start_link(keyword) :: GenServer.on_start()
|
||||
def start_link(options), do: GenServer.start_link(__MODULE__, options)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(options) do
|
||||
with {:ok, options} <- Options.validate(options, @options_schema),
|
||||
{:ok, uri} <- to_uri(options[:scheme], options[:address], options[:port]) do
|
||||
target = options |> Keyword.take(~w[scheme address port transport]a) |> Map.new()
|
||||
module = options[:module]
|
||||
|
||||
key = {module, target.scheme, target.address, target.port}
|
||||
|
||||
checks =
|
||||
options
|
||||
|> Keyword.get(:health_checks, [])
|
||||
|> Map.new(fn check ->
|
||||
id = make_ref()
|
||||
|
||||
check =
|
||||
check
|
||||
|> Map.new()
|
||||
|> Map.merge(%{
|
||||
status: :initial,
|
||||
scheme: target.scheme,
|
||||
address: IP.Address.to_tuple(target.address),
|
||||
port: target.port,
|
||||
uri: %{uri | path: check[:path]},
|
||||
id: id,
|
||||
ref: {key, id},
|
||||
method: check[:method] |> to_string() |> String.upcase(),
|
||||
headers: @default_headers,
|
||||
hostname: check[:hostname] || uri.host,
|
||||
transport: target.transport,
|
||||
passes: 0
|
||||
})
|
||||
|
||||
{id, check}
|
||||
end)
|
||||
|
||||
Logger.info("Starting Wayfarer target #{uri}.")
|
||||
|
||||
state = %{
|
||||
target: target,
|
||||
checks: checks,
|
||||
uri: uri,
|
||||
module: module,
|
||||
name: options[:name],
|
||||
status: :initial
|
||||
}
|
||||
|
||||
Registry.register(Wayfarer.Target.Registry, key, uri)
|
||||
|
||||
{:ok, state, {:continue, :perform_health_checks}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_continue(:perform_health_checks, state) do
|
||||
perform_health_checks(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_cast({:check_failed, id}, state)
|
||||
when is_map_key(state.checks, id) and state.status == :unhealthy do
|
||||
checks =
|
||||
Map.update!(state.checks, id, fn check ->
|
||||
check
|
||||
|> queue_check()
|
||||
|> then(&%{&1 | status: :unhealthy, passes: 0})
|
||||
end)
|
||||
|
||||
{:noreply, %{state | checks: checks}}
|
||||
end
|
||||
|
||||
def handle_cast({:check_failed, id}, state) when is_map_key(state.checks, id) do
|
||||
checks =
|
||||
Map.update!(state.checks, id, fn check ->
|
||||
check
|
||||
|> queue_check()
|
||||
|> then(&%{&1 | status: :unhealthy, passes: 0})
|
||||
end)
|
||||
|
||||
Server.target_status_change(
|
||||
{state.module, state.target.scheme, state.target.address, state.target.port,
|
||||
state.target.transport},
|
||||
:unhealthy
|
||||
)
|
||||
|
||||
{:noreply, %{state | checks: checks, status: :unhealthy}}
|
||||
end
|
||||
|
||||
def handle_cast({:check_passed, id}, state) when state.status == :healthy do
|
||||
check =
|
||||
state.checks
|
||||
|> Map.fetch!(id)
|
||||
|> queue_check()
|
||||
|> increment_success()
|
||||
|
||||
checks = Map.put(state.checks, id, check)
|
||||
|
||||
{:noreply, %{state | checks: checks}}
|
||||
end
|
||||
|
||||
def handle_cast({:check_passed, id}, state) when is_map_key(state.checks, id) do
|
||||
check =
|
||||
state.checks
|
||||
|> Map.fetch!(id)
|
||||
|> queue_check()
|
||||
|> increment_success()
|
||||
|
||||
checks = Map.put(state.checks, id, check)
|
||||
|
||||
target_became_healthy? =
|
||||
checks
|
||||
|> Map.values()
|
||||
|> Enum.all?(&(&1.status == :healthy))
|
||||
|
||||
if target_became_healthy? do
|
||||
Server.target_status_change(
|
||||
{state.module, state.target.scheme, state.target.address, state.target.port,
|
||||
state.target.transport},
|
||||
:healthy
|
||||
)
|
||||
|
||||
Logger.info("Target #{state.uri} became healthy")
|
||||
|
||||
{:noreply, %{state | checks: checks, status: :healthy}}
|
||||
else
|
||||
{:noreply, %{state | checks: checks}}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_cast(_message, state), do: {:noreply, state}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info({:perform_check, id}, state) do
|
||||
check = Map.fetch!(state.checks, id)
|
||||
{:ok, _pid} = GenServer.start(Target.Check, check)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_call(:current_status, _from, state) do
|
||||
{:reply, {:ok, state.status}, state}
|
||||
end
|
||||
|
||||
defp perform_health_checks(state) do
|
||||
for {_ref, check} <- state.checks do
|
||||
{:ok, _pid} = GenServer.start(Target.Check, check)
|
||||
end
|
||||
end
|
||||
|
||||
defp queue_check(check) do
|
||||
Process.send_after(self(), {:perform_check, check.id}, check.interval)
|
||||
check
|
||||
end
|
||||
|
||||
defp increment_success(check), do: increment_success(check, check.passes + 1)
|
||||
|
||||
defp increment_success(check, passes) when passes >= check.threshold,
|
||||
do: %{check | passes: passes, status: :healthy}
|
||||
|
||||
defp increment_success(check, passes), do: %{check | passes: passes}
|
||||
end
|
126
lib/wayfarer/target/active_connections.ex
Normal file
126
lib/wayfarer/target/active_connections.ex
Normal file
|
@ -0,0 +1,126 @@
|
|||
defmodule Wayfarer.Target.ActiveConnections do
|
||||
@moduledoc """
|
||||
A simple ETS table that tracks active connections to a given target.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
alias Wayfarer.{Router, Utils}
|
||||
|
||||
@type state :: %{table: :ets.tid(), timer: :timer.tref()}
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: {:ok, state} | {:stop, any}
|
||||
def init(_) do
|
||||
report_interval()
|
||||
|> :timer.send_interval(:tick)
|
||||
|> case do
|
||||
{:ok, timer} ->
|
||||
table =
|
||||
__MODULE__
|
||||
|> :ets.new([:public, :named_table, :duplicate_bag])
|
||||
|
||||
{:ok, %{table: table, timer: timer}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_info(:tick | {:DOWN, any, :process, any, pid, any}, state) :: {:noreply, state}
|
||||
def handle_info(:tick, state) do
|
||||
# size = :ets.info(state.table, :size)
|
||||
# Logger.debug("Active connections: #{size}")
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:DOWN, _, :process, pid, _}, state) do
|
||||
:ets.match_delete(__MODULE__, {:_, pid, :_})
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_cast({:monitor, pid}, state) :: {:noreply, state}
|
||||
def handle_cast({:monitor, pid}, state) do
|
||||
Process.monitor(pid)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Track a new active connection.
|
||||
"""
|
||||
@spec connect(Router.target()) :: :ok
|
||||
def connect(target) do
|
||||
:ets.insert(__MODULE__, {target, self(), System.monotonic_time()})
|
||||
GenServer.cast(__MODULE__, {:monitor, self()})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Remove an inactive connection.
|
||||
"""
|
||||
@spec disconnect(Router.target()) :: :ok
|
||||
def disconnect(target) do
|
||||
:ets.match_delete(__MODULE__, {target, self(), :_})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the request count for each of the named targets.
|
||||
"""
|
||||
@spec request_count(Router.target() | [Router.target()]) :: %{
|
||||
Router.target() => non_neg_integer()
|
||||
}
|
||||
def request_count(targets) do
|
||||
# :ets.fun2ms(fn {target, _, _} when target in [:targeta, :targetb] -> target end)
|
||||
|
||||
targets = List.wrap(targets)
|
||||
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
|
||||
match_spec = [{{:"$1", :_, :_}, target_guard, [:"$1"]}]
|
||||
|
||||
__MODULE__
|
||||
|> :ets.select(match_spec)
|
||||
|> Enum.frequencies()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the most recent request time for the named targets.
|
||||
"""
|
||||
@spec last_request_time([Router.target()]) :: %{Router.target() => non_neg_integer()}
|
||||
def last_request_time(targets) do
|
||||
# :ets.fun2ms(fn {target, _, t} when target in [:targeta, :targetb] -> {target, t} end)
|
||||
|
||||
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
|
||||
match_spec = [{{:"$1", :_, :"$2"}, target_guard, [{{:"$1", :"$2"}}]}]
|
||||
|
||||
__MODULE__
|
||||
|> :ets.select(match_spec)
|
||||
|> most_recent_request_time_per_target()
|
||||
end
|
||||
|
||||
defp most_recent_request_time_per_target(target_times),
|
||||
do: most_recent_request_time_per_target(target_times, %{})
|
||||
|
||||
defp most_recent_request_time_per_target([{target, time} | tail], result)
|
||||
when time <= :erlang.map_get(target, result),
|
||||
do: most_recent_request_time_per_target(tail, result)
|
||||
|
||||
defp most_recent_request_time_per_target([{target, time} | tail], result),
|
||||
do: most_recent_request_time_per_target(tail, Map.put(result, target, time))
|
||||
|
||||
defp most_recent_request_time_per_target([], result), do: result
|
||||
|
||||
defp report_interval do
|
||||
:wayfarer
|
||||
|> Application.get_env(__MODULE__, [])
|
||||
|> Keyword.get(:report_interval, :timer.seconds(1))
|
||||
end
|
||||
end
|
182
lib/wayfarer/target/check.ex
Normal file
182
lib/wayfarer/target/check.ex
Normal file
|
@ -0,0 +1,182 @@
|
|||
defmodule Wayfarer.Target.Check do
|
||||
@moduledoc """
|
||||
A GenServer which represents a single check to an HTTP endpoint.
|
||||
"""
|
||||
|
||||
use GenServer, restart: :transient
|
||||
alias Mint.{HTTP, HTTP1, HTTP2, WebSocket}
|
||||
alias Wayfarer.{Target, Target.TotalConnections, Telemetry}
|
||||
require Logger
|
||||
|
||||
@type state :: %{
|
||||
conn: struct(),
|
||||
req: reference(),
|
||||
scheme: :http | :https | :ws | :wss,
|
||||
address: :inet.ip_address(),
|
||||
port: :socket.port_number(),
|
||||
uri: URI.t(),
|
||||
ref: any,
|
||||
method: String.t(),
|
||||
headers: [{String.t(), String.t()}],
|
||||
hostname: String.t(),
|
||||
transport: :http1 | :http2 | :auto,
|
||||
span: map
|
||||
}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(state), do: {:ok, state, {:continue, :start_check}}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_continue(:start_check, state) :: {:noreply, state, timeout} | {:stop, :normal, nil}
|
||||
def handle_continue(:start_check, state) do
|
||||
state =
|
||||
state
|
||||
|> Map.put(:span, %{
|
||||
metadata: %{
|
||||
target: %{scheme: state.scheme, address: state.address, port: state.port},
|
||||
method: state.method,
|
||||
uri: state.uri,
|
||||
hostname: state.hostname,
|
||||
telemetry_span_context: make_ref()
|
||||
}
|
||||
})
|
||||
|> Telemetry.health_check_start()
|
||||
|
||||
with {:ok, state} <- connect(state),
|
||||
{:ok, state} <- request(state) do
|
||||
{:noreply, state, state.response_timeout}
|
||||
else
|
||||
{:error, reason} ->
|
||||
check_failed(state, reason)
|
||||
|
||||
{:error, _conn, reason} ->
|
||||
check_failed(state, reason)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info(:timeout, state), do: check_failed(state, :timeout)
|
||||
|
||||
def handle_info(message, state) do
|
||||
with {:ok, conn, responses} <- WebSocket.stream(state.conn, message),
|
||||
:ok <-
|
||||
TotalConnections.health_check_connect(
|
||||
{state.scheme, state.address, state.port, state.transport}
|
||||
),
|
||||
{:ok, status} <- get_status_response(conn, responses) do
|
||||
if Enum.any?(state.success_codes, &Enum.member?(&1, status)) do
|
||||
Target.check_passed(state.ref)
|
||||
Telemetry.health_check_pass(state, status)
|
||||
{:stop, :normal, nil}
|
||||
else
|
||||
check_failed(state, "received #{status} status code")
|
||||
end
|
||||
else
|
||||
{:continue, conn} ->
|
||||
{:noreply, Map.put(state, :conn, conn)}
|
||||
|
||||
:unknown ->
|
||||
check_failed(state, "Received unknown message: `#{inspect(message)}`")
|
||||
|
||||
{:error, _conn, error, _responses} ->
|
||||
check_failed(state, error)
|
||||
end
|
||||
end
|
||||
|
||||
defp connect(state) when state.scheme == :ws,
|
||||
do: connect(%{state | scheme: :http})
|
||||
|
||||
defp connect(state) when state.scheme == :wss,
|
||||
do: connect(%{state | scheme: :https})
|
||||
|
||||
defp connect(state) when state.transport == :http1 do
|
||||
with {:ok, conn} <-
|
||||
HTTP1.connect(state.scheme, state.address, state.port,
|
||||
timeout: state.connect_timeout,
|
||||
hostname: state.hostname
|
||||
) do
|
||||
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), :http1)}
|
||||
end
|
||||
end
|
||||
|
||||
defp connect(state) when state.transport == :http2 do
|
||||
with {:ok, conn} <-
|
||||
HTTP2.connect(state.scheme, state.address, state.port,
|
||||
timeout: state.connect_timeout,
|
||||
hostname: state.hostname
|
||||
) do
|
||||
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), :http2)}
|
||||
end
|
||||
end
|
||||
|
||||
defp connect(state) do
|
||||
with {:ok, conn} <-
|
||||
HTTP.connect(state.scheme, state.address, state.port,
|
||||
timeout: state.connect_timeout,
|
||||
hostname: state.hostname
|
||||
) do
|
||||
transport =
|
||||
case conn do
|
||||
%Mint.HTTP1{} -> :http1
|
||||
%Mint.HTTP2{} -> :http2
|
||||
end
|
||||
|
||||
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), transport)}
|
||||
end
|
||||
end
|
||||
|
||||
defp request(state) when state.scheme in [:ws, :wss] do
|
||||
with {:ok, conn, req} <-
|
||||
WebSocket.upgrade(state.scheme, state.conn, state.path, state.headers, []) do
|
||||
state = Map.merge(state, %{conn: conn, req: req})
|
||||
|
||||
{:ok, Telemetry.health_check_request(state)}
|
||||
end
|
||||
end
|
||||
|
||||
defp request(state) do
|
||||
with {:ok, conn, req} <-
|
||||
HTTP.request(state.conn, state.method, state.path, state.headers, nil) do
|
||||
state = Map.merge(state, %{conn: conn, req: req})
|
||||
|
||||
{:ok, Telemetry.health_check_request(state)}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_failed(state, reason) when is_binary(reason) do
|
||||
Target.check_failed(state.ref)
|
||||
Telemetry.health_check_fail(state, reason)
|
||||
|
||||
Logger.warning(fn -> "Health check failed for #{state.method} #{state.uri}: #{reason}." end)
|
||||
{:stop, :normal, nil}
|
||||
end
|
||||
|
||||
defp check_failed(state, exception) when is_exception(exception) do
|
||||
Target.check_failed(state.ref)
|
||||
Telemetry.health_check_fail(state, exception)
|
||||
|
||||
Logger.warning(fn ->
|
||||
"Health check failed for #{state.method} #{state.uri}: #{Exception.message(exception)}"
|
||||
end)
|
||||
|
||||
{:stop, :normal, nil}
|
||||
end
|
||||
|
||||
defp check_failed(state, reason) do
|
||||
Target.check_failed(state.ref)
|
||||
Telemetry.health_check_fail(state, reason)
|
||||
|
||||
Logger.warning(fn ->
|
||||
"Health check failed for #{state.method} #{state.uri}: `#{inspect(reason)}`"
|
||||
end)
|
||||
|
||||
{:stop, :normal, nil}
|
||||
end
|
||||
|
||||
defp get_status_response(conn, []), do: {:continue, conn}
|
||||
defp get_status_response(_conn, [{:status, _, status} | _]), do: {:ok, status}
|
||||
defp get_status_response(conn, [_ | tail]), do: get_status_response(conn, tail)
|
||||
end
|
86
lib/wayfarer/target/selector.ex
Normal file
86
lib/wayfarer/target/selector.ex
Normal file
|
@ -0,0 +1,86 @@
|
|||
defmodule Wayfarer.Target.Selector do
|
||||
@moduledoc """
|
||||
Given a list of targets and an algorithm decide which target the request
|
||||
should be forwarded to.
|
||||
"""
|
||||
|
||||
alias Plug.Conn
|
||||
alias Wayfarer.{Router, Target.ActiveConnections, Target.TotalConnections}
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
@typedoc """
|
||||
The algorithm used to select which target to forward requests to (when there
|
||||
is more than one matching target).
|
||||
"""
|
||||
@type algorithm :: :least_connections | :random | :round_robin | :sticky
|
||||
|
||||
@doc """
|
||||
A guard for testing if an algorithm is supported.
|
||||
"""
|
||||
@spec is_algorithm(any) :: Macro.output()
|
||||
defguard is_algorithm(algorithm)
|
||||
when algorithm in ~w[least_connections random round_robin sticky]a
|
||||
|
||||
@doc """
|
||||
Tries to choose a target from the list of targets to send the request to based
|
||||
on the chosen algorithm.
|
||||
"""
|
||||
@spec choose(Conn.t(), [Router.target()], Router.algorithm()) :: {:ok, Router.target()} | :error
|
||||
|
||||
# We can't choose from an empty list.
|
||||
def choose(_conn, [], _), do: :error
|
||||
|
||||
# When there's only one target, we don't need to choose anything.
|
||||
def choose(_conn, [target], _), do: {:ok, target}
|
||||
|
||||
# Sticky targets try and use the same target for each request from the same
|
||||
# client if possible.
|
||||
def choose(conn, targets, :sticky) do
|
||||
peer = get_peer_data(conn)
|
||||
listener = conn.private.wayfarer.listener
|
||||
|
||||
index =
|
||||
:erlang.phash2(
|
||||
{listener.address, listener.port, conn.remote_ip, peer.address, peer.port},
|
||||
length(targets)
|
||||
)
|
||||
|
||||
targets
|
||||
|> Enum.at(index, :error)
|
||||
|> case do
|
||||
:error -> :error
|
||||
target -> {:ok, target}
|
||||
end
|
||||
end
|
||||
|
||||
def choose(_conn, targets, :random) do
|
||||
{:ok, Enum.random(targets)}
|
||||
end
|
||||
|
||||
# Choose the target with the fewest active connections from the list.
|
||||
def choose(_conn, targets, :least_connections) do
|
||||
targets
|
||||
|> ActiveConnections.request_count()
|
||||
|> case do
|
||||
request_counts when map_size(request_counts) == 0 ->
|
||||
{:ok, Enum.random(targets)}
|
||||
|
||||
request_counts ->
|
||||
{:ok,
|
||||
request_counts
|
||||
|> Enum.min_by(&elem(&1, 1))
|
||||
|> elem(0)}
|
||||
end
|
||||
end
|
||||
|
||||
# Select from targets sequentially.
|
||||
def choose(_conn, targets, :round_robin) do
|
||||
{target, _} =
|
||||
targets
|
||||
|> TotalConnections.proxy_count()
|
||||
|> Enum.min_by(&elem(&1, 1), &<=/2, fn -> {Enum.random(targets), 0} end)
|
||||
|
||||
{:ok, target}
|
||||
end
|
||||
end
|
18
lib/wayfarer/target/supervisor.ex
Normal file
18
lib/wayfarer/target/supervisor.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule Wayfarer.Target.Supervisor do
|
||||
@moduledoc false
|
||||
|
||||
use Supervisor
|
||||
|
||||
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
[
|
||||
{Registry, keys: :unique, name: Wayfarer.Target.Registry},
|
||||
{DynamicSupervisor, name: Wayfarer.Target.DynamicSupervisor, strategy: :one_for_one},
|
||||
Wayfarer.Target.ActiveConnections,
|
||||
Wayfarer.Target.TotalConnections
|
||||
]
|
||||
|> Supervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
end
|
111
lib/wayfarer/target/total_connections.ex
Normal file
111
lib/wayfarer/target/total_connections.ex
Normal file
|
@ -0,0 +1,111 @@
|
|||
defmodule Wayfarer.Target.TotalConnections do
|
||||
@moduledoc """
|
||||
A simple ETS table that tracks the total number of requests for each target.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
alias Wayfarer.{Router, Utils}
|
||||
require Logger
|
||||
|
||||
@type state :: %{table: :ets.tid(), timer: :timer.tref()}
|
||||
|
||||
@doc """
|
||||
Increment the proxy connection counter for the specified target.
|
||||
"""
|
||||
@spec proxy_connect(Router.target()) :: :ok
|
||||
def proxy_connect(target) do
|
||||
:ets.update_counter(__MODULE__, target, {2, 1}, {target, 0, 0})
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increment the health check connection counter for the specified target.
|
||||
"""
|
||||
@spec health_check_connect(Router.target()) :: :ok
|
||||
def health_check_connect(target) do
|
||||
:ets.update_counter(__MODULE__, target, {3, 1}, {target, 0, 0})
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the total number of requests for the provided targets.
|
||||
"""
|
||||
@spec request_count(Router.target() | [Router.target()]) :: %{
|
||||
Router.target() => %{proxied: non_neg_integer(), health_checks: non_neg_integer()}
|
||||
}
|
||||
def request_count(targets) do
|
||||
# :ets.fun2ms(fn {target, proxied, checks} when target in [:a, :b] -> {target, proxied, checks} end)
|
||||
|
||||
targets = List.wrap(targets)
|
||||
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
|
||||
|
||||
match_spec = [
|
||||
{{:"$1", :"$2", :"$3"}, target_guard, [{{:"$1", :"$2", :"$3"}}]}
|
||||
]
|
||||
|
||||
__MODULE__
|
||||
|> :ets.select(match_spec)
|
||||
|> Map.new(fn {target, proxied, checks} ->
|
||||
{target, %{proxied: proxied, health_checks: checks}}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the total number of proxy requests for the provided targets.
|
||||
"""
|
||||
@spec proxy_count(Router.target() | [Router.target()]) :: %{
|
||||
Router.target() => non_neg_integer()
|
||||
}
|
||||
def proxy_count(targets) do
|
||||
# :ets.fun2ms(fn {target, proxied, _} when target in [:a, :b] -> {target, proxied} end)
|
||||
|
||||
targets = List.wrap(targets)
|
||||
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
|
||||
match_spec = [{{:"$1", :"$2", :_}, target_guard, [{{:"$1", :"$2"}}]}]
|
||||
|
||||
__MODULE__
|
||||
|> :ets.select(match_spec)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: {:ok, state} | {:stop, any}
|
||||
def init(_) do
|
||||
report_interval()
|
||||
|> :timer.send_interval(:tick)
|
||||
|> case do
|
||||
{:ok, timer} ->
|
||||
table =
|
||||
__MODULE__
|
||||
|> :ets.new([:public, :named_table, :set])
|
||||
|
||||
{:ok, %{table: table, timer: timer}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec handle_info(:tick, state) :: {:noreply, state}
|
||||
def handle_info(:tick, state) do
|
||||
for {target, count} <- :ets.tab2list(state.table) do
|
||||
Logger.debug("Total connections for #{inspect(target)}: #{count}")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp report_interval do
|
||||
:wayfarer
|
||||
|> Application.get_env(__MODULE__, [])
|
||||
|> Keyword.get(:report_interval, :timer.seconds(10))
|
||||
end
|
||||
end
|
764
lib/wayfarer/telemetry.ex
Normal file
764
lib/wayfarer/telemetry.ex
Normal file
|
@ -0,0 +1,764 @@
|
|||
defmodule Wayfarer.Telemetry do
|
||||
@moduledoc """
|
||||
Wayfarer emits a number of telemetry events and spans on top of the excellent
|
||||
telemetry events emitted by `Bandit.Telemetry`.
|
||||
"""
|
||||
|
||||
alias Plug.Conn
|
||||
alias Wayfarer.{Router, Target.Check, Target.Selector}
|
||||
|
||||
@typedoc """
|
||||
Information about the target a request has been routed to.
|
||||
"""
|
||||
@type target ::
|
||||
%{
|
||||
scheme: :http | :https | :ws | :wss,
|
||||
address: :inet.ip_address(),
|
||||
port: :socket.port_number()
|
||||
}
|
||||
| %{scheme: :plug, module: module, options: any}
|
||||
|
||||
@typedoc """
|
||||
Information about the listener that received the request.
|
||||
"""
|
||||
@type listener ::
|
||||
%{
|
||||
scheme: :http | :https,
|
||||
module: module(),
|
||||
port: :socket.port_number(),
|
||||
address: :inet.ip_address()
|
||||
}
|
||||
|
||||
@typedoc """
|
||||
The time that the event was emitted, in `:native` time units.
|
||||
|
||||
This is sources from `System.monotonic_time/0` which has some caveats but in
|
||||
general is better for calculating durations as it should never go backwards.
|
||||
"""
|
||||
@type monotonic_time :: integer()
|
||||
|
||||
@typedoc """
|
||||
The time passed since the beginning of the span, in `:native` time units.
|
||||
|
||||
The difference between the current `monotonic_time` and the first
|
||||
`monotonic_time` at the start of the span.
|
||||
"""
|
||||
@type duration :: integer()
|
||||
|
||||
@typedoc "The HTTP protocol version of the request"
|
||||
@type transport :: :http1 | :http2
|
||||
|
||||
@typedoc "A unique identifier for the span"
|
||||
@type telemetry_span_context :: reference()
|
||||
|
||||
@typedoc "A convenience type for describing telemetry events"
|
||||
@opaque event(measurements, metadata) :: {measurements, metadata}
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :start]` event.
|
||||
|
||||
This event signals the start of a request span tracking a client request to
|
||||
completion.
|
||||
|
||||
You can use the `telemetry_span_context` metadata value to correlate
|
||||
subsequent events within the same span.
|
||||
"""
|
||||
@type request_start ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :routed]` event.
|
||||
|
||||
This event signals that the routing process has completed and a target has
|
||||
been chosen to serve the request.
|
||||
"""
|
||||
@type request_routed ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :exception]` event.
|
||||
|
||||
This event signals that something went wrong while processing the event. You
|
||||
will likely still receive other events (eg `:stop`) for this span however.
|
||||
"""
|
||||
@type request_exception ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
optional(:target) => target(),
|
||||
optional(:algorithm) => Selector.algorithm(),
|
||||
required(:kind) => :throw | :exit | :error | :exception,
|
||||
required(:reason) => any,
|
||||
required(:stacktrace) => Exception.stacktrace()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :stop]` event.
|
||||
|
||||
This event signals that the request has completed.
|
||||
|
||||
The measurements will contain any incrementing counters accumulated during the
|
||||
course of the request.
|
||||
"""
|
||||
@type request_stop ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:status) => nil | 100..599,
|
||||
optional(:req_body_bytes) => non_neg_integer(),
|
||||
optional(:resp_body_bytes) => non_neg_integer(),
|
||||
optional(:client_frame_bytes) => non_neg_integer(),
|
||||
optional(:client_frame_count) => non_neg_integer(),
|
||||
optional(:server_frame_bytes) => non_neg_integer(),
|
||||
optional(:server_frame_count) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
optional(:target) => target(),
|
||||
optional(:algorithm) => Selector.algorithm(),
|
||||
optional(:status) => nil | 100..599,
|
||||
optional(:kind) => :throw | :exit | :error | :exception,
|
||||
optional(:reason) => any,
|
||||
optional(:stacktrace) => Exception.stacktrace()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :received_status]` event.
|
||||
|
||||
This event signals that an HTTP status code has been received from the
|
||||
upstream target.
|
||||
"""
|
||||
@type request_received_status ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:status) => nil | 100..599,
|
||||
optional(:req_body_bytes) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :req_body_chunk]` event.
|
||||
|
||||
This event is emitted while streaming the request body from the client to the
|
||||
target. Under the hood `Plug.Conn.read_body/2` is being called with the
|
||||
default options, meaning that each chunk is likely to be up to 8MB in size.
|
||||
|
||||
If there is no request body then this event will not be emitted and the
|
||||
`req_body_bytes` and `req_body_chunks` counters will both be set to zero for
|
||||
this request.
|
||||
"""
|
||||
@type request_req_body_chunk ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:status) => nil | 100..599,
|
||||
required(:req_body_bytes) => non_neg_integer(),
|
||||
required(:req_body_chunks) => non_neg_integer(),
|
||||
required(:chunk_bytes) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :resp_started]` event.
|
||||
|
||||
This event indicates that the HTTP status and headers have been received from
|
||||
the target and the response will now start being sent to the target.
|
||||
"""
|
||||
@type request_resp_started ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:status) => nil | 100..599,
|
||||
optional(:req_body_bytes) => non_neg_integer(),
|
||||
optional(:req_body_chunks) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :resp_body_chunk]` event.
|
||||
|
||||
This event is emitted every time a chunk of response body is received from the
|
||||
target for streaming to the client. Under the hood, these are emitted every
|
||||
time `Mint.HTTP.stream/2` returns a data frame.
|
||||
"""
|
||||
@type request_resp_body_chunk ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
optional(:req_body_bytes) => non_neg_integer(),
|
||||
optional(:req_body_chunks) => non_neg_integer(),
|
||||
required(:resp_body_bytes) => non_neg_integer(),
|
||||
required(:resp_body_chunks) => non_neg_integer(),
|
||||
required(:chunk_bytes) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :upgraded]` event.
|
||||
|
||||
This event is emitted when a client connection is upgraded to a WebSocket
|
||||
connection.
|
||||
"""
|
||||
@type request_upgraded ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :client_frame]` event.
|
||||
|
||||
This event is emitted any time a WebSocket frame is received from the client
|
||||
for transmission to the target.
|
||||
"""
|
||||
@type request_client_frame ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:frame_size) => non_neg_integer(),
|
||||
required(:client_frame_bytes) => non_neg_integer(),
|
||||
required(:client_frame_count) => non_neg_integer(),
|
||||
optional(:server_frame_bytes) => non_neg_integer(),
|
||||
optional(:server_frame_count) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599,
|
||||
required(:opcode) => :text | :binary | :ping | :pong | :close
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :request, :server_frame]` event.
|
||||
|
||||
This event is emitted any time a WebSocket frame is received from the target
|
||||
for transmission to the client.
|
||||
"""
|
||||
@type request_server_frame ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:duration) => duration(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:frame_size) => non_neg_integer(),
|
||||
required(:server_frame_bytes) => non_neg_integer(),
|
||||
required(:server_frame_count) => non_neg_integer(),
|
||||
optional(:client_frame_bytes) => non_neg_integer(),
|
||||
optional(:client_frame_count) => non_neg_integer()
|
||||
},
|
||||
%{
|
||||
required(:conn) => Conn.t(),
|
||||
required(:listener) => listener(),
|
||||
required(:transport) => transport(),
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:target) => target(),
|
||||
required(:algorithm) => Selector.algorithm(),
|
||||
required(:status) => nil | 100..599
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
All the event types that make up the `[:wayfarer, :request, :*]` span.
|
||||
"""
|
||||
@type request_span ::
|
||||
request_start
|
||||
| request_routed
|
||||
| request_exception
|
||||
| request_stop
|
||||
| request_received_status
|
||||
| request_req_body_chunk
|
||||
| request_resp_started
|
||||
| request_resp_body_chunk
|
||||
| request_upgraded
|
||||
| request_client_frame
|
||||
| request_server_frame
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :health_check, :start]` event.
|
||||
|
||||
This event signals the start of a health check span.
|
||||
|
||||
You can use the `telemetry_span_context` metadata value to correlate
|
||||
subsequent events within the same span.
|
||||
"""
|
||||
@type health_check_start ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t()
|
||||
},
|
||||
%{
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:hostname) => String.t(),
|
||||
required(:uri) => URI.t(),
|
||||
required(:target) => target(),
|
||||
required(:method) => String.t()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :health_check, :connect]` event.
|
||||
|
||||
This event signals that the outgoing TCP connection has been made to the
|
||||
target.
|
||||
"""
|
||||
@type health_check_connect ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:duration) => duration()
|
||||
},
|
||||
%{
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:hostname) => String.t(),
|
||||
required(:uri) => URI.t(),
|
||||
required(:target) => target(),
|
||||
required(:method) => String.t(),
|
||||
required(:transport) => transport()
|
||||
}
|
||||
)
|
||||
@typedoc """
|
||||
The `[:wayfarer, :health_check, :request]` event.
|
||||
|
||||
This event signals that the HTTP or WebSocket request has been sent.
|
||||
"""
|
||||
@type health_check_request ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:duration) => duration()
|
||||
},
|
||||
%{
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:hostname) => String.t(),
|
||||
required(:uri) => URI.t(),
|
||||
required(:target) => target(),
|
||||
required(:method) => String.t(),
|
||||
required(:transport) => transport()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :health_check, :pass]` event.
|
||||
|
||||
This event signals that the HTTP status code returned by the target matches
|
||||
one of the configured success codes.
|
||||
|
||||
It also signals the end of the span.
|
||||
"""
|
||||
@type health_check_pass ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:duration) => duration(),
|
||||
required(:status) => 100..599
|
||||
},
|
||||
%{
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:hostname) => String.t(),
|
||||
required(:uri) => URI.t(),
|
||||
required(:target) => target(),
|
||||
required(:method) => String.t(),
|
||||
required(:transport) => transport()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
The `[:wayfarer, :health_check, :fail]` event.
|
||||
|
||||
This event signals that the HTTP status code returned by the target did not
|
||||
match any of the configured success codes.
|
||||
|
||||
It also signals the end of the span.
|
||||
"""
|
||||
@type health_check_fail ::
|
||||
event(
|
||||
%{
|
||||
required(:monotonic_time) => monotonic_time(),
|
||||
required(:wallclock_time) => DateTime.t(),
|
||||
required(:duration) => duration(),
|
||||
required(:reason) => any
|
||||
},
|
||||
%{
|
||||
required(:telemetry_span_context) => telemetry_span_context(),
|
||||
required(:hostname) => String.t(),
|
||||
required(:uri) => URI.t(),
|
||||
required(:target) => target(),
|
||||
required(:method) => String.t(),
|
||||
required(:transport) => transport()
|
||||
}
|
||||
)
|
||||
|
||||
@typedoc """
|
||||
All the event types that make up the `[:wayfarer, :health_check, :*]` span.
|
||||
"""
|
||||
@type health_check_span ::
|
||||
health_check_start | health_check_request | health_check_pass | health_check_fail
|
||||
|
||||
@typedoc """
|
||||
All the spans that can be emitted by Wayfarer.
|
||||
"""
|
||||
@type spans :: request_span | health_check_span
|
||||
|
||||
@doc false
|
||||
@spec request_start(Conn.t()) :: Conn.t()
|
||||
def request_start(conn) do
|
||||
telemetry_span_context = make_ref()
|
||||
|
||||
metadata =
|
||||
conn
|
||||
|> Map.get(:private, %{})
|
||||
|> Map.get(:wayfarer, %{})
|
||||
|> Map.merge(%{telemetry_span_context: telemetry_span_context, conn: conn})
|
||||
|
||||
conn
|
||||
|> execute_request_span_event(:start, %{}, metadata)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_routed(Conn.t(), Router.target(), Router.algorithm()) :: Conn.t()
|
||||
def request_routed(conn, target, algorithm) do
|
||||
target =
|
||||
case target do
|
||||
{scheme, address, port, _transport} -> %{scheme: scheme, address: address, port: port}
|
||||
{:plug, {module, opts}} -> %{scheme: :plug, module: module, options: opts}
|
||||
{:plug, module} -> %{scheme: :plug, module: module, options: []}
|
||||
end
|
||||
|
||||
conn
|
||||
|> execute_request_span_event(:routed, %{}, %{
|
||||
target: target,
|
||||
algorithm: algorithm
|
||||
})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_exception(Conn.t(), kind :: any, reason :: any, stacktrace :: list | nil) ::
|
||||
Conn.t()
|
||||
def request_exception(conn, kind, reason, stacktrace \\ nil) do
|
||||
conn
|
||||
|> execute_request_span_event(:exception, %{}, %{
|
||||
kind: kind,
|
||||
reason: reason,
|
||||
stacktrace: stacktrace || Process.info(self(), :current_stacktrace)
|
||||
})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def request_stop(conn) do
|
||||
conn
|
||||
|> execute_request_span_event(:stop, %{status: conn.status}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_received_status(Conn.t(), non_neg_integer()) :: Conn.t()
|
||||
def request_received_status(conn, status) do
|
||||
conn
|
||||
|> execute_request_span_event(:received_status, %{status: status}, %{status: status})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_req_body_chunk(Conn.t(), non_neg_integer()) :: Conn.t()
|
||||
def request_req_body_chunk(conn, chunk_bytes) do
|
||||
conn
|
||||
|> execute_request_span_event(:req_body_chunk, %{chunk_bytes: chunk_bytes}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_resp_started(Conn.t()) :: Conn.t()
|
||||
def request_resp_started(conn) do
|
||||
metric = %{status: conn.status}
|
||||
|
||||
conn
|
||||
|> execute_request_span_event(:resp_started, metric, metric)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_resp_body_chunk(Conn.t(), non_neg_integer()) :: Conn.t()
|
||||
def request_resp_body_chunk(conn, chunk_bytes) do
|
||||
conn
|
||||
|> execute_request_span_event(:resp_body_chunk, %{chunk_bytes: chunk_bytes}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_upgraded(Conn.t()) :: Conn.t()
|
||||
def request_upgraded(conn) do
|
||||
conn
|
||||
|> execute_request_span_event(:upgraded, %{}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_client_frame(Conn.t(), non_neg_integer(), atom) :: Conn.t()
|
||||
def request_client_frame(conn, bytes, opcode) do
|
||||
conn
|
||||
|> execute_request_span_event(:client_frame, %{frame_size: bytes}, %{opcode: opcode})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec request_server_frame(Conn.t(), non_neg_integer(), atom) :: Conn.t()
|
||||
def request_server_frame(conn, bytes, opcode) do
|
||||
conn
|
||||
|> execute_request_span_event(:server_frame, %{frame_size: bytes}, %{opcode: opcode})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec set_metrics(Conn.t(), %{atom => number}) :: Conn.t()
|
||||
def set_metrics(conn, metrics) do
|
||||
update_metrics(conn, &Map.merge(&1, metrics))
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec increment_metrics(Conn.t(), %{atom => number}) :: Conn.t()
|
||||
def increment_metrics(conn, to_increment) do
|
||||
update_metrics(conn, fn metrics ->
|
||||
Enum.reduce(to_increment, metrics, fn {metric_name, increment_by}, metrics ->
|
||||
Map.update(metrics, metric_name, increment_by, &(&1 + increment_by))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec health_check_start(Check.state()) :: Check.state()
|
||||
def health_check_start(check) do
|
||||
execute_check_span_event(check, :start, %{}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec health_check_connect(Check.state(), atom) :: Check.state()
|
||||
def health_check_connect(check, transport) do
|
||||
execute_check_span_event(check, :connect, %{}, %{transport: transport})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec health_check_request(Check.state()) :: Check.state()
|
||||
def health_check_request(check) do
|
||||
execute_check_span_event(check, :request, %{}, %{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec health_check_fail(Check.state(), any) :: Check.state()
|
||||
def health_check_fail(check, reason) do
|
||||
execute_check_span_event(check, :fail, %{}, %{reason: reason})
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec health_check_pass(Check.state(), 100..599) :: Check.state()
|
||||
def health_check_pass(check, status) do
|
||||
execute_check_span_event(check, :pass, %{}, %{status: status})
|
||||
end
|
||||
|
||||
defp update_metrics(conn, callback) do
|
||||
private =
|
||||
conn
|
||||
|> Map.get(:private, %{})
|
||||
|> Map.get(:wayfarer, %{})
|
||||
|
||||
metrics =
|
||||
private
|
||||
|> Map.get(:metrics, %{})
|
||||
|> callback.()
|
||||
|
||||
private = Map.put(private, :metrics, metrics)
|
||||
|
||||
conn
|
||||
|> Conn.put_private(:wayfarer, private)
|
||||
end
|
||||
|
||||
defp execute_request_span_event(conn, event, measurements, metadata) do
|
||||
monotonic_time = System.monotonic_time()
|
||||
now = DateTime.utc_now()
|
||||
|
||||
private =
|
||||
conn
|
||||
|> Map.get(:private, %{})
|
||||
|> Map.get(:wayfarer, %{})
|
||||
|
||||
span_info =
|
||||
private
|
||||
|> Map.get(:request_span, %{})
|
||||
|
||||
metrics =
|
||||
private
|
||||
|> Map.get(:metrics, %{})
|
||||
|
||||
measurements =
|
||||
if Map.has_key?(span_info, :start_time) do
|
||||
%{
|
||||
monotonic_time: monotonic_time,
|
||||
duration: monotonic_time - span_info.start_time,
|
||||
wallclock_time: now
|
||||
}
|
||||
else
|
||||
%{
|
||||
monotonic_time: monotonic_time,
|
||||
wallclock_time: now
|
||||
}
|
||||
end
|
||||
|> Map.merge(metrics)
|
||||
|> Map.merge(measurements)
|
||||
|
||||
metadata =
|
||||
span_info
|
||||
|> Map.get(:metadata, %{})
|
||||
|> Map.merge(metadata)
|
||||
|
||||
:telemetry.execute([:wayfarer, :request, event], measurements, Map.put(metadata, :conn, conn))
|
||||
|
||||
span_info =
|
||||
span_info
|
||||
|> Map.put(:metadata, metadata)
|
||||
|> Map.put_new(:start_time, monotonic_time)
|
||||
|
||||
private =
|
||||
private
|
||||
|> Map.put(:request_span, span_info)
|
||||
|
||||
conn
|
||||
|> Conn.put_private(:wayfarer, private)
|
||||
end
|
||||
|
||||
defp execute_check_span_event(check, event, measurements, metadata) do
|
||||
monotonic_time = System.monotonic_time()
|
||||
now = DateTime.utc_now()
|
||||
span_info = Map.get(check, :span, %{})
|
||||
|
||||
metrics = Map.get(span_info, :metrics, %{})
|
||||
|
||||
measurements =
|
||||
if Map.has_key?(span_info, :start_time) do
|
||||
%{
|
||||
monotonic_time: monotonic_time,
|
||||
duration: monotonic_time - span_info.start_time,
|
||||
wallclock_time: now
|
||||
}
|
||||
else
|
||||
%{
|
||||
monotonic_time: monotonic_time,
|
||||
wallclock_time: now
|
||||
}
|
||||
end
|
||||
|> Map.merge(metrics)
|
||||
|> Map.merge(measurements)
|
||||
|
||||
metadata =
|
||||
span_info
|
||||
|> Map.get(:metadata, %{})
|
||||
|> Map.merge(metadata)
|
||||
|
||||
:telemetry.execute([:wayfarer, :health_check, event], measurements, metadata)
|
||||
|
||||
span_info =
|
||||
span_info
|
||||
|> Map.put(:metadata, metadata)
|
||||
|> Map.put_new(:start_time, monotonic_time)
|
||||
|
||||
Map.put(check, :span, span_info)
|
||||
end
|
||||
end
|
100
lib/wayfarer/utils.ex
Normal file
100
lib/wayfarer/utils.ex
Normal file
|
@ -0,0 +1,100 @@
|
|||
defmodule Wayfarer.Utils do
|
||||
@moduledoc """
|
||||
A grab-bag of useful functions which are used all over the codebase.
|
||||
"""
|
||||
|
||||
@type address_input :: IP.Address.t() | String.t() | :inet.ip_address()
|
||||
@type port_number :: 1..0xFFFF
|
||||
@type scheme :: :http | :https | :ws | :wss
|
||||
@type transport :: :http1 | :http2 | :auto
|
||||
|
||||
@doc """
|
||||
Verify an IP address and convert it into a tuple.
|
||||
"""
|
||||
@spec sanitise_ip_address(address_input) :: {:ok, :inet.ip_address()} | {:error, any}
|
||||
def sanitise_ip_address(address) when is_binary(address) do
|
||||
with {:ok, address} <- IP.Address.from_string(address) do
|
||||
{:ok, IP.Address.to_tuple(address)}
|
||||
end
|
||||
end
|
||||
|
||||
def sanitise_ip_address(address) when is_struct(address, IP.Address),
|
||||
do: {:ok, IP.Address.to_tuple(address)}
|
||||
|
||||
def sanitise_ip_address(address) when is_tuple(address) do
|
||||
with {:ok, _} <- IP.Address.from_tuple(address) do
|
||||
{:ok, address}
|
||||
end
|
||||
end
|
||||
|
||||
def sanitise_ip_address(address),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(message: "Value `#{inspect(address)}` is not a valid IP address.")}
|
||||
|
||||
@doc """
|
||||
Verify a port number.
|
||||
"""
|
||||
@spec sanitise_port(port_number) :: {:ok, port_number} | {:error, any}
|
||||
def sanitise_port(port) when is_integer(port) and port > 0 and port <= 0xFFFF, do: {:ok, port}
|
||||
|
||||
def sanitise_port(port),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(message: "Value `#{inspect(port)}` is not a valid port number.")}
|
||||
|
||||
@doc """
|
||||
Verify a scheme.
|
||||
"""
|
||||
@spec sanitise_scheme(scheme) :: {:ok, scheme} | {:error, any}
|
||||
def sanitise_scheme(scheme) when scheme in [:http, :https, :ws, :wss], do: {:ok, scheme}
|
||||
|
||||
def sanitise_scheme(scheme),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Value `#{inspect(scheme)}` is not a supported URI scheme."
|
||||
)}
|
||||
|
||||
@doc """
|
||||
Convert a scheme, address, port tuple into a `URI`.
|
||||
"""
|
||||
@spec to_uri(scheme, address_input, port_number) :: {:ok, URI.t()} | {:error, any}
|
||||
def to_uri(scheme, address, port) do
|
||||
with {:ok, scheme} <- sanitise_scheme(scheme),
|
||||
{:ok, address} <- sanitise_ip_address(address),
|
||||
{:ok, address} <- IP.Address.from_tuple(address),
|
||||
{:ok, port} <- sanitise_port(port) do
|
||||
%URI{
|
||||
scheme: to_string(scheme),
|
||||
host: to_string(address),
|
||||
port: port
|
||||
}
|
||||
|> URI.new()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Convert a list of targets into a match spec guard.
|
||||
"""
|
||||
@spec targets_to_ms_guard(atom, [{scheme, :inet.ip_address(), port_number, transport}]) :: [
|
||||
{atom, any, any}
|
||||
]
|
||||
def targets_to_ms_guard(_var, []), do: []
|
||||
|
||||
def targets_to_ms_guard(var, [head | tail]),
|
||||
do: targets_to_ms_guard(var, tail, {:"=:=", var, target_to_ms(head)})
|
||||
|
||||
defp targets_to_ms_guard(_var, [], guard), do: [guard]
|
||||
|
||||
defp targets_to_ms_guard(var, [head | tail], guard),
|
||||
do: targets_to_ms_guard(var, tail, {:orelse, {:"=:=", var, target_to_ms(head)}, guard})
|
||||
|
||||
@doc """
|
||||
Convert a target tuple into a tuple safe for injection into a match spec.
|
||||
"""
|
||||
@spec target_to_ms({scheme, :inet.ip_address(), port_number, transport}) ::
|
||||
{{scheme, {:inet.ip_address()}, port_number, transport}}
|
||||
def target_to_ms({scheme, address, port, transport}) when is_tuple(address),
|
||||
do: {{scheme, {address}, port, transport}}
|
||||
end
|
73
mix.exs
73
mix.exs
|
@ -5,7 +5,7 @@ defmodule Wayfarer.MixProject do
|
|||
A runtime-configurable HTTP reverse proxy based on Bandit.
|
||||
"""
|
||||
|
||||
@version "0.2.0"
|
||||
@version "0.6.1"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
@ -18,14 +18,47 @@ defmodule Wayfarer.MixProject do
|
|||
deps: deps(),
|
||||
description: @moduledoc,
|
||||
package: package(),
|
||||
source_url: "https://code.harton.nz/bivouac/wayfarer",
|
||||
homepage_url: "https://code.harton.nz/bivouac/wayfarer",
|
||||
source_url: "https://harton.dev/james/wayfarer",
|
||||
homepage_url: "https://harton.dev/james/wayfarer",
|
||||
aliases: aliases(),
|
||||
dialyzer: [plt_add_apps: []],
|
||||
dialyzer: [plt_ignore_apps: [:mint]],
|
||||
docs: [
|
||||
main: "Wayfarer",
|
||||
extras: ["README.md"],
|
||||
formatters: ["html"]
|
||||
formatters: ["html"],
|
||||
extra_section: "GUIDES",
|
||||
filter_modules: ~r/^Elixir.Wayfarer/,
|
||||
source_url_pattern: "https://harton.dev/james/wayfarer/src/branch/main/%{path}#L%{line}",
|
||||
spark: [
|
||||
extensions: [
|
||||
%{
|
||||
module: Wayfarer.Dsl,
|
||||
name: "Wayfarer.Dsl",
|
||||
target: "Wayfarer",
|
||||
type: "Wayfarer"
|
||||
}
|
||||
]
|
||||
],
|
||||
extras:
|
||||
["README.md"]
|
||||
|> Enum.concat(Path.wildcard("documentation/**/*.{md,livemd,cheatmd}")),
|
||||
groups_for_extras:
|
||||
"documentation/*"
|
||||
|> Path.wildcard()
|
||||
|> Enum.map(fn dir ->
|
||||
name =
|
||||
dir
|
||||
|> Path.split()
|
||||
|> List.last()
|
||||
|> String.split("_")
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
|
||||
files =
|
||||
dir
|
||||
|> Path.join("**.{md,livemd,cheatmd}")
|
||||
|> Path.wildcard()
|
||||
|
||||
{name, files}
|
||||
end)
|
||||
]
|
||||
]
|
||||
end
|
||||
|
@ -34,11 +67,15 @@ defmodule Wayfarer.MixProject do
|
|||
[
|
||||
name: :wayfarer,
|
||||
files: ~w[lib .formatter.exs mix.exs README.md LICENSE.md CHANGELOG.md],
|
||||
maintainers: ["James Harton <james@harton.nz>"],
|
||||
licenses: ["HL3-FULL"],
|
||||
links: %{
|
||||
"Source" => "https://code.harton.nz/bivouac/wayfarer"
|
||||
"Source" => "https://harton.dev/james/wayfarer",
|
||||
"GitHub" => "https://github.com/jimsynz/wayfarer",
|
||||
"Changelog" => "https://docs.harton.nz/james/wayfarer/changelog.html",
|
||||
"Sponsor" => "https://github.com/sponsors/jimsynz"
|
||||
},
|
||||
source_url: "https://code.harton.nz/bivouac/wayfarer"
|
||||
source_url: "https://harton.dev/james/wayfarer"
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -55,27 +92,37 @@ defmodule Wayfarer.MixProject do
|
|||
opts = [only: ~w[dev test]a, runtime: false]
|
||||
|
||||
[
|
||||
{:bandit, "~> 0.7"},
|
||||
{:bandit, "~> 1.0"},
|
||||
{:castore, "~> 1.0"},
|
||||
{:ip, "~> 2.0"},
|
||||
{:mint, "~> 1.5"},
|
||||
{:mint_web_socket, "~> 1.0"},
|
||||
{:nimble_options, "~> 1.0"},
|
||||
{:plug, "~> 1.15"},
|
||||
{:spark, "~> 2.0"},
|
||||
{:telemetry, "~> 1.2"},
|
||||
{:websock, "~> 0.5"},
|
||||
{:websock_adapter, "~> 0.5"},
|
||||
|
||||
# Dev/test
|
||||
{:credo, "~> 1.7", opts},
|
||||
{:dialyxir, "~> 1.3", opts},
|
||||
{:doctor, "~> 0.21", opts},
|
||||
{:earmark, ">= 0.0.0", opts},
|
||||
{:ex_check, "~> 0.15", opts},
|
||||
{:ex_check, "~> 0.16", opts},
|
||||
{:ex_doc, ">= 0.0.0", opts},
|
||||
{:faker, "~> 0.17", opts},
|
||||
{:finch, "~> 0.16", opts},
|
||||
{:faker, "~> 0.18", opts},
|
||||
{:git_ops, "~> 2.6", opts},
|
||||
{:mimic, "~> 1.7", Keyword.delete(opts, :runtime)},
|
||||
{:mix_audit, "~> 2.1", opts}
|
||||
]
|
||||
end
|
||||
|
||||
defp aliases, do: []
|
||||
defp aliases,
|
||||
do: [
|
||||
"spark.formatter": "spark.formatter --extensions=Wayfarer.Dsl",
|
||||
"spark.cheat_sheets": "spark.cheat_sheets --extensions=Wayfarer.Dsl"
|
||||
]
|
||||
|
||||
defp elixirc_paths(env) when env in ~w[dev test]a, do: ~w[lib test/support]
|
||||
defp elixirc_paths(_), do: ~w[lib]
|
||||
|
|
70
mix.lock
70
mix.lock
|
@ -1,37 +1,49 @@
|
|||
%{
|
||||
"bandit": {:hex, :bandit, "0.7.7", "48456d09022607a312cf723a91992236aeaffe4af50615e6e2d2e383fb6bef10", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.6.7", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "772f0a32632c2ce41026d85e24b13a469151bb8cea1891e597fb38fde103640a"},
|
||||
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||
"castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"},
|
||||
"credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"},
|
||||
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
|
||||
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
|
||||
"doctor": {:hex, :doctor, "0.21.0", "20ef89355c67778e206225fe74913e96141c4d001cb04efdeba1a2a9704f1ab5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a227831daa79784eb24cdeedfa403c46a4cb7d0eab0e31232ec654314447e4e0"},
|
||||
"earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"},
|
||||
"earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"},
|
||||
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
|
||||
"ex_check": {:hex, :ex_check, "0.15.0", "074b94c02de11c37bba1ca82ae5cc4926e6ccee862e57a485b6ba60fca2d8dc1", [:mix], [], "hexpm", "33848031a0c7e4209c3b4369ce154019788b5219956220c35ca5474299fb6a0e"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"},
|
||||
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
|
||||
"ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"},
|
||||
"faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
|
||||
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
|
||||
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
|
||||
"git_ops": {:hex, :git_ops, "2.6.0", "e0791ee1cf5db03f2c61b7ebd70e2e95cba2bb9b9793011f26609f22c0900087", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "b98fca849b18aaf490f4ac7d1dd8c6c469b0cc3e6632562d366cab095e666ffe"},
|
||||
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
||||
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
|
||||
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},
|
||||
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
|
||||
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
|
||||
"plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
|
||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||
"thousand_island": {:hex, :thousand_island, "0.6.7", "3a91a7e362ca407036c6691e8a4f6e01ac8e901db3598875863a149279ac8571", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "541a5cb26b88adf8d8180b6b96a90f09566b4aad7a6b3608dcac969648cf6765"},
|
||||
"git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"},
|
||||
"ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"},
|
||||
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
|
||||
"igniter": {:hex, :igniter, "0.3.24", "791a91650ffab9d66b9a3011c66491f767577ad55c363f820cc188554207ee6f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:owl, "~> 0.9", [hex: :owl, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}, {:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: false]}], "hexpm", "2e1d336534c6129bae0db043fae650303b96974c0488c290191d6d4c61ec9a9f"},
|
||||
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
|
||||
"ip": {:hex, :ip, "2.0.3", "290d71c05b79ad62c99d8fe175e86130dc120489d119b8c2819cec16bad3c77c", [:mix], [], "hexpm", "19fa2f9c6f5cb288ca2192499888bd96f88af3564eaa7bbcfc1231ffdc5df8c2"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
|
||||
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
|
||||
"mimic": {:hex, :mimic, "1.10.0", "58ee13aa46addcadbb033ce311bb5ed8b0a825c2dec6e1d55ca767138a6374c8", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "ea639e48f6a5bd043218297b80c3a52e227541aafa3dc8a299cc0c01991523a5"},
|
||||
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
|
||||
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
|
||||
"owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"},
|
||||
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
|
||||
"rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"},
|
||||
"sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"},
|
||||
"spark": {:hex, :spark, "2.2.23", "78f0a1b0b713a91ad556fe9dc19ec92d977aaa0803cce2e255d90e58b9045c2a", [:mix], [{:igniter, ">= 0.2.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "a354b5cd7c3f021e3cd1da5a033b7643fe7b3c71c96b96d9f500a742f40c94db"},
|
||||
"spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
|
||||
"ucwidth": {:hex, :ucwidth, "0.2.0", "1f0a440f541d895dff142275b96355f7e91e15bca525d4a0cc788ea51f0e3441", [:mix], [], "hexpm", "c1efd1798b8eeb11fb2bec3cafa3dd9c0c3647bee020543f0340b996177355bf"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
|
||||
}
|
||||
|
|
25
test/support/example.ex
Normal file
25
test/support/example.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule Support.Example do
|
||||
@moduledoc false
|
||||
use Wayfarer
|
||||
|
||||
config "Example" do
|
||||
listeners do
|
||||
http "0.0.0.0", 8000
|
||||
end
|
||||
|
||||
targets do
|
||||
http "127.0.0.1", 4000
|
||||
end
|
||||
|
||||
health_checks do
|
||||
check do
|
||||
interval :timer.seconds(5)
|
||||
success_codes 200..399
|
||||
end
|
||||
end
|
||||
|
||||
host_patterns do
|
||||
pattern "localhost"
|
||||
end
|
||||
end
|
||||
end
|
97
test/support/http_request.ex
Normal file
97
test/support/http_request.ex
Normal file
|
@ -0,0 +1,97 @@
|
|||
defmodule Support.HttpRequest do
|
||||
@moduledoc false
|
||||
alias Mint.HTTP
|
||||
import Wayfarer.Utils
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
import Support.HttpRequest
|
||||
import IP.Sigil
|
||||
end
|
||||
end
|
||||
|
||||
@type headers :: [{String.t(), String.t()}]
|
||||
|
||||
@type options :: [
|
||||
host: String.t(),
|
||||
method: String.t(),
|
||||
path: String.t(),
|
||||
headers: headers,
|
||||
body: iodata,
|
||||
options: Keyword.t()
|
||||
]
|
||||
|
||||
@doc """
|
||||
Perform an HTTP request.
|
||||
|
||||
This is a little more annoying than most of the Elixir HTTP clients (Tesla,
|
||||
Finch, etc) can handle easily because you need to make a potentially SNI SSL
|
||||
request to an arbitrary address without resolving it via DNS.
|
||||
"""
|
||||
@spec request(
|
||||
:http | :https,
|
||||
:inet.ip_address() | String.t() | IP.Address.t(),
|
||||
:inet.port_number(),
|
||||
options
|
||||
) ::
|
||||
{:ok, %{status: nil | non_neg_integer(), headers: headers, body: iodata()}}
|
||||
| {:error, any}
|
||||
def request(scheme, address, port, options \\ []) do
|
||||
host = Keyword.get(options, :host, "example.com")
|
||||
method = Keyword.get(options, :method, "GET")
|
||||
path = Keyword.get(options, :path, "/")
|
||||
headers = Keyword.get(options, :headers, [])
|
||||
body = Keyword.get(options, :body, [])
|
||||
|
||||
options =
|
||||
options
|
||||
|> Keyword.get(:options, [])
|
||||
|> Keyword.put_new(:hostname, host)
|
||||
|
||||
Task.async(fn ->
|
||||
with {:ok, address} <- sanitise_ip_address(address),
|
||||
{:ok, mint} <- HTTP.connect(scheme, address, port, options),
|
||||
{:ok, mint, req} <- HTTP.request(mint, method, path, headers, body) do
|
||||
handle_response(mint, req, %{status: nil, headers: [], body: []})
|
||||
end
|
||||
end)
|
||||
|> Task.await()
|
||||
end
|
||||
|
||||
defp handle_response(mint, req, state) do
|
||||
receive do
|
||||
message ->
|
||||
case HTTP.stream(mint, message) do
|
||||
:unknown -> {:error, {:unknown_message, message}}
|
||||
{:ok, mint, responses} -> handle_responses(responses, mint, req, state)
|
||||
{:error, _, reason, _} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_responses([], mint, req, state), do: handle_response(mint, req, state)
|
||||
|
||||
defp handle_responses([{:status, req, status} | responses], mint, req, state),
|
||||
do: handle_responses(responses, mint, req, %{state | status: status})
|
||||
|
||||
defp handle_responses([{:headers, req, headers} | responses], mint, req, state),
|
||||
do:
|
||||
handle_responses(
|
||||
responses,
|
||||
mint,
|
||||
req,
|
||||
Map.update!(state, :headers, &Enum.concat(&1, headers))
|
||||
)
|
||||
|
||||
defp handle_responses([{:data, req, body} | responses], mint, req, state),
|
||||
do:
|
||||
handle_responses(
|
||||
responses,
|
||||
mint,
|
||||
req,
|
||||
Map.update!(state, :body, &Enum.concat(&1, [body]))
|
||||
)
|
||||
|
||||
defp handle_responses([{:done, req} | _], _mint, req, state), do: {:ok, state}
|
||||
defp handle_responses([{:error, req, reason}], _mint, req, _state), do: {:error, reason}
|
||||
end
|
60
test/support/http_server.ex
Normal file
60
test/support/http_server.ex
Normal file
|
@ -0,0 +1,60 @@
|
|||
defmodule Support.HttpServer do
|
||||
@moduledoc """
|
||||
A basic HTTP server which returns a canned response.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
import Plug.Conn
|
||||
|
||||
@doc false
|
||||
@spec start_link(:inet.port_number(), 100..599, String.t()) :: Supervisor.on_start()
|
||||
def start_link(port, status, body, notify \\ false) do
|
||||
notify =
|
||||
case notify do
|
||||
true -> self()
|
||||
pid when is_pid(pid) -> pid
|
||||
_ -> false
|
||||
end
|
||||
|
||||
Bandit.start_link(
|
||||
scheme: :http,
|
||||
port: port,
|
||||
ip: {127, 0, 0, 1},
|
||||
plug: {__MODULE__, {status, body, notify}}
|
||||
)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: any
|
||||
def init({status, body, notify}), do: {status, body, notify}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec call(Plug.Conn.t(), any) :: Plug.Conn.t()
|
||||
def call(conn, {status, body, notify}) do
|
||||
if is_pid(notify) do
|
||||
Process.send_after(notify, {:request, __MODULE__, conn}, 20)
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(status, body)
|
||||
end
|
||||
|
||||
@doc "Wait for the next HTTP request to be served"
|
||||
@spec await_request :: Plug.Conn.t()
|
||||
def await_request do
|
||||
receive do
|
||||
{:request, __MODULE__, conn} -> conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Wait for a certain number of requests to have been served"
|
||||
@spec await_requests(non_neg_integer()) :: [Plug.Conn.t()]
|
||||
def await_requests(how_many \\ 1) when how_many > 0 and is_integer(how_many) do
|
||||
Enum.map(1..how_many, fn _ ->
|
||||
await_request()
|
||||
end)
|
||||
end
|
||||
end
|
61
test/support/port_tracker.ex
Normal file
61
test/support/port_tracker.ex
Normal file
|
@ -0,0 +1,61 @@
|
|||
defmodule Support.PortTracker do
|
||||
@moduledoc """
|
||||
Generates port numbers which aren't currently being used by any processes.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
defp random_port(opts \\ []) do
|
||||
unquote(__MODULE__).allocate(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def init(_) do
|
||||
table = :ets.new(__MODULE__, [:public, :named_table, :set])
|
||||
{:ok, table}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_cast({:monitor, pid}, table) do
|
||||
Process.monitor(pid)
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info({:DOWN, _, :process, pid, _}, table) do
|
||||
:ets.match_delete(table, {:_, pid})
|
||||
{:noreply, table}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Allocate an unused random port between `min_port` and `max_port`.
|
||||
"""
|
||||
def allocate(opts) do
|
||||
port = random_port(opts)
|
||||
|
||||
if :ets.insert_new(__MODULE__, {port, self()}) do
|
||||
GenServer.cast(__MODULE__, {:monitor, self()})
|
||||
port
|
||||
else
|
||||
allocate(opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp random_port(opts) do
|
||||
max_port = Keyword.get(opts, :max_port, 0xFFFF)
|
||||
min_port = Keyword.get(opts, :min_port, 20_000)
|
||||
:rand.uniform(max_port - min_port) + min_port
|
||||
end
|
||||
end
|
|
@ -1 +1,8 @@
|
|||
{:ok, _} = Support.PortTracker.start_link([])
|
||||
Mimic.copy(Mint.HTTP)
|
||||
Mimic.copy(Wayfarer.Router)
|
||||
Mimic.copy(Wayfarer.Target.ActiveConnections)
|
||||
Mimic.copy(Wayfarer.Target.TotalConnections)
|
||||
Mimic.copy(Wayfarer.Target.Selector)
|
||||
|
||||
ExUnit.start()
|
||||
|
|
|
@ -1,139 +1,54 @@
|
|||
defmodule Wayfarer.ListenerTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
alias Wayfarer.Listener
|
||||
import ExUnit.CaptureLog
|
||||
use Support.PortTracker
|
||||
use Support.HttpRequest
|
||||
|
||||
alias Wayfarer.{Listener, Router}
|
||||
import IP.Sigil
|
||||
|
||||
setup do
|
||||
start_supervised!(Wayfarer.Listener.Supervisor)
|
||||
|
||||
start_supervised!(
|
||||
{Finch,
|
||||
name: :test_client,
|
||||
pools: %{
|
||||
default: [
|
||||
conn_opts: [
|
||||
transport_opts: [
|
||||
verify: :verify_none
|
||||
]
|
||||
]
|
||||
]
|
||||
}}
|
||||
)
|
||||
{:ok, _table} = Router.init(Support.Example)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "start_listener/1" do
|
||||
test "it returns an error when the scheme option is missing" do
|
||||
assert {:error, {:required_option, :scheme}} = Listener.start_listener([])
|
||||
end
|
||||
test "it can start an HTTP listener" do
|
||||
port = random_port()
|
||||
|
||||
test "it returns an error when an option is incorrect" do
|
||||
assert {:error, _} = Listener.start_listener(scheme: "Marty McFly", port: random_port())
|
||||
end
|
||||
assert {:ok, _pid} =
|
||||
Listener.start_link(
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: port,
|
||||
module: Support.Example
|
||||
)
|
||||
|
||||
test "it can start an HTTP listener" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, _pid} =
|
||||
Listener.start_listener(
|
||||
scheme: :http,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port
|
||||
)
|
||||
|
||||
assert {:ok, %{status: 502}} =
|
||||
:get
|
||||
|> Finch.build("http://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
end
|
||||
|
||||
test "it can start an HTTPS listener" do
|
||||
port = random_port()
|
||||
|
||||
certfile = Path.join(__DIR__, "../support/test.cert")
|
||||
keyfile = Path.join(__DIR__, "../support/test.key")
|
||||
|
||||
assert {:ok, _pid} =
|
||||
Listener.start_listener(
|
||||
scheme: :https,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port,
|
||||
certfile: certfile,
|
||||
keyfile: keyfile
|
||||
)
|
||||
|
||||
assert {:ok, %{status: 502}} =
|
||||
:get
|
||||
|> Finch.build("https://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
end
|
||||
|
||||
test "it restarts listeners when they crash" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, _pid} =
|
||||
Listener.start_listener(
|
||||
scheme: :http,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port
|
||||
)
|
||||
|
||||
# It's up
|
||||
assert {:ok, %{status: 502}} =
|
||||
:get
|
||||
|> Finch.build("http://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
|
||||
# Crash it
|
||||
capture_log(fn ->
|
||||
[{_, pid}] = Registry.lookup(Listener.Registry, {{127, 0, 0, 1}, port})
|
||||
Process.exit(pid, :kill)
|
||||
end)
|
||||
|
||||
# It's up again
|
||||
assert {:ok, %{status: 502}} =
|
||||
:get
|
||||
|> Finch.build("http://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
end
|
||||
assert {:ok, %{status: 502}} = request(:http, ~i"127.0.0.1", port)
|
||||
end
|
||||
|
||||
describe "stop_listener/2" do
|
||||
test "it can shut down a listener" do
|
||||
port = random_port()
|
||||
test "it can start an HTTPS listener" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, pid} =
|
||||
Listener.start_listener(
|
||||
scheme: :http,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port
|
||||
)
|
||||
certfile = Path.join(__DIR__, "../support/test.cert")
|
||||
keyfile = Path.join(__DIR__, "../support/test.key")
|
||||
|
||||
assert {:ok, %{status: 502}} =
|
||||
:get
|
||||
|> Finch.build("http://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
assert {:ok, _pid} =
|
||||
Listener.start_link(
|
||||
scheme: :https,
|
||||
address: ~i"127.0.0.1",
|
||||
port: port,
|
||||
certfile: certfile,
|
||||
keyfile: keyfile,
|
||||
module: Support.Example,
|
||||
cipher_suite: :compatible
|
||||
)
|
||||
|
||||
Listener.stop_listener({127, 0, 0, 1}, port)
|
||||
|
||||
wait_until_dead(pid)
|
||||
|
||||
assert {:error, _} =
|
||||
:get
|
||||
|> Finch.build("http://127.0.0.1:#{port}/")
|
||||
|> Finch.request(:test_client)
|
||||
end
|
||||
end
|
||||
|
||||
defp random_port, do: :rand.uniform(0xFFFF - 1000) + 1000
|
||||
|
||||
defp wait_until_dead(pid) do
|
||||
if Process.alive?(pid) do
|
||||
wait_until_dead(pid)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
assert {:ok, %{status: 502}} =
|
||||
request(:https, ~i"127.0.0.1", port,
|
||||
host: "www.example.com",
|
||||
options: [transport_opts: [verify: :verify_none]]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
280
test/wayfarer/router_test.exs
Normal file
280
test/wayfarer/router_test.exs
Normal file
|
@ -0,0 +1,280 @@
|
|||
defmodule Wayfarer.RouterTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
use Support.PortTracker
|
||||
alias Wayfarer.Router
|
||||
import IP.Sigil
|
||||
|
||||
describe "init/1" do
|
||||
test "it creates a new ETS table" do
|
||||
assert {:ok, table} = Router.init(Support.Example)
|
||||
|
||||
assert [] = :ets.tab2list(table)
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_route/5" do
|
||||
test "when the listener is not valid it returns an error" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
assert {:error, error} =
|
||||
Router.add_route(
|
||||
table,
|
||||
:wat,
|
||||
{:http, ~i"127.0.0.1", random_port()},
|
||||
["example.com"],
|
||||
:round_robin
|
||||
)
|
||||
|
||||
assert Exception.message(error) =~ ~r/not a valid listener/i
|
||||
end
|
||||
|
||||
test "when the target is not valid it returns an error" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
assert {:error, error} =
|
||||
Router.add_route(
|
||||
table,
|
||||
{:http, ~i"127.0.0.1", random_port()},
|
||||
:wat,
|
||||
["example.com"],
|
||||
:round_robin
|
||||
)
|
||||
|
||||
assert Exception.message(error) =~ ~r/not a valid target/i
|
||||
end
|
||||
|
||||
test "it converts wildcard hostnames into tuples" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
assert :ok = Router.add_route(table, listener, target, ["*.example.com"], :round_robin)
|
||||
|
||||
assert :ets.tab2list(table) == [
|
||||
{listener, {:_, "example", "com"}, target, :round_robin, :initial}
|
||||
]
|
||||
end
|
||||
|
||||
test "it converts normal hostnames into tuples" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
assert :ok = Router.add_route(table, listener, target, ["www.example.com"], :round_robin)
|
||||
|
||||
assert :ets.tab2list(table) == [
|
||||
{listener, {"www", "example", "com"}, target, :round_robin, :initial}
|
||||
]
|
||||
end
|
||||
|
||||
test "it converts multiple hostnames into multiple routes" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
assert :ok =
|
||||
Router.add_route(
|
||||
table,
|
||||
listener,
|
||||
target,
|
||||
["example.com", "*.example.com"],
|
||||
:round_robin
|
||||
)
|
||||
|
||||
all_routes =
|
||||
table
|
||||
|> :ets.tab2list()
|
||||
|> Enum.sort()
|
||||
|
||||
assert all_routes == [
|
||||
{listener, {"example", "com"}, target, :round_robin, :initial},
|
||||
{listener, {:_, "example", "com"}, target, :round_robin, :initial}
|
||||
]
|
||||
end
|
||||
|
||||
test "when not a valid algorithm it returns an error" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
assert {:error, error} =
|
||||
Router.add_route(table, listener, target, ["www.example.com"], :marty)
|
||||
|
||||
assert Exception.message(error) =~ ~r/not a valid load balancing algorithm/i
|
||||
end
|
||||
end
|
||||
|
||||
describe "import_routes/2" do
|
||||
test "it inserts multiple routes into the routing table" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
listener1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
assert :ok =
|
||||
Router.import_routes(table, [
|
||||
{listener0, target0, ["0.example.com"], :round_robin},
|
||||
{listener1, target1, ["1.example.com"], :random}
|
||||
])
|
||||
|
||||
all_routes =
|
||||
table
|
||||
|> :ets.tab2list()
|
||||
|> Enum.sort()
|
||||
|
||||
assert all_routes ==
|
||||
Enum.sort([
|
||||
{listener0, {"0", "example", "com"}, target0, :round_robin, :initial},
|
||||
{listener1, {"1", "example", "com"}, target1, :random, :initial}
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_listener/2" do
|
||||
test "removes any matching routes from the table" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
listener1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
Router.import_routes(table, [
|
||||
{listener0, target0, ["0.example.com"], :round_robin},
|
||||
{listener1, target1, ["1.example.com"], :random}
|
||||
])
|
||||
|
||||
Router.remove_listener(table, listener0)
|
||||
|
||||
assert :ets.tab2list(table) == [
|
||||
{listener1, {"1", "example", "com"}, target1, :random, :initial}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_target/2" do
|
||||
test "removes any matching routes from the table" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
listener1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
Router.import_routes(table, [
|
||||
{listener0, target0, ["0.example.com"], :round_robin},
|
||||
{listener1, target1, ["1.example.com"], :random}
|
||||
])
|
||||
|
||||
Router.remove_target(table, target0)
|
||||
|
||||
assert :ets.tab2list(table) == [
|
||||
{listener1, {"1", "example", "com"}, target1, :random, :initial}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_target_health_status/3" do
|
||||
test "updates all routes to the specified target with the new health status" do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
listener1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
Router.import_routes(table, [
|
||||
{listener0, target0, ["0.example.com"], :round_robin},
|
||||
{listener1, target1, ["1.example.com"], :random}
|
||||
])
|
||||
|
||||
assert :ok = Router.update_target_health_status(table, target1, :healthy)
|
||||
|
||||
all_routes =
|
||||
table
|
||||
|> :ets.tab2list()
|
||||
|> Enum.sort()
|
||||
|
||||
assert all_routes ==
|
||||
Enum.sort([
|
||||
{listener0, {"0", "example", "com"}, target0, :round_robin, :initial},
|
||||
{listener1, {"1", "example", "com"}, target1, :random, :healthy}
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_healthy_targets/3" do
|
||||
setup do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
:ok =
|
||||
Router.import_routes(table, [
|
||||
{listener, target0, ["*.example.com"], :random},
|
||||
{listener, target1, ["example.com"], :random}
|
||||
])
|
||||
|
||||
:ok = Router.update_target_health_status(table, target0, :healthy)
|
||||
:ok = Router.update_target_health_status(table, target1, :healthy)
|
||||
|
||||
{:ok, table: table, listener: listener, target0: target0, target1: target1}
|
||||
end
|
||||
|
||||
test "when the request matches a wildcard route it returns the target", %{
|
||||
table: table,
|
||||
listener: listener,
|
||||
target0: target0
|
||||
} do
|
||||
assert {:ok, [{^target0, :random}]} =
|
||||
Router.find_healthy_targets(table, listener, "www.example.com")
|
||||
end
|
||||
|
||||
test "when the request matches a specific route it returns the target", %{
|
||||
table: table,
|
||||
listener: listener,
|
||||
target1: target1
|
||||
} do
|
||||
assert {:ok, [{^target1, :random}]} =
|
||||
Router.find_healthy_targets(table, listener, "example.com")
|
||||
end
|
||||
|
||||
test "when the request matches a multiple targets it returns them both", %{
|
||||
table: table,
|
||||
listener: listener,
|
||||
target0: target0,
|
||||
target1: target1
|
||||
} do
|
||||
:ok =
|
||||
Router.import_routes(table, [
|
||||
{listener, target1, ["www.example.com"], :random}
|
||||
])
|
||||
|
||||
assert {:ok, targets} = Router.find_healthy_targets(table, listener, "www.example.com")
|
||||
assert Enum.sort(targets) == Enum.sort([{target0, :random}, {target1, :random}])
|
||||
end
|
||||
|
||||
test "when the request matches the same target multiple times it returns it once", %{
|
||||
table: table,
|
||||
listener: listener,
|
||||
target0: target0
|
||||
} do
|
||||
:ok =
|
||||
Router.import_routes(table, [
|
||||
{listener, target0, ["www.example.com"], :random}
|
||||
])
|
||||
|
||||
assert {:ok, [{^target0, :random}]} =
|
||||
Router.find_healthy_targets(table, listener, "www.example.com")
|
||||
end
|
||||
end
|
||||
end
|
102
test/wayfarer/server/plug_test.exs
Normal file
102
test/wayfarer/server/plug_test.exs
Normal file
|
@ -0,0 +1,102 @@
|
|||
defmodule Wayfarer.Server.PlugTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
use Mimic
|
||||
use Plug.Test
|
||||
use Support.PortTracker
|
||||
alias Support.HttpServer
|
||||
alias Wayfarer.Router
|
||||
alias Wayfarer.Server.Plug, as: SUT
|
||||
alias Wayfarer.Target.Selector
|
||||
|
||||
setup do
|
||||
{:ok, table} = Router.init(Support.Example)
|
||||
|
||||
start_supervised!(Wayfarer.Server.Supervisor)
|
||||
start_supervised!(Wayfarer.Target.Supervisor)
|
||||
|
||||
{:ok, table: table}
|
||||
end
|
||||
|
||||
describe "init/1" do
|
||||
test "it mappifies it's argument" do
|
||||
assert %{a: 1} == SUT.init(a: 1)
|
||||
end
|
||||
end
|
||||
|
||||
describe "call/2" do
|
||||
test "it results in a bad gateway when the config map contains a wayfarer module" do
|
||||
conn =
|
||||
:gen
|
||||
|> conn("/")
|
||||
|> SUT.call(%{})
|
||||
|
||||
assert conn.status == 502
|
||||
end
|
||||
|
||||
test "it stores the listener config in the conn private" do
|
||||
port = random_port()
|
||||
|
||||
conn =
|
||||
:get
|
||||
|> conn("/")
|
||||
|> SUT.call(%{module: Support.Example, scheme: :http, address: {127, 0, 0, 1}, port: port})
|
||||
|
||||
assert conn.private.wayfarer.listener == %{
|
||||
module: Support.Example,
|
||||
port: port,
|
||||
scheme: :http,
|
||||
address: {127, 0, 0, 1}
|
||||
}
|
||||
end
|
||||
|
||||
test "it looks for healthy targets in the router" do
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
{:ok, _} = HttpServer.start_link(elem(target, 2), 200, "OK")
|
||||
|
||||
Router
|
||||
|> expect(:find_healthy_targets, fn module, ^listener, host ->
|
||||
assert module == Support.Example
|
||||
assert host == "www.example.com"
|
||||
|
||||
{:ok, [{target, :random}]}
|
||||
end)
|
||||
|
||||
:get
|
||||
|> conn("/")
|
||||
|> SUT.call(%{
|
||||
module: Support.Example,
|
||||
scheme: elem(listener, 0),
|
||||
address: elem(listener, 1),
|
||||
port: elem(listener, 2)
|
||||
})
|
||||
end
|
||||
|
||||
test "it selects a target to proxy to" do
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
{:ok, _} = HttpServer.start_link(elem(target, 2), 200, "OK")
|
||||
|
||||
Router
|
||||
|> stub(:find_healthy_targets, fn _, _, _ -> {:ok, [{target, :random}]} end)
|
||||
|
||||
Selector
|
||||
|> expect(:choose, fn _conn, targets, :random ->
|
||||
assert targets == [target]
|
||||
{:ok, target}
|
||||
end)
|
||||
|
||||
:get
|
||||
|> conn("/")
|
||||
|> SUT.call(%{
|
||||
module: Support.Example,
|
||||
scheme: elem(listener, 0),
|
||||
address: elem(listener, 1),
|
||||
port: elem(listener, 2)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
235
test/wayfarer/server/proxy_test.exs
Normal file
235
test/wayfarer/server/proxy_test.exs
Normal file
|
@ -0,0 +1,235 @@
|
|||
defmodule Wayfarer.Server.ProxyTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Mint.HTTP
|
||||
alias Wayfarer.Server.Proxy
|
||||
alias Wayfarer.Target.ActiveConnections
|
||||
|
||||
use Mimic
|
||||
use Plug.Test
|
||||
use Support.PortTracker
|
||||
|
||||
import IP.Sigil
|
||||
|
||||
setup do
|
||||
start_supervised!(Wayfarer.Target.Supervisor)
|
||||
|
||||
listener = {:http, {127, 0, 0, 1}, random_port()}
|
||||
peer_data = %{address: {192, 0, 2, 2}, port: random_port(), ssl_cert: nil}
|
||||
|
||||
conn =
|
||||
:get
|
||||
|> conn("/")
|
||||
|> put_private(:wayfarer, %{
|
||||
listener: %{
|
||||
scheme: elem(listener, 0),
|
||||
address: elem(listener, 1),
|
||||
port: elem(listener, 2)
|
||||
}
|
||||
})
|
||||
|> put_peer_data(peer_data)
|
||||
|> put_req_header("accept", "*/*")
|
||||
|
||||
{:ok, listener: listener, conn: conn, peer: peer_data}
|
||||
end
|
||||
|
||||
defp stub_request(HTTP, responses \\ [:done]) do
|
||||
req = make_ref()
|
||||
|
||||
responses =
|
||||
responses
|
||||
|> Enum.map(fn
|
||||
:done -> {:done, req}
|
||||
{:status, status} -> {:status, req, status}
|
||||
{:headers, headers} -> {:headers, req, headers}
|
||||
{:data, chunk} -> {:data, req, chunk}
|
||||
{:error, reason} -> {:error, req, reason}
|
||||
end)
|
||||
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|
||||
|> stub(:stream_request_body, fn mint, _, _ -> {:ok, mint} end)
|
||||
|> stub(:request, fn mint, _, _, _, _ ->
|
||||
send(self(), :ignore)
|
||||
{:ok, mint, req}
|
||||
end)
|
||||
|> stub(:stream, fn mint, :ignore -> {:ok, mint, responses} end)
|
||||
|> stub(:stream, fn mint, _ -> {:ok, mint, [{:status, req, 200}, {:done, req}]} end)
|
||||
end
|
||||
|
||||
describe "request/2" do
|
||||
test "it opens an HTTP connection to the target and sends the request", %{conn: conn} do
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
req = make_ref()
|
||||
|
||||
HTTP
|
||||
|> expect(:connect, fn scheme, address, port, options ->
|
||||
assert scheme == elem(target, 0)
|
||||
assert address == elem(target, 1)
|
||||
assert port == elem(target, 2)
|
||||
assert options[:hostname] == "www.example.com"
|
||||
assert options[:timeout] == 5000
|
||||
|
||||
{:ok, :fake_conn}
|
||||
end)
|
||||
|> expect(:stream_request_body, fn mint, _, _ -> {:ok, mint} end)
|
||||
|> expect(:request, fn mint, _, _, _, _ ->
|
||||
send(self(), :ignore)
|
||||
{:ok, mint, req}
|
||||
end)
|
||||
|> expect(:stream, fn mint, :ignore ->
|
||||
{:ok, mint, [{:status, req, 200}, {:done, req}]}
|
||||
end)
|
||||
|
||||
assert conn = Proxy.request(conn, target)
|
||||
assert conn.halted
|
||||
end
|
||||
|
||||
test "it records the outgoing connection", %{conn: conn} do
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
|
||||
ActiveConnections
|
||||
|> expect(:connect, fn ^target -> :ok end)
|
||||
|
||||
HTTP
|
||||
|> stub_request()
|
||||
|
||||
assert conn = Proxy.request(conn, target)
|
||||
assert conn.halted
|
||||
end
|
||||
|
||||
test "when there is a connection error, it returns a bad gateway error", %{conn: conn} do
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ ->
|
||||
{:error, %Mint.TransportError{reason: :protocol_not_negotiated}}
|
||||
end)
|
||||
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
assert conn = Proxy.request(conn, target)
|
||||
assert conn.status == 502
|
||||
end
|
||||
|
||||
test "when there is a timeout during connection, it returns a gateway timeout error", %{
|
||||
conn: conn
|
||||
} do
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ ->
|
||||
{:error, %Mint.TransportError{reason: :timeout}}
|
||||
end)
|
||||
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
assert conn = Proxy.request(conn, target)
|
||||
assert conn.status == 504
|
||||
end
|
||||
|
||||
test "it injects the correct `forwarded` header", %{
|
||||
conn: conn,
|
||||
peer: peer,
|
||||
listener: listener
|
||||
} do
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|
||||
|> stub(:request, fn _, _, _, headers, _ ->
|
||||
[forwarded] =
|
||||
headers
|
||||
|> parse_forwarded_headers()
|
||||
|
||||
assert forwarded[:by] == "#{:inet.ntoa(elem(listener, 1))}:#{elem(listener, 2)}"
|
||||
assert forwarded[:for] == "#{:inet.ntoa(peer.address)}:#{peer.port}"
|
||||
assert forwarded[:host] == "www.example.com"
|
||||
assert forwarded[:proto] == "http"
|
||||
|
||||
{:error, :ignore}
|
||||
end)
|
||||
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
Proxy.request(conn, target)
|
||||
end
|
||||
|
||||
test "when the listener and peer are IPv6 address, it injects the correct `forwarded` header",
|
||||
%{conn: conn, peer: peer, listener: listener} do
|
||||
peer = %{peer | address: IP.Address.to_tuple(~i"2001:db8::1")}
|
||||
listener = put_elem(listener, 1, IP.Address.to_tuple(~i"2001:db8::2"))
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_peer_data(peer)
|
||||
|> put_private(:wayfarer, %{
|
||||
listener: %{
|
||||
scheme: elem(listener, 0),
|
||||
address: elem(listener, 1),
|
||||
port: elem(listener, 2)
|
||||
}
|
||||
})
|
||||
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|
||||
|> stub(:request, fn _, _, _, headers, _ ->
|
||||
[forwarded] =
|
||||
headers
|
||||
|> parse_forwarded_headers()
|
||||
|
||||
assert forwarded[:by] == "[#{:inet.ntoa(elem(listener, 1))}]:#{elem(listener, 2)}"
|
||||
assert forwarded[:for] == "[#{:inet.ntoa(peer.address)}]:#{peer.port}"
|
||||
assert forwarded[:host] == "www.example.com"
|
||||
assert forwarded[:proto] == "http"
|
||||
|
||||
{:error, :ignore}
|
||||
end)
|
||||
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
Proxy.request(conn, target)
|
||||
end
|
||||
|
||||
test "when the request has already been forwarded by another proxy, it still injects the correct `forwarded` header",
|
||||
%{conn: conn, peer: peer, listener: listener} do
|
||||
origin_header =
|
||||
"by=[2001:db8::1]:#{random_port()};for=[2001:db8::2]:#{random_port()};host=test.example.com;proto=https"
|
||||
|
||||
conn = put_req_header(conn, "forwarded", origin_header)
|
||||
[origin_header] = parse_forwarded_headers([{"forwarded", origin_header}])
|
||||
|
||||
HTTP
|
||||
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|
||||
|> stub(:request, fn _, _, _, headers, _ ->
|
||||
[forwarded, origin] =
|
||||
headers
|
||||
|> parse_forwarded_headers()
|
||||
|
||||
assert forwarded[:by] == "#{:inet.ntoa(elem(listener, 1))}:#{elem(listener, 2)}"
|
||||
assert forwarded[:for] == "#{:inet.ntoa(peer.address)}:#{peer.port}"
|
||||
assert forwarded[:host] == "www.example.com"
|
||||
assert forwarded[:proto] == "http"
|
||||
|
||||
assert origin == origin_header
|
||||
|
||||
{:error, :ignore}
|
||||
end)
|
||||
|
||||
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
|
||||
Proxy.request(conn, target)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_forwarded_headers(headers) do
|
||||
headers
|
||||
|> Enum.filter(&(elem(&1, 0) == "forwarded"))
|
||||
|> Enum.map(fn {_, header} ->
|
||||
header
|
||||
|> String.split(";")
|
||||
|> Enum.map(&parse_forwarded_header/1)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_forwarded_header(header) do
|
||||
header
|
||||
|> String.split("=")
|
||||
|> case do
|
||||
["by", f_by] -> {:by, f_by}
|
||||
["for", f_for] -> {:for, f_for}
|
||||
["host", host] -> {:host, host}
|
||||
["proto", proto] -> {:proto, proto}
|
||||
end
|
||||
end
|
||||
end
|
70
test/wayfarer/server_test.exs
Normal file
70
test/wayfarer/server_test.exs
Normal file
|
@ -0,0 +1,70 @@
|
|||
defmodule Wayfarer.ServerTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
use Support.PortTracker
|
||||
use Support.HttpRequest
|
||||
import IP.Sigil
|
||||
|
||||
setup do
|
||||
start_supervised!(Wayfarer.Listener.Supervisor)
|
||||
start_supervised!(Wayfarer.Target.Supervisor)
|
||||
start_supervised!(Wayfarer.Server.Supervisor)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "init/1" do
|
||||
test "it requires a module option which implements the `Wayfarer.Server` behaviour" do
|
||||
assert {:stop, reason} = Wayfarer.Server.init(module: URI)
|
||||
assert reason =~ ~r/does not implement/
|
||||
end
|
||||
|
||||
test "a list of listeners can be passed as options and they are started" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, _state} =
|
||||
Wayfarer.Server.init(
|
||||
module: Support.Example,
|
||||
listeners: [[address: ~i"127.0.0.1", port: port, scheme: :http]]
|
||||
)
|
||||
|
||||
assert {:ok, %{status: 502}} = request(:http, ~i"127.0.0.1", port)
|
||||
end
|
||||
|
||||
test "a list of targets can be passed as options and they are started" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, _state} =
|
||||
Wayfarer.Server.init(
|
||||
module: Support.Example,
|
||||
targets: [[address: ~i"127.0.0.1", port: port, scheme: :http]]
|
||||
)
|
||||
|
||||
assert {:ok, :initial} =
|
||||
Wayfarer.Target.current_status({Support.Example, :http, ~i"127.0.0.1", port})
|
||||
end
|
||||
|
||||
test "an initial routing table can be passed as options" do
|
||||
listen_port = random_port()
|
||||
target_port = random_port()
|
||||
|
||||
assert {:ok, _state} =
|
||||
Wayfarer.Server.init(
|
||||
module: Support.Example,
|
||||
routing_table: [
|
||||
{
|
||||
{:http, ~i"127.0.0.1", listen_port},
|
||||
{:http, ~i"127.0.0.1", target_port, :auto},
|
||||
["example.com"],
|
||||
:round_robin
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert :ets.tab2list(Support.Example) == [
|
||||
{{:http, {127, 0, 0, 1}, listen_port}, {"example", "com"},
|
||||
{:http, {127, 0, 0, 1}, target_port, :auto}, :round_robin, :initial}
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
114
test/wayfarer/target/selector_test.exs
Normal file
114
test/wayfarer/target/selector_test.exs
Normal file
|
@ -0,0 +1,114 @@
|
|||
defmodule Wayfarer.Target.SelectorTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
use Plug.Test
|
||||
use Mimic
|
||||
use Support.PortTracker
|
||||
alias Wayfarer.Target.{ActiveConnections, Selector, TotalConnections}
|
||||
|
||||
@algorithms ~w[least_connections random round_robin sticky]a
|
||||
|
||||
describe "choose/3" do
|
||||
test "when there are no targets to choose from it returns an error regardless of algorithm" do
|
||||
conn = conn(:get, "/")
|
||||
|
||||
for algorithm <- @algorithms do
|
||||
assert :error = Selector.choose(conn, [], algorithm)
|
||||
end
|
||||
end
|
||||
|
||||
test "when there is only one target to choose it is used regardless of algorithm" do
|
||||
conn = conn(:get, "/")
|
||||
target = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
for algorithm <- @algorithms do
|
||||
assert {:ok, ^target} = Selector.choose(conn, [target], algorithm)
|
||||
end
|
||||
end
|
||||
|
||||
test "sticky targets route repeated requests from the same client to the same target" do
|
||||
paths = ["/", "/sign-in", "/dashboard"]
|
||||
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
wayfarer = %{
|
||||
listener: %{
|
||||
address: {127, 0, 0, 1},
|
||||
port: random_port(),
|
||||
remote_ip: {192, 0, 2, 1}
|
||||
}
|
||||
}
|
||||
|
||||
peer_data = %{address: {192, 0, 2, 2}, port: random_port(), ssl_cert: nil}
|
||||
|
||||
assert {:http, {127, 0, 0, 1}, _} =
|
||||
paths
|
||||
|> Enum.reduce(nil, fn path, last_target ->
|
||||
conn =
|
||||
conn(:get, path)
|
||||
|> put_private(:wayfarer, wayfarer)
|
||||
|> put_peer_data(peer_data)
|
||||
|
||||
assert {:ok, target} = Selector.choose(conn, [target0, target1], :sticky)
|
||||
|
||||
if is_nil(last_target) do
|
||||
target
|
||||
else
|
||||
assert target == last_target
|
||||
last_target
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
test "random targets route requests to random targets" do
|
||||
paths = ["/", "/sign-in", "/dashboard"]
|
||||
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
for path <- paths do
|
||||
conn = conn(:get, path)
|
||||
|
||||
assert {:ok, target} = Selector.choose(conn, [target0, target1], :random)
|
||||
assert target in [target0, target1]
|
||||
end
|
||||
end
|
||||
|
||||
test "least_connections targets route requests to the target with the least connections" do
|
||||
paths = ["/", "/sign-in", "/dashboard"]
|
||||
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
ActiveConnections
|
||||
|> stub(:request_count, fn _targets ->
|
||||
%{target0 => 37, target1 => 3}
|
||||
end)
|
||||
|
||||
for path <- paths do
|
||||
conn = conn(:get, path)
|
||||
|
||||
assert {:ok, ^target1} = Selector.choose(conn, [target0, target1], :least_connections)
|
||||
end
|
||||
end
|
||||
|
||||
test "round_robin targets route requests to the target with lowest total connections" do
|
||||
paths = ["/", "/sign-in", "/dashboard"]
|
||||
|
||||
target0 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
target1 = {:http, {127, 0, 0, 1}, random_port()}
|
||||
|
||||
TotalConnections
|
||||
|> stub(:proxy_count, fn _targets ->
|
||||
%{target0 => 37, target1 => 3}
|
||||
end)
|
||||
|
||||
for path <- paths do
|
||||
conn = conn(:get, path)
|
||||
|
||||
assert {:ok, ^target1} = Selector.choose(conn, [target0, target1], :round_robin)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
73
test/wayfarer/target_test.exs
Normal file
73
test/wayfarer/target_test.exs
Normal file
|
@ -0,0 +1,73 @@
|
|||
defmodule Wayfarer.TargetTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: false
|
||||
use Support.PortTracker
|
||||
alias Support.HttpServer
|
||||
|
||||
alias Wayfarer.Target
|
||||
import IP.Sigil
|
||||
|
||||
setup do
|
||||
start_supervised!(Target.Supervisor)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "start_link/1" do
|
||||
test "it can start a target checker" do
|
||||
port = random_port()
|
||||
|
||||
assert {:ok, pid} =
|
||||
Target.start_link(
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: port,
|
||||
module: Support.Example
|
||||
)
|
||||
|
||||
assert {:ok, :initial} = Target.current_status(pid)
|
||||
end
|
||||
|
||||
test "it becomes healthy when the target is okay" do
|
||||
port = random_port()
|
||||
|
||||
HttpServer.start_link(port, 200, "OK", self())
|
||||
|
||||
assert {:ok, pid} =
|
||||
Target.start_link(
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: port,
|
||||
module: Support.Example,
|
||||
health_checks: [[interval: 10, threshold: 3]]
|
||||
)
|
||||
|
||||
assert {:ok, :initial} = Target.current_status(pid)
|
||||
|
||||
HttpServer.await_requests(3)
|
||||
|
||||
assert {:ok, :healthy} = Target.current_status(pid)
|
||||
end
|
||||
end
|
||||
|
||||
test "it becomes unhealthy when the target is not okay" do
|
||||
port = random_port()
|
||||
|
||||
HttpServer.start_link(port, 500, "ISE", self())
|
||||
|
||||
assert {:ok, pid} =
|
||||
Target.start_link(
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: port,
|
||||
module: Support.Example,
|
||||
health_checks: [[interval: 10, threshold: 3]]
|
||||
)
|
||||
|
||||
assert {:ok, :initial} = Target.current_status(pid)
|
||||
|
||||
HttpServer.await_requests(2)
|
||||
|
||||
assert {:ok, :unhealthy} = Target.current_status(pid)
|
||||
end
|
||||
end
|
67
test/wayfarer/utils_test.exs
Normal file
67
test/wayfarer/utils_test.exs
Normal file
|
@ -0,0 +1,67 @@
|
|||
defmodule Wayfarer.UtilsTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
use Support.PortTracker
|
||||
import IP.Sigil
|
||||
import Wayfarer.Utils
|
||||
|
||||
describe "sanitise_ip_address/1" do
|
||||
test "when passed a valid IPv6 string it returns the parsed tuple" do
|
||||
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} = sanitise_ip_address("2001:db8::1")
|
||||
end
|
||||
|
||||
test "when passed a valid IPv4 string it returns a parsed tuple" do
|
||||
assert {:ok, {192, 0, 2, 1}} = sanitise_ip_address("192.0.2.1")
|
||||
end
|
||||
|
||||
test "when passed an IP address struct it returns a tuple" do
|
||||
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} = sanitise_ip_address(~i"2001:db8::1")
|
||||
end
|
||||
|
||||
test "when passed a valid IPv6 tuple it returns it" do
|
||||
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} =
|
||||
sanitise_ip_address({8193, 3512, 0, 0, 0, 0, 0, 1})
|
||||
end
|
||||
|
||||
test "when passed a valid IPv4 tuple it returns it" do
|
||||
assert {:ok, {192, 0, 2, 1}} = sanitise_ip_address({192, 0, 2, 1})
|
||||
end
|
||||
|
||||
test "when passed an invalid IPv4 tuple, it returns an error" do
|
||||
assert {:error, "Invalid address"} = sanitise_ip_address({192, 0, 2, 257})
|
||||
end
|
||||
|
||||
test "when passed an invalid IPv6 tuple, it returns an error" do
|
||||
assert {:error, "Invalid address"} =
|
||||
sanitise_ip_address({8193, 3512, 0, 0, 0, 0, 0, 0xFFFF1})
|
||||
end
|
||||
end
|
||||
|
||||
describe "sanitise_scheme/1" do
|
||||
test "when passed `:http` it is ok" do
|
||||
assert {:ok, :http} = sanitise_scheme(:http)
|
||||
end
|
||||
|
||||
test "when passed `:https` it is ok" do
|
||||
assert {:ok, :https} = sanitise_scheme(:https)
|
||||
end
|
||||
|
||||
test "when passed an unsupported scheme it returns an error" do
|
||||
assert {:error, _} = sanitise_scheme(:spdy)
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_uri/3" do
|
||||
test "when given an IPv6 address it correctly encodes the URI" do
|
||||
port = random_port()
|
||||
assert {:ok, uri} = to_uri(:http, ~i"2001:db8::1", port)
|
||||
assert "http://[2001:db8::1]:#{port}" == to_string(uri)
|
||||
end
|
||||
|
||||
test "when given an IPv4 address it correctly encodes the URI" do
|
||||
port = random_port()
|
||||
assert {:ok, uri} = to_uri(:http, ~i"192.0.2.1", port)
|
||||
assert "http://192.0.2.1:#{port}" == to_string(uri)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,5 +1,109 @@
|
|||
defmodule WayfarerTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case
|
||||
use ExUnit.Case, async: false
|
||||
doctest Wayfarer
|
||||
|
||||
alias Support.HttpServer
|
||||
alias Wayfarer.Target.TotalConnections
|
||||
|
||||
use Support.HttpRequest
|
||||
use Support.PortTracker
|
||||
|
||||
import IP.Sigil
|
||||
|
||||
setup do
|
||||
start_supervised!(Wayfarer.Target.Supervisor)
|
||||
start_supervised!(Wayfarer.Listener.Supervisor)
|
||||
start_supervised!(Wayfarer.Server.Supervisor)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defmodule IntegrationProxy do
|
||||
@moduledoc false
|
||||
use Wayfarer.Server
|
||||
end
|
||||
|
||||
describe "integration tests" do
|
||||
test "round robin integration test" do
|
||||
listener_port = random_port()
|
||||
target0_port = random_port()
|
||||
target1_port = random_port()
|
||||
|
||||
{:ok, _} = HttpServer.start_link(target0_port, 200, "OK", true)
|
||||
{:ok, _} = HttpServer.start_link(target1_port, 200, "OK", true)
|
||||
|
||||
{:ok, _proxy} =
|
||||
IntegrationProxy.start_link(
|
||||
listeners: [[scheme: :http, address: ~i"127.0.0.1", port: listener_port]],
|
||||
targets: [
|
||||
[
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: target0_port,
|
||||
health_checks: [[interval: 100]]
|
||||
],
|
||||
[
|
||||
scheme: :http,
|
||||
address: ~i"127.0.0.1",
|
||||
port: target1_port,
|
||||
health_checks: [[interval: 100]]
|
||||
]
|
||||
],
|
||||
routing_table: [
|
||||
{{:http, ~i"127.0.0.1", listener_port}, {:http, ~i"127.0.0.1", target0_port, :auto},
|
||||
["www.example.com"], :round_robin},
|
||||
{{:http, ~i"127.0.0.1", listener_port}, {:http, ~i"127.0.0.1", target1_port, :auto},
|
||||
["www.example.com"], :round_robin}
|
||||
]
|
||||
)
|
||||
|
||||
wait_for_target_state({IntegrationProxy, :http, ~i"127.0.0.1", target0_port}, :healthy)
|
||||
wait_for_target_state({IntegrationProxy, :http, ~i"127.0.0.1", target1_port}, :healthy)
|
||||
|
||||
assert {:ok, %{status: 200}} =
|
||||
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
|
||||
|
||||
assert {:ok, %{status: 200}} =
|
||||
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
|
||||
|
||||
assert [1, 1] =
|
||||
[
|
||||
{:http, {127, 0, 0, 1}, target0_port, :auto},
|
||||
{:http, {127, 0, 0, 1}, target1_port, :auto}
|
||||
]
|
||||
|> TotalConnections.proxy_count()
|
||||
|> Enum.map(&elem(&1, 1))
|
||||
|
||||
for _ <- 1..10 do
|
||||
assert {:ok, %{status: 200}} =
|
||||
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
|
||||
|
||||
assert {:ok, %{status: 200}} =
|
||||
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
|
||||
end
|
||||
|
||||
assert [11, 11] =
|
||||
[
|
||||
{:http, {127, 0, 0, 1}, target0_port, :auto},
|
||||
{:http, {127, 0, 0, 1}, target1_port, :auto}
|
||||
]
|
||||
|> TotalConnections.proxy_count()
|
||||
|> Enum.map(&elem(&1, 1))
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_target_state(key, state) do
|
||||
case Wayfarer.Target.current_status(key) do
|
||||
{:ok, ^state} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
Process.sleep(100)
|
||||
wait_for_target_state(key, state)
|
||||
|
||||
{:error, reason} ->
|
||||
raise reason
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue