Skip to content

Commit

Permalink
Add phx.gen.release task for release/docker based deployments (phoeni…
Browse files Browse the repository at this point in the history
…xframework#4609)

* Add phx.gen.release task for release and docker based deployments
  • Loading branch information
chrismccord authored Dec 7, 2021
1 parent 940664c commit 380a281
Show file tree
Hide file tree
Showing 14 changed files with 479 additions and 13 deletions.
17 changes: 11 additions & 6 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

1. Check related deps for required version bumps and compatibility (`phoenix_ecto`, `phoenix_pubsub_redis`, `phoenix_html`)
2. Bump version in related files below
3. Run tests:
3. Bump external dependency version in related external files below
4. Run tests:
- `mix test` in the root folder
- `mix test` in the `installer/` folder
4. Commit, push code
5. Publish `phx_new` and `phoenix` packages and docs after pruning any extraneous uncommitted files
6. Test installer by generating a new app, running `mix deps.get`, and compiling
7. Publish to `npm` with `npm publish`
8. Start -dev version in related files below
5. Commit, push code
6. Publish `phx_new` and `phoenix` packages and docs after pruning any extraneous uncommitted files
7. Test installer by generating a new app, running `mix deps.get`, and compiling
8. Publish to `npm` with `npm publish`
9. Start -dev version in related files below

## Files with version

Expand All @@ -18,3 +19,7 @@
* `installer/mix.exs`
* `package.json`
* `assets/package.json`

## Files with external dependency versions
* `priv/templates/phx.gen.release/Docker.eex` (debian)
* `priv/templates/phx.gen.release/Docker.eex` (esbuild)
6 changes: 4 additions & 2 deletions installer/lib/phx_new/generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,14 @@ defmodule Phx.New.Generator do
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
\"""
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
""",
prod_config: """
# ssl: true,
# socket_options: [:inet6],
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
"""
]
end
Expand Down
4 changes: 1 addition & 3 deletions installer/templates/phx_single/config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import Config
# manifest is generated by the `mix phx.digest` task,
# which you should run after static files are built and
# before starting your production server.
config :<%= @web_app_name %>, <%= @endpoint_module %>,
url: [host: "example.com", port: 80],
cache_static_manifest: "priv/static/cache_manifest.json"
config :<%= @web_app_name %>, <%= @endpoint_module %>, cache_static_manifest: "priv/static/cache_manifest.json"

# Do not print debug messages in production
config :logger, level: :info
Expand Down
8 changes: 7 additions & 1 deletion installer/templates/phx_single/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""

server? = System.get_env("PHX_SERVER") != nil
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")

config :<%= @app_name %>, <%= @endpoint_module %>,
url: [host: host, port: 443],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000")
port: port
],
server: server?,
secret_key_base: secret_key_base

# ## Using releases
Expand Down
10 changes: 9 additions & 1 deletion installer/test/phx_new_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,15 @@ defmodule Mix.Tasks.Phx.NewTest do
end
assert_file "phx_blog/config/dev.exs", config
assert_file "phx_blog/config/test.exs", config
assert_file "phx_blog/config/runtime.exs", config
assert_file "phx_blog/config/runtime.exs", fn file ->
assert file =~ config
assert file =~ ~S|ipv6? = System.get_env("ECTO_IPV6") == "true"|
assert file =~ ~S|server? = System.get_env("PHX_SERVER") == "true"|
assert file =~ "host = System.get_env(\"PHX_HOST\") || \"example.com\""
assert file =~ ~S|url: [host: host, port: 80],|
assert file =~ ~S|server: server?|
assert file =~ ~S|socket_options: if(ipv6?, do: [:inet6], else: []),|
end
assert_file "phx_blog/config/test.exs", ~R/database: "phx_blog_test#\{System.get_env\("MIX_TEST_PARTITION"\)\}"/
assert_file "phx_blog/lib/phx_blog/repo.ex", ~r"defmodule PhxBlog.Repo"
assert_file "phx_blog/lib/phx_blog_web.ex", ~r"defmodule PhxBlogWeb"
Expand Down
179 changes: 179 additions & 0 deletions lib/mix/tasks/phx.gen.release.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
defmodule Mix.Tasks.Phx.Gen.Release do
@shortdoc "Generates release files and optional Dockerfile for release-based deployments"

@moduledoc """
Generates release files and optional Dockerfile for release-based deployments.
The following release files are created:
* `lib/app_name/release.exs` - A release module containing tasks for running
migrations inside a release
* `rel/overlays/bin/migrate` - A migrate script for conveniently invoking
the release system migrations
* `rel/overlays/bin/server` - A server script for conveniently invoking
the release system with environment variables to start the phoenix web server
Note, the `rel/overlays` directory is copied into the release build by default when
running `mix release`.
When the `--docker` flag is passed, the following docker files are generated:
* `Dockerfile` - The Dockerfile for use in any standard docker deployment
* `.dockerignore` - A docker ignore file with standard elixir defaults
For extended release configuration, the `mix release.init`task can be used
in addition to this task. See the `Mix.Release` docs for more details.
"""

use Mix.Task

@doc false
def run(args) do
docker? = "--docker" in args
ecto? = "--ecto" in args || Code.ensure_loaded?(Ecto)

if Mix.Project.umbrella?() do
Mix.raise("""
mix phx.gen.release is not supported in umbrella applications.
Run this task in your web application instead.
""")
end

app = Mix.Phoenix.otp_app()
app_namespace = Mix.Phoenix.base()

binding = [
app_namespace: app_namespace,
otp_app: app,
elixir_vsn: System.version(),
otp_vsn: otp_vsn()
]

