Skip to content

Instantly share code, notes, and snippets.

@ivanxuu
Last active November 15, 2019 17:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ivanxuu/f04ef726518b2ab20b9b5e396c281363 to your computer and use it in GitHub Desktop.
Save ivanxuu/f04ef726518b2ab20b9b5e396c281363 to your computer and use it in GitHub Desktop.
How to deploy elixir using distillery and edeliver with a production machine in vagrant

Instrucctions

Build your vagrant machine in some dir using the files Vagrantfile and pre-install.app.sh with the command vagrant up. That will deploy a new virtual machine accessible with vagrant ssh, write down its ip visible with ifconfig and update that IP/URL in the .deliver/config file in this guide.

ivan@z11:~/Development/elixir$ mix phx.new deploy_me
ivan@z11:~/Development/elixir$ cd deploy_me/
ivan@z11:~/Development/elixir/deploy_me$ mix do ecto.create, phx.server
ivan@z11:~/Development/elixir/deploy_me$ git init && git add .  && git commit -av -m "initial"
ivan@z11:~/Development/elixir/deploy_me (master)$ vim config/prod.secret.exs
   config :deploy_me, DeployMe.Repo,
     adapter: Ecto.Adapters.Postgres,
     username: "deploy_me",
     password: "passw0rd",
     database: "deploy_me_prod",
     pool_size: 15
ivan@z11:~/Development/elixir/deploy_me (master)$ vim mix.exs
  def deps do
    ...
    {:edeliver, "~> 1.4.4"},
    {:distillery, "~> 1.5", runtime: false},

ivan@z11:~/Development/elixir/deploy_me (master)$ mix deps.get
ivan@z11:~/Development/elixir/deploy_me (master)$ mkdir .deliver/
ivan@z11:~/Development/elixir/deploy_me (master)$ vim .deliver/config
  APP="deploy_me"

  BUILD_HOST="172.28.128.4" # <-- PUT HERE YOUR VIRTUAL MACHINE IP
  BUILD_USER="ivan"
  BUILD_AT="/tmp/edeliver/$APP/builds"

  #RELEASE_DIR="/tmp/edeliver/$APP/builds/_build/prod/rel/$APP"

  # prevent re-installing node modules; this defaults to "."
  #GIT_CLEAN_PATHS="_build rel priv/static"

  # STAGING_HOSTS="188.166.182.170"
  # STAGING_USER="deploy"
  # TEST_AT="/home/deploy/staging"

  PRODUCTION_HOSTS="172.28.128.4" # <-- PUT HERE YOUR VIRTUAL MACHINE IP
  PRODUCTION_USER="ivan"
  DELIVER_TO="/home/$PRODUCTION_USER"

  # For *Phoenix* projects, symlink prod.secret.exs to our tmp source
  pre_erlang_get_and_update_deps() {
    local _prod_secret_path="/home/$PRODUCTION_USER/prod.secret.exs"
    if [ "$TARGET_MIX_ENV" = "prod" ]; then
      __sync_remote "
        ln -sfn '$_prod_secret_path' '$BUILD_AT/config/prod.secret.exs'
      "
    fi
  }

  pre_erlang_clean_compile() {
    status "Running phoenix.digest" # log output prepended with "----->"
    __sync_remote " # runs the commands on the build host
      # [ -f ~/.profile ] && source ~/.profile # load profile (optional)
      source ~/.profile
      # echo \$PATH # check if rbenv is in the path
      set -e # fail if any command fails (recommended)
      cd '$BUILD_AT/assets' # enter the build directory on the build host (required)
      # prepare something
      mkdir -p priv/static # required by the phx.digest task
      npm install
      ./node_modules/brunch/bin/brunch build --production

      cd '$BUILD_AT' # enter the build directory on the build host (required)
      # run your custom task
      APP='$APP' MIX_ENV='$TARGET_MIX_ENV' $MIX_CMD phx.digest $SILENCE
    "
  }

