Back to Template

Diff of Changes

Previous version at this commit

say "👋 Welcome to interactive Ruby on Whales installer 🐳.\n" \
    "Make sure you've read the guide: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development"

DOCKER_DEV_ROOT = ".dockerdev"

# Prepare variables and utility files
# Collect the app's metadata

app_name = Rails.application.class.name.parameterize
# Load the project's deps and required Ruby version

ruby_version = nil
gemspecs = {}

begin
  if File.file?("Gemfile.lock")
    bundler_parser = Bundler::LockfileParser.new(Bundler.read_file("Gemfile.lock"))
    gemspecs =  Hash[bundler_parser.specs.map { |spec| [spec.name, spec.version] }]
    maybe_ruby_version = bundler_parser.ruby_version.match(/ruby (\d+\.\d+\.\d+)./i)&.[](1)
  end

  begin
    if maybe_ruby_version
      ruby_version = ask("Which Ruby version would you like to use? (Press ENTER to use #{maybe_ruby_version})")
      ruby_version = maybe_ruby_version if ruby_version.empty?
    else
      ruby_version = ask("Which Ruby version would you like to use? (For example, 3.1.0)")
    end

    Gem::Version.new(ruby_version)
  rescue ArgumentError
    say "Invalid version. Please, try again"
    retry
  end
end
# Generates the Aptfile with system deps
DEFAULT_APTFILE = <<~CODE
  # An editor to work with credentials
  vim
CODE

begin
  deps = []
  loop do
    dep = ask "Which system package do you want to install? (Press ENTER to continue)"
    break if dep.empty?
    deps << dep
  end

  aptfile = File.join(DOCKER_DEV_ROOT, "Aptfile")
  FileUtils.mkdir_p(File.dirname(aptfile))

  app_deps =
    if deps.empty?
      "\n"
    else
      (["# Application dependencies"] + deps + [""]).join("\n")
    end

  File.write(aptfile, DEFAULT_APTFILE + app_deps)
end
# Set up database related variables, create files

database_adapter = nil
database_url = nil

begin
  supported_adapters = %w(postgresql postgis postgres)

  config_path = "config/database.yml"

  if File.file?(config_path)
    require "yaml"
    maybe_database_adapter = begin
      ::YAML.load_file(config_path, aliases: true) || {}
    rescue ArgumentError
      ::YAML.load_file(config_path) || {}
    end.dig("development", "adapter")
  end

  selected_database_adapter =
    if maybe_database_adapter
      ask "Which database adapter do you use? (Press ENTER to use #{maybe_database_adapter})"
    else
      ask "Which database adapter do you use?"
    end

  selected_database_adapter = maybe_database_adapter if selected_database_adapter.empty?

  if supported_adapters.include?(selected_database_adapter)
    database_adapter = selected_database_adapter
  else
    say_status :warn, "Unfortunately, we do no support #{selected_database_adapter} yet. Please, configure it yourself"
  end
end
# Specify PostgreSQL version

postgres_version = nil
postgres_base_image = "postgres"

DEFAULT_POSTGRES_VERSION = "14"
POSTGRES_ADAPTERS = %w[postgres postgresql postgis]

if POSTGRES_ADAPTERS.include?(database_adapter)
  begin
    selected_postgres_version = ask "Which PostgreSQL version do you want to install? (Press ENTER to use #{DEFAULT_POSTGRES_VERSION})"

    postgres_version = selected_postgres_version.empty? ? DEFAULT_POSTGRES_VERSION : selected_postgres_version
    database_url = "#{database_adapter}://postgres:postgres@postgres:5432"

    if database_adapter == "postgis"
      postgres_base_image = "postgis/postgis"
    end
  end
end
# Node/Yarn configuration
# TODO: Read Node/Yarn versions from .nvmrc/package.json.

node_version = nil
yarn_version = nil

DEFAULT_NODE_VERSION = "16"

begin
  selected_node_version = ask(
    "Which Node version do you want to install? (Press ENTER to use 16, type 'n/no' to skip installing Node)"
  )

  unless selected_node_version =~ /^\s*no?\s*$/
    node_version = selected_node_version.empty? ? DEFAULT_NODE_VERSION : selected_node_version

    yarn_version = ask "Which Yarn version do you want to install? (Press ENTER to install the latest one)"
    yarn_version = "latest" if yarn_version.empty?
  end
end
# Redis info

redis_version = nil

DEFAULT_REDIS_VERSION = "6.0"

begin
  if gemspecs.key?("redis")
    maybe_redis_version = ask "Which Redis version do you want to install? (Press ENTER to use #{DEFAULT_REDIS_VERSION})"

    redis_version = maybe_redis_version.empty? ? DEFAULT_REDIS_VERSION : maybe_redis_version
  end