Mix.Phoenix.copy_from(paths(), "priv/templates/phx.gen.release", binding, [
{:eex, "rel/server.sh.eex", "rel/overlays/bin/server"},
{:eex, "rel/server.bat.eex", "rel/overlays/bin/server.bat"}
])

if ecto? do
Mix.Phoenix.copy_from(paths(), "priv/templates/phx.gen.release", binding, [
{:eex, "rel/migrate.sh.eex", "rel/overlays/bin/migrate"},
{:eex, "rel/migrate.bat.eex", "rel/overlays/bin/migrate.bat"},
{:eex, "release.ex", Mix.Phoenix.context_lib_path(app, "release.ex")}
])
end

if docker? do
Mix.Phoenix.copy_from(paths(), "priv/templates/phx.gen.release", binding, [
{:eex, "Dockerfile.eex", "Dockerfile"},
{:eex, "dockerignore.eex", ".dockerignore"}
])
end

File.chmod!("rel/overlays/bin/server", 0o755)
File.chmod!("rel/overlays/bin/server.bat", 0o755)
if ecto? do
File.chmod!("rel/overlays/bin/migrate", 0o755)
File.chmod!("rel/overlays/bin/migrate.bat", 0o755)
end

Mix.shell().info("""
Your application is ready to be deployed in a release!
# To start your system
_build/dev/rel/#{app}/bin/#{app} start
# To start your system with the Phoenix server running
_build/dev/rel/#{app}/bin/server
#{ecto? && ecto_instructions(app)}
Once the release is running:
# To connect to it remotely
_build/dev/rel/#{app}/bin/#{app} remote
# To stop it gracefully (you may also send SIGINT/SIGTERM)
_build/dev/rel/#{app}/bin/#{app} stop
To list all commands:
_build/dev/rel/#{app}/bin/#{app}
""")

ecto? &&
post_install_instructions("config/runtime.exs", ~r/ECTO_IPV6/, """
[warn] Conditional IPV6 support missing from runtime configuration.
Add the following to your config/runtime.exs:
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
config :#{app}, #{app_namespace}.Repo,
...,
socket_options: maybe_ipv6
""")

post_install_instructions("config/runtime.exs", ~r/PHX_SERVER/, """
[warn] Conditional server startup is missing from runtime configuration.
Add the following to your config/runtime.exs:
server? = System.get_env("PHX_SERVER") != nil
config :#{app}, #{app_namespace}.Endpoint,
...,
server: server?
""")

post_install_instructions("config/runtime.exs", ~r/PHX_HOST/, """
[warn] Environment based URL export is missing from runtime configuration.
Add the following to your config/runtime.exs:
host = System.get_env("PHX_HOST") || "example.com"
config :#{app}, #{app_namespace}.Endpoint,
...,
url: [host: host, port: 443]
""")
end

defp ecto_instructions(app) do
"""
# To run migrations
_build/dev/rel/#{app}/bin/migrate
"""
end

defp paths do
[".", :phoenix]
end

defp post_install_instructions(path, matching, msg) do
case File.read(path) do
{:ok, content} ->
unless content =~ matching, do: Mix.shell().info(msg)

{:error, _} ->
Mix.shell().info(msg)
end
end

def otp_vsn do
major = to_string(:erlang.system_info(:otp_release))
path = Path.join([:code.root_dir(), "releases", major, "OTP_VERSION"])

case File.read(path) do
{:ok, content} ->
String.trim(content)

{:error, _} ->
IO.warn("unable to read OTP minor version at #{path}. Falling back to #{major}.0")
"#{major}.0"
end
end
end
89 changes: 89 additions & 0 deletions priv/templates/phx.gen.release/Dockerfile.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
# Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:<%= elixir_vsn %>-erlang-<%= otp_vsn %>-debian-bullseye-20210902-slim
#
ARG BUILDER_IMAGE="hexpm/elixir:<%= elixir_vsn %>-erlang-<%= otp_vsn %>-debian-bullseye-20210902-slim"
ARG RUNNER_IMAGE="debian:bullseye-20210902-slim"

FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*

# prepare build dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force

# set build ENV
ENV MIX_ENV="prod"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv

# note: if your project uses a tool like https://purgecss.com/,
# which customizes asset compilation based on what it finds in
# your Elixir templates, you will need to move the asset compilation
# step down so that `lib` is available.
COPY assets assets

# compile assets
RUN mix assets.deploy

# Compile the release
COPY lib lib

RUN mix compile

# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/

COPY rel rel
RUN mix release

# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/prod/rel/<%= otp_app %> ./

USER nobody

CMD /app/bin/server
18 changes: 18 additions & 0 deletions priv/templates/phx.gen.release/dockerignore.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.dockerignore
# there are valid reasons to keep the .git, namely so that you can get the
# current commit hash
#.git
.log
tmp

# Mix artifacts
_build
deps
*.ez
releases

# Generate on crash by the VM
erl_crash.dump

# Static artifacts
node_modules
1 change: 1 addition & 0 deletions priv/templates/phx.gen.release/rel/migrate.bat.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
call "%~dp0\<%= otp_app %>" eval <%= app_namespace %>.Release.migrate
3 changes: 3 additions & 0 deletions priv/templates/phx.gen.release/rel/migrate.sh.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
cd -P -- "$(dirname -- "$0")"
exec ./<%= otp_app %> eval <%= app_namespace %>.Release.migrate
2 changes: 2 additions & 0 deletions priv/templates/phx.gen.release/rel/server.bat.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
set PHX_SERVER=true
call "%~dp0\<%= otp_app %>" start
Loading

0 comments on commit 380a281

Please sign in to comment.