ivan@z11:~/Development/elixir/deploy_me (master)$ echo "" >> .gitignore
ivan@z11:~/Development/elixir/deploy_me (master)$ echo ".deliver/releases/" >> .gitignore
ivan@z11:~/Development/elixir/deploy_me (master)$ git add .
ivan@z11:~/Development/elixir/deploy_me (master)$ git commit -m "Edeliver"
ivan@z11:~/Development/elixir/deploy_me (master)$ vim config/prod.exs
  config :deploy_me, DeployMeWeb.Endpoint,
    # load_from_system_env: true, # COMMENT THIS!
    # https://stackoverflow.com/questions/47071275
    cache_static_manifest: "priv/static/cache_manifest.json",
    # The port that the app runs on
    http: [port: 4000],
    # This is to generate links
    url: [host: "example.com", port: 80],
  config :phoenix, :serve_endpoints, true

# ESTA HACE FALTA? ivan@z11:~/Development/elixir/deploy_me (master)$ mix do deps.get, compile
ivan@z11:~/Development/elixir/deploy_me (master)$ mix release.init
ivan@z11:~/Development/elixir/deploy_me (master)$ vim rel/config.exs
  enviroment :prod do
    set output_dir: "rel/deploy_me"
ivan@z11:~/Development/elixir/deploy_me (master)$ git add .
ivan@z11:~/Development/elixir/deploy_me (master)$ git commit -m "Distillery"
ivan@z11:~/Development/elixir/deploy_me (master)$ scp config/prod.secret.exs 172.28.128.4:~/
ivan@z11:~/Development/elixir/deploy_me (master)$ mix edeliver build release production --verbose
ivan@z11:~/Development/elixir/deploy_me (master)$ mix edeliver deploy release production
ivan@z11:~/Development/elixir/deploy_me (master)$ mix edeliver start production
ivan@z11:~/Development/elixir/deploy_me (master)$ mix edeliver migrate production
Elije:
├── Cambios en frio o primera subida
│               │
│     edeliver build release
│         │          │
│         │   edeliver migrate production
│         │          │
│     edeliver migrate production
│
└── Para cambios en caliente
          │               │
          │      edeliver upgrade production
          │
       edeliver build upgrade --from=commit
          │
       edeliver deploy update production
