├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── google_app_engine.ex ├── mix.exs ├── mix.lock └── test ├── libcluster_gae_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | libcluster_gae-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 5 | the Software, and to permit persons to whom the Software is furnished to do so, 6 | subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This library is a strategy for `libcluster` for connecting nodes in Google App Engine. If you're unfamiliar with `libcluster`, please read the [documentation](https://github.com/bitwalker/libcluster). 2 | 3 | This library makes the assumption that the elixir application is using [Distillery](https://github.com/bitwalker/distillery) releases. 4 | 5 | ## Installation 6 | 7 | Add `:libcluster_gae` to your project's mix dependencies. 8 | 9 | ```elixir 10 | def deps do 11 | [ 12 | {:libcluster_gae, "~> 0.1"} 13 | ] 14 | end 15 | ``` 16 | 17 | ## Deployment Assumptions 18 | 19 | Clustering will only apply to nodes that are configured to receive HTTP traffic in App Engine are currently running and belong to the same service. If this doesn't fit your deployment strategy, please open a Github issue describing your deployment configuration. 20 | 21 | ## Configuration 22 | 23 | ### Google Cloud 24 | 25 | Before clustering can work, enable the **App Engine Admin API** for your application's Google Cloud Project. Follow the guide on [enabling APIs](https://cloud.google.com/apis/docs/enable-disable-apis). 26 | 27 | ![Video demonstrating how to enable the App Engine Admin API](https://i.imgur.com/jBhOGYG.gif) 28 | 29 | ### Elixir Application 30 | 31 | To cluster an application running in Google App Engine, define a topology for `libcluster`. 32 | 33 | ```elixir 34 | # config.exs 35 | config :libcluster, 36 | topologies: [ 37 | my_app: [ 38 | strategy: Cluster.Strategy.GoogleAppEngine 39 | ] 40 | ] 41 | ``` 42 | 43 | Make sure a cluster supervisor is part of your application. 44 | 45 | ```elixir 46 | defmodule MyApp.App do 47 | use Application 48 | 49 | def start(_type, _args) do 50 | topologies = Application.get_env(:libcluster, :topologies) 51 | 52 | children = [ 53 | {Cluster.Supervisor, [topologies, [name: MyApp.ClusterSupervisor]]}, 54 | # ... 55 | ] 56 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 57 | end 58 | end 59 | ``` 60 | 61 | Update your release's `vm.args` file to include the following lines. 62 | 63 | ``` 64 | ## Name of the node 65 | -name <%= release_name%>@${GAE_INSTANCE}.c.${GOOGLE_CLOUD_PROJECT}.internal 66 | 67 | ## Limit distributed erlang ports to a single port 68 | -kernel inet_dist_listen_min 9999 69 | -kernel inet_dist_listen_max 9999 70 | ``` 71 | 72 | Update the `app.yaml` configuration file for Google App Engine. 73 | 74 | ```yaml 75 | env_variables: 76 | REPLACE_OS_VARS: true 77 | 78 | network: 79 | forwarded_ports: 80 | # epmd 81 | - 4369 82 | # erlang distribution 83 | - 9999 84 | ``` 85 | 86 | Now run `gcloud app deploy` and enjoy clustering on GAE! 87 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :libcluster_gae, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:libcluster_gae, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/google_app_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule Cluster.Strategy.GoogleAppEngine do 2 | @moduledoc """ 3 | Clustering strategy for Google App Engine. 4 | 5 | This strategy checks for the list of app versions that are currently receiving HTTP. 6 | For each version that is listed, the list of instances running for that version are fetched. 7 | Once all of the instances have been received, they attempt to connect to each other. 8 | 9 | **Note**: This strategy only connects nodes that are able to receive HTTP traffic. 10 | 11 | Here's an example configuration: 12 | 13 | ```elixir 14 | config :libcluster, 15 | topologies: [ 16 | my_app: [ 17 | strategy: Cluster.Strategy.GoogleAppEngine, 18 | config: [ 19 | polling_interval: 10_000 20 | ] 21 | ] 22 | ] 23 | ``` 24 | 25 | ## Configurable Options 26 | 27 | Options can be set for the strategy under the `:config` key when defining the topology. 28 | 29 | * `:polling_interval` - Interval for checking for the list of running instances. Defaults to `10_000` 30 | 31 | ## Application Setup 32 | 33 | ### Google Cloud 34 | 35 | Enable the **App Engine Admin API** for your application's Google Cloud Project. Follow the guide on [enabling APIs](https://cloud.google.com/apis/docs/enable-disable-apis). 36 | 37 | ### Release Configuration 38 | 39 | Update your release's `vm.args` file to include the following lines. 40 | 41 | ``` 42 | ## Name of the node 43 | -name <%= release_name%>@${GAE_INSTANCE}.c.${GOOGLE_CLOUD_PROJECT}.internal 44 | 45 | ## Limit distributed erlang ports to a single port 46 | -kernel inet_dist_listen_min 9999 47 | -kernel inet_dist_listen_max 9999 48 | ``` 49 | 50 | ### GAE Configuration File 51 | 52 | Update the `app.yaml` configuration file for Google App Engine. 53 | 54 | ```yaml 55 | env_variables: 56 | REPLACE_OS_VARS: true 57 | 58 | network: 59 | forwarded_ports: 60 | # epmd 61 | - 4369 62 | # erlang distribution 63 | - 9999 64 | ``` 65 | """ 66 | 67 | use GenServer 68 | use Cluster.Strategy 69 | 70 | alias Cluster.Strategy.State 71 | 72 | @default_polling_interval 10_000 73 | @access_token_path 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token' 74 | 75 | def start_link(args) do 76 | GenServer.start_link(__MODULE__, args) 77 | end 78 | 79 | @impl true 80 | def init([%State{} = state]) do 81 | {:ok, load(state)} 82 | end 83 | 84 | @impl true 85 | def handle_info(:timeout, state) do 86 | handle_info(:load, state) 87 | end 88 | 89 | def handle_info(:load, %State{} = state) do 90 | {:noreply, load(state)} 91 | end 92 | 93 | def handle_info(_, state) do 94 | {:noreply, state} 95 | end 96 | 97 | defp load(%State{} = state) do 98 | connect = state.connect 99 | list_nodes = state.list_nodes 100 | topology = state.topology 101 | 102 | nodes = get_nodes(state) 103 | 104 | Cluster.Strategy.connect_nodes(topology, connect, list_nodes, nodes) 105 | 106 | Process.send_after(self(), :load, polling_interval(state)) 107 | 108 | state 109 | end 110 | 111 | defp polling_interval(%State{config: config}) do 112 | Keyword.get(config, :polling_interval, @default_polling_interval) 113 | end 114 | 115 | defp get_nodes(%State{}) do 116 | project_id = System.get_env("GOOGLE_CLOUD_PROJECT") 117 | instances = get_running_instances(project_id) 118 | 119 | release_name = System.get_env("REL_NAME") 120 | 121 | Enum.map(instances, & :"#{release_name}@#{&1}.c.#{project_id}.internal") 122 | end 123 | 124 | defp get_running_instances(project_id) do 125 | service_id = System.get_env("GAE_SERVICE") 126 | 127 | versions = get_running_versions(project_id, service_id) 128 | 129 | Enum.flat_map(versions, &get_instances_for_version(project_id, service_id, &1)) 130 | end 131 | 132 | defp get_running_versions(project_id, service_id) do 133 | access_token = access_token() 134 | headers = [{'Authorization', 'Bearer #{access_token}'}] 135 | 136 | api_url = 'https://appengine.googleapis.com/v1/apps/#{project_id}/services/#{service_id}' 137 | 138 | case :httpc.request(:get, {api_url, headers}, [], []) do 139 | {:ok, {{_, 200, _}, _headers, body}} -> 140 | %{"split" => %{"allocations" => allocations}} = Jason.decode!(body) 141 | Map.keys(allocations) 142 | end 143 | end 144 | 145 | defp get_instances_for_version(project_id, service_id, version) do 146 | access_token = access_token() 147 | headers = [{'Authorization', 'Bearer #{access_token}'}] 148 | 149 | api_url = 'https://appengine.googleapis.com/v1/apps/#{project_id}/services/#{service_id}/versions/#{version}/instances' 150 | 151 | case :httpc.request(:get, {api_url, headers}, [], []) do 152 | {:ok, {{_, 200, _}, _headers, body}} -> 153 | handle_instances(Jason.decode!(body)) 154 | end 155 | end 156 | 157 | defp handle_instances(%{"instances" => instances}) do 158 | instances 159 | |> Enum.filter(& &1["vmStatus"] == "RUNNING") 160 | |> Enum.map(& &1["id"]) 161 | end 162 | 163 | defp handle_instances(_), do: [] 164 | 165 | defp access_token do 166 | headers = [{'Metadata-Flavor', 'Google'}] 167 | 168 | case :httpc.request(:get, {@access_token_path, headers}, [], []) do 169 | {:ok, {{_, 200, _}, _headers, body}} -> 170 | %{"access_token" => token} = Jason.decode!(body) 171 | token 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LibclusterGae.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :libcluster_gae, 7 | description: "", 8 | version: "0.1.2", 9 | elixir: "~> 1.7", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | description: description(), 13 | source_url: source_url(), 14 | project_url: source_url(), 15 | package: package() 16 | ] 17 | end 18 | 19 | defp description do 20 | """ 21 | Clustering strategy for connecting nodes running on Google App Engine. 22 | """ 23 | end 24 | 25 | defp source_url do 26 | "https://github.com/alexgaribay/libcluster_gae" 27 | end 28 | 29 | defp package do 30 | [ 31 | files: ["lib", "mix.exs", "LICENSE", "README.md"], 32 | maintainers: ["Alex Garibay"], 33 | licenses: ["MIT"], 34 | links: %{"GitHub" => source_url()} 35 | ] 36 | end 37 | # Run "mix help compile.app" to learn about applications. 38 | def application do 39 | [ 40 | extra_applications: [:logger] 41 | ] 42 | end 43 | 44 | # Run "mix help deps" to learn about dependencies. 45 | defp deps do 46 | [ 47 | {:ex_doc, ">= 0.0.0", only: :dev}, 48 | {:libcluster, "~> 3.0"} 49 | ] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 3 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 5 | "libcluster": {:hex, :libcluster, "3.0.3", "492e98c7f5c9a6e95b8d51f0b198cf8eab60af3b490f40b958d4bc326d11e40e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 9 | } 10 | -------------------------------------------------------------------------------- /test/libcluster_gae_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LibclusterGaeTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------