end

# Generate configuration
file "#{DOCKER_DEV_ROOT}/Dockerfile", ERB.new(
  *[
<<~'CODE'
ARG RUBY_VERSION
ARG DISTRO_NAME=bullseye

FROM ruby:$RUBY_VERSION-slim-$DISTRO_NAME

ARG DISTRO_NAME

# Common dependencies
RUN apt-get update -qq \
  && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    build-essential \
    gnupg2 \
    curl \
    less \
    git \
  && apt-get clean \
  && rm -rf /var/cache/apt/archives/* \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
  && truncate -s 0 /var/log/*log
<% if postgres_version %>

ARG PG_MAJOR
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
  && echo deb http://apt.postgresql.org/pub/repos/apt/ $DISTRO_NAME-pgdg main $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    libpq-dev \
    postgresql-client-$PG_MAJOR \
    && apt-get clean \
    && rm -rf /var/cache/apt/archives/* \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
    && truncate -s 0 /var/log/*log
<% end %>
<% if node_version %>

ARG NODE_MAJOR
RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    nodejs \
    && apt-get clean \
    && rm -rf /var/cache/apt/archives/* \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
    && truncate -s 0 /var/log/*log
<% if yarn_version == 'latest' %>

RUN npm install -g yarn
<% else %>

ARG YARN_VERSION
RUN npm install -g yarn@$YARN_VERSION
<% end %>
<% end %>

# Application dependencies
# We use an external Aptfile for this, stay tuned
COPY Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
  DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    $(grep -Ev '^\s*#' /tmp/Aptfile | xargs) \
    && apt-get clean \
    && rm -rf /var/cache/apt/archives/* \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
    && truncate -s 0 /var/log/*log

# Configure bundler
ENV LANG=C.UTF-8 \
  BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3

# Store Bundler settings in the project's root
ENV BUNDLE_APP_CONFIG=.bundle

# Uncomment this line if you want to run binstubs without prefixing with `bin/` or `bundle exec`
# ENV PATH /app/bin:$PATH

# Upgrade RubyGems and install the latest Bundler version
RUN gem update --system && \
    gem install bundler

# Create a directory for the app code
RUN mkdir -p /app
WORKDIR /app

# Document that we're going to expose port 3000
EXPOSE 3000
# Use Bash as the default command
CMD ["/usr/bin/bash"]
CODE
], trim_mode: "<>").result(binding)
file "#{DOCKER_DEV_ROOT}/compose.yml", ERB.new(
  *[
<<~'CODE'
x-app: &app
  build:
    context: .
    args:
      RUBY_VERSION: '<%= ruby_version %>'
<% if postgres_version %>
      PG_MAJOR: '<%= postgres_version.split('.', 2).first %>'
<% end %>
<% if node_version %>
      NODE_MAJOR: '<%= node_version.split('.', 2).first %>'
<% end %>
<% if yarn_version && yarn_version != 'latest' %>
      YARN_VERSION: '<%= yarn_version %>'
<% end %>
  image: <%= app_name %>-dev:1.0.0
  environment: &env
<% if node_version %>
    NODE_ENV: ${NODE_ENV:-development}
<% end %>
    RAILS_ENV: ${RAILS_ENV:-development}
  tmpfs:
    - /tmp
    - /app/tmp/pids

x-backend: &backend
  <<: *app
  stdin_open: true
  tty: true
  volumes:
    - ..:/app:cached
    - bundle:/usr/local/bundle
    - rails_cache:/app/tmp/cache
<% if File.directory?("app/assets") %>
    - assets:/app/public/assets
<% end %>
<% if node_version %>
    - node_modules:/app/node_modules
<% end %>
<% if gemspecs.key?("webpacker") %>
    - packs:/app/public/packs
    - packs-test:/app/public/packs-test
<% end %>
    - history:/usr/local/hist
<% if postgres_version %>
    - ./.psqlrc:/root/.psqlrc:ro
<% end %>
    - ./.bashrc:/root/.bashrc:ro
  environment: &backend_environment
    <<: *env
<% if redis_version %>
    REDIS_URL: redis://redis:6379/
<% end %>
<% if database_url %>
    DATABASE_URL: postgres://postgres:postgres@postgres:5432
<% end %>
<% if gemspecs.key?("webpacker") %>
    WEBPACKER_DEV_SERVER_HOST: webpacker
<% end %>
    MALLOC_ARENA_MAX: 2
    WEB_CONCURRENCY: ${WEB_CONCURRENCY:-1}
    BOOTSNAP_CACHE_DIR: /usr/local/bundle/_bootsnap
    XDG_DATA_HOME: /app/tmp/caches
<% if yarn_version %>
    YARN_CACHE_FOLDER: /app/node_modules/.yarn-cache
<% end %>
    HISTFILE: /usr/local/hist/.bash_history
<% if postgres_version %>
    PSQL_HISTFILE: /usr/local/hist/.psql_history
<% end %>
    IRB_HISTFILE: /usr/local/hst/.irb_history
    EDITOR: vi
<% if postgres_version || redis_version %>
  depends_on:
<% if postgres_version %>
    postgres:
      condition: service_healthy
<% end %>
<% if redis_version %>
    redis:
      condition: service_healthy
<% end %>
<% end %>

services:
  rails:
    <<: *backend
    command: bundle exec rails

  web:
    <<: *backend
    command: bundle exec rails server -b 0.0.0.0
    ports:
      - '3000:3000'
<% if gemspecs.key?("webpacker") || gemspecs.key?("sidekiq") %>
    depends_on:
<% if gemspecs.key?("webpacker") %>
      webpacker:
        condition: service_started
<% end %>
<% if gemspecs.key?("sidekiq") %>
      sidekiq:
        condition: service_started
<% end %>
<% end %>
<% if gemspecs.key?("sidekiq") %>

  sidekiq:
    <<: *backend
    command: bundle exec sidekiq
<% end %>
<% if postgres_version %>

  postgres:
    image: postgres:<%= postgres_version %>
    volumes:
      - .psqlrc:/root/.psqlrc:ro
      - postgres:/var/lib/postgresql/data
      - history:/user/local/hist
    environment:
      PSQL_HISTFILE: /user/local/hist/.psql_history
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432
    healthcheck:
      test: pg_isready -U postgres -h 127.0.0.1
      interval: 5s
<% end %>
<% if redis_version %>

  redis:
    image: redis:<%= redis_version %>-alpine
    volumes:
      - redis:/data
    ports:
      - 6379
    healthcheck:
      test: redis-cli ping
      interval: 1s
      timeout: 3s
      retries: 30
<% end %>
<% if gemspecs.key?("webpacker") %>

  webpacker:
    <<: *app
    command: bundle exec ./bin/webpack-dev-server
    ports:
      - '3035:3035'
    volumes:
      - ..:/app:cached
      - bundle:/usr/local/bundle
      - node_modules:/app/node_modules
      - packs:/app/public/packs
      - packs-test:/app/public/packs-test
    environment:
      <<: *env
      WEBPACKER_DEV_SERVER_HOST: 0.0.0.0
      YARN_CACHE_FOLDER: /app/node_modules/.yarn-cache
<% end %>

volumes:
  bundle:
<% if node_version %>
  node_modules:
<% end %>
  history:
  rails_cache:
<% if postgres_version %>
  postgres:
<% end %>
<% if redis_version %>
  redis:
<% end %>
<% if File.directory?("app/assets") %>
  assets:
<% end %>
<% if gemspecs.key?("webpacker") %>
  packs:
  packs-test:
<% end %>
CODE
], trim_mode: "<>").result(binding)
file "dip.yml", ERB.new(
  *[
<<~'CODE'
version: '7.1'

# Define default environment variables to pass
# to Docker Compose
environment:
  RAILS_ENV: development

compose:
  files:
    - <%= DOCKER_DEV_ROOT %>/compose.yml
  project_name: <%= app_name %>

interaction:
  # This command spins up a Rails container with the requried dependencies (such as databases),
  # and opens a terminal within it.
  runner:
    description: Open a Bash shell within a Rails container (with dependencies up)
    service: rails
    command: /bin/bash

  # Run a Rails container without any dependent services (useful for non-Rails scripts)
  bash:
    description: Run an arbitrary script within a container (or open a shell without deps)
    service: rails
    command: /bin/bash
    compose_run_options: [ no-deps ]

  # A shortcut to run Bundler commands
  bundle:
    description: Run Bundler commands
    service: rails
    command: bundle
    compose_run_options: [ no-deps ]
<% if gemspecs.key?("rspec") %>

  # A shortcut to run RSpec (which overrides the RAILS_ENV)
  rspec:
    description: Run Rspec commands
    service: rails
    environment:
      RAILS_ENV: test
    command: bundle exec rspec
<% end %>

  rails:
    description: Run Rails commands
    service: rails
    command: bundle exec rails
    subcommands:
      s:
        description: Run Rails server at http://localhost:3000
        service: web
        compose:
          run_options: [ service-ports, use-aliases ]
<% if !gemspecs.key?("rspec") %>
      test:
        description: Run unit tests
        service: rails
        command: bundle exec rails test
        environment:
          RAILS_ENV: test
<% end %>
<% if yarn_version %>

  yarn:
    description: Run Yarn commands
    service: rails
    command: yarn
    compose_run_options: [ no-deps ]
<% end %>
<% if postgres_version %>

  psql:
    description: Run Postgres psql console
    service: postgres
    default_args: anycasts_dev
    command: psql -h postgres -U postgres
<% end %>
<% if redis_version %>

  'redis-cli':
    description: Run Redis console
    service: redis
    command: redis-cli -h redis
<% end %>

provision:
  - dip compose down --volumes
<% if postgres_version %>
  - dip compose up -d postgres
<% end %>
<% if redis_version %>
  - dip compose up -d redis
<% end %>
  - dip bash -c bin/setup
CODE
], trim_mode: "<>").result(binding)

file "#{DOCKER_DEV_ROOT}/.bashrc", ERB.new(
  *[
<<~'CODE'
alias be="bundle exec"
CODE
], trim_mode: "<>").result(binding)
if postgres_version
  file "#{DOCKER_DEV_ROOT}/.psqlrc", ERB.new(
  *[
<<~'CODE'
-- Don't display the "helpful" message on startup.
\set QUIET 1

-- Allow specifying the path to history file via `PSQL_HISTFILE` env variable
-- (and fallback to the default $HOME/.psql_history otherwise)
\set HISTFILE `[ -z $PSQL_HISTFILE ] && echo $HOME/.psql_history || echo $PSQL_HISTFILE`

-- Show how long each query takes to execute
\timing

-- Use best available output format
\x auto

-- Verbose error reports
\set VERBOSITY verbose

-- If a command is run more than once in a row,
-- only store it once in the history
\set HISTCONTROL ignoredups
\set COMP_KEYWORD_CASE upper

-- By default, NULL displays as an empty space. Is it actually an empty
-- string, or is it null? This makes that distinction visible
\pset null '[NULL]'

\unset QUIET
CODE
], trim_mode: "<>").result(binding)
end

file "#{DOCKER_DEV_ROOT}/README.md", ERB.new(
  *[
<<~'CODE'
# Docker for Development

Source: [Ruby on Whales: Dockerizing Ruby and Rails development](https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development).

## Installation

- Docker installed.

For MacOS just use the [official app](https://docs.docker.com/engine/installation/mac/).

- [`dip`](https://github.com/bibendi/dip) installed.

You can install `dip` as Ruby gem:

```sh
gem install dip
```

## Provisioning

When using Dip it could be done with a single command:

```sh
dip provision
```

## Running

```sh
dip rails s
```

## Developing with Dip

### Useful commands

```sh
# run rails console
dip rails c

# run rails server with debugging capabilities (i.e., `debugger` would work)
dip rails s

# or run the while web app (with all the dependencies)
dip up web

# run migrations
dip rails db:migrate

# pass env variables into application
dip VERSION=20100905201547 rails db:migrate:down

# simply launch bash within app directory (with dependencies up)
dip runner

# execute an arbitrary command via Bash
dip bash ls -al tmp/cache

# Additional commands

# update gems or packages
dip bundle install
<% if yarn_version %>
dip yarn install
<% end %>
<% if postgres_version %>

# run psql console
dip psql
<% end %>

<% if redis_version %>

# run Redis console
dip redis-cli
<% end %>

# run tests
<% if gemspecs.key?("rspec") %>
# TIP: `dip rspec` is already auto prefixed with `RAILS_ENV=test`
dip rspec spec/path/to/single/test.rb:23
<% else %>
# TIP: `dip rails test` is already auto prefixed with `RAILS_ENV=test`
dip rails test
<% end %>

# shutdown all containers
dip down
```

### Development flow

Another way is to run `dip <smth>` for every interaction. If you prefer this way and use ZSH, you can reduce the typing
by integrating `dip` into your session:

```sh
$ dip console | source /dev/stdin
# no `dip` prefix is required anymore!
$ rails c
Loading development environment (Rails 7.0.1)
pry>
```
CODE
], trim_mode: "<>").result(binding)

todos = [
  "📝  Important things to take care of:",
  "  - Make sure you have `ENV[\"RAILS_ENV\"] = \"test\"` (not `ENV[\"RAILS_ENV\"] ||= \"test\"`) in your test helper."
]

if database_url
  todos << "  - Don't forget to add `url:  ENV[\"DATABASE_URL\"]` to your database.yml"
end

if todos.any?
  say_status(:warn, todos.join("\n"))
end

say_status :info, "✅  You're ready to sail! Check out #{DOCKER_DEV_ROOT}/README.md or run `dip provision && dip up web` 🚀"