# -*- mode: ruby -*-
# vi: set ft=ruby :
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# All Vagrant configuration is done here. The most common configuration
# options are documented and commented below. For a complete reference,
# please see the online documentation at vagrantup.com.
# Every Vagrant virtual environment requires a box to build off of.
config.vm.box = "ubuntu/xenial64"
# First time take a lot of time. Wait 3 minutes at least
config.vm.boot_timeout = 200
# Disable automatic box update checking. If you disable this, then
# boxes will only be checked for updates when the user runs
# `vagrant box outdated`. This is not recommended.
# config.vm.box_check_update = false
# Create a forwarded port mapping which allows access to a specific port
# within the machine from a port on the host machine. In the example below,
# accessing "localhost:8080" will access port 80 on the guest machine.
#config.vm.network "forwarded_port", guest: 80, host: 8080
#config.vm.network "forwarded_port", guest: 80, host: 8080
# Create a private network, which allows host-only access to the machine
# using a specific IP.
# config.vm.network "private_network", ip: "192.168.33.10"
#config.vm.network "private_network", ip: "192.168.33.10"
# Create a public network, which generally matched to bridged network.
# Bridged networks make the machine appear as another physical device on
# your network.
# config.vm.network "public_network"
# If true, then any SSH connections made will enable agent forwarding.
# Default value: false
# config.ssh.forward_agent = true
# Share an additional folder to the guest VM. The first argument is
# the path on the host to the actual folder. The second argument is
# the path on the guest to mount the folder. And the optional third
# argument is a set of non-required options.
# config.vm.synced_folder "../data", "/vagrant_data"
#config.vm.synced_folder "data", "/vagrant_data"
# Provider-specific configuration so you can fine-tune various
# backing providers for Vagrant. These expose provider-specific options.
# Example for VirtualBox:
#
# config.vm.provider "virtualbox" do |vb|
# # Don't boot with headless mode
# vb.gui = true
#
# # Use VBoxManage to customize the VM. For example to change memory:
# vb.customize ["modifyvm", :id, "--memory", "1024"]
# end
#
# View the documentation for the provider you're using for more
# information on available options.
# Enable provisioning with CFEngine. CFEngine Community packages are
# automatically installed. For example, configure the host as a
# policy server and optionally a policy file to run:
#
# config.vm.provision "cfengine" do |cf|
# cf.am_policy_hub = true
# # cf.run_file = "motd.cf"
# end
#
# You can also configure and bootstrap a client to an existing
# policy server:
#
# config.vm.provision "cfengine" do |cf|
# cf.policy_server_address = "10.0.2.15"
# end
# Enable provisioning with Puppet stand alone. Puppet manifests
# are contained in a directory path relative to this Vagrantfile.
# You will need to create the manifests directory and a manifest in
# the file default.pp in the manifests_path directory.
#
# config.vm.provision "puppet" do |puppet|
# puppet.manifests_path = "manifests"
# puppet.manifest_file = "site.pp"
# end
# Enable provisioning with chef solo, specifying a cookbooks path, roles
# path, and data_bags path (all relative to this Vagrantfile), and adding
# some recipes and/or roles.
#
# config.vm.provision "chef_solo" do |chef|
# chef.cookbooks_path = "../my-recipes/cookbooks"
# chef.roles_path = "../my-recipes/roles"
# chef.data_bags_path = "../my-recipes/data_bags"
# chef.add_recipe "mysql"
# chef.add_role "web"
#
# # You may also specify custom JSON attributes:
# chef.json = { mysql_password: "foo" }
# end
# Enable provisioning with chef server, specifying the chef server URL,
# and the path to the validation key (relative to this Vagrantfile).
#
# The Opscode Platform uses HTTPS. Substitute your organization for
# ORGNAME in the URL and validation key.
#
# If you have your own Chef Server, use the appropriate URL, which may be
# HTTP instead of HTTPS depending on your configuration. Also change the
# validation key to validation.pem.
#
# config.vm.provision "chef_client" do |chef|
# chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME"
# chef.validation_key_path = "ORGNAME-validator.pem"
# end
#
# If you're using the Opscode platform, your validator client is
# ORGNAME-validator, replacing ORGNAME with your organization name.
#
# If you have your own Chef Server, the default validation client name is
# chef-validator, unless you changed the configuration.
#
# chef.validation_client_name = "ORGNAME-validator"
config.vm.define :app1 do |app1|
app1.vm.network "private_network", type: "dhcp"
#app1.vm.hostname = "phoenix"
# Remember that the domain us needed, set domain in router
app1.vm.provider :virtualbox do |vb|
vb.memory = 2048
vb.cpus = 2
end
app1.vm.provision "shell",
path: './pre-install.app.sh',
env: {
APPNAME: 'deploy_me',
# Will be used to create a UNIX user wich will have the app code
# in his HOME folder
OWNER: 'ivan',
# The unix password for the OWNER user
OWNER_PASSWORD: 'passw0rd',
# The password for the user of the project in the postgres
# database. The username of the user is the same that the
# PROJECT_NAME variable
DATABASE_USER: 'deploy_me',
DATABASE_NAME: 'deploy_me_prod',
DATABASE_PASSWORD: 'passw0rd',
# Enter here the key of a developer machine where all connections
# from this public key will be accepted.
ACCEPTED_SSH_PUBLIC_KEY: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+9Z1PP1LN6NXheWaeo+uKyIB/1V1oY5MPnFsQoxzxT3sQ4c0aspZQQezK1C9ujsgbrcfYM6WGigg9Lvdlj+auzNyIbqxwHE22/6B9vCRDo6SJWTpEJFn2s+KmWLZUaunX87k0IAZ07cmNJ6fzno0StUyQPz6blwr5hW6wh19ENaqwnVzBZAxAtIaYcVsIKaDF4Dml4dAGhQ1F1Egcw7+rt1bcOmUwNMLNTOtDy5WBXGJOu/wD9cjydsiDHqAoHTqI2BkhUY7g1Urm4sqU/Py/XaEHNNjUlAQKOsFfBVo6mIrSF9JohNSKvtK6s0OUUufUwCZBt5AQuFeKDHE9s9bf ivan@z11',
# (Optional), to change the hostname of the production machine
NEW_HOSTNAME: 'phoenix',
}, privileged: true
end
#config.vm.define :img1 do |img1|
# img1.vm.hostname = "img1"
# # Remember that the domain us needed, set domain in router
# #img1.vm.network "public_network", bridge: "eth0",:mac => "08002797d86b"
# img1.vm.network "private_network", type: "dhcp"
# img1.vm.provider :virtualbox do |vb|
# vb.memory = 2048
# vb.cpus = 2
# end
# img1.vm.provision "shell",
# path: './pre-install.imageserver.sh',
# env: {
# # Will be used to create a UNIX user wich will have the img code
# # in his HOME folder
# OWNER: 'ivan',
# # The unix password for the OWNER user
# OWNER_PASSWORD: '0123456789',
# # Enter here the key of a developer machine where all connections
# # from this public key will be accepted.
# ACCEPTED_SSH_PUBLIC_KEY: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+9Z1PP1LN6NXheWaeo+uKyIB/1V1oY5MPnFsQoxzxT3sQ4c0aspZQQezK1C9ujsgbrcfYM6WGigg9Lvdlj+auzNyIbqxwHE22/6B9vCRDo6SJWTpEJFn2s+KmWLZUaunX87k0IAZ07cmNJ6fzno0StUyQPz6blwr5hW6wh19ENaqwnVzBZAxAtIaYcVsIKaDF4Dml4dAGhQ1F1Egcw7+rt1bcOmUwNMLNTOtDy5WBXGJOu/wD9cjydsiDHqAoHTqI2BkhUY7g1Urm4sqU/Py/XaEHNNjUlAQKOsFfBVo6mIrSF9JohNSKvtK6s0OUUufUwCZBt5AQuFeKDHE9s9bf ivan@z11',
# }, privileged: true
#end
#config.vm.define :img2 do |img2|
# img2.vm.hostname = "img2"
# # Remember that the domain us needed, set domain in router
# img2.vm.network "private_network", type: "dhcp"
# img2.vm.provider :virtualbox do |vb|
# vb.memory = 2048
# vb.cpus = 2
# end
# img2.vm.provision "shell",
# path: './pre-install.imageserver.sh',
# env: {
# # Will be used to create a UNIX user wich will have the img code
# # in his HOME folder
# OWNER: 'ivan',
# # The unix password for the OWNER user
# OWNER_PASSWORD: '0123456789',
# # Enter here the key of a developer machine where all connections
# # from this public key will be accepted.
# ACCEPTED_SSH_PUBLIC_KEY: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+9Z1PP1LN6NXheWaeo+uKyIB/1V1oY5MPnFsQoxzxT3sQ4c0aspZQQezK1C9ujsgbrcfYM6WGigg9Lvdlj+auzNyIbqxwHE22/6B9vCRDo6SJWTpEJFn2s+KmWLZUaunX87k0IAZ07cmNJ6fzno0StUyQPz6blwr5hW6wh19ENaqwnVzBZAxAtIaYcVsIKaDF4Dml4dAGhQ1F1Egcw7+rt1bcOmUwNMLNTOtDy5WBXGJOu/wD9cjydsiDHqAoHTqI2BkhUY7g1Urm4sqU/Py/XaEHNNjUlAQKOsFfBVo6mIrSF9JohNSKvtK6s0OUUufUwCZBt5AQuFeKDHE9s9bf ivan@z11',
# }, privileged: true
#end
end
# Because distillery uses `:systools_make.make_tar(...)` to create
# the release tar which resoves all links using the `:dereference`
# option, the release tar needs to be repackaged including the links.
# To be able use this plugin, it must be added in the
# `rel/config.exs` distillery config as plugin like this:
environment :prod do
..
plugin Releases.Plugin.LinkConfig
end
------------
Remove the line set output_dir: "rel/project" from rel/config.exs.
Add line RELEASE_DIR="/tmp/edeliver/$APP/builds/_build/prod/rel/$APP"
path should match the line of `BUILD_AT="/tmp/edeliver/$APP/builds"`
-------------------
# In .deliver/config add the following:
LINK_VM_ARGS=/home/ivan/vm.args
-------------
#vm.args
-name one@172.28.128.3
-kernel inet_dist_listen_min 9100 inet_dist_listen_max 9155
-setcookie 123456789123456789123456789123456789
-config /home/ivan/chat.config
-------------
#chat.config
[{kernel,
[
{sync_nodes_optional, ['one@172.28.128.4']},
{sync_nodes_timeout, 30000}
]}
].
# CHECK ALL VARIABLES ARE SET
if [ -z "$APPNAME" ]
then echo "Variable APPNAME is not set" >&2; exit;
fi
if [ -z "$DATABASE_USER" ]
then echo "Variable DATABASE_USER is not set" >&2; exit;
fi
if [ -z "$OWNER" ]
then echo "Variable OWNER is not set" >&2; exit;
fi
if [ -z "$OWNER_PASSWORD" ]
then echo "Variable OWNER_PASSWORD is not set" >&2; exit;
fi
if [ -z "$DATABASE_PASSWORD" ]
then echo "Variable DATABASE_PASSWORD is not set" >&2; exit;
fi
if [ -z "$ACCEPTED_SSH_PUBLIC_KEY" ]
then echo "Variable ACCEPTED_SSH_PUBLIC_KEY is not set" >&2; exit;
fi
if [ -z "$DATABASE_NAME" ]
then echo "Variable DATABASE_NAME is not set" >&2; exit;
fi
if [ -z "$NEW_HOSTNAME" ]
then echo "Variable NEW_HOSTNAME is not set" >&2; exit;
fi
### Configure hostname and hosts
###echo $NEW_HOSTNAME > /etc/hostname
###hostname --file /etc/hostname
###echo "$(hostname --ip-address) $HOSTNAME.touristed.com $HOSTNAME" >> /etc/hosts
#CREATE & CONFIGURE UNIX USERS
echo " "
echo "CREATE & CONFIGURE UNIX USERS"
# Add git user to hold the repositories and custom user to hold the
# webserver.
useradd -G sudo --password $OWNER_PASSWORD --create-home $OWNER \
--shell /bin/bash
# Create authorized keys file to let ssh accept connections from the
# public key of the developer machine to git and owner account
sudo -u $OWNER mkdir -m 700 /home/$OWNER/.ssh && \
sudo -u $OWNER echo "$ACCEPTED_SSH_PUBLIC_KEY" >> /home/$OWNER/.ssh/authorized_keys && \
chown $OWNER:$OWNER /home/$OWNER/.ssh/authorized_keys && \
chmod 600 /home/$OWNER/.ssh/authorized_keys && \
echo "SSH access to $OWNER@$(hostname --fqdn) granted using your SSH key"
# Hide dirs from other users
# Give read access to www server (www-data)
chown $OWNER:www-data /home/$OWNER
# Make OWNER to change his password on next login
passwd --expire ivan
# Avoid warning in production machine "warning: the VM is running with native name encoding of latin1 which may cause Elixir to malfunction as it expects utf8. Please ensure your locale is set to UTF-8 (which can be verified by running "locale" in your shell)"
echo "export LANG=en_US.utf8" >> /home/$OWNER/.profile
# Install postgres and postgis keys
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release --codename | cut -f2)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | \
sudo apt-key add -
# Update packages in system
apt-get update && apt-get upgrade -y && apt-get dist-upgrade -y
#Install some utility packages
apt-get install -y htop tree git build-essential imagemagick libpq-dev autoconf curl postgresql || \
echo "Couldn't install some packages :(" >&2
echo " " && \
echo "Install NodeJS" && \
curl -sL https://deb.nodesource.com/setup_6.x -o nodesource_setup.sh && \
bash nodesource_setup.sh && \
sudo apt-get install -y nodejs && \
echo "Installed NodeJS" || \
echo "Couldn't install NODEJS :(" >&2
# CREATE DB NEW POSTGRES USER W/ PASSWORD
# Note: This wont create any database, just the user, that if are given
# administration rights let rails create the database or migrate it. Asks
# for a password
echo " "
echo "CREATE NEW POSTGRES USER"
# CREATE USER deployphoenix WITH PASSWORD 'mypassword'; si no necesitas superuser
sudo -u postgres psql -d template1 -U postgres -c "CREATE USER $DATABASE_USER WITH PASSWORD '$DATABASE_PASSWORD' SUPERUSER;" && \
sudo -u postgres psql -d template1 -U postgres -c "CREATE DATABASE $DATABASE_NAME OWNER=$DATABASE_USER" && \
#sudo -u postgres psql -U postgres -d $DATABASE_NAME -c "CREATE EXTENSION postgis" && \
echo "Created $DATABASE_USER postgres user to handle the database $DATABASE_USER" || \
echo "Couldn't create a user in database :(" >&2
echo ""
echo "Install Erlang and Elixir" && \
wget https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb && \
sudo dpkg -i erlang-solutions_1.0_all.deb && \
apt-get update && \
apt-get install -y esl-erlang elixir && \
echo "Installed Erlang and Elixir" || \
echo " Failed to install Erlang and Elixir :(" >&2
echo ""
echo "Configure Iptables" && \
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 4000 && \
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 4443 && \
iptables -t nat --line-numbers -n -L || \
echo " Failed to install ip tables in 4000/4443 :(" >&2
# NOTE: You can delete routes with `iptables -t nat -D PREROUTING 2`
# where 2 is the number in the table
cat <<EOT >/lib/systemd/system/deploy_phoenix.service
[Unit]
Description=Phoenix server for DeployPhoenix app
After=network.target
[Service]
User=$OWNER
Group=$OWNER
Restart=on-failure
Environment=HOME=/home/$OWNER/$APPNAME
ExecStart= /home/$OWNER/$APPNAME/bin/$APPNAME foreground
ExecStop= /home/$OWNER/$APPNAME/bin/$APPNAME stop
[Install]
WantedBy=multi-user.target
EOT
systemctl enable deploy_phoenix.service && \
systemctl daemon-reload && \
echo "Installed phoenix service to boot up the server" || \
echo "phoenix server already installed or there was a problem"
# RECONFIGURE SSHD
echo " "
echo "RECONFIGURE SSHD"
# LoginGraceTime
# The login grace time is a period of time where a user may be
# connected but has not begun the authentication process. By default,
# sshd will allow a connected user to wait 120 seconds (2 minutes)
# before starting to authenticate. By shortening this time, you can
# decrease the chances of someone attempting a brute force attack
# against your SSH server from being successfull.
# PermitRootLogin
# This will disable root's access to logon via SSH
# X11Forwarding
# The option X11Forwarding specifies whether X11 forwarding
# should be enabled or not on this server. Since we setup a server without GUI
# installed on it, we can safely turn this option off.
# PasswordAuthentication
# Do not allow plain password authentication. Only SSH keys are accepted
# AcceptEnv
# UsePAM
# As using keys this is more secure. But remember to give a password to
# all users or they wont be able to login
sed --in-place=.bak \
-e 's/^[#\s]*LoginGraceTime.*/LoginGraceTime 20/' \
-e 's/^[#\s]*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' \
-e 's/^[#\s]*PermitRootLogin.*/PermitRootLogin no/' \
-e 's/^[#\s]*X11Forwarding.*/X11Forwarding no/' \
-e 's/^[#\s]*PasswordAuthentication.*/PasswordAuthentication no/' \
-e 's/^[#\s]*UsePAM.*/UsePAM no/' \
-e 's/^[#\s]*AcceptEnv/#AcceptEnv/' /etc/ssh/sshd_config && \
service ssh reload && \
echo "Congigured /etc/ssh/sshd_config to be more restrictive" || \
( echo "Couldn't make sshd more secure :(" >&2 && exit )
echo " "
echo ' ____ ____ ____ _ _ ____ ____ ____ ____ __ ____ _ _ _ '
echo '/ ___)( __)( _ \/ )( \( __)( _ \ ( _ \( __) / _\ ( \( \/ )/ \'
echo '\___ \ ) _) ) /\ \/ / ) _) ) / ) / ) _) / \ ) D ( ) / \_/'
echo '(____/(____)(__\_) \__/ (____)(__\_) (__\_)(____)\_/\_/(____/(__/ (_)